aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-09 15:36:12 +0200
committerblasty <blasty@local>2026-07-09 15:36:12 +0200
commit6a8e022441c973bbdfb1c4ee0f55373ebf1d1456 (patch)
tree6075f908ee4c7d0d02aa0a86ca09332a9057cbda
parentpaging: preserve the cursor's viewport-relative row (IDA-style) (diff)
downloadida-tui-6a8e022441c973bbdfb1c4ee0f55373ebf1d1456.tar.gz
ida-tui-6a8e022441c973bbdfb1c4ee0f55373ebf1d1456.tar.xz
ida-tui-6a8e022441c973bbdfb1c4ee0f55373ebf1d1456.zip
fix input visibility (search/rename) + refresh stale names across history
- Visibility: #search/#rename/#status were dock:bottom like the Footer and got placed on the SAME row (y=39) as the Footer, which drew over them, so the typed text was invisible. Drop the docking; they now sit in normal flow just above the Footer (y=38). Verified: input region is above the footer. - Stale names on 'back': a rename only invalidated the current function, so popping back to a caller (cached earlier) showed the old name. Add Program.bump_names(): a rename bumps a name-generation, clears all disasm block caches (disasm names are live), and decompilation is generation-checked and force-recompiled lazily on next view. _after_rename now invalidates globally. - verified: rename a callee, 'back' to the caller shows the new name. - pilot suite 51/51.
-rw-r--r--TODO5
-rw-r--r--idatui/app.py24
-rw-r--r--idatui/domain.py28
-rw-r--r--tests/test_tui.py70
4 files changed, 106 insertions, 21 deletions
diff --git a/TODO b/TODO
index 14514fa..6f063e6 100644
--- a/TODO
+++ b/TODO
@@ -1,4 +1,5 @@
[ ] RPC endpoint for robot-spectator-ida
-[ ] page up/down cursor x/y preserve
-[ ] hlsearch should jump cursor
+[ ] rename -> history stack pop -> old name
+[x] page up/down cursor x/y preserve
+[x] hlsearch should jump cursor
[x] makes names pane sortable (addr/name columns)
diff --git a/idatui/app.py b/idatui/app.py
index 87420c2..3dfc553 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -951,14 +951,14 @@ class IdaTui(App):
DisasmView { width: 1fr; padding: 0 1; }
DecompView { width: 1fr; }
#search {
- dock: bottom; height: 1; border: none; padding: 0 1;
+ height: 1; border: none; padding: 0 1;
background: $primary-darken-2; color: $text;
}
#rename {
- dock: bottom; height: 1; border: none; padding: 0 1;
+ height: 1; border: none; padding: 0 1;
background: $warning-darken-2; color: $text;
}
- #status { dock: bottom; height: 1; background: $panel; color: $text; padding: 0 1; }
+ #status { height: 1; background: $panel; color: $text; padding: 0 1; }
XrefsScreen { align: center middle; }
#xref-box { width: 84; max-height: 70%; height: auto; border: thick $accent; background: $panel; }
#xref-title { dock: top; height: 1; background: $accent; color: $text; padding: 0 1; }
@@ -1449,8 +1449,13 @@ class IdaTui(App):
def _after_rename(self, kind: str, addr: int | None, old: str, new: str) -> None:
cur = self._cur
+ # A renamed symbol can appear in many functions, so invalidate globally;
+ # each function refreshes its names the next time it's viewed.
+ self.program.bump_names()
if cur is not None:
- self._invalidate_and_reload(cur)
+ self._save_current_pos()
+ self.query_one(DecompView).loaded_ea = None # force pseudocode reload
+ self._open_entry(cur, push=False)
if kind == "func" and addr is not None and self._func_index is not None:
self._func_index.update_name(addr, new)
for e in self._nav:
@@ -1467,17 +1472,6 @@ class IdaTui(App):
self._dirty = True
self._status(f"renamed {old} → {new} (Ctrl+S to save)")
- def _invalidate_and_reload(self, cur: NavEntry) -> None:
- assert self.program is not None
- try:
- self.program.client.call("force_recompile", addr=hex(cur.ea))
- except Exception: # noqa: BLE001
- pass
- self.program.invalidate(cur.ea)
- self._save_current_pos() # capture cursor + scroll before reloading
- self.query_one(DecompView).loaded_ea = None # force pseudocode reload
- self._open_entry(cur, push=False)
-
@work(thread=True, exclusive=True, group="save")
def _save(self) -> None:
assert self.program is not None
diff --git a/idatui/domain.py b/idatui/domain.py
index b219bbe..1f52be6 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -374,7 +374,8 @@ class Program:
)
self._indices: dict[str | None, FunctionIndex] = {}
self._disasm: dict[int, DisasmModel] = {}
- self._decomp: dict[int, Decompilation] = {}
+ self._decomp: dict[int, tuple[Decompilation, int]] = {}
+ self._name_gen = 0 # bumped on rename; invalidates stale name caches
self._lock = threading.Lock()
# -- prefetch plumbing ------------------------------------------------- #
@@ -417,8 +418,17 @@ class Program:
if not refresh:
with self._lock:
hit = self._decomp.get(ea)
- if hit is not None:
- return hit
+ gen = self._name_gen
+ if hit is not None:
+ dec, hit_gen = hit
+ if hit_gen == gen:
+ return dec
+ # Cached before a rename: names may be stale. Drop the server's
+ # Hex-Rays cache so the refetch reflects the new names.
+ try:
+ self.client.call("force_recompile", addr=hex(ea))
+ except Exception: # noqa: BLE001
+ pass
envelope = self.client.call_envelope("decompile", addr=hex(ea))
result = envelope.get("result", {})
payload = result.get("structuredContent")
@@ -431,9 +441,19 @@ class Program:
payload = full
dec = _parse_decompilation(ea, payload)
with self._lock:
- self._decomp[ea] = dec
+ self._decomp[ea] = (dec, self._name_gen)
return dec
+ def bump_names(self) -> None:
+ """Signal that symbol names changed (a rename). Disasm names are live in
+ the IDB, so clearing the block caches is enough for those; decompilation
+ is generation-checked and force-recompiled lazily on next access."""
+ with self._lock:
+ self._name_gen += 1
+ models = list(self._disasm.values())
+ for m in models:
+ m.invalidate()
+
@staticmethod
def _fetch_output(url: str, timeout: float = 15.0):
"""GET the server's cached full-output blob (plain HTTP, not MCP)."""
diff --git a/tests/test_tui.py b/tests/test_tui.py
index 47c5a67..3b45ef9 100644
--- a/tests/test_tui.py
+++ b/tests/test_tui.py
@@ -195,6 +195,11 @@ async def run(db):
await pilot.pause(0.2)
check("search bar visible, status hidden (no overlap)",
si.display and not status.display, f"si={si.display} status={status.display}")
+ from textual.widgets import Footer as _Footer
+ footer = app.query_one(_Footer)
+ check("search input is rendered above the footer (not overlapping)",
+ si.region.height >= 1 and si.region.y < footer.region.y,
+ f"search={si.region} footer={footer.region}")
for ch in term:
await pilot.press(ch)
await pilot.pause(0.1)
@@ -522,6 +527,71 @@ async def run(db):
dis.cursor - round(dis.scroll_offset.y) == rel,
f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}")
+ # Rename refresh across history: rename a callee while viewing it, then
+ # 'back' to the caller (cached earlier) shows the new name.
+ table.focus()
+ table.move_cursor(row=biggest_i)
+ await pilot.press("enter")
+ await wait_until(pilot, lambda: dis.total > 0, timeout=20)
+ dis.focus()
+ await pilot.pause(0.2)
+ hrow = hsym = None
+ for _ in range(40):
+ top = round(dis.scroll_offset.y)
+ dis.model.lines(top, dis.size.height + 2, prefetch=False)
+ for r in range(min(dis.size.height, dis.total - top)):
+ p = dis._line_plain(top + r)
+ if p and "call sub_" in p:
+ mm = re.search(r"\bcall (sub_[0-9A-Fa-f]+)", p)
+ if mm:
+ hrow, hsym = top + r, mm.group(1)
+ break
+ if hrow is not None:
+ break
+ await pilot.press("pagedown")
+ await pilot.pause(0.05)
+ if hrow is not None:
+ htarget = app.program.resolve(hsym)
+ await pilot.press("g")
+ await pilot.pause(0.2)
+ for ch in hsym:
+ await pilot.press(ch)
+ await pilot.press("enter")
+ await wait_until(pilot, lambda: app._cur.ea == htarget, timeout=20)
+ if app._active != "decomp":
+ await pilot.press("tab")
+ await wait_until(pilot, lambda: dec.loaded_ea == htarget, timeout=20)
+ nx = dec._texts[0].find(hsym) if dec._texts else -1
+ if nx >= 0:
+ dec.focus()
+ dec.cursor, dec.cursor_x = 0, nx + 1
+ dec.refresh()
+ await pilot.pause(0.1)
+ hnew = f"h_{os.getpid()}"
+ await pilot.press("n")
+ await pilot.pause(0.2)
+ app.query_one("#rename", Input).value = hnew
+ await pilot.press("enter")
+ await wait_until(
+ pilot,
+ lambda: app._func_index.by_addr(htarget)
+ and app._func_index.by_addr(htarget).name == hnew,
+ timeout=25,
+ )
+ if app._active != "disasm":
+ await pilot.press("tab")
+ await pilot.press("escape")
+ await wait_until(
+ pilot, lambda: dis.total > 0 and app._cur.ea != htarget, timeout=25)
+ await pilot.pause(0.4)
+ dis.model.lines(hrow, 4, prefetch=False)
+ await pilot.pause(0.2)
+ hline = dis._line_plain(hrow)
+ check("caller shows renamed callee after 'back' (cache refresh)",
+ hline is not None and hnew in hline, f"line={hline!r}")
+ app.program.client.call(
+ "rename", batch={"func": {"addr": hex(htarget), "name": hsym}})
+
def main(argv):
db = None