aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-10 09:53:56 +0200
committerblasty <blasty@local>2026-07-10 09:53:56 +0200
commit29f8a238d0e316c4a0fe4081c5412a18f5bb3c64 (patch)
treef380c21b8c59e78962bf0d6bfe1d2be365ae93c6
parentdecompiler: fall back to disassembly when decompilation fails (diff)
downloadida-tui-29f8a238d0e316c4a0fe4081c5412a18f5bb3c64.tar.gz
ida-tui-29f8a238d0e316c4a0fe4081c5412a18f5bb3c64.tar.xz
ida-tui-29f8a238d0e316c4a0fe4081c5412a18f5bb3c64.zip
struct editor: C-style CRUD for local types (Ctrl+T)
New StructEditor overlay: left is the list of local structs/unions, right is an editable C definition. Enter loads a struct (reconstructed as C from its member layout via type_inspect), Ctrl+S declares it (create or update via declare_type), Ctrl+N starts a new one, Del deletes, Esc returns to the list then closes. Domain: Struct model + Program.list_structs / struct_source / declare_type / delete_type (anonymous $-types filtered). Delete needs a 'del_type' tool the ida-pro-mcp server doesn't currently expose; delete_type detects its absence and reports a clear message instead of failing. Create/read/update work fully. Pilot checks: open, list, view C, create, update-in-place, delete(-or-report), close. full suite 84/84.
-rw-r--r--idatui/__init__.py2
-rw-r--r--idatui/app.py186
-rw-r--r--idatui/domain.py77
-rw-r--r--tests/test_tui.py63
4 files changed, 324 insertions, 4 deletions
diff --git a/idatui/__init__.py b/idatui/__init__.py
index 24f3c0f..a52ba8b 100644
--- a/idatui/__init__.py
+++ b/idatui/__init__.py
@@ -19,6 +19,7 @@ from .domain import (
Func,
Line,
Ref,
+ Struct,
Decompilation,
LIST_PAGE,
DISASM_BLOCK,
@@ -31,6 +32,7 @@ __all__ = [
"Func",
"Line",
"Ref",
+ "Struct",
"Decompilation",
"LIST_PAGE",
"DISASM_BLOCK",
diff --git a/idatui/app.py b/idatui/app.py
index c87c27d..9cb0db5 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -32,13 +32,15 @@ from textual.reactive import reactive
from textual.screen import ModalScreen
from textual.scroll_view import ScrollView
from textual.strip import Strip
-from textual.widgets import DataTable, Footer, Header, Input, OptionList, Static
+from textual.widgets import (
+ DataTable, Footer, Header, Input, OptionList, Static, TextArea,
+)
from textual.widgets.option_list import Option
from .highlight import highlight_c
from .client import IDAClient, IDAToolError
-from .domain import DisasmModel, Func, Program
+from .domain import DisasmModel, Func, Program, Struct
# Styles for the disassembly listing.
_S_ADDR = Style(color="grey58")
@@ -1164,6 +1166,169 @@ class SymbolPalette(ModalScreen):
# --------------------------------------------------------------------------- #
+# Struct editor (C-style local type editor)
+# --------------------------------------------------------------------------- #
+class StructEditor(ModalScreen):
+ """CRUD editor for local structs/unions, written as plain C.
+
+ Left: the list of structs. Right: an editable C definition. Enter loads the
+ selected struct; Ctrl+S declares (creates or updates) it; Ctrl+N starts a
+ new one; Delete removes the highlighted struct; Esc returns to the list then
+ closes.
+ """
+
+ BINDINGS = [
+ Binding("ctrl+s", "save", "Save", priority=True),
+ Binding("ctrl+n", "new", "New", priority=True),
+ Binding("delete", "delete", "Delete", show=False),
+ Binding("escape", "close", "Close"),
+ ]
+
+ NEW_TEMPLATE = "struct NewStruct\n{\n int field;\n};\n"
+
+ def __init__(self, program: Program) -> None:
+ super().__init__()
+ self._program = program
+ self._structs: list[Struct] = []
+ self._loaded: str | None = None # name currently in the editor
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="se-box"):
+ with Horizontal(id="se-panes"):
+ with Vertical(id="se-left"):
+ yield Static(" structs", id="se-title")
+ yield OptionList(id="se-list")
+ with Vertical(id="se-right"):
+ yield Static(" C definition", id="se-hint")
+ yield TextArea("", id="se-edit")
+ yield Static(
+ "Enter edit · Ctrl+S save · Ctrl+N new · Del delete · Esc back/close",
+ id="se-status")
+
+ def on_mount(self) -> None:
+ self._refresh()
+ self.query_one("#se-list", OptionList).focus()
+
+ # -- data --------------------------------------------------------------- #
+ @work(thread=True, exclusive=True, group="se-refresh")
+ def _refresh(self, select: str | None = None) -> None:
+ try:
+ structs = self._program.list_structs()
+ except Exception as e: # noqa: BLE001
+ self.app.call_from_thread(self._set_status, f"list failed: {e}")
+ return
+ self.app.call_from_thread(self._populate, structs, select)
+
+ def _populate(self, structs: list[Struct], select: str | None) -> None:
+ self._structs = structs
+ ol = self.query_one("#se-list", OptionList)
+ ol.clear_options()
+ for s in structs:
+ kw = "union" if s.is_union else "struct"
+ label = Text()
+ label.append(s.name, _S_LABEL)
+ label.append(f" {s.size:#x} {s.members}f {kw}", _S_DIM)
+ ol.add_option(Option(label))
+ if structs:
+ idx = 0
+ if select is not None:
+ idx = next((i for i, s in enumerate(structs) if s.name == select), 0)
+ ol.highlighted = idx
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ i = event.option_index
+ if 0 <= i < len(self._structs):
+ self._load(self._structs[i].name)
+
+ @work(thread=True, exclusive=True, group="se-load")
+ def _load(self, name: str) -> None:
+ try:
+ src = self._program.struct_source(name)
+ except Exception as e: # noqa: BLE001
+ self.app.call_from_thread(self._set_status, f"load failed: {e}")
+ return
+ self.app.call_from_thread(self._set_editor, name, src)
+
+ def _set_editor(self, name: str, src: str) -> None:
+ self._loaded = name
+ ta = self.query_one("#se-edit", TextArea)
+ ta.text = src
+ ta.focus()
+ self._set_status(f"editing {name} — Ctrl+S to apply changes")
+
+ # -- actions ------------------------------------------------------------ #
+ def action_save(self) -> None:
+ text = self.query_one("#se-edit", TextArea).text.strip()
+ if not text:
+ self._set_status("nothing to declare")
+ return
+ self._set_status("declaring…")
+ self._save(text)
+
+ @work(thread=True, exclusive=True, group="se-save")
+ def _save(self, text: str) -> None:
+ try:
+ err = self._program.declare_type(text)
+ except Exception as e: # noqa: BLE001
+ self.app.call_from_thread(self._set_status, f"declare failed: {e}")
+ return
+ m = re.search(r"\b(?:struct|union)\s+([A-Za-z_]\w*)", text)
+ name = m.group(1) if m else None
+ self.app.call_from_thread(self._after_save, name, err)
+
+ def _after_save(self, name: str | None, err: str | None) -> None:
+ if err:
+ self._set_status(f"declare failed: {(err.strip() or 'parse error')[:80]}")
+ return
+ self._loaded = name
+ if getattr(self.app, "_dirty", None) is not None:
+ self.app._dirty = True # struct change is unsaved until Ctrl+S in the app
+ self._refresh(select=name)
+ self._set_status(f"saved {name}" if name else "saved")
+
+ def action_new(self) -> None:
+ ta = self.query_one("#se-edit", TextArea)
+ ta.text = self.NEW_TEMPLATE
+ self._loaded = None
+ ta.focus()
+ self._set_status("new struct — edit and Ctrl+S")
+
+ def action_delete(self) -> None:
+ ol = self.query_one("#se-list", OptionList)
+ i = ol.highlighted
+ if i is None or not (0 <= i < len(self._structs)):
+ return
+ self._delete(self._structs[i].name)
+
+ @work(thread=True, exclusive=True, group="se-del")
+ def _delete(self, name: str) -> None:
+ try:
+ err = self._program.delete_type(name)
+ except Exception as e: # noqa: BLE001
+ err = str(e)
+ self.app.call_from_thread(self._after_delete, name, err)
+
+ def _after_delete(self, name: str, err: str | None) -> None:
+ if err:
+ self._set_status(err)
+ return
+ if getattr(self.app, "_dirty", None) is not None:
+ self.app._dirty = True
+ self._refresh()
+ self._set_status(f"deleted {name}")
+
+ def action_close(self) -> None:
+ # A stray Esc while editing returns to the list instead of discarding.
+ if self.focused is self.query_one("#se-edit", TextArea):
+ self.query_one("#se-list", OptionList).focus()
+ return
+ self.dismiss(None)
+
+ def _set_status(self, text: str) -> None:
+ self.query_one("#se-status", Static).update(text)
+
+
+# --------------------------------------------------------------------------- #
# The app
# --------------------------------------------------------------------------- #
class IdaTui(App):
@@ -1202,11 +1367,21 @@ class IdaTui(App):
#pal-title { dock: top; height: 1; background: $accent; color: $text; padding: 0 1; }
#pal-input { border: none; height: 1; margin: 0 1; background: $panel; color: $text; }
#pal-list { height: auto; max-height: 24; }
+ StructEditor { align: center middle; }
+ #se-box { width: 90%; height: 84%; border: thick $accent; background: $panel; }
+ #se-panes { height: 1fr; }
+ #se-left { width: 38; border-right: solid $accent; }
+ #se-right { width: 1fr; }
+ #se-title, #se-hint { height: 1; background: $accent; color: $text; padding: 0 1; }
+ #se-list { height: 1fr; }
+ #se-edit { height: 1fr; border: none; }
+ #se-status { height: 1; background: $panel-darken-2; color: $text-muted; padding: 0 1; }
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("ctrl+n", "symbols", "Symbols"),
+ Binding("ctrl+t", "structs", "Structs"),
Binding("g", "goto", "Goto"),
Binding("slash", "filter", "Filter", show=False),
Binding("ctrl+b", "toggle_functions", "Names", show=False),
@@ -1444,6 +1619,13 @@ class IdaTui(App):
else:
self.query_one("#func-table", DataTable).focus()
+ def action_structs(self) -> None:
+ """Ctrl+T: open the C-style struct editor overlay."""
+ if self.program is None:
+ self._status("not connected yet")
+ return
+ self.push_screen(StructEditor(self.program))
+
def action_symbols(self) -> None:
"""Ctrl+N: fuzzy-find a symbol in a command-palette overlay."""
idx = self._func_index
diff --git a/idatui/domain.py b/idatui/domain.py
index cdbdd67..f52535c 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -92,6 +92,25 @@ class Xref:
fn_addr: int | None
+@dataclass(frozen=True)
+class Struct:
+ name: str
+ size: int
+ is_union: bool
+ members: int # field count
+ ordinal: int
+
+ @classmethod
+ def from_raw(cls, d: dict) -> "Struct":
+ return cls(
+ name=d.get("name", ""),
+ size=int(d.get("size", 0) or 0),
+ is_union=bool(d.get("is_union", False)),
+ members=int(d.get("cardinality", 0) or 0),
+ ordinal=int(d.get("ordinal", 0) or 0),
+ )
+
+
@dataclass
class Decompilation:
ea: int
@@ -431,6 +450,64 @@ class Program:
return secs[i][2]
return None
+ # -- structs / local types -------------------------------------------- #
+ def list_structs(self, filter: str = "") -> list[Struct]:
+ """All local structs/unions (optionally name-substring filtered), sorted
+ by name."""
+ payload = self.client.call("search_structs", filter=filter)
+ res = payload.get("result", []) if isinstance(payload, dict) else []
+ out = [Struct.from_raw(d) for d in res
+ if isinstance(d, dict) and d.get("name")
+ and not str(d["name"]).startswith("$")] # skip anonymous UDTs
+ out.sort(key=lambda s: s.name.lower())
+ return out
+
+ def struct_source(self, name: str) -> str:
+ """A C definition for ``name`` reconstructed from its member layout
+ (the server exposes members, not printable source). Faithful to IDA's
+ field names/types; array dims are moved after the field name."""
+ payload = self.client.call(
+ "type_inspect", queries=[{"name": name, "include_members": True}])
+ res = payload.get("result", []) if isinstance(payload, dict) else []
+ info = res[0] if res and isinstance(res[0], dict) else {}
+ kw = "union" if info.get("is_union") else "struct"
+ lines = [f"{kw} {name}", "{"]
+ for m in info.get("members", []) or []:
+ if not isinstance(m, dict):
+ continue
+ t = str(m.get("type", "")).strip()
+ fn = m.get("name", "")
+ base, arr = t, ""
+ am = re.match(r"^(.*?)((?:\s*\[\d+\])+)\s*$", t)
+ if am:
+ base, arr = am.group(1).rstrip(), am.group(2).replace(" ", "")
+ sep = "" if base.endswith("*") else " "
+ lines.append(f" {base}{sep}{fn}{arr};")
+ lines.append("};")
+ return "\n".join(lines)
+
+ def declare_type(self, decl: str) -> str | None:
+ """Create or update a C type. Returns None on success, else the parse
+ error. (Re-declaring a name updates it in place.)"""
+ payload = self.client.call("declare_type", decls=decl)
+ res = payload.get("result", []) if isinstance(payload, dict) else []
+ if res and isinstance(res[0], dict):
+ return res[0].get("error")
+ return None
+
+ def delete_type(self, name: str) -> str | None:
+ """Delete a named type. Returns None on success, else an error string.
+ Requires a server-side ``del_type`` tool; if absent, a clear message is
+ returned instead of raising."""
+ try:
+ self.client.call("del_type", name=name)
+ return None
+ except IDAToolError as e:
+ msg = e.message
+ if "not found" in msg.lower() and "del_type" in msg:
+ return "delete needs a 'del_type' tool on the ida-pro-mcp server"
+ return msg
+
# -- disassembly ------------------------------------------------------- #
def disasm(self, ea: int, name: str | None = None) -> DisasmModel:
with self._lock:
diff --git a/tests/test_tui.py b/tests/test_tui.py
index 47a0e3e..f2e38ff 100644
--- a/tests/test_tui.py
+++ b/tests/test_tui.py
@@ -19,9 +19,10 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from idatui.app import ( # noqa: E402
- DecompView, DisasmView, FunctionsPanel, IdaTui, SymbolPalette, XrefsScreen,
+ DecompView, DisasmView, FunctionsPanel, IdaTui, StructEditor, SymbolPalette,
+ XrefsScreen,
)
-from textual.widgets import DataTable, Input, OptionList, Static # noqa: E402
+from textual.widgets import DataTable, Input, OptionList, Static, TextArea # noqa: E402
from rich.text import Text # noqa: E402
PASS = FAIL = 0
@@ -143,6 +144,64 @@ async def run(db):
check("found a decompile-failing function", failing is not None,
f"pref={app._pref}")
+ # Struct editor (Ctrl+T): list / view / create / update / delete.
+ await pilot.press("ctrl+t")
+ se_open = await wait_until(pilot, lambda: isinstance(app.screen, StructEditor), 10)
+ check("Ctrl+T opens the struct editor", se_open,
+ f"screen={type(app.screen).__name__}")
+ if se_open:
+ se = app.screen
+ await wait_until(pilot, lambda: bool(se._structs), 15)
+ check("struct editor lists existing structs", len(se._structs) > 0,
+ f"n={len(se._structs)}")
+ ta = se.query_one(TextArea)
+ tname = next((s.name for s in se._structs if s.name == "timespec"),
+ se._structs[0].name)
+ idx = next(i for i, s in enumerate(se._structs) if s.name == tname)
+ se.query_one(OptionList).highlighted = idx
+ se.on_option_list_option_selected(type("E", (), {"option_index": idx})())
+ await wait_until(pilot, lambda: tname in ta.text and "{" in ta.text, 15)
+ check("selecting a struct shows its C definition",
+ tname in ta.text and "{" in ta.text, f"text={ta.text[:40]!r}")
+ # create (fixed name so re-runs update in place -> no churn)
+ sname = "TuiEdTest"
+ await pilot.press("ctrl+n")
+ await pilot.pause(0.05)
+ ta.text = f"struct {sname} {{ int a; char b[8]; }};"
+ await pilot.press("ctrl+s")
+ await wait_until(pilot, lambda: any(s.name == sname for s in se._structs), 15)
+ check("Ctrl+S declares a new struct",
+ any(s.name == sname for s in se._structs), "not created")
+ # update it (add a field -> member count grows)
+ idx = next(i for i, s in enumerate(se._structs) if s.name == sname)
+ se.on_option_list_option_selected(type("E", (), {"option_index": idx})())
+ await wait_until(pilot, lambda: sname in ta.text, 10)
+ ta.text = f"struct {sname} {{ int a; char b[8]; long c; }};"
+ await pilot.press("ctrl+s")
+ await wait_until(
+ pilot,
+ lambda: next((s.members for s in se._structs if s.name == sname), 0) == 3,
+ 15)
+ check("Ctrl+S updates an existing struct in place",
+ next((s.members for s in se._structs if s.name == sname), 0) == 3,
+ "member count not 3")
+ # delete: removes it if the server supports del_type, else reports it
+ idx = next(i for i, s in enumerate(se._structs) if s.name == sname)
+ se.query_one(OptionList).highlighted = idx
+ se.action_delete()
+ await wait_until(
+ pilot,
+ lambda: not any(s.name == sname for s in se._structs)
+ or "del_type" in str(se.query_one("#se-status").render()), 15)
+ st = str(se.query_one("#se-status").render())
+ gone = not any(s.name == sname for s in se._structs)
+ check("delete removes the struct (or reports the missing server tool)",
+ gone or "del_type" in st, f"gone={gone} status={st!r}")
+ se.query_one(OptionList).focus()
+ await pilot.press("escape")
+ await wait_until(pilot, lambda: not isinstance(app.screen, StructEditor), 10)
+ check("Esc closes the struct editor", not isinstance(app.screen, StructEditor))
+
# Open the biggest function we can find (scan a sample of rows for size).
# Simpler: select the row whose Size column is largest among first N.
biggest_i, biggest_sz = 0, -1