aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-09 12:25:49 +0200
committerblasty <blasty@local>2026-07-09 12:25:49 +0200
commit51b7cc5a1e4fbb861909946b2feb842294d412e8 (patch)
treef1f8b6de3dc9fb039d619f656b5e35e3694c2b75
parenttui: --open PATH to create/attach a session for an arbitrary binary (diff)
downloadida-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.py94
-rw-r--r--idatui/domain.py37
-rw-r--r--tests/test_tui.py21
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