aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-10 12:04:48 +0200
committerblasty <blasty@local>2026-07-10 12:04:48 +0200
commitbc03ad0a233927dfbcbe6d91c6ddd89d33e8ebc8 (patch)
tree6c2f6b75fd3b5dfc780815c5b3d6099a3827bcec
parentserver: inject a del_type tool into ida-pro-mcp (enables struct delete) (diff)
downloadida-tui-bc03ad0a233927dfbcbe6d91c6ddd89d33e8ebc8.tar.gz
ida-tui-bc03ad0a233927dfbcbe6d91c6ddd89d33e8ebc8.tar.xz
ida-tui-bc03ad0a233927dfbcbe6d91c6ddd89d33e8ebc8.zip
struct editor: discoverable, confirmed delete (d/Del + ConfirmScreen)
Delete was wired to the Del key but undiscoverable (modal has no footer) and unguarded. Add a 'd' alias (Del still works), and route delete through a small ConfirmScreen (Enter/y confirm, Esc/n cancel) since it's destructive. 'd' in the editor pane types normally; only the list triggers delete. Hint updated to 'd/Del delete'. Pilot: 'd' opens the confirm, Esc cancels (kept), Enter deletes. full suite 85/85.
-rw-r--r--idatui/app.py45
-rw-r--r--tests/test_tui.py18
2 files changed, 55 insertions, 8 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 9cb0db5..6d2f38d 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -1166,6 +1166,33 @@ class SymbolPalette(ModalScreen):
# --------------------------------------------------------------------------- #
+# Confirmation dialog
+# --------------------------------------------------------------------------- #
+class ConfirmScreen(ModalScreen):
+ """A tiny yes/no confirmation; dismisses True (confirm) or False (cancel)."""
+
+ BINDINGS = [
+ Binding("enter,y", "confirm", "Confirm"),
+ Binding("escape,n", "cancel", "Cancel"),
+ ]
+
+ def __init__(self, message: str) -> None:
+ super().__init__()
+ self._message = message
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="confirm-box"):
+ yield Static(self._message, id="confirm-msg")
+ yield Static("[Enter/y] confirm [Esc/n] cancel", id="confirm-help")
+
+ def action_confirm(self) -> None:
+ self.dismiss(True)
+
+ def action_cancel(self) -> None:
+ self.dismiss(False)
+
+
+# --------------------------------------------------------------------------- #
# Struct editor (C-style local type editor)
# --------------------------------------------------------------------------- #
class StructEditor(ModalScreen):
@@ -1180,7 +1207,7 @@ class StructEditor(ModalScreen):
BINDINGS = [
Binding("ctrl+s", "save", "Save", priority=True),
Binding("ctrl+n", "new", "New", priority=True),
- Binding("delete", "delete", "Delete", show=False),
+ Binding("delete,d", "delete", "Delete", show=False),
Binding("escape", "close", "Close"),
]
@@ -1202,7 +1229,7 @@ class StructEditor(ModalScreen):
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",
+ "Enter edit · Ctrl+S save · Ctrl+N new · d/Del delete · Esc back/close",
id="se-status")
def on_mount(self) -> None:
@@ -1294,11 +1321,18 @@ class StructEditor(ModalScreen):
self._set_status("new struct — edit and Ctrl+S")
def action_delete(self) -> None:
+ # Delete is only meaningful from the list; a bare 'd' in the editor types.
+ if self.focused is self.query_one("#se-edit", TextArea):
+ return
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)
+ s = self._structs[i]
+ kind = "union" if s.is_union else "struct"
+ self.app.push_screen(
+ ConfirmScreen(f"Delete {kind} '{s.name}' ?"),
+ lambda ok, name=s.name: self._delete(name) if ok else None)
@work(thread=True, exclusive=True, group="se-del")
def _delete(self, name: str) -> None:
@@ -1376,6 +1410,11 @@ class IdaTui(App):
#se-list { height: 1fr; }
#se-edit { height: 1fr; border: none; }
#se-status { height: 1; background: $panel-darken-2; color: $text-muted; padding: 0 1; }
+ ConfirmScreen { align: center middle; }
+ #confirm-box { width: 60; height: auto; border: thick $warning;
+ background: $panel; padding: 1 2; }
+ #confirm-msg { height: auto; }
+ #confirm-help { height: 1; color: $text-muted; margin-top: 1; }
"""
BINDINGS = [
diff --git a/tests/test_tui.py b/tests/test_tui.py
index f2e38ff..90c882b 100644
--- a/tests/test_tui.py
+++ b/tests/test_tui.py
@@ -19,8 +19,8 @@ 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, StructEditor, SymbolPalette,
- XrefsScreen,
+ ConfirmScreen, DecompView, DisasmView, FunctionsPanel, IdaTui, StructEditor,
+ SymbolPalette, XrefsScreen,
)
from textual.widgets import DataTable, Input, OptionList, Static, TextArea # noqa: E402
from rich.text import Text # noqa: E402
@@ -185,17 +185,25 @@ async def run(db):
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
+ # delete: 'd' asks to confirm, Enter deletes (server del_type). If the
+ # server lacks del_type, the status reports it instead.
idx = next(i for i, s in enumerate(se._structs) if s.name == sname)
+ se.query_one(OptionList).focus()
se.query_one(OptionList).highlighted = idx
- se.action_delete()
+ await pilot.press("d")
+ confirmed = await wait_until(
+ pilot, lambda: isinstance(app.screen, ConfirmScreen), 10)
+ check("delete asks for confirmation", confirmed,
+ f"screen={type(app.screen).__name__}")
+ await pilot.press("enter")
+ await wait_until(pilot, lambda: isinstance(app.screen, StructEditor), 10)
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)",
+ check("confirming delete removes the struct (or reports missing tool)",
gone or "del_type" in st, f"gone={gone} status={st!r}")
se.query_one(OptionList).focus()
await pilot.press("escape")