| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
idatui/index.py — one on-disk index (<sidecar>/idx/project.db) over every binary
in a project, so search works for binaries whose worker isn't running.
Indexing choice, measured rather than guessed:
* SQLite FTS5 with the TRIGRAM tokenizer — stdlib, no dependency (nothing else
was installed and nothing is needed), and unlike a prefix index it matches
arbitrary substrings, which is what symbol names and string bodies need.
* 300k-entry corpus: 1.9 ms per query vs 11.8 ms for a Python scan and 28.9 ms
for plain LIKE; 0.2 ms per incremental insert.
* Size was the stated worry and turned out not to bite: bash contributes 5.9k
entries / 0.15MB of text, libcrypto.so.3 30.7k / 0.52MB. At ~5.7x the text a
20-binary project is ~12-23MB — against .i64 files already in the sidecar
(libcrypto's alone is 72MB), roughly 1% of what the project already costs. The
reason to be on disk is residency, not size.
* Trigram can't answer queries under 3 chars and returns nothing rather than
erroring, so search() falls back to LIKE — otherwise incremental typing would
look broken until the third keystroke.
Wiring: after a binary's functions load, its symbols + strings are folded into
the index (skipped when the source's size/mtime is unchanged). Ctrl+N gains a
scope toggle on F2 — not ctrl+a, which the focused Input binds to "home" so it
never reaches the palette. Project scope narrows via the index then ranks with
the existing _fuzzy, keeping the same feel; hits are prefixed with their binary,
and choosing one elsewhere switches binary and jumps to it.
Also fixes another instance of the Textual-markup trap: the palette titles ate
"[project]" as a style tag (same class of bug as the status bar), so the pal
titles are markup=False now.
tests/test_index.py: 24 stdlib checks — substring/case-insensitive matching, kind
filter, the <3 char fallback, multi-binary search, per-binary incremental
reindex, staleness, forget, persistence. Suite 191/0.
Strings (") still needs the same scope toggle; the index already carries them.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Quitting used to be silent AND inconsistent. Single-binary mode closed the worker
with save=False, so renames/types/comments were DROPPED without a word; project
mode did the opposite and saved everything silently via the pool. Neither told
you anything.
Now 'q'/ctrl+q routes through action_quit: clean databases exit immediately, and
anything unsaved raises a QuitScreen naming the affected databases with
s save & quit d discard & quit Esc cancel
Saving happens with an overlay up, because writing a large .i64 takes seconds and
doing it during teardown would look like a hang with no UI left to explain it.
_dirty_labels() covers project mode too: the active binary plus any still-resident
one that was edited. Evicted binaries were already saved on the way out, so they
can't be silently lost.
on_unmount now distinguishes an explicit choice from an unexpected teardown:
_save_on_exit is None (crash/kill -> save defensively, including single-binary
mode which previously discarded), False (user chose discard, or we already saved).
Verified end-to-end across two sessions on a temp copy: rename + 's' -> the rename
is still there on reopen; rename + 'd' -> it is not. Plus a quit_guard scenario
(clean exits immediately, dirty asks, Esc cancels).
Also hardens Ctx.open(view="decomp"): F5/Tab only decompiles from a focused code
pane and the listing may still be settling, so a swallowed Tab surfaced much later
as "pseudocode view shows: active=listing". It now retries instead of assuming the
first Tab takes. Full suite 191/0, green twice in a row.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Neither was a flake; both were test bugs that happened to be timing/binary
dependent.
1. filter: the scenario hardcoded the glob 'sub_1*', which matches NOTHING in a
binary whose code never reaches 0x1xxx — echo's functions are sub_2xxx..
sub_7xxx, so it failed deterministically, every run. Derive the glob from real
names instead (first sub_ prefix present) and assert the row count equals the
expected match count, which is stronger than the old 0 < n < total. A
self-check asserts the derived glob actually matches something, so this can't
silently rot again on another binary.
2. view_toggle: "tab switches to disassembly" asserted after a fixed pause(0.1),
but decomp -> listing runs through _toggle_to_listing, a BACKGROUND WORKER —
_active only flips once the listing model has loaded. The pause held in short
runs and lost the race in a full one, which is why this looked like collateral
from the filter failure. Wait for the state instead. Two related hardenings:
focus the decomp pane before Tab (elsewhere Tab is focus-next, silently
leaving us in the decompiler), and wait for the listing cursor to carry an ea
before the F5 check (F5 legitimately no-ops on an unaddressed row).
Full suite now 187 passed / 0 failed, the first fully green run — previously
174-182 with 2-6 "known flakes" whose count drifted with machine load.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The permanent Footer spent a screen row on a truncated, always-visible key list.
Remove it (the status line now owns the bottom row) and put the full cheatsheet
behind F1 — grouped by task (Navigate / Views / Move / Edit / Search) rather than
by widget, which is what makes it readable. Esc, F1 or q closes it; there's also
a "Keyboard shortcuts" command-palette entry.
F1 rather than '?' because '?' is already search-backwards in the code views.
Textual leaves F1 unbound (App only claims ctrl+q/ctrl+c), so it traps cleanly.
Gotcha worth recording: the helper that builds the cheatsheet was first called
_render, which collides with Widget._render — Textual invoked ours internally and
got a rich Text where it wanted a Visual, so the whole screen failed to paint
("'Text' object has no attribute 'render_strips'"). Renamed to _cheatsheet.
The search scenario's "rendered above the footer" check now asserts the search
input owns the bottom row instead. New `help` scenario: footer gone, F1 opens it,
groups + real bindings present, Esc closes (5 checks).
Suite note: view_toggle fails in a full run ONLY as collateral from the standing
`filter` flake, which runs immediately before it — when filter leaves the table
empty, view_toggle's open_biggest has nothing to open. Verified: view_toggle
passes alone (13/13) and directly after `help` (18/18), and reproduces as a
cascade with --only filter,view_toggle. Not a regression from this change.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The pseudocode view still had the old mapping (home -> goto_top, end ->
goto_bottom), so <End> jumped you to the bottom of the function instead of the
end of the line. Bring it in line with the listing view:
home start of line ctrl+home top of the function
shift+home first non-blank ctrl+end/G bottom of the function
end end of line
shift+home skips the C indentation — the pseudocode analogue of the listing's
skip-the-address-gutter.
Verified live and locked in view_toggle (5 checks): <end> stays on the line with
the scroll unchanged, <home> hits column 0, <shift+home> lands on the indent
width, and ctrl+home/ctrl+end still reach top/bottom. Full suite 179/2-flake.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
In the decompiler, 'y' on a local variable already worked (func_types -> lvars ->
set_lvar_type), but a GLOBAL fell through every case and silently retyped the
ENCLOSING FUNCTION'S PROTOTYPE — worse than not working, since the prompt said
"prototype" while you thought you were typing a variable.
* server/patch_server.py: new data_type tool — {addr,name,type,size,is_func} for
a data item, so the prompt can prefill the current type and the caller can tell
a global from a function.
* domain: Program.data_type() + set_data_type() (set_type with kind="global").
* app: _prepare_retype gains the data case between "function" and the
current-function fallback, with a size-based prefill when the global is still
untyped; _do_retype routes kind="data" to set_data_type.
Classification verified on echo/main: 'v3' -> lvar (prefill 'char *'), 'stdout'
-> data (prefill 'FILE *'), 'main' -> func prototype, an unresolvable token ->
the enclosing prototype (unchanged fallback).
Also fixes a latent crash found while probing this: on_listing/decomp_view_
cursor_moved called self.query_one(ListingView), but App.query_one searches the
TOP screen — a cursor-moved message landing while any modal is up (loading
overlay, project switch) raised NoMatches out of a message handler and killed the
app. Both handlers now go through _try_view().
Pilot `retype` extended to 9 checks covering all three flavours, each asserting
the other targets are left alone. Two of the new checks needed settles: applying
a retype recompiles asynchronously, so scanning/indexing the pseudocode without
waiting reads text that's about to be replaced (this also cut the scenario from
30s to 2.6s of previously-wasted timeout). Full suite 174/2-flake.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The art rendered flush against the left of the box (inset 3, with 9 to spare on
the right). #loading-box's align-horizontal couldn't fix it: the 1fr title/note
siblings make the child group span the full content width, so container
alignment has nothing left to centre. Text justify="center" doesn't do it either
— no_wrap bypasses the justification pass.
Wrap the logo in rich.align.Align.center and give the widget width: 100%.
Measured: the strip is now the full 66-cell content width with the 60-wide art
padded 3/3.
|
| |
|
|
|
|
|
|
|
| |
`align: center middle` was set on SymbolPalette only, but StringsPalette and
ProjectPalette reuse the same #pal-box without a centring rule of their own — so
both rendered clamped against the top-left corner. Apply the rule to all three.
Measured on a 140x44 screen: every palette now has equal left/right (22/22) and
top/bottom margins. Palette scenarios still green (13/13).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Wires the project model + worker pool into the app. Project mode is ADDITIVE —
without --project the app is exactly the single-binary tool it was, which is what
keeps the 167-check pilot meaningful.
* IdaTui(project=...) builds a WorkerPool and opens the project's first binary;
_open_worker_client asks the pool instead of spawning directly.
* BinaryState snapshots what a switch leaves behind (program, func_index, nav,
cur, view prefs, filter). Switching reuses the _after_reconnect shape: swap
client+program, rebuild the index, reopen the entry. A still-resident binary
restores instantly (Program + index are in memory); an evicted one gets a fresh
worker but keeps its nav history, which is just addresses.
* ProjectPalette (Ctrl+O, + a "Switch binary…" palette command): the project's
binaries with resident/analysed/pinned/active state and memory, filterable.
* launch.py --project FILE, creating the project when binaries are also given;
stages everything up front so the source tree is never written to.
Two bugs found while testing:
* _did_auto_land is app-wide, but landing is per-binary: after the first binary
landed, a cold switch never landed at all AND left the switch overlay up
forever. Reset it per switch.
* PRE-EXISTING: the status Static had Textual markup enabled, so a single-word
bracket marker parses as a style tag and is silently eaten — [listing] and
[pseudocode] have never actually rendered (only [split · listing] survived,
because the · makes it an invalid tag). Status is plain text with brackets and
symbol names, so markup=False.
tests/test_project_ui.py: end-to-end pilot on two real binaries (18 checks) —
boot, switcher contents, switch, per-binary index, both workers resident,
switch back with state intact, return-to-where-you-were, and the promise that
the source tree stays pristine while every artifact lands in the sidecar.
Full single-binary suite unchanged at 167/2 (the standing flakes).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
idatui/pool.py — WorkerPool keeps one live worker per project binary:
* lazy spawn on first use; staging + a scratch sweep happen first, so a database
wedged by a previously hard-killed worker reopens instead of crash-looping.
* residency is bounded by a MEMORY BUDGET (default project.memory_pct of RAM),
not a worker count — a count is the wrong knob when one project holds a 50KB
helper and a 6MB crypto lib. Cost is measured per worker from
/proc/<pid>/smaps_rollup (PSS, which splits shared pages so summing means
something).
* over budget -> evict least-recently-used, never the active or pinned binary,
and give up rather than thrash when nothing is evictable. Eviction calls
idb_save first, so returning to a binary is a DB load, not a re-analysis.
* status() feeds the switcher UI (resident/pinned/active/analysed/memory).
worker_client: fix the wedge-file bug this depends on. close() sent
__shutdown__ and then IMMEDIATELY SIGTERMed, killing the worker mid
close_database() — which is what leaves the unpacked .id0/.id1/... behind and
makes the .i64 refuse to reopen. Now it waits out a grace period (the worker
returns from serve() on __shutdown__ and closes the DB in its finally) and only
escalates if it's genuinely stuck. Also expose .pid for memory accounting.
Verified: a pilot run that used to leave echo.id0/.id1/.id2/.nam/.til now leaves
only echo.i64, and teardown is no slower (14 checks in 5s).
tests/test_pool.py: 22 checks with an injected fake client — LRU order, budget
eviction, active/pinned protection, save-before-close, thrash avoidance,
status(), teardown. Pure stdlib, no idalib.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
First slice of multi-binary projects (docs/PROJECTS.md): the on-disk model, with
no runtime wiring yet.
idatui/project.py (stdlib-only, like domain/worker):
* Project.load/create/save — an explicit JSON project file listing binaries;
paths resolve relative to it, labels default to the basename and are
disambiguated on collision (they name files).
* A sidecar dir beside the project file (<stem>.idatui.d/) holds bin/ (staged
binaries), their .i64 + scratch, and idx/ for phase 2. IDA opens the STAGED
file, so nothing lands in the source tree — today targets/ carries ~244MB of
IDA litter around ~13MB of binaries, much of it stale wedge files.
* stage() copies rather than hardlinks. A hardlink is free but makes source and
staged one inode, so an in-place rebuild (cp over the path truncates instead of
replacing) would silently swap the bytes under an analysed DB with nothing to
detect it. The unit test caught exactly that. A copy also leaves the sidecar
self-contained once the sources are gone.
* Re-staging a changed source drops its now-stale DB; sweep_scratch() clears the
unpacked working files a hard-killed worker leaves behind (never the .i64).
tests/test_project.py: 27 checks, pure stdlib (no IDA/textual/worker), <1s.
docs/PROJECTS.md: the full design — the one-worker-per-DB constraint with
measured costs (bash worker = 126MB RSS/117MB PSS; libcrypto's DB is 72MB, so
residency is budgeted by MEMORY, not a worker count), the switch between
"switching needs a live worker" and "searching doesn't (cached index)", and
phases 1-4.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The sync only ran off CursorMoved, so a wheel-scroll or scrollbar drag — which
moves the viewport but never the cursor — left the other pane behind.
* ListingView/DecompView post a new Scrolled message from watch_scroll_y when the
rounded scroll changes; the app re-syncs on it (guarded to the active pane, so
a companion's align() can't feed back).
* _split_anchor(view): the sync now anchors on the driver's cursor while it is
visible, else on the top visible row. Cursor moves behave exactly as before
(key-nav always scrolls the cursor into view); once a pure scroll takes the
cursor off-screen the viewport itself becomes the anchor, so the companion
keeps following what you're actually looking at — including re-decompiling as
you scroll across function boundaries.
Pilot split_view gains a pure-scroll check (viewport moves, cursor doesn't, the
companion still moves): 20/20. Full suite 167/2-flake.
Three existing checks were setting .cursor without scrolling it into view, which
the anchor correctly treats as "cursor not visible"; they now scroll like real
key-nav. The multi-region check also had to make the decomp the ACTIVE pane
before a decomp-driven sync — otherwise the companion's align() fires Scrolled
and re-syncs listing-driven, clobbering the link (impossible in real usage, where
the companion is by definition not the active pane).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Scrolling either pane left the other one wherever it happened to be: the
companion only scrolled when the linked row went off-screen (reveal()), so the
link could sit at the bottom edge while the driver's cursor was mid-viewport —
visually incoherent, your eye had to hunt for it.
Add ListingView/DecompView.align(row, screen_row): scroll so the linked row lands
at the SAME viewport offset as the driver's cursor, and use it in _sync_split for
both directions. The two panes now track each other line-for-line, so the eye
reads straight across. Best-effort at the ends (can't scroll above line 0, nor
past the end when the pseudocode is shorter than the viewport).
Pilot split_view: deterministic alignment check — park the listing cursor at a
known viewport offset deep in the function, sync, and assert the decomp's scroll
top is exactly the aligned value (accounting for both clamps). 19/19; full suite
166/2-flake.
|
| |
|
|
|
|
| |
Three shipped features were missing from the feature list: the Ghidra-style
synced split view (s), the filterable strings browser ("), and the ida-tui
command palette (Ctrl+P).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
ida-pro-mcp exposes no full strings list (only a filtered/capped "interesting"
survey), so this is a new injected tool plus a filterable browser.
* server/patch_server.py: list_strings(offset,count,min_len,refresh) — every
literal from idautils.Strings() as {addr,text,len,type}, paginated, with a
module-level cache keyed by min_len (rebuilding is O(n) and the browser pages
the whole list).
* domain: StrLit dataclass + Program.strings() — pages the full list once and
caches it.
* app: StringsPalette modal (mirrors SymbolPalette) — case-insensitive substring
filter with the match highlighted, addr/len/text columns, ↑↓/Enter/Esc.
Bodies are sanitized to one printable line (\n/\r/\t escaped, non-printables
dropped, long strings clipped) so control chars can't break the layout;
display strings are pre-rendered+pre-lowered once since filtering runs per
keystroke. Enter jumps to the literal in the unified listing via _goto_ea.
Bound to '"' and Shift+F12, plus a "Strings…" command-palette entry.
Verified on echo: 150 strings listed with addr/len/text, filtering 'usage'
narrows to 2 (case-insensitive), Enter lands the listing cursor on the literal.
Pilot `strings` scenario 6/6; full suite 165/2-flake.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
The unified listing spans many functions, but the decomp pane was pinned to the
one function it was opened on — scrolling the listing cursor past that function's
bounds stopped syncing. Now _sync_split tracks the decompiled function's ea span
(_split_range, from decomp_map) and, when the listing cursor leaves it, re-points
the decomp pane to the function under the cursor: _resync_decomp (exclusive
worker) -> function_of(ea) -> _apply_resync re-decompiles + reloads the region
map, or just drops the band over data/undefined (keeping the last function).
Pilot split_view: moving the listing cursor into another function re-syncs the
decomp (18/18).
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The sync direction follows the FOCUSED pane, but that was only discoverable via
Tab. Add on_descendant_focus: in split, focusing a pane (Tab or a mouse click)
makes it the leading/driver pane and re-syncs — so clicking into the pseudocode
and cursoring around now drives the listing, matching intuition (previously a
click focused the widget but left _active — and thus the sync direction —
pointing at the other pane). The split status now ends with "(Tab/click: drive
<other pane>)" so it's obvious.
Pilot split_view: clicking the pseudocode pane makes it the driver (17/17).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Polish for the split view:
* _split_status(): status line now reads "name @ ea [split · pseudocode line N
↔ K insn]" / "[split · listing]" from the focused pane, instead of the
single-view [pseudocode]/[listing] labels clobbering it. Wired into both
cursor-moved handlers and _apply_decomp.
* min-width gate: action_toggle_split refuses to enter split below
_SPLIT_MIN_WIDTH (100 cols) so two usable code panes always have room.
* navigation keeps both panes on the same function: verified (not fixed —
goto/follow in split already routes _open_entry -> _show_active split branch,
reloading the listing + decomp + region map for the new function).
Review turned two roadmap items into non-issues: split is a persistent mode
orthogonal to nav entries (back/forward just navigate within split), and search
is already per-focused-pane.
Pilot split_view gains: split-aware status, goto-in-split navigates, and both
panes reload on navigation (16/16). Full suite 157/2-flake. Split view is
feature-complete (phases 1-4).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Ghidra "region band": moving the pseudocode cursor now lights up EVERY
instruction that C line owns, not just one.
* server/patch_server.py: new decomp_map tool — sweeps cfunc.get_line_item across
each pseudocode line's columns and collects the ea from each item's dstr()
('EA: desc', matching the /*ea*/ marker source so it aligns with the display
lines). Returns {addr, lines:[{ea, eas:[...]}]}. (First tried item.get_ea(),
which reports a different ea and didn't align — dstr() is the right source.)
* domain: Program.decomp_map(ea) -> per-line ea lists, cached by name-gen.
* app: _load_split_map fetches it off-thread into _split_eamap/_split_ea2line;
_sync_split bands the full instruction region for a C line (decomp drives) and
uses the exact ea->line inverse (listing drives), falling back to the single
marker until the map lands. Maps cleared on leaving split.
Pilot split_view gains: decomp_map returns/aligns with the markers, and a
multi-instruction C line bands >1 listing row (13/13). Full suite 154/2-flake.
The idalib spike ran on the pilot's own worker (the standalone worker kept
getting reaped in this sandbox).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Ghidra sync: in split, the focused pane drives and the companion shows a
subtle band (_S_LINK) on the linked location + scrolls it into view. The
companion's cursor never moves (band + scroll only), so there's no echo/
ping-pong and no guard is needed.
* _sync_split(source): listing drives -> DecompView.line_for_ea (largest /*ea*/
marker <= cursor ea) bands the covering C line; decomp drives ->
ListingModel.ensure_ea bands the covering instruction row.
* wired into on_listing/decomp_view_cursor_moved (gated on the focused pane),
the Tab focus-switch (re-link from the new driver), _apply_decomp (link once
the pseudocode loads) and _apply_enter_split; bands cleared on leaving split.
* ListingView/DecompView gain _link_rows/_link_line + set_link/reveal, a
render_line apply_style(_S_LINK) band, and DecompView.line_for_ea.
Still single-ea per line (one instruction highlighted) — the full instruction
range is phase 3 (the decomp_map tool). Pilot split_view now covers both sync
directions + that the band actually paints (10/10); full suite 151/2-flake.
|
| |
|
|
|
|
|
|
| |
Two small correctness tidies before phase 2:
* action_toggle_split now no-ops while a search/rename/... prompt owns the
keyboard (parity with the other app actions; a stray 's' can't split mid-edit).
* the pilot reset() clears app._split so split state can't leak into the next
scenario if one crashes mid-run.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Ghidra-style dual view, layout + toggle (no cursor sync yet — that's phase 2).
's' (and a "Split view" palette command) toggles a _split mode where the listing
(left) and pseudocode (right) show together, divided by a keyline, one focused.
Entering split loads the listing + decompiles the current function into both
panes; Tab/F5 switches the focused pane; any single-view target (hex, etc.) or 's'
again collapses back to the focused pane.
* app: _split flag; _show_active gains a split branch (both panes, load decomp,
focus active, #panes.split class); action_toggle_split + _enter_split worker;
action_toggle_view switches panes when split; 's' binding + palette entry;
#panes.split ListingView divider CSS.
* docs/SPLIT_VIEW.md: the full design + phased roadmap (the hard part — line<->EA
set mapping via a sweep of cfunc.get_line_item — is scoped for phase 3).
* tests: split_view scenario (enter/load/tab-focus/exit, 5 checks). Also fix
disasm_nav's stale goto-bottom (press ctrl+end; plain 'end' is end-of-line now).
Verified live over RPC (both panes render, Tab flips focus, 's' exits) and pilot
split_view 5/5.
|
| |
|
|
|
|
|
|
|
|
|
| |
The F5-from-listing overlay fix raises dec.loading=True before _show_active, but
_show_active only clears it via _load_decomp when the function needs
(re)decompiling. F5 on a function already shown in the pseudocode pane
(dec.loaded_ea == cur.ea) took the else branch, which never cleared loading -> the
spinner stayed up forever. Clear dec.loading=False in that branch.
Pilot view_toggle gains a check: F5 the same cached function again and assert the
overlay clears (8/8).
|
| |
|
|
|
|
|
|
|
| |
Replace the "― decompiling… ―" ASCII-bar label with a small animated widget
(_DecompLoading): an amber braille spinner (matching the app's #b58900 accent)
+ a muted italic "decompiling…", dim over the grayed pseudocode. Spins at 12fps
via set_interval; initial frame set in __init__ so the cover has content
immediately (the pilot reads it synchronously). CSS keeps the dim panel bg +
centering; per-glyph colors come from the Rich Text now.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The F5/Tab-from-listing path decompiled the function inside _decomp_from_listing
(via _decomp_line_for) in a background thread with no overlay, THEN _show_active
re-decompiled it -- but that second call hit the cache and returned instantly, so
dec.loading was set and cleared within a frame and the overlay only flashed. The
long wait (the real decompile) happened with nothing on screen.
Raise the pseudocode pane + loading overlay synchronously in action_toggle_view
before launching the background decompile, so the wait is covered. Non-function
F5 restores the listing via _decomp_from_listing_failed.
Pilot view_toggle now drives the real path (action_toggle_view from the listing)
and asserts the overlay is raised synchronously; F5 paths (decomp_fallback,
continuous_view, region_define) all still pass (20/20 + view_toggle 7/7).
|
| |
|
|
|
|
|
|
|
| |
The extra `CommandPalette CommandList { width: 80% }` rule set the results list to
80% of the already-80% container -> ~64%, so the results were visibly narrower
than the input/modal. Drop it and instead pin the overlay `#--results` to 100% of
the palette Vertical, so the results span the full modal width. Verified the
input/results width relationship now matches the stock palette exactly (results =
modal width, input 2 cols narrower from its border) at 80% scale.
|
| |
|
|
|
|
|
|
|
|
| |
The stock Ctrl+P palette spans the full terminal width, which looks off on wide
terminals. Constrain CommandPalette > Vertical (and the #--results/CommandList
overlay) to 80% width capped at 120 cols; it's already center-aligned, so this
leaves symmetric left/right margins.
Verified via pilot: input-row width 78/110/118 for terminals 100/140/200 (vs
full width before), and the palette scenarios still pass (7/7).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Ctrl+P palette was Textual's stock system commands (change theme / take
screenshot / quit) -- useless for RE. Add an IdaCommands command Provider and set
IdaTui.COMMANDS = {IdaCommands} so the palette lists real actions instead:
goto, find symbol, follow, xrefs, toggle disasm/pseudocode, continuous listing,
hex, rename, retype, comment, define code/func, make data/string, undefine,
toggle opcodes, structs editor, filter, names pane, save, quit -- each with its
keybind as help text, fuzzy-searchable.
App-level actions run directly; cursor-scoped ones (rename/xrefs/comment/...) are
dispatched to the active code view via IdaTui._palette_action (focus + run the
view's action_*), so a palette pick does exactly what the key does.
Verified live over RPC: 'hex' switches view, 'goto' opens the prompt, 'struct'
opens the StructEditor modal, and the stock 'theme' command is gone. Pilot
command_palette scenario: Ctrl+P opens it and a command executes (7/7).
|
| |
|
|
|
|
|
|
|
|
|
| |
The logo.ans art is 33 rows tall; on a short/narrow terminal the auto-height
loading box would overflow and clip both the art and the title/note/help
(center-aligned, so the useful text scrolls off top and bottom). LoadingScreen
now only mounts the logo when app.size fits it plus the chrome (height >= lines+9
and width >= 64); otherwise it falls back to the text-only overlay.
Verified across sizes: 90x44 -> shown, 90x30 -> omitted (too short), 50x44 ->
omitted (too narrow), 120x50 -> shown.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Replace the clamp-to-nearest-edge follow with a true screen-position freeze:
watch_scroll_y now shifts the byte cursor by the exact scroll delta on a user
scroll (wheel/scrollbar), so it points at a new byte but stays on the same screen
row. Cursor-driven scrolls (_scroll_to_cursor via _apply_scroll) are marked with
_internal_top and skipped, so key-nav/click don't double-move the cursor. All
scroll sources funnel through the one watch point.
Pilot `hex` scenario: put the cursor mid-viewport, scroll 30 rows, assert its
screen row is unchanged (+ clicks still land on the exact byte). 10/10 pass.
|
| |
|
|
|
|
|
| |
_load_logo() reads logo.ans (repo root) once, strips the cursor show/hide escapes
so Rich sees only SGR, and parses it with Text.from_ansi (no_wrap). LoadingScreen
renders it above the title, centered in the box; missing/unreadable logo degrades
to no splash art. Verified: 33x60 art mounts in the modal and renders in 256-color.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Override watch_scroll_y so a viewport scroll drags the byte cursor along: when
the rounded scroll changes, clamp the cursor's row into the visible range
[top, top+height) keeping its column, so it rides the nearest edge instead of
being left off-screen. No feedback loop -- key-nav's _scroll_to_cursor already
puts the cursor in view (clamp is then a no-op), and HexView.Moved only updates
the status line. Pairs with the click-to-place support.
Pilot `hex` scenario updated: after a scroll the cursor must now be within the
visible rows (was: asserted it stayed put). 9/9 hex checks pass.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
HexView subclasses ScrollView directly and had no mouse handler, so clicking
never moved the byte cursor -- after a wheel-scroll the cursor was stuck off
screen with no way to reposition it by mouse. Add on_click: map the content
offset (scroll_offset + click, past the 1-col padding) to a row, and the x column
to a byte 0..15 across both the hex cells (3 cols each, +1 gap before byte 8) and
the ascii pane. Double-click jumps to code, mirroring Enter.
Pilot scenario extends `hex`: scroll the viewport (cursor goes off-screen), then
click in the hex pane and the ascii pane and assert the cursor lands on the exact
clicked row+byte. 9/9 hex checks pass.
|
| |
|
|
|
|
|
|
|
| |
The Architecture section still described the deleted client.py (HTTP MCP client);
replace it with worker.py + worker_client.py + errors.py, and note the
patch_server custom-tool injection. Modernize "What it does" (unified IDA-style
listing as the default view, F5/Tab decompile, xref kinds, the new line-motion
keys, fuzzy palette) and drop stale mentions of the 127.0.0.1:8745 supervisor and
"page over the MCP server".
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
ida-pro-mcp's xref_query only classifies xrefs as code/data (xr.iscode). Add an
injected `xref_types` tool (server/patch_server.py) that mirrors xref_query's
query/envelope shape but derives a fine `kind` from the IDA xref type:
call/jump/flow for code (fl_CF/CN/JF/JN/F), read/write/offset/text/info for data
(dr_R/W/O/T/I). The worker self-injects it on startup like the other custom tools.
* domain: Xref gains a `kind` field; _parse_xrefs reads it; xrefs_to() now calls
xref_types (falling back to xref_query if absent). xrefs_from is unchanged.
* app: the `x` dialog shows the kind as an aligned column after the address
(`000034F4 read sub_34F0+0x4`).
* rpc: the structured xrefs_to read carries `kind` too.
Verified live over the worker/RPC harness on targets/echo: sub_2C00 callers ->
call; __progname -> offset (GOT), read (sub_34F0), write (sub_3500); stdout ->
read/offset.
|
| |
|
|
|
|
|
|
|
|
|
| |
<home> goes to the true start of line (the address); shift+home now lands on the
mnemonic, skipping the address + opcode-bytes gutter. _insn_col() mirrors
_line_plain()'s prefix (address + indent + opcode field + member indent + name
prefix) so the column matches what's rendered; funchdr/label land after the
address, sep after the indent.
Verified live over RPC: on a code line, <home> -> col 0 (word '0000249A', the
address), shift+home -> col 40 (word 'push', the mnemonic).
|
| |
|
|
|
|
|
|
|
| |
Mirror the <end> change: in the unified ListingView, `home` now maps to col_home
(start of current line) instead of goto_top. Top/bottom move to the conventional
ctrl+home / ctrl+end (G still does bottom too), so no navigation is lost.
Verified live over RPC: <home> moves col 47->0 on the SAME line, scroll unchanged;
ctrl+home lands at line 0, ctrl+end/G at the last line.
|
| |
|
|
|
|
|
|
|
|
|
| |
In the unified ListingView, `end` was bound (with `G`) to goto_bottom, so it
triggered a vertical scroll. Rebind `end` -> col_end (same as `$`) so it moves the
column cursor to the end of the current line; `G` still does goto_bottom, and
`home`/`0` are unchanged.
Verified live over RPC: with the cursor forced to col 0, <end> moves col 0->47 on
the SAME line with scroll_y unchanged (no vertical jump); `G` still lands at the
last line.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
* RPC.md: the TUI is launched via `./ida-tui <bin> --rpc <sock>` (was
`idatui.tui --db`); point the "regression lock" note at rpcclient/drive +
the pilot instead of the deleted rpc_smoke.py.
* PAGING_FINDINGS.md: reframe the intro as the ida-pro-mcp tool functions the
worker now calls in-process (shapes/caps unchanged); replace the
supervisor-era "idle self-exit" + "max_workers cap" sections with the
single-owned-worker lifecycle (no cap, no idle-exit, crash -> reconnect).
* TEXTUAL_NOTES.md / TUI_DRIVING_BLUEPRINT.md: drop the ida-pro-mcp framing and
the deleted rpc_smoke.py references (-> test_scenarios.py / rpcclient).
Also updated the idatui + idatui-rpc skills (in ~/.pi, outside the repo) to the
worker model: no supervisor/spawn.sh/--db, worker python + $IDATUI_WORKER_PYTHON,
worker.py/worker_client.py/errors.py architecture, and the load-starvation gotcha.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The idalib worker is the only backend now, so remove the dead HTTP/supervisor
surface entirely (~2200 lines):
* deleted idatui/client.py (the IDAClient HTTP/JSON-RPC transport + session
manager), idatui/tui.py (the old mcp TUI entry, superseded by launch.py),
spawn.sh, and systemd/ (the supervisor unit).
* deleted the mcp-only tests (stress_client, smoke_client, test_keepalive,
stress_paging, rpc_smoke, serverctl.sh, pane_smoke, test_domain) -- the worker
pilot (tests/test_scenarios.py) supersedes them.
* migrated the tmux RPC harness (idatui/pane.py) to the worker: it spawns
`idatui.launch <binary> --rpc <sock>` instead of the mcp `idatui.tui`, drops
the supervisor auto-start/ensure machinery, and reaps our own worker
(idatui/worker.py) instead of ida_pro_mcp.idalib_server. --db/--url/--no-
ensure-server are gone; --open is required.
* __init__ / __main__ / domain no longer import client (exceptions come from
errors.py, the domain client hint is WorkerClient); pyproject points both
console scripts at idatui.launch; README + ida-tui header describe the
worker-only flow.
What stays (by design): the ida_pro_mcp *package* (the worker reuses its @tool
functions in-process) and server/patch_server.py (the worker injects its custom
tools on startup). Verified: whole package imports + IdaTui constructs + pilot
lists 31 scenarios. The worker pilot (134 pass / 2 known flakes) is the E2E gate.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The idalib worker is now the sole backend for opening a binary, so remove the
ida-pro-mcp code paths from the hot path:
* app.py: IdaTui.__init__ drops url/db/ensure_server/backend (now just
open_path/keepalive/rpc_path/ttl); _connect calls _open_worker_client directly;
_open_mcp_client deleted; _reconnect respawns the worker only; self.client and
_after_reconnect typed WorkerClient. No more IDAClient import.
* launch.py: rewritten worker-only -- validate the binary, sweep stale locks,
spawn the TUI (which starts the private worker behind its overlay). The whole
supervisor dance (_ensure_server/_start_supervisor/_open_binary/
_existing_session) is gone; `ida-tui foo.elf` is the one usage.
* tests/test_scenarios.py: pilot is worker-only (run(binary); binary via
positional/--worker/--binary, defaults to targets/echo).
Verified: app + launch + pilot import and construct; pilot lists 31 scenarios.
The mcp modules (client.py/pane.py/tui.py/spawn.sh) still exist as dead code and
are deleted in the next commit. Worker pilot (134/2-known-flakes) still the gate.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
The IDAError/IDAConnectionError/IDAToolError/... exceptions and the Session
dataclass were defined in client.py (the ida-pro-mcp HTTP client), but the idalib
worker path (worker_client/domain/app) needs them without the HTTP transport.
Move them to a transport-agnostic errors.py; client.py re-exports them so the
deprecated mcp tooling and stress tests are unchanged (verified:
errors.IDAToolError IS client.IDAToolError, so cross-module `except` still works).
worker_client, domain (TYPE_CHECKING-guarded IDAClient hint), app, and __init__
now import the shared types from errors.py. This decouples the worker path from
client.py at runtime -- the prerequisite for deleting the mcp transport.
|
| |
|
|
|
|
| |
IdaTui.__init__ requires url and db positionally (the launcher passes url=args.url,
db=None even for the worker). The pilot's --worker branch omitted them ->
TypeError. Pass url="" (unused by the worker backend) and db=None.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
run()/main() gain a worker path: IdaTui(open_path=binary, backend="worker",
ensure_server=False) instead of attaching to an mcp supervisor via --db. boot()
and every scenario are backend-agnostic (they drive app.program), so the full
31-scenario suite runs unchanged on the worker. This is the verification gate
before deleting the mcp transport:
~/ida-venv/bin/python tests/test_scenarios.py --worker targets/echo
The default (no --worker) still uses the mcp supervisor + --db, so nothing
regresses until we pull the plug.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
The worker reuses ida-pro-mcp's @tool functions, but our custom tools
(heads/read_raw/resolve_names/func_types/del_type/set_lvar_type) only exist
because server/patch_server.py injected them into the installed api_types.py --
which historically only ran via spawn.sh. On a fresh box (or worker-only setup
with spawn.sh gone) those tools would be missing and heads/listing would break.
_ensure_tools_injected() now runs patch_server.main() (idempotent, IDA-free) at
the top of _open_and_register, before ida_pro_mcp.ida_mcp is imported, so the
worker guarantees its own tool surface. Verified under /usr/bin/python: injection
runs and heads/read_raw/resolve_names/func_types land in api_types.py. This is
the prerequisite for deleting spawn.sh from the worker path.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Opening a binary now defaults to our own idalib worker; the ida-pro-mcp HTTP
supervisor path is deprecated (kept only for --db/attach and --backend mcp).
* launch.py: --backend default resolves to worker for a fresh binary open, mcp
for the attach modes (--db / bare `ida-tui`, which have no worker equivalent);
explicit --backend or IDATUI_BACKEND still wins. Logs a deprecation notice when
the mcp path is used.
* Deprecation markers on client.py and server/patch_server.py; the ida-tui shell
header and README now describe the worker as primary and note
$IDATUI_WORKER_PYTHON. TODO tracks the removal checklist.
No code deleted yet — the mcp fallback stays until the worker is proven on a box
where idalib can spawn (pilot against --backend worker is the gate). Backend
resolution matrix verified: `ida-tui bash`->worker, bare/`--db`->mcp, explicit
flag/env honored.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The MCP server sets structuredContent = result if isinstance(result, dict) else
{"result": result} (zeromcp mcp.py:832), and domain.py parses that exact shape —
e.g. function_of() reads payload["result"] from lookup_funcs, which returns a
bare list. Our worker returned the raw list, so function_of got a non-dict and
returned None: hence "F5 — cursor is not inside a defined function" on Tab.
Replicate the rule in the worker's dispatch: dict passes through, anything else
(list/scalar) is wrapped as {"result": ...}. Fixes function_of and every other
list-returning tool (resolve_names, xref_query, list_funcs) in one shot.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Root cause of "ModuleNotFoundError: No module named 'ida_pro_mcp'": the worker was
spawned with sys.executable — the TUI's python (~/ida-venv) which has idalib +
textual but NOT ida_pro_mcp. The package split on this box:
/usr/bin/python : idapro + ida_pro_mcp (the "IDA python")
~/ida-venv/python : idapro + textual (the "TUI python", runs the app)
Fix: WorkerClient now auto-detects a python that can import ida_pro_mcp
(IDATUI_WORKER_PYTHON override, else /usr/bin/python[3], else sys.executable) and
runs worker.py as a SCRIPT rather than `-m idatui.worker`, so it doesn't import
the textual-dependent idatui package __init__ under a python that has no textual.
worker.py itself is pure stdlib at load; idapro/ida_pro_mcp are imported at
runtime (both present in the IDA python).
Verified: detection returns /usr/bin/python; worker.py loads clean there.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The worker's stderr was swallowed by the TUI, so an open failure showed only
"worker exited during startup (code 1)". Now:
* WorkerClient captures the worker's stdout+stderr to /tmp/idatui-worker-*.log
and, on a startup exit, surfaces the last meaningful line in the error (the
worker prints a clean 'WORKER-FATAL: ...' marker; _log_tail prefers it).
* worker.py wraps main() to print that marker + traceback before exiting 1, and
gives an ACTIONABLE open error: "failed to open <bin>: the .i64 is likely held
by a running ida-mcp worker (pkill -f idalib) or wedged (delete .id0/.id1/
.id2/.nam/.til)". Also calls ida_auto.auto_wait() after open to fully match
ida-mcp's session manager (open_database + auto_wait).
Root cause of the reported failure is almost certainly a leftover ida-mcp worker
still holding bash's .i64 from earlier --backend mcp runs: idalib can't open a
database another process has locked. Fix: pkill -f idalib, then retry
--backend worker; the error message now says so instead of "code 1".
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
step 3)
Wires WorkerClient into the app as a selectable backend, so you can launch:
ida-tui --backend worker /path/to/binary # or IDATUI_BACKEND=worker
* IdaTui gains a `backend` param. _connect is refactored into _open_mcp_client()
(the existing ida-pro-mcp path, unchanged) and _open_worker_client() (spawns a
private idalib worker via WorkerClient that opens+analyzes THIS binary and
streams progress into the loading overlay). The common tail (health, keepalive,
Program, load_functions) is shared, so domain.py and every view are untouched.
* _reconnect branches the same way: a dropped worker (segfault → closed socket)
respawns a fresh WorkerClient — the connection-loss recovery already built
works verbatim for the worker.
* launch.py adds --backend (env IDATUI_BACKEND); the worker path skips all the
supervisor/server plumbing (it owns a private worker) and just sweeps stale
locks + requires a binary. keepalive is a no-op for the worker (it never idles
out). mcp remains the default — nothing changes unless you opt in.
Verified without idalib: all four files parse; --backend is in --help; IdaTui
constructs for both backends with both openers present; WorkerClient covers the
full client surface. The idalib E2E (experiments/worker_smoke.py, and actually
launching --backend worker) still can't run in this sandbox — it now reaps every
idalib spawn before a byte is written — but the mcp default is untouched and the
worker path reuses proven pieces (the unix protocol benched at ~50us/call; the
worker dispatches the same tool functions the HTTP path does).
To validate on a real box: ida-tui --backend worker targets/echo (or run
experiments/worker_smoke.py for the headless read-path check).
|