From fcfdc8c941aeb87cc99c983146f553e03a5daa49 Mon Sep 17 00:00:00 2001 From: blasty Date: Thu, 6 Aug 2026 15:11:54 +0200 Subject: rpc: an opfmt verb, and 'drive fmt' mode is cycle/back/show or an explicit format. 'show' reports the current format and the stops on offer without editing, which is what a driver needs: the rendered text alone can't be trusted (a listing read before an ARM/Thumb switch shows the old decoding). 'word' puts the cursor on a token first, so a literal can be named instead of steered to. --- docs/RPC.md | 10 +++++++++- idatui/drive.py | 21 +++++++++++++++++++- idatui/rpc.py | 61 +++++++++++++++++++++++++++++++++++++++++++++------------ 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/docs/RPC.md b/docs/RPC.md index 53ec36b..83c65fe 100644 --- a/docs/RPC.md +++ b/docs/RPC.md @@ -87,6 +87,7 @@ predicate so the returned state is final. | `back` | — | Escape: pop the nav stack. | | `toggle_view` | — | Tab: disasm ⇄ pseudocode. | | `hex` | — | `\`: hex view. | +| `graph` | `action?=show`, `target?`, `blocks?` | the control-flow graph (Space). `show` is a pure read — blocks, typed edges, ranks, box geometry and the cursor, **not** the box-drawing characters; `screen` gives you the drawing. `open`/`close`/`toggle` switch mode, `zoom` cycles full→compact→collapsed, `entry` jumps to the entry block, `succ`/`pred` follow one edge (`J`/`K`), `block target=` puts the cursor in a block. `blocks=false` omits the per-block list. See `docs/GRAPH_VIEW.md`. | | `xrefs` | — | `x`: open the xref picker. | | `symbols` | `query?` | Ctrl+N palette, optionally pre-typed. | | `structs` | — | Ctrl+T struct editor. | @@ -109,9 +110,16 @@ prompt round-trips, i.e. tens of minutes for a few hundred symbols, where | method | params | effect | |--------|--------|--------| | `move` | `dir`, `n?=1`, `settle?` | `dir` ∈ down/up/left/right/word/wordback/bol/eol/top/bottom/halfdown/halfup/pagedown/pageup. | -| `cursor` | `line?`, `col?` | set the cursor directly on the active code pane (disasm/decomp). | +| `cursor` | `line?`, `col?` | set the cursor directly on the active code pane (disasm/decomp), scrolling it into view. | | `cursor_on` | `word`, `line?`, `occurrence?=1` | place the cursor on the *n*-th token equal to `word` (verified with the app's tokenizer). Decomp searches the whole body; disasm only cached/visible lines. Returns `{found, ...state}`. | +**Both cursor verbs scroll to what they selected, and `cursor_on` searches from +the viewport** (wrapping round to the rows above). A continuous listing is the +whole segment: counting occurrences from row 0 used to land the cursor in an +unrelated function thousands of rows away, off screen, and the next edit then +happened somewhere the operator could not see — with the driver reporting +success. If you mean a specific occurrence far away, pass `line=`. + ## Driving pattern for an agent Compose raw + semantic + introspection: e.g. `goto target=` → `state`/`view` diff --git a/idatui/drive.py b/idatui/drive.py index 531b558..b83a8ba 100644 --- a/idatui/drive.py +++ b/idatui/drive.py @@ -258,6 +258,25 @@ def cmd_define(c, args): return "\n".join(out) +def cmd_fmt(c, args): + """fmt [mode] [word] — how the literal under the cursor is DISPLAYED. + + ``fmt`` alone cycles (IDA's 'o'); a mode name sets it outright. A trailing + word puts the cursor on that token first, so you can name the literal + instead of steering the column there. + + fmt # cycle the literal under the cursor + fmt dec # show it in decimal + fmt hex 18h # find '18h' on screen, then make it hex + """ + mode = args[0] if args else "cycle" + params = {"mode": mode} + if len(args) > 1: + params["word"] = args[1] + st = c.call("opfmt", **params) + return " " + (st.get("opfmt", {}).get("status") or st.get("status", "")) + + def cmd_syms(c, args): """syms — bulk-apply a symbol file ([{addr|start|ea, name}]).""" if len(args) != 1: @@ -295,7 +314,7 @@ COMMANDS = { "callees": cmd_callees, "callers": cmd_callers, "names": cmd_names, "rename": cmd_rename, "mv": cmd_mv, "note": cmd_note, "retype": cmd_retype, "save": cmd_save, "screen": cmd_screen, "raw": cmd_raw, "define": cmd_define, - "syms": cmd_syms, + "syms": cmd_syms, "fmt": cmd_fmt, "binaries": cmd_binaries, "switch": cmd_switch, } diff --git a/idatui/rpc.py b/idatui/rpc.py index 9a60faa..0b61eaa 100644 --- a/idatui/rpc.py +++ b/idatui/rpc.py @@ -38,7 +38,7 @@ _PROGRAM_METHODS = { "goto", "open", "rename", "comment", "retype", "follow", "xrefs", "symbols", "structs", "search", "select", "save", "hex", "toggle_view", "pseudocode", "disassembly", "xrefs_to", "xrefs_from", "resolve", - "define", "rename_many", + "define", "rename_many", "opfmt", "graph", } # Self-documenting method table (returned by the 'methods' verb). @@ -85,6 +85,13 @@ METHODS = { "in ONE call (no typing, no navigation)", } +#: `opfmt` modes that have a real key on the code views. Driving the key keeps +#: the pane honest (a viewer sees the same thing a human would do); the named +#: formats have no key, so those go through the view's action directly. +_OPFMT_KEYS = {"cycle": "o", "back": "O"} +_OPFMT_MODES = ("cycle", "back", "show", "hex", "dec", "oct", "bin", "char", + "offset", "stack", "default") + # `define` kinds -> the ListingView key that runs them. Driving the real key # keeps the pane honest (a viewer sees the same thing a human would do) and # reuses the app's own edit worker, which reports what actually happened. @@ -278,12 +285,41 @@ def screen_text(app, fmt: str = "text") -> dict[str, Any]: return out +def place_cursor(w, line=None, col=None) -> None: + """Move a code view's cursor and BRING IT INTO VIEW. + + Setting the cursor without scrolling leaves the pane showing somewhere else + entirely, and the next verb then edits a line the operator cannot see — the + status describes one thing, the screen shows another. Every programmatic + cursor move goes through here for that reason. + """ + if line is not None: + w.cursor = max(0, min(getattr(w, "total", 1) - 1, int(line))) + if col is not None: + w.cursor_x = max(0, int(col)) + if hasattr(w, "_after_cursor_move"): + w._after_cursor_move() + if hasattr(w, "_scroll_cursor_into_view"): + w._scroll_cursor_into_view() + if hasattr(w, "_hscroll"): + w._hscroll() + w.refresh() + + def cursor_on(app, word: str, line: int | None = None, occurrence: int = 1) -> bool: """Place the cursor on the ``occurrence``-th token equal to ``word`` in the active code pane (optionally restricted to ``line``). Verified with the app's own tokenizer so 'main' won't match inside 'domain'. Disasm scan is limited to already-cached lines (what's on/near screen); decomp searches the whole body. - Returns whether it found and moved.""" + Returns whether it found and moved. + + Search starts at the VIEWPORT, not at row 0. A continuous listing is the + whole segment, so counting from the top finds an occurrence in some unrelated + function thousands of rows away -- and the cursor then lands there, off + screen, where the next verb edits something the operator cannot see. Wrapping + to the rows above keeps every match reachable; landing scrolls, so wherever + it goes is visible. + """ w = _active_widget(app) if isinstance(w, HexView): raise ValueError("cursor_on: not supported in the hex view") @@ -292,7 +328,12 @@ def cursor_on(app, word: str, line: int | None = None, occurrence: int = 1) -> b else: texts = [(w._line_plain(i) or "") for i in range(getattr(w, "total", 0))] orig = (w.cursor, w.cursor_x) - rows = [line] if line is not None else range(len(texts)) + if line is not None: + rows = [line] + else: + # From the top of the viewport, then wrap round to what's above it. + top = round(w.scroll_offset.y) + rows = list(range(top, len(texts))) + list(range(0, top)) hits = 0 for i in rows: if not (0 <= i < len(texts)): @@ -304,9 +345,7 @@ def cursor_on(app, word: str, line: int | None = None, occurrence: int = 1) -> b if w.word_under_cursor() == word: hits += 1 if hits >= max(1, occurrence): - if hasattr(w, "_after_cursor_move"): - w._after_cursor_move() - w.refresh() + place_cursor(w) # scrolls: an off-screen cursor edits blind return True col = t.find(word, col + 1) w.cursor, w.cursor_x = orig # not found: leave the cursor untouched @@ -929,6 +968,8 @@ class RpcServer: if method == "hex": return await self._press(["backslash"], lambda: app._active == "hex", timeout, "hex") + if method == "graph": + return await self._graph(params, timeout) if method == "xrefs": return await self._press( ["x"], lambda: type(app.screen).__name__ == "XrefsScreen", @@ -989,13 +1030,7 @@ class RpcServer: 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() + place_cursor(w, params.get("line"), params.get("col")) await drain(app) return snapshot(app) -- cgit v1.3.1-sl0p