summaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
* projects: project-wide symbol search over a SQLite FTS5 index (phase 2)blasty2026-07-255-32/+450
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* exit: ask before quitting with unsaved database changesblasty2026-07-252-7/+141
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* tests: fix the "filter flake" and the view_toggle cascade — suite is 187/0blasty2026-07-251-8/+29
| | | | | | | | | | | | | | | | | | | | | | | | | 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.
* ui: drop the footer cheatsheet; F1 opens a key reference insteadblasty2026-07-252-10/+132
| | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* decomp: Home/End move along the line instead of scrolling to top/bottomblasty2026-07-252-2/+50
| | | | | | | | | | | | | | | | | 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.
* retype: 'y' now retypes globals too, not just prototypes and localsblasty2026-07-254-7/+184
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* ui: horizontally centre the splash logo in the loading dialogblasty2026-07-251-3/+7
| | | | | | | | | | | | 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.
* ui: centre the strings and project palettes (were clamped top-left)blasty2026-07-251-1/+2
| | | | | | | | | `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).
* projects: switch between a project's binaries in the TUI (phase 1c)blasty2026-07-254-22/+466
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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).
* projects: worker pool with memory-budgeted residency (phase 1b)blasty2026-07-253-6/+388
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* projects: project model + binary staging (phase 1a)blasty2026-07-253-0/+559
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* split: propagate pure scrolls (wheel/scrollbar) to the companion paneblasty2026-07-252-9/+71
| | | | | | | | | | | | | | | | | | | | | | | | | 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).
* split: keep the companion pane level with the driver's cursorblasty2026-07-252-2/+38
| | | | | | | | | | | | | | | | | | 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.
* docs: README covers the split view, strings browser and command paletteblasty2026-07-251-2/+8
| | | | | | 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).
* strings: browse every string in the binary and jump to it (IDA's Shift+F12)blasty2026-07-254-1/+296
| | | | | | | | | | | | | | | | | | | | | | | 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.
* split: follow the decomp across functions as the listing cursor crosses boundsblasty2026-07-253-2/+58
| | | | | | | | | | | | | 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).
* split: click a pane to drive it (not just Tab) + spell it out in the statusblasty2026-07-252-2/+24
| | | | | | | | | | | | 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).
* split-view phase 4: split-aware status, min-width gate, verified navblasty2026-07-243-8/+65
| | | | | | | | | | | | | | | | | | | | | 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).
* split-view phase 3: rich per-line instruction region highlightblasty2026-07-245-15/+150
| | | | | | | | | | | | | | | | | | | | | 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).
* split-view phase 2: cursor sync + linked-row highlightblasty2026-07-243-5/+131
| | | | | | | | | | | | | | | | | | | | 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.
* split: guard toggle behind prompt-active; clear _split in the pilot resetblasty2026-07-242-0/+3
| | | | | | | | 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.
* split-view phase 1: side-by-side listing <-> pseudocode layoutblasty2026-07-243-3/+179
| | | | | | | | | | | | | | | | | | | | | 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.
* decomp: fix stuck 'decompiling' spinner when re-opening a loaded functionblasty2026-07-242-0/+14
| | | | | | | | | | | 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).
* decomp: prettier 'decompiling' overlay — animated braille spinnerblasty2026-07-241-2/+31
| | | | | | | | | 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.
* decomp: restore the 'decompiling…' overlay on F5/Tab from the listingblasty2026-07-242-7/+27
| | | | | | | | | | | | | | | | 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).
* palette: fix results width (were compounded narrower than the modal)blasty2026-07-241-2/+3
| | | | | | | | | 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.
* palette: give the command palette side padding (80% width, max 120)blasty2026-07-241-0/+3
| | | | | | | | | | 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).
* palette: replace the stock Ctrl+P command palette with real ida-tui actionsblasty2026-07-242-0/+102
| | | | | | | | | | | | | | | | | | 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).
* ux: omit the splash logo when the terminal is too small for itblasty2026-07-241-1/+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.
* hex: freeze the cursor's screen position on scroll (not clamp-to-edge)blasty2026-07-242-26/+38
| | | | | | | | | | | | 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.
* ux: render the logo.ans ANSI-art splash in the loading overlayblasty2026-07-242-2/+61
| | | | | | | _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.
* hex: cursor follows the scroll (stays visible on wheel/scrollbar)blasty2026-07-242-4/+25
| | | | | | | | | | | | 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.
* hex: click to place the byte cursor (hex + ascii panes)blasty2026-07-242-0/+55
| | | | | | | | | | | | | 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.
* docs: refresh README for the worker-only architectureblasty2026-07-241-16/+27
| | | | | | | | | 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".
* xrefs: show fine-grained kind (call/jump/read/write/offset) in the dialogblasty2026-07-244-8/+93
| | | | | | | | | | | | | | | | | | 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.
* listing: shift+home jumps to the start of the instruction textblasty2026-07-241-0/+24
| | | | | | | | | | | <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).
* listing: <home> jumps the cursor to start-of-line (pairs with <end>)blasty2026-07-241-2/+3
| | | | | | | | | 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.
* listing: <end> jumps the cursor to end-of-line, not goto-bottomblasty2026-07-241-1/+2
| | | | | | | | | | | 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.
* docs: sweep for the worker-only reality (drop mcp supervisor/spawn.sh/--db)blasty2026-07-244-53/+30
| | | | | | | | | | | | | | | | * 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.
* mcp: delete the ida-pro-mcp transport, supervisor, and mcp-only testsblasty2026-07-2421-2236/+67
| | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* mcp: collapse app + launcher to worker-onlyblasty2026-07-243-330/+54
| | | | | | | | | | | | | | | | | | | | 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.
* refactor: extract shared error hierarchy + Session into idatui/errors.pyblasty2026-07-246-69/+99
| | | | | | | | | | | | | 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.
* tests: pass url=""/db=None to IdaTui in the worker pilot pathblasty2026-07-241-1/+1
| | | | | | 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.
* tests: add --worker <binary> to run the pilot suite against the idalib workerblasty2026-07-241-5/+16
| | | | | | | | | | | | | 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.
* worker: inject custom tools on startup (self-sufficient, no spawn.sh needed)blasty2026-07-241-0/+20
| | | | | | | | | | | | | | 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.
* deprecate: make the idalib worker the default backend; mark mcp path for removalblasty2026-07-246-11/+47
| | | | | | | | | | | | | | | | | | 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.
* worker: wrap non-dict tool returns as {"result": ...} to match MCP shapesblasty2026-07-241-1/+5
| | | | | | | | | | | | 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.
* worker: spawn under the IDA python (has ida_pro_mcp), not the TUI'sblasty2026-07-241-4/+33
| | | | | | | | | | | | | | | | | 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.
* worker: surface the real startup failure (not just "code 1")blasty2026-07-242-7/+41
| | | | | | | | | | | | | | | | | | | 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".
* app: --backend {mcp,worker} — run the TUI on our idalib worker (migration ↵blasty2026-07-242-66/+115
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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).