diff options
| author | blasty <blasty@local> | 2026-07-24 23:56:09 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-24 23:56:09 +0200 |
| commit | 4cf09c7cebd59f2fcaebafb7d2abfee9b521c50f (patch) | |
| tree | 16d8931ebae39b5e7f247d8a40086426a812e0f0 | |
| parent | split-view phase 3: rich per-line instruction region highlight (diff) | |
| download | ida-tui-4cf09c7cebd59f2fcaebafb7d2abfee9b521c50f.tar.gz ida-tui-4cf09c7cebd59f2fcaebafb7d2abfee9b521c50f.tar.xz ida-tui-4cf09c7cebd59f2fcaebafb7d2abfee9b521c50f.zip | |
split-view phase 4: split-aware status, min-width gate, verified nav
Polish for the split view:
* _split_status(): status line now reads "name @ ea [split · pseudocode line N
↔ K insn]" / "[split · listing]" from the focused pane, instead of the
single-view [pseudocode]/[listing] labels clobbering it. Wired into both
cursor-moved handlers and _apply_decomp.
* min-width gate: action_toggle_split refuses to enter split below
_SPLIT_MIN_WIDTH (100 cols) so two usable code panes always have room.
* navigation keeps both panes on the same function: verified (not fixed —
goto/follow in split already routes _open_entry -> _show_active split branch,
reloading the listing + decomp + region map for the new function).
Review turned two roadmap items into non-issues: split is a persistent mode
orthogonal to nav entries (back/forward just navigate within split), and search
is already per-focused-pane.
Pilot split_view gains: split-aware status, goto-in-split navigates, and both
panes reload on navigation (16/16). Full suite 157/2-flake. Split view is
feature-complete (phases 1-4).
| -rw-r--r-- | docs/SPLIT_VIEW.md | 15 | ||||
| -rw-r--r-- | idatui/app.py | 42 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 16 |
3 files changed, 65 insertions, 8 deletions
diff --git a/docs/SPLIT_VIEW.md b/docs/SPLIT_VIEW.md index a8546fa..1a61efc 100644 --- a/docs/SPLIT_VIEW.md +++ b/docs/SPLIT_VIEW.md @@ -88,9 +88,18 @@ region of a C line (and uses the exact ea→line inverse for the reverse). Falls back to the single marker until the map lands. Verified on the pilot's real worker (alignment + multi-instruction region band). -**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. +**Phase 4 — polish. DONE.** +- Split-aware status: `_split_status()` shows `name @ ea [split · pseudocode line + N ↔ K insn]` / `[split · listing]` instead of the single-view labels. +- Min-width gate: `action_toggle_split` refuses to enter split below + `_SPLIT_MIN_WIDTH` (100 cols) — two usable panes need the room. +- Navigation keeps both panes on the same function: verified — a goto/follow in + split routes through `_open_entry` (listing) → `_show_active` split branch + (decomp + `_load_split_map`), so both panes reload for the new function and + split stays on. +- Non-issues after review: split is a persistent *mode* orthogonal to nav + entries, so back/forward simply navigate within split (no per-entry state + needed); search is already per-focused-pane. ## Open questions diff --git a/idatui/app.py b/idatui/app.py index dbd5025..0d4ae6a 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -62,6 +62,7 @@ _S_FUNCHDR = Style(color="#d7a021", bold=True) # 'name proc'/'endp' headers _LST_INDENT = " " # one depth level: function names sit at level 0, code at 1 _OP_LIMIT = 8 # opcode bytes shown in the 'limited' column mode _JUMP_CONTEXT = 4 # lines of context kept above a jump target (cursor stays on it) +_SPLIT_MIN_WIDTH = 100 # need room for two usable code panes side by side # Tokens that look like identifiers but aren't renamable symbols (so 'n' on them # in the listing names the address instead of trying to rename the token). @@ -3144,6 +3145,10 @@ class IdaTui(App): if self._cur is None or self.program is None: self._status("open a function first") return + if not self._split and self.size.width < _SPLIT_MIN_WIDTH: + self._status(f"terminal too narrow for split — need ≈{_SPLIT_MIN_WIDTH} " + f"cols (have {self.size.width})") + return self._split = not self._split if self._active not in ("listing", "decomp"): self._active = "listing" @@ -4564,10 +4569,13 @@ class IdaTui(App): sx = cur.dec_scroll_x if same else 0 note = " (truncated)" if dec.truncated else "" view.show(ea, dec.code or "", cursor=c, cursor_x=cx, scroll_y=sy, scroll_x=sx) - self._status(f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}") if self._split: self._sync_split(self._active) # crude link now self._load_split_map(ea) # then upgrade to the region map + self._split_status() + else: + self._status( + f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}") def _sync_split(self, source: str) -> None: """Split view: highlight (+ scroll into view) the companion pane's @@ -4606,6 +4614,24 @@ class IdaTui(App): else: dec.set_link(None) + def _split_status(self) -> None: + """A split-aware status line reflecting the focused pane + the link.""" + if self._cur is None: + return + if self._active == "decomp": + dec = self.query_one(DecompView) + ea = dec._line_ea(dec.cursor) + n = (len(self._split_eamap[dec.cursor]) + if 0 <= dec.cursor < len(self._split_eamap) else 0) + at = f" @ {ea:#x}" if ea is not None else "" + rel = f" \u2194 {n} insn" if n else "" + self._status(f"{self._cur.name}{at} " + f"[split \u00b7 pseudocode line {dec.cursor + 1}{rel}]") + else: + ea = self.query_one(ListingView)._cursor_ea() + at = f" @ {ea:#x}" if ea is not None else "" + self._status(f"{self._cur.name}{at} [split \u00b7 listing]") + @work(thread=True, group="split-map") def _load_split_map(self, ea: int) -> None: # Fetch the rich per-line instruction map off the UI thread; sync stays @@ -4628,8 +4654,11 @@ class IdaTui(App): if self._nav: self._nav[-1].dec_cursor = msg.index self._nav[-1].dec_cursor_x = self.query_one(DecompView).cursor_x - if self._split and self._active == "decomp": - self._sync_split("decomp") + if self._split: + if self._active == "decomp": + self._sync_split("decomp") + self._split_status() + return if self._cur is not None: loc = f" @ {msg.ea:#x}" if msg.ea is not None else "" self._status(f"{self._cur.name}{loc} [pseudocode line {msg.index}]") @@ -4640,8 +4669,11 @@ class IdaTui(App): self._nav[-1].cursor_x = self.query_one(ListingView).cursor_x if msg.index >= 0: self._nav[-1].scroll_y = round(self.query_one(ListingView).scroll_offset.y) - if self._split and self._active == "listing": - self._sync_split("listing") + if self._split: + if self._active == "listing": + self._sync_split("listing") + self._split_status() + return ea = msg.ea if ea is not None: sec = self.program.section_of(ea) if self.program else None diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 0bca4d8..3401645 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -350,6 +350,9 @@ async def s_split_view(c: Ctx): 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.wait(lambda: "[split" in c.status(), 5) + c.check("split view shows a split-aware status", "[split" in c.status(), + f"status={c.status()!r}") # phase 3: the rich per-line instruction map (decomp_map tool, run on the # pilot's real worker) — verify it returns, aligns with the markers, and # bands a whole region for a multi-instruction C line. @@ -412,6 +415,19 @@ async def s_split_view(c: Ctx): await c.pause(0.1) c.check("Tab again focuses the listing pane", app._active == "listing", f"active={app._active}") + # navigation in split keeps BOTH panes on the (new) function + nf = c.find_func(lambda f: f.addr != app._cur.ea and f.size > 80) + if nf is not None: + await c.press("g") + await c.type(hex(nf.addr)) + await c.press("enter") + nav = await c.wait(lambda: app._cur and app._cur.ea == nf.addr, 15) + c.check("goto in split navigates", nav, + f"cur={app._cur.ea if app._cur else None} want={nf.addr}") + both = await c.wait(lambda: dec.loaded_ea == nf.addr and app._split + and lst.display and dec.display, 25) + c.check("split reloads both panes on navigation", both, + f"dec={dec.loaded_ea} split={app._split}") await c.press("s") gone = await c.wait(lambda: not app._split and lst.display and not dec.display, 10) |
