aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-10 15:14:49 +0200
committerblasty <blasty@local>2026-07-10 15:14:49 +0200
commit85c717ac6aa7e6a2559c1ffb8e4e4f344419a667 (patch)
treeb9201affa090401627923a181be6a87c07417a5e
parentrpc: unix-socket puppeteering server (raw keys + introspection + screen) (diff)
downloadida-tui-85c717ac6aa7e6a2559c1ffb8e4e4f344419a667.tar.gz
ida-tui-85c717ac6aa7e6a2559c1ffb8e4e4f344419a667.tar.xz
ida-tui-85c717ac6aa7e6a2559c1ffb8e4e4f344419a667.zip
rpc: semantic verbs (typed-with-delay ops + fast movement)
Layer high-level verbs over the raw injection: goto/open, rename, comment, retype, follow, back, toggle_view, hex, xrefs, symbols, structs, close, plus fast move/cursor. Editing/goto verbs type through the real prompts with a per-char delay (default 35ms) so viewers see it typed; movement uses bare keypresses with a pump-only settle to stay snappy. Each verb settles on an op-specific predicate where one exists (goto lands on the target, follow grows the nav stack, toggle flips _active, modals open), so the returned state is never stale. rpc_smoke now drives the semantic path end-to-end (rename round-trip via the prompt-fill seam), 11 green.
-rw-r--r--idatui/rpc.py123
-rw-r--r--tests/rpc_smoke.py45
2 files changed, 165 insertions, 3 deletions
diff --git a/idatui/rpc.py b/idatui/rpc.py
index e613a91..e324586 100644
--- a/idatui/rpc.py
+++ b/idatui/rpc.py
@@ -27,10 +27,20 @@ from typing import Any
from rich.console import Console
-from ._sync import settle
+from ._sync import drain, settle
from .app import DecompView, DisasmView, HexView
PROTO_VERSION = 1
+TYPE_DELAY_MS = 35 # default per-char delay for high-level typed ops (aesthetic)
+
+# Movement keys — driven fast (no typed delay) so the pane still visibly moves.
+_MOVE_KEYS = {
+ "down": "j", "up": "k", "left": "h", "right": "l",
+ "word": "w", "wordback": "b", "bol": "0", "eol": "dollar_sign",
+ "top": "home", "bottom": "G",
+ "halfdown": "ctrl+d", "halfup": "ctrl+u",
+ "pagedown": "pagedown", "pageup": "pageup",
+}
# --------------------------------------------------------------------------- #
@@ -239,6 +249,40 @@ class RpcServer:
except Exception as e: # noqa: BLE001 — report, never kill the connection
return {"id": rid, "error": {"message": f"{type(e).__name__}: {e}"}}
+ # -- composed helpers (semantic verbs) -------------------------------- #
+ async def _press(self, keys, pred=None, timeout=20.0):
+ await self.app._press_keys([str(k) for k in keys])
+ await settle(self.app, pred, timeout=timeout)
+ return snapshot(self.app)
+
+ async def _fill_prompt(self, open_key, input_id, value, delay_ms, clear):
+ """Open a prompt (a keystroke), optionally clear its prefill, type the
+ value with the typed-out delay, submit. Returns after the prompt closes."""
+ from textual.widgets import Input
+ app = self.app
+ await app._press_keys([open_key])
+ await settle(app, lambda: app.query_one(f"#{input_id}", Input).display, timeout=10)
+ inp = app.query_one(f"#{input_id}", Input)
+ if not inp.display:
+ raise RuntimeError(f"{input_id!r} prompt did not open (word under cursor?)")
+ if clear:
+ inp.value = ""
+ await app._press_keys(_text_to_keys(value, delay_ms))
+ await app._press_keys(["enter"])
+
+ def _goto_target_pred(self, target):
+ """A predicate that holds once a goto to ``target`` has landed."""
+ app = self.app
+ try:
+ ea = app.program.resolve(target)
+ except Exception: # noqa: BLE001 — unknown name; caller falls back to generic
+ return None
+ if app._active == "hex":
+ return lambda: app.query_one(HexView).cursor_va() == ea
+ fn = app.program.function_of(ea)
+ want = fn.addr if fn else ea
+ return lambda: app._cur is not None and app._cur.ea == want
+
async def _dispatch(self, method: str, params: dict[str, Any]) -> Any:
app = self.app
if method in (None, "ping"):
@@ -270,4 +314,81 @@ class RpcServer:
if method == "functions":
return functions(app, params.get("filter"), int(params.get("limit", 50)))
+ # -- semantic verbs (typed-with-delay high-level ops) ------------- #
+ delay = int(params.get("delay_ms", TYPE_DELAY_MS))
+ timeout = float(params.get("timeout", 20.0))
+
+ if method in ("goto", "open"):
+ target = str(params.get("target", ""))
+ pred = self._goto_target_pred(target)
+ await self._fill_prompt("g", "goto", target, delay, clear=False)
+ await settle(app, pred, timeout=timeout)
+ return snapshot(app)
+
+ if method == "rename":
+ await self._fill_prompt("n", "rename", str(params["name"]), delay, clear=True)
+ await settle(app, timeout=timeout)
+ return snapshot(app)
+ if method == "comment":
+ await self._fill_prompt("semicolon", "comment", str(params["text"]), delay,
+ clear=True)
+ await settle(app, timeout=timeout)
+ return snapshot(app)
+ if method == "retype":
+ await self._fill_prompt("y", "retype", str(params["proto"]), delay, clear=True)
+ await settle(app, timeout=timeout)
+ return snapshot(app)
+
+ if method == "follow":
+ depth = len(app._nav)
+ return await self._press(["enter"], lambda: len(app._nav) > depth, timeout)
+ if method == "back":
+ return await self._press(["escape"], timeout=timeout)
+ if method == "toggle_view":
+ before = app._active
+ return await self._press(["tab"], lambda: app._active != before, timeout)
+ if method == "hex":
+ return await self._press(["backslash"], lambda: app._active == "hex", timeout)
+ if method == "xrefs":
+ return await self._press(
+ ["x"], lambda: type(app.screen).__name__ == "XrefsScreen", timeout)
+ if method == "symbols":
+ await app._press_keys(["ctrl+n"])
+ await settle(app, lambda: type(app.screen).__name__ == "SymbolPalette", timeout=10)
+ q = params.get("query")
+ if q:
+ await app._press_keys(_text_to_keys(str(q), delay))
+ await settle(app, timeout=timeout)
+ return snapshot(app)
+ if method == "structs":
+ return await self._press(
+ ["ctrl+t"], lambda: type(app.screen).__name__ == "StructEditor", timeout)
+ if method == "close":
+ return await self._press(["escape"], timeout=timeout)
+
+ if method == "move":
+ key = _MOVE_KEYS.get(str(params.get("dir")))
+ if key is None:
+ raise ValueError(f"unknown move dir: {params.get('dir')!r} "
+ f"(one of {sorted(_MOVE_KEYS)})")
+ n = max(1, int(params.get("n", 1)))
+ await app._press_keys([key] * n)
+ if params.get("settle", True):
+ await drain(app) # light: pump only, keep movement snappy
+ return snapshot(app)
+
+ if method == "cursor":
+ w = _active_widget(app)
+ if isinstance(w, HexView):
+ raise ValueError("cursor: not supported in the hex view (use goto)")
+ if "line" in params and params["line"] is not None:
+ w.cursor = max(0, min(getattr(w, "total", 1) - 1, int(params["line"])))
+ if "col" in params and params["col"] is not None:
+ w.cursor_x = max(0, int(params["col"]))
+ if hasattr(w, "_after_cursor_move"):
+ w._after_cursor_move()
+ w.refresh()
+ await drain(app)
+ return snapshot(app)
+
raise ValueError(f"unknown method: {method!r}")
diff --git a/tests/rpc_smoke.py b/tests/rpc_smoke.py
index 92fc801..d456552 100644
--- a/tests/rpc_smoke.py
+++ b/tests/rpc_smoke.py
@@ -64,10 +64,14 @@ async def run(db):
resp = await c.call("ping")
check("ping returns proto", resp.get("result", {}).get("proto") == 1, str(resp))
- resp = await c.call("functions", limit=5)
+ resp = await c.call("functions", limit=400)
funcs = resp.get("result", [])
check("functions lists entries", isinstance(funcs, list) and len(funcs) > 0,
str(resp)[:120])
+ # a normally-named function (its name appears verbatim in its own decomp,
+ # unlike e.g. '.init_proc' which IDA renders as 'init_proc')
+ target = next((f for f in funcs if f["name"] == "main"),
+ next((f for f in funcs if f["name"].startswith("sub_")), funcs[0]))
resp = await c.call("state")
st = resp.get("result", {})
@@ -75,7 +79,6 @@ async def run(db):
str(st)[:120])
# drive it like a user: goto a function by name via raw keys
- target = next((f for f in funcs if f["name"] == "main"), funcs[0])
keys = ["g"] + list(target["name"]) + ["enter"]
resp = await c.call("keys", keys=keys)
st = resp.get("result", {})
@@ -103,6 +106,44 @@ async def run(db):
check("text primitive types into the focused input", val == "sub_", f"val={val!r}")
await c.call("keys", keys=["escape"])
+ # --- semantic verbs ------------------------------------------------ #
+ resp = await c.call("goto", target=target["name"], delay_ms=0)
+ check("semantic goto lands on the function",
+ resp.get("result", {}).get("function", {}).get("name") == target["name"],
+ str(resp.get("result", {}).get("function")))
+
+ before = app._active
+ resp = await c.call("toggle_view")
+ check("toggle_view flips the active pane",
+ resp.get("result", {}).get("active") != before, f"still {before}")
+ await c.call("toggle_view") # flip back to a known state
+
+ # fast movement: cursor should advance a few lines
+ line0 = (await c.call("state"))["result"]["cursor"].get("line")
+ resp = await c.call("move", dir="down", n=4)
+ line1 = resp["result"]["cursor"].get("line")
+ check("move(down,4) advances the cursor",
+ isinstance(line0, int) and isinstance(line1, int) and line1 > line0,
+ f"{line0} -> {line1}")
+
+ # rename round-trip through the prompt-fill path (place cursor on the
+ # function's own name via view(), rename, verify, revert via the API)
+ v = (await c.call("view", lines=1))["result"]
+ line0_text = v["lines"][0]["text"] if v.get("lines") else ""
+ col = line0_text.find(target["name"])
+ if col >= 0:
+ await c.call("cursor", line=0, col=col + 1)
+ newname = f"rpc_{os.getpid()}"
+ await c.call("rename", name=newname, delay_ms=0)
+ got = app._func_index.by_addr(target["ea"])
+ check("semantic rename updates the function name",
+ got is not None and got.name == newname,
+ got.name if got else None)
+ app.program.client.call(
+ "rename", batch={"func": {"addr": hex(target["ea"]), "name": target["name"]}})
+ else:
+ check("found the function name to rename", False, repr(line0_text[:60]))
+
w.close()
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0