aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-24 23:56:09 +0200
committerblasty <blasty@local>2026-07-24 23:56:09 +0200
commit4cf09c7cebd59f2fcaebafb7d2abfee9b521c50f (patch)
tree16d8931ebae39b5e7f247d8a40086426a812e0f0
parentsplit-view phase 3: rich per-line instruction region highlight (diff)
downloadida-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.md15
-rw-r--r--idatui/app.py42
-rw-r--r--tests/test_scenarios.py16
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)