aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-24 21:48:20 +0200
committerblasty <blasty@local>2026-07-24 21:48:20 +0200
commit41b42e4e7ebb9303e7a861ee8c6d948d8cd813fa (patch)
treede39ed2d30e47441839bf7d1911a37e1de025f6e
parentdecomp: fix stuck 'decompiling' spinner when re-opening a loaded function (diff)
downloadida-tui-41b42e4e7ebb9303e7a861ee8c6d948d8cd813fa.tar.gz
ida-tui-41b42e4e7ebb9303e7a861ee8c6d948d8cd813fa.tar.xz
ida-tui-41b42e4e7ebb9303e7a861ee8c6d948d8cd813fa.zip
split-view phase 1: side-by-side listing <-> pseudocode layout
The Ghidra-style dual view, layout + toggle (no cursor sync yet — that's phase 2). 's' (and a "Split view" palette command) toggles a _split mode where the listing (left) and pseudocode (right) show together, divided by a keyline, one focused. Entering split loads the listing + decompiles the current function into both panes; Tab/F5 switches the focused pane; any single-view target (hex, etc.) or 's' again collapses back to the focused pane. * app: _split flag; _show_active gains a split branch (both panes, load decomp, focus active, #panes.split class); action_toggle_split + _enter_split worker; action_toggle_view switches panes when split; 's' binding + palette entry; #panes.split ListingView divider CSS. * docs/SPLIT_VIEW.md: the full design + phased roadmap (the hard part — line<->EA set mapping via a sweep of cfunc.get_line_item — is scoped for phase 3). * tests: split_view scenario (enter/load/tab-focus/exit, 5 checks). Also fix disasm_nav's stale goto-bottom (press ctrl+end; plain 'end' is end-of-line now). Verified live over RPC (both panes render, Tab flips focus, 's' exits) and pilot split_view 5/5.
-rw-r--r--docs/SPLIT_VIEW.md92
-rw-r--r--idatui/app.py59
-rw-r--r--tests/test_scenarios.py31
3 files changed, 179 insertions, 3 deletions
diff --git a/docs/SPLIT_VIEW.md b/docs/SPLIT_VIEW.md
new file mode 100644
index 0000000..e68528f
--- /dev/null
+++ b/docs/SPLIT_VIEW.md
@@ -0,0 +1,92 @@
+# Split view — Ghidra-style synced listing ⇄ pseudocode
+
+Goal: show the unified **listing** (left) and the **pseudocode** (right) side by
+side, kept in **cursor sync**. Move the cursor in the C view and the covering
+instructions light up (and scroll into view) on the asm side; move in the asm and
+the owning C line lights up. Both panes track the same function and navigate
+together.
+
+## Why this is tractable (existing substrate)
+
+- **Layout is free.** `ListingView`, `DecompView`, `HexView` are already siblings
+ in the one `#panes` `Horizontal`. Today `_show_active()` just flips `display` so
+ a single code view shows at a time. Split = show listing **and** decomp together
+ (each `width: 1fr`).
+- **Per-line address already exists.** `DecompView._line_eas[line]` holds each C
+ line's `/*0xEA*/` marker (Hex-Rays' primary ea for that line). Crude sync needs
+ no new tooling.
+- **Reverse jump already exists.** `_decomp_line_for(fn, ea)` maps an instruction
+ address → the best pseudocode line (largest marker ≤ ea).
+- **Sync hook points exist.** `on_decomp_view_cursor_moved` /
+ `on_listing_view_cursor_moved` fire on every cursor move.
+- **Highlight overlays are a known pattern.** `render_line` already overlays
+ styles (word-under-cursor, search matches); a "linked region" background is one
+ more overlay.
+
+## The one hard part: line ↔ instruction mapping
+
+Ghidra highlights **all** instructions a C line owns. We have one ea per line
+(the marker), not the set. Getting the set is the only real work, and it's a
+known technique:
+
+ida-pro-mcp derives the per-line marker via
+`cfunc.get_line_item(line, col=0, …).get_ea()`. To get the **full set**, sweep
+every column of the line (`get_line_item(line, x, …).get_ea()` for `x` in
+`0..len`) and collect distinct non-`BADADDR` EAs. Same proven API, swept across
+the line. A custom `decomp_map(ea)` tool in `server/patch_server.py` returns
+`[{line, primary_ea, eas:[…]}, …]`; invert for `ea → line`.
+
+## State model
+
+- `self._split: bool` — split mode on/off.
+- While split: both panes displayed; `_active` names the **focused** pane
+ (`"listing"` or `"decomp"`); the other is the companion.
+- `_show_active()` gains a split branch: display listing + decomp, hide hex.
+- Entering split loads both panes for the current function; exiting collapses to
+ the focused pane.
+
+## Sync model (Phase 2/3)
+
+- **decomp cursor moves** → look up the C line's ea(s) → highlight those rows in
+ the listing + scroll the primary ea into view.
+- **listing cursor moves** → `_decomp_line_for` (or the inverted rich map) → move
+ the decomp cursor to the owning line + highlight it.
+- A guard flag (`_syncing`) prevents the two `cursor_moved` handlers from
+ ping-ponging each other.
+- Highlight: a subtle background style (`_S_LINK`) overlaid on the linked rows;
+ the *focused* line keeps the normal block cursor.
+
+## Rendering the highlight
+
+`ListingView.render_line`: if a row's ea ∈ `self._link_eas` (a set the app sets on
+sync), overlay `_S_LINK` on that row (below the block cursor / word overlays).
+`DecompView`: mark `self._link_line` and tint that row.
+
+## Phased roadmap
+
+**Phase 1 — split layout, no sync.** `_split` mode; show both panes at `1fr` with
+a divider; focus one; a toggle (key + palette command "Toggle split view").
+Entering split loads listing + decomp for the current function; exiting returns to
+the focused pane. `Tab` in split switches the focused pane (single-view `Tab`
+still toggles disasm/pseudocode). No new tools. *← this doc's current target.*
+
+**Phase 2 — crude sync (MVP).** Wire both `cursor_moved` handlers using the
+existing single-ea map + `_decomp_line_for`. Subtle linked-row highlight
+(`_S_LINK`) + scroll-follow. Feels like Ghidra for most lines. No new tools.
+
+**Phase 3 — rich highlight.** `decomp_map` custom tool (line → ea-set) stored on
+`Decompilation`; highlight the whole instruction range of a C line; tighten the
+reverse map. One custom tool + an idalib spike to confirm the sweep works.
+
+**Phase 4 — polish.** Navigation keeps both panes on the same function; loop/goto
+edge cases; decompile-failed fallback; min-width gate + resize; split state in
+nav history; per-pane vs shared search.
+
+## Open questions
+
+- Entry-point key (proposed: `s` = split, plus the palette command). `Tab` in
+ split = switch focused pane.
+- Width policy: 50/50 to start; maybe listing-biased later. Min terminal width to
+ allow split (à la the logo gate).
+- Navigation-in-split: Phase 1 navigates the focused pane only; Phase 4 keeps both
+ on the same function.
diff --git a/idatui/app.py b/idatui/app.py
index f098b71..68e23df 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -2396,6 +2396,8 @@ class IdaCommands(Provider):
("Continuous listing here", "flat segment listing (L)",
app.action_continuous_here),
("Hex view", "raw bytes at the cursor (\\)", app.action_hex),
+ ("Split view (listing ⇄ pseudocode)",
+ "side-by-side synced views (s)", app.action_toggle_split),
("Rename symbol…", "rename the symbol under the cursor (n)",
lambda: va("rename")),
("Set type / prototype…", "retype the symbol under the cursor (y)",
@@ -2446,6 +2448,7 @@ class IdaTui(App):
DisasmView { width: 1fr; padding: 0 1; }
DecompView { width: 1fr; }
ListingView { width: 1fr; padding: 0 1; }
+ #panes.split ListingView { border-right: tall $panel-lighten-2; }
HexView { width: 1fr; padding: 0 1; }
#search {
height: 1; border: none; padding: 0 1;
@@ -2523,6 +2526,7 @@ class IdaTui(App):
Binding("ctrl+n", "symbols", "Symbols"),
Binding("ctrl+t", "structs", "Structs"),
Binding("backslash", "hex", "Hex"),
+ Binding("s", "toggle_split", "Split", show=False),
Binding("g", "goto", "Goto"),
Binding("slash", "filter", "Filter", show=False),
Binding("ctrl+b", "toggle_functions", "Names", show=False),
@@ -2552,7 +2556,8 @@ class IdaTui(App):
self._sort_col = 0 # 0=addr, 1=name, 2=size
self._sort_reverse = False
self._pref = "listing" # unified: the linear listing is the code view
- self._active = "listing" # currently shown view
+ self._active = "listing" # currently shown view (in split: the focused pane)
+ self._split = False # side-by-side listing + pseudocode
self._hex_pending_ea: int | None = None
self._cur: NavEntry | None = None
self._pending_focus_name: str | None = None # token to land the cursor on
@@ -2976,6 +2981,13 @@ class IdaTui(App):
the hex view back to the preferred code view)."""
if self._cur is None:
return
+ if self._split:
+ # In split mode Tab/F5 just moves focus between the two panes.
+ self._active = "decomp" if self._active == "listing" else "listing"
+ (self.query_one(DecompView) if self._active == "decomp"
+ else self.query_one(ListingView)).focus()
+ self._status_for_cur("split")
+ return
if self._active == "hex":
self._active = self._code_mode()
self._show_active()
@@ -3074,6 +3086,35 @@ class IdaTui(App):
self._active = "decomp"
self._show_active()
+ def action_toggle_split(self) -> None:
+ """Toggle the side-by-side listing ⇄ pseudocode view (Ghidra-style)."""
+ if self._cur is None or self.program is None:
+ self._status("open a function first")
+ return
+ self._split = not self._split
+ if self._active not in ("listing", "decomp"):
+ self._active = "listing"
+ if self._split:
+ self._enter_split(self._cur.ea, self._cur.name)
+ else:
+ self._show_active()
+
+ @work(thread=True, group="split")
+ def _enter_split(self, ea: int, name: str) -> None:
+ # Load the listing for the current function (bg) then reveal both panes.
+ assert self.program is not None
+ lm = self.program.listing(ea)
+ idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0
+ self.app.call_from_thread(self._apply_enter_split, lm, ea, name, idx)
+
+ def _apply_enter_split(self, lm, ea: int, name: str, idx: int) -> None: # type: ignore[no-untyped-def]
+ if not self._split:
+ return # toggled back off before the listing finished loading
+ lst = self.query_one(ListingView)
+ if lm is not None:
+ lst.load(lm, name, cursor=idx, scroll_y=max(idx - _JUMP_CONTEXT, 0))
+ self._show_active() # split branch shows both + loads the decomp
+
def action_hex(self) -> None:
"""Backslash: show the raw bytes of the loaded image, synced to the code
cursor; press again (or Tab/Esc) to return to the code view."""
@@ -4337,6 +4378,22 @@ class IdaTui(App):
# Don't steal focus from an open prompt (search/rename/…) — a late async
# navigation completing here would otherwise pull it into the code view.
grab = not self._prompt_active()
+ if self._split and self._active in ("listing", "decomp"):
+ # Side-by-side: listing (left) + pseudocode (right), one focused.
+ hx.display = False
+ lst.display = dec.display = True
+ self.query_one("#panes").set_class(True, "split")
+ if self._cur is not None and dec.loaded_ea != self._cur.ea:
+ dec.loading = True
+ self._load_decomp(self._cur.ea, self._cur.name)
+ else:
+ dec.loading = False
+ if grab:
+ (dec if self._active == "decomp" else lst).focus()
+ self._status_for_cur("split")
+ return
+ self._split = False # a single-view target (hex, etc.) leaves split
+ self.query_one("#panes").set_class(False, "split")
dec.display = lst.display = hx.display = False
if self._active in ("listing", "disasm"):
lst.display = True
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 39f8d86..aa0ecbf 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -338,6 +338,32 @@ async def s_command_palette(c: Ctx):
await c.wait(lambda: app._active != "hex", 5)
+@scenario("split_view")
+async def s_split_view(c: Ctx):
+ app, lst, dec = c.app, c.lst, c.dec
+ await c.open_biggest("listing")
+ await c.press("s")
+ shown = await c.wait(lambda: app._split and lst.display and dec.display, 20)
+ c.check("'s' enters split view (both panes shown)", shown,
+ f"split={app._split} lst={lst.display} dec={dec.display}")
+ loaded = await c.wait(lambda: dec.loaded_ea == app._cur.ea, 25)
+ c.check("split loads the pseudocode alongside the listing", loaded,
+ f"loaded={dec.loaded_ea} cur={app._cur.ea if app._cur else None}")
+ await c.press("tab")
+ await c.pause(0.1)
+ c.check("Tab in split focuses the pseudocode pane", app._active == "decomp",
+ f"active={app._active}")
+ await c.press("tab")
+ await c.pause(0.1)
+ c.check("Tab again focuses the listing pane", app._active == "listing",
+ f"active={app._active}")
+ await c.press("s")
+ gone = await c.wait(lambda: not app._split and lst.display
+ and not dec.display, 10)
+ c.check("'s' exits split back to a single view", gone,
+ f"split={app._split} lst={lst.display} dec={dec.display}")
+
+
@scenario("decomp_fallback")
async def s_fallback(c: Ctx):
app = c.app
@@ -485,8 +511,9 @@ async def s_disasm_nav(c: Ctx):
await c.wait(lambda: app._clipboard == cur_line, 10)
c.check("Ctrl+Y copies the current code line to the clipboard",
bool(cur_line) and app._clipboard == cur_line, f"clip={app._clipboard!r}")
- # goto-bottom must not hang on a huge function.
- await c.press("end")
+ # goto-bottom must not hang on a huge function (ctrl+end; plain 'end' now
+ # moves the cursor to end-of-line).
+ await c.press("ctrl+end")
await c.pause(0.05)
c.check("goto-bottom lands near end", view.cursor >= view.total - 1,
f"cursor={view.cursor}/{view.total}")