diff options
| author | blasty <blasty@local> | 2026-07-09 12:25:49 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 12:25:49 +0200 |
| commit | 51b7cc5a1e4fbb861909946b2feb842294d412e8 (patch) | |
| tree | f1f8b6de3dc9fb039d619f656b5e35e3694c2b75 | |
| parent | tui: --open PATH to create/attach a session for an arbitrary binary (diff) | |
| download | ida-tui-51b7cc5a1e4fbb861909946b2feb842294d412e8.tar.gz ida-tui-51b7cc5a1e4fbb861909946b2feb842294d412e8.tar.xz ida-tui-51b7cc5a1e4fbb861909946b2feb842294d412e8.zip | |
decompiler view: Tab/Shift+Tab toggle disasm<->pseudocode, full bodies
- domain.decompile now fetches the FULL body: server caps responses at 50KB and
clips strings to 1KB, but caches the full output and exposes it at
_meta.ida_mcp.download_url -> we GET that so pseudocode is complete
(main: 43257 chars, not a 1KB stub). include_addresses gives per-line /*0xEA*/.
- DecompView (read-only TextArea, cpp highlighting best-effort, line numbers)
- Tab and Shift+Tab toggle the code pane; lazy decompile in a worker, cached;
hard-failures ('decompilation failed') shown gracefully with disasm fallback
- pilot suite 14/14 (adds tab->pseudocode, full-body, shift+tab->disasm)
| -rw-r--r-- | idatui/app.py | 94 | ||||
| -rw-r--r-- | idatui/domain.py | 37 | ||||
| -rw-r--r-- | tests/test_tui.py | 21 |
3 files changed, 134 insertions, 18 deletions
diff --git a/idatui/app.py b/idatui/app.py index 574ec4d..22b5879 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -29,7 +29,7 @@ from textual.message import Message from textual.reactive import reactive from textual.scroll_view import ScrollView from textual.strip import Strip -from textual.widgets import DataTable, Footer, Header, Input, Static +from textual.widgets import DataTable, Footer, Header, Input, Static, TextArea from .client import IDAClient from .domain import DisasmModel, Func, Program @@ -65,6 +65,7 @@ class DisasmView(ScrollView, can_focus=True): Binding("pageup,b", "page(-1)", "PgUp", show=False), Binding("home", "goto_top", "Top", show=False), Binding("G,end", "goto_bottom", "Bottom", show=False), + Binding("tab,shift+tab", "app.toggle_view", "Pseudocode", priority=True), ] cursor = reactive(0) @@ -210,6 +211,32 @@ def _join(base: Style | None, style: Style) -> Style: # --------------------------------------------------------------------------- # +# Decompiler (pseudocode) view +# --------------------------------------------------------------------------- # +class DecompView(TextArea): + """Read-only Hex-Rays pseudocode for one function. Bounded text (the domain + fetches the full body even when the server truncates), so a plain scrollable + TextArea is the right tool.""" + + BINDINGS = [Binding("tab,shift+tab", "app.toggle_view", "Disasm", priority=True)] + + def __init__(self) -> None: + super().__init__("", read_only=True, show_line_numbers=True, id="decomp") + self.loaded_ea: int | None = None + + def on_mount(self) -> None: + try: + self.language = "cpp" # best-effort syntax highlighting + except Exception: # noqa: BLE001 -- no tree-sitter grammar available + pass + + def show(self, ea: int, text: str) -> None: + self.load_text(text) + self.loaded_ea = ea + self.move_cursor((0, 0)) + + +# --------------------------------------------------------------------------- # # Function list panel # --------------------------------------------------------------------------- # class FunctionsPanel(Vertical): @@ -235,6 +262,7 @@ class IdaTui(App): #func-table { height: 1fr; } #func-filter { dock: top; } DisasmView { width: 1fr; padding: 0 1; } + DecompView { width: 1fr; } #status { dock: bottom; height: 1; background: $panel; color: $text; padding: 0 1; } """ @@ -243,7 +271,7 @@ class IdaTui(App): Binding("slash", "filter", "Filter"), Binding("g", "goto", "Goto"), Binding("ctrl+b", "toggle_functions", "Names"), - Binding("tab", "toggle_focus", "Switch pane"), + Binding("tab,shift+tab", "toggle_view", "Disasm/Pseudocode"), Binding("escape", "back", "Back"), ] @@ -259,6 +287,8 @@ class IdaTui(App): self._ka = None self._nav: list[NavEntry] = [] self._cur_filter: str | None = None + self._active = "disasm" # or "decomp" + self._cur: NavEntry | None = None # -- layout ------------------------------------------------------------ # def compose(self) -> ComposeResult: @@ -267,6 +297,9 @@ class IdaTui(App): with FunctionsPanel(id="left"): pass yield DisasmView() + dv = DecompView() + dv.display = False + yield dv yield Static("connecting…", id="status") yield Footer() @@ -364,12 +397,12 @@ class IdaTui(App): else: self.query_one("#func-table", DataTable).focus() - def action_toggle_focus(self) -> None: - table = self.query_one("#func-table", DataTable) - if self.focused is table: - self.query_one(DisasmView).focus() - else: - table.focus() + def action_toggle_view(self) -> None: + """Tab: switch the code pane between disassembly and pseudocode.""" + if self._cur is None: + return + self._active = "decomp" if self._active == "disasm" else "disasm" + self._show_active() def action_filter(self) -> None: inp = self.query_one("#func-filter", Input) @@ -442,11 +475,48 @@ class IdaTui(App): def _open_entry(self, entry: NavEntry, push: bool) -> None: if self.program is None: return - view = self.query_one(DisasmView) + self._cur = entry model = self.program.disasm(entry.ea, entry.name) - view.load(model, entry.name, cursor=entry.cursor) - view.focus() - self._status(f"{entry.name} @ {entry.ea:#x}") + self.query_one(DisasmView).load(model, entry.name, cursor=entry.cursor) + self._show_active() + + def _show_active(self) -> None: + dis = self.query_one(DisasmView) + dec = self.query_one(DecompView) + if self._active == "disasm": + dec.display = False + dis.display = True + dis.focus() + self._status_for_cur("disasm") + else: + dis.display = False + dec.display = True + dec.focus() + if self._cur is not None and dec.loaded_ea != self._cur.ea: + self._status(f"{self._cur.name} — decompiling…") + self._load_decomp(self._cur.ea, self._cur.name) + else: + self._status_for_cur("pseudocode") + + def _status_for_cur(self, mode: str) -> None: + if self._cur is not None: + self._status(f"{self._cur.name} @ {self._cur.ea:#x} [{mode}]") + + @work(thread=True, exclusive=True, group="decomp") + def _load_decomp(self, ea: int, name: str) -> None: + assert self.program is not None + dec = self.program.decompile(ea) + self.app.call_from_thread(self._apply_decomp, ea, name, dec) + + def _apply_decomp(self, ea: int, name: str, dec) -> None: # type: ignore[no-untyped-def] + view = self.query_one(DecompView) + if dec.failed: + view.show(ea, f"/* decompilation failed at {ea:#x}: {dec.error} */\n") + self._status(f"{name} — decompile failed; Tab for disasm") + else: + note = " (truncated)" if dec.truncated else "" + view.show(ea, dec.code or "") + self._status(f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}") def on_disasm_view_cursor_moved(self, msg: DisasmView.CursorMoved) -> None: if self._nav: diff --git a/idatui/domain.py b/idatui/domain.py index 859c883..9417150 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -21,11 +21,13 @@ Textual worker threads; the internal prefetch pool is separate and small. from __future__ import annotations +import json import re import threading +import urllib.request from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field -from typing import Callable, Iterable +from typing import Callable from .client import IDAClient, IDAToolError @@ -352,16 +354,41 @@ class Program: # -- decompilation ----------------------------------------------------- # def decompile(self, ea: int, refresh: bool = False) -> Decompilation: + """Full pseudocode for a function. + + The server truncates responses over 50KB (strings clipped to 1000 + chars) but caches the full output and exposes it at + ``_meta.ida_mcp.download_url``. We transparently fetch that so the view + always gets the complete body, not a 1KB stub. + """ if not refresh: with self._lock: hit = self._decomp.get(ea) if hit is not None: return hit - payload = self.client.call("decompile", addr=hex(ea)) - result = _parse_decompilation(ea, payload) + envelope = self.client.call_envelope("decompile", addr=hex(ea)) + result = envelope.get("result", {}) + payload = result.get("structuredContent") + if payload is None: # fall back to text content + payload = self.client._extract_payload("decompile", result) + meta = (result.get("_meta") or {}).get("ida_mcp") + if isinstance(meta, dict) and meta.get("download_url"): + full = self._fetch_output(meta["download_url"]) + if isinstance(full, dict) and full.get("code"): + payload = full + dec = _parse_decompilation(ea, payload) with self._lock: - self._decomp[ea] = result - return result + self._decomp[ea] = dec + return dec + + @staticmethod + def _fetch_output(url: str, timeout: float = 15.0): + """GET the server's cached full-output blob (plain HTTP, not MCP).""" + try: + with urllib.request.urlopen(url, timeout=timeout) as r: + return json.loads(r.read().decode("utf-8", "replace")) + except Exception: # noqa: BLE001 -- fall back to the truncated preview + return None # -- address resolution ------------------------------------------------ # def resolve(self, target: int | str) -> int: diff --git a/tests/test_tui.py b/tests/test_tui.py index 8769321..683f896 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -12,7 +12,7 @@ import os import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from idatui.app import DisasmView, FunctionsPanel, IdaTui # noqa: E402 +from idatui.app import DecompView, DisasmView, FunctionsPanel, IdaTui # noqa: E402 from textual.widgets import DataTable, Static # noqa: E402 PASS = FAIL = 0 @@ -124,6 +124,25 @@ async def run(db): left.display and isinstance(app.focused, DataTable), f"display={left.display} focus={type(app.focused).__name__}") + # Decompiler toggle: open a function, Tab -> pseudocode, Shift+Tab -> back. + table.focus() + table.move_cursor(row=biggest_i) + await pilot.press("enter") + dis = app.query_one(DisasmView) + dec = app.query_one(DecompView) + await wait_until(pilot, lambda: dis.total > 0, timeout=20) + await pilot.press("tab") + pc = await wait_until(pilot, lambda: dec.display and dec.loaded_ea is not None, 25) + check("tab shows pseudocode", pc and app._active == "decomp", + f"active={app._active} disp={dec.display}") + check("pseudocode has real body (not 1KB stub)", len(dec.text) > 1200, + f"len={len(dec.text)}") + await pilot.press("shift+tab") + await pilot.pause(0.2) + check("shift+tab returns to disassembly", + app._active == "disasm" and dis.display and not dec.display, + f"active={app._active}") + def main(argv): db = None |
