aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-09 20:56:08 +0200
committerblasty <blasty@local>2026-07-09 20:56:08 +0200
commitc5499b4e69c969e1c8dcc0f98f7df023c8b8407f (patch)
treeb9330c781ff89dfee8d957c311a234233f6baeeb
parentrename: refuse pseudocode goto-labels with a clear message (diff)
downloadida-tui-c5499b4e69c969e1c8dcc0f98f7df023c8b8407f.tar.gz
ida-tui-c5499b4e69c969e1c8dcc0f98f7df023c8b8407f.tar.xz
ida-tui-c5499b4e69c969e1c8dcc0f98f7df023c8b8407f.zip
comments: ';' sets a comment on the current line (disasm + pseudocode)
Bind ';' (NavMixin, so both code views) to a comment prompt for the line under the cursor. Resolve the line's address (_cursor_ea in disasm, the /*0xEA*/ marker in pseudocode), prefill any existing comment (parsed from the rendered '//' text), and apply via the set_comments tool; an empty value clears it. After setting, reuse the name-generation invalidation (bump_names) so Hex-Rays is force_recompiled lazily and the pseudocode/disasm refresh in place at the saved position -- the new '// comment' shows on the line. Marks the IDB dirty (Ctrl+S). Pilot checks for the pseudocode round-trip. full suite 63/63.
-rw-r--r--idatui/app.py104
-rw-r--r--idatui/domain.py7
-rw-r--r--tests/test_tui.py29
3 files changed, 140 insertions, 0 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 0bd6a7c..5e4061c 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -103,6 +103,14 @@ class RenameRequested(Message):
self.name = name
+class CommentRequested(Message):
+ """A code view asks to set a comment on the current line."""
+
+ def __init__(self, view) -> None: # type: ignore[no-untyped-def]
+ super().__init__()
+ self.view = view
+
+
class NavMixin:
"""Follow / xrefs actions shared by the code views (bindings live on each
view since Textual only merges BINDINGS from DOMNode subclasses)."""
@@ -111,6 +119,7 @@ class NavMixin:
Binding("enter", "follow", "Follow"),
Binding("x", "xrefs", "Xrefs"),
Binding("n", "rename", "Rename"),
+ Binding("semicolon", "comment", "Comment"),
]
def action_follow(self) -> None:
@@ -122,6 +131,9 @@ class NavMixin:
def action_rename(self) -> None:
self.post_message(RenameRequested(self, self.word_under_cursor()))
+ def action_comment(self) -> None:
+ self.post_message(CommentRequested(self))
+
def _overlay_ranges(strip: Strip, ranges: list[tuple[int, int]], style: Style) -> Strip:
"""Return a copy of ``strip`` with ``style`` merged over the given cell
@@ -997,6 +1009,10 @@ class IdaTui(App):
height: 1; border: none; padding: 0 1;
background: $warning-darken-2; color: $text;
}
+ #comment {
+ height: 1; border: none; padding: 0 1;
+ background: $success-darken-2; color: $text;
+ }
#status { height: 1; background: $panel; color: $text; padding: 0 1; }
.decomp-loading {
width: 100%; height: 100%; content-align: center middle;
@@ -1039,6 +1055,7 @@ class IdaTui(App):
self._cur: NavEntry | None = None
self._search_ctx: tuple[object | None, int] = (None, 1)
self._rename_ctx: tuple[object | None, str] = (None, "")
+ self._comment_ctx: tuple[object | None, int, str] = (None, 0, "")
self._dirty = False
# -- layout ------------------------------------------------------------ #
@@ -1059,6 +1076,10 @@ class IdaTui(App):
ri.display = False
ri.can_focus = False
yield ri
+ ci = Input(id="comment")
+ ci.display = False
+ ci.can_focus = False
+ yield ci
yield Static("connecting…", id="status")
yield Footer()
@@ -1478,6 +1499,78 @@ class IdaTui(App):
if view is not None:
view.focus()
+ # -- comments ---------------------------------------------------------- #
+ def _line_ea_for(self, view) -> int | None: # type: ignore[no-untyped-def]
+ """Address of the line under the cursor in either code view."""
+ if isinstance(view, DisasmView):
+ return view._cursor_ea()
+ if isinstance(view, DecompView):
+ return view._line_ea(view.cursor)
+ return None
+
+ @staticmethod
+ def _existing_comment(view) -> str:
+ """Current line comment (for prefill), parsed from the rendered text. In
+ pseudocode a comment is `// text` before the trailing /*0xEA*/ markers;
+ C has no `//` operator, so the last `//` is unambiguously the comment."""
+ if isinstance(view, DecompView) and 0 <= view.cursor < len(view._texts):
+ s = re.sub(r"(?:/\*\s*0x[0-9A-Fa-f]+\s*\*/\s*)+$", "", view._texts[view.cursor])
+ i = s.rfind("//")
+ return s[i + 2:].strip() if i >= 0 else ""
+ return ""
+
+ def on_comment_requested(self, msg: CommentRequested) -> None:
+ ea = self._line_ea_for(msg.view)
+ if ea is None:
+ self._status("no address on this line to comment")
+ return
+ existing = self._existing_comment(msg.view)
+ self._comment_ctx = (msg.view, ea, existing)
+ self.query_one("#status", Static).display = False
+ inp = self.query_one("#comment", Input)
+ inp.placeholder = f"comment @ {ea:#x} — Enter=apply (empty=clear) Esc=cancel"
+ inp.can_focus = True
+ inp.display = True
+ inp.value = existing
+ inp.focus()
+
+ def _end_comment(self) -> None:
+ inp = self.query_one("#comment", Input)
+ inp.display = False
+ inp.can_focus = False
+ self.query_one("#status", Static).display = True
+ view, _, _ = self._comment_ctx
+ if view is not None:
+ view.focus()
+
+ @work(thread=True, exclusive=True, group="comment")
+ def _do_comment(self, ea: int, text: str) -> None:
+ assert self.program is not None
+ try:
+ res = self.program.set_comment(ea, text)
+ except IDAToolError as e:
+ self.app.call_from_thread(self._status, f"comment failed: {e.message}")
+ return
+ data = res.get("result") if isinstance(res, dict) else None
+ if isinstance(data, list) and data and isinstance(data[0], dict) and data[0].get("error"):
+ self.app.call_from_thread(self._status, f"comment failed: {data[0]['error']}")
+ return
+ self.app.call_from_thread(self._after_comment, ea, text)
+
+ def _after_comment(self, ea: int, text: str) -> None:
+ cur = self._cur
+ # A comment shows in both views but only after Hex-Rays recompiles, so
+ # reuse the name-generation invalidation (bumps gen -> decompile is
+ # force_recompiled lazily; disasm block caches are cleared).
+ self.program.bump_names()
+ if cur is not None:
+ self._save_current_pos()
+ self.query_one(DecompView).loaded_ea = None # force pseudocode reload
+ self._open_entry(cur, push=False)
+ self._dirty = True
+ verb = "cleared comment" if not text else "commented"
+ self._status(f"{verb} @ {ea:#x} (Ctrl+S to save)")
+
@work(thread=True, exclusive=True, group="rename")
def _do_rename(self, view, old: str, new: str) -> None: # type: ignore[no-untyped-def]
assert self.program is not None
@@ -1661,6 +1754,11 @@ class IdaTui(App):
event.prevent_default()
self._end_rename()
return
+ if self.query_one("#comment", Input).display:
+ event.stop()
+ event.prevent_default()
+ self._end_comment()
+ return
fi = self.query_one("#func-filter", Input)
if fi.display:
event.stop()
@@ -1693,6 +1791,12 @@ class IdaTui(App):
if view is not None and value and value != old:
self._do_rename(view, old, value)
return
+ if inp.id == "comment":
+ view, ea, existing = self._comment_ctx
+ self._end_comment()
+ if view is not None and value != existing: # empty value clears it
+ self._do_comment(ea, value)
+ return
if getattr(self, "_goto_mode", False):
self._goto_mode = False
self.query_one("#func-table", DataTable).focus()
diff --git a/idatui/domain.py b/idatui/domain.py
index 55a95d5..4902a64 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -508,6 +508,13 @@ class Program:
raise KeyError(f"cannot resolve {target!r}")
return _as_int(fn["addr"])
+ # -- comments ---------------------------------------------------------- #
+ def set_comment(self, ea: int, text: str):
+ """Set (empty text clears) the comment at ``ea``; affects both the disasm
+ and decompiler views. Returns the raw payload so the caller can surface a
+ soft per-item error. The caller must invalidate/recompile to see it."""
+ return self.client.call("set_comments", items=[{"addr": hex(ea), "comment": text}])
+
# -- invalidation (after edits) --------------------------------------- #
def invalidate(self, ea: int) -> None:
"""Drop caches for a function after a rename/comment/patch/etc."""
diff --git a/tests/test_tui.py b/tests/test_tui.py
index ca5cea9..b8eba79 100644
--- a/tests/test_tui.py
+++ b/tests/test_tui.py
@@ -629,6 +629,35 @@ async def run(db):
else:
check("found a pseudocode label to test", False, "no LABEL_ found")
+ # Add a comment on the current pseudocode line via ';' and confirm it
+ # renders after the decompiler refreshes.
+ cline = next((i for i, t in enumerate(dec._texts)
+ if i > 5 and "/*0x" in t and t.strip() and "//" not in t), None)
+ if cline is not None:
+ cea = dec._line_ea(cline)
+ dec.focus()
+ dec.cursor, dec.cursor_x = cline, 2
+ dec.refresh()
+ await pilot.pause(0.05)
+ await pilot.press("semicolon")
+ await pilot.pause(0.1)
+ ci = app.query_one("#comment", Input)
+ cnote = f"note_{os.getpid()}"
+ check("';' opens the comment prompt on the current line", ci.display,
+ f"display={ci.display}")
+ ci.value = cnote
+ await pilot.press("enter")
+ await wait_until(
+ pilot, lambda: dec.loaded_ea == app._cur.ea
+ and any(cnote in t for t in dec._texts), timeout=25)
+ check("comment appears in the pseudocode after ';'",
+ any(cnote in t for t in dec._texts), "comment not shown")
+ # revert via the API for clean, race-free cleanup
+ app.program.client.call(
+ "set_comments", items=[{"addr": hex(cea), "comment": ""}])
+ else:
+ check("found a pseudocode line to comment", False, "no marker line")
+
# Decompiler follow of a name NOT in refs (the function's own name).
await pilot.press("g")
await pilot.pause(0.1)