aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/app.py (follow)
Commit message (Collapse)AuthorAgeFilesLines
* theme: rebuild the code palette on measured contrast, not tasteblasty2026-07-251-25/+25
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The old palette had the hierarchy INVERTED. Measured against the background: address grey58 = 6.0:1 and opcode bytes grey50 = 4.6:1, versus mnemonic cyan 3.8:1 and label yellow 4.3:1 — the structural noise you never read was louder than the mnemonics and names that are the entire point. The pseudocode gutter (grey37, 2.8:1) sat below the 3:1 accessibility floor. Half the colours were terminal-palette names (cyan/yellow/white/greyNN), so the scheme rendered differently on every user's terminal and could never match our hex theme. And amber meant five different things (label, function header, data, member, match). Principles now applied: * frequency is inverse to intensity — mnemonics are on every line, so they get the brightest NEUTRAL rather than a hue; colour is spent on what you scan for * one hue, one meaning — amber is attention/match and nothing else * structure recedes: addresses/opcodes/gutters 3.2-3.9:1, never above body * all hex, no terminal-palette names * foreground encodes kind, background encodes state (match/cursor/link) * never hue alone — bold/italic carries the same distinction for red-green colour-blind users Result, all measured vs #12161c: mnemonic #e8ecf2 15.3 body #c3cad3 11.0 strings #9ece6a 9.9 data/num #d8a657 8.2 member #93aee0 8.1 names #7aa2f7 7.2 comment #7c8b9e 5.2 address #6b7684 3.9 gutter #626c7a 3.4 opcodes #5e6875 3.2 Signal band 7.2-15.3:1, structure band 3.2-3.9:1 — separated, nothing below 3:1, and the old 2.8:1 gutter failure is fixed. Verified by rendering a real listing: opcode bytes dimmest, addresses dim, mnemonics brightest, names in blue. Suite 192/0. Not touched: highlight.py maps Pygments tokens for the pseudocode view and still uses its own colours — worth bringing into the same scheme next.
* theme: stop inheriting Textual's neon orange; ship our own paletteblasty2026-07-251-5/+29
| | | | | | | | | | | | | | | | | | | | | Nobody picked that orange — it's #ffa62b from Textual's default textual-dark, which sets accent AND warning to the same value, so every border, title bar and the quit dialog were all the same neon. Meanwhile the code views already use a coherent muted palette (amber #b58900 for matches and the spinner, #6a9955 green for comments, #264f78 blue for the word highlight), so the chrome was the only thing shouting. Register an "idatui" theme in the same family: desaturated blue-greys for background/surface/panel, amber (#b58900 — the exact match highlight colour) as the accent, a burnt orange (#c9762f) for warning so it reads as care rather than as another accent, blue (#5aa0d6) reserved for focus, and the reds/greens we already used for failure and comments. Title bars were color:$text on the accent, which was passable on bright orange and muddy on amber; they're now dark-on-amber and bold, which reads as intentional. Suite 192/0, project UI 23/23.
* help: lay the cheatsheet out as fluid columns of cardsblasty2026-07-251-13/+66
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Five sections stacked vertically ran ~45 rows, so F1 scrolled on any terminal under ~50 lines. Each section is now its own bordered card (title in the border), and the cards flow into as many columns as the width allows. Textual CSS has no media queries, so the column count is computed in compose from the real app width. Sizing off the WIDEST section would let one long row (Move's "Ctrl+Home / Ctrl+End") inflate every column and cost a column that would otherwise fit, so _columns() measures the actual layout instead: chunk the sections, sum the per-chunk maxima, take the most columns that fit. Keys are right-aligned per card rather than globally, so a card of short keys stays narrow. 200x50 -> 3 cols 140x44 -> 2 cols 100x36 -> 1 col (scrolls) 160x44 -> 3 cols 120x40 -> 2 cols The scroll container stays, so a very small terminal degrades to scrolling instead of clipping — but at any normal size nothing scrolls. The box hugs its content (width:auto all the way down; VerticalScroll needed width:auto too or it filled to max-width) and stays centred. Tried a real CSS Grid first: it collapsed to zero height at narrow widths, and grid sizing from on_mount read a stale app width. Explicit columns are predictable. Also shortened one description that was pushing the widest card out. help scenario now asserts the five cards exist and that the content fits without a scrollbar. Suite 192/0.
* strings: F2 widens the strings browser to the whole projectblasty2026-07-251-24/+70
| | | | | | | | | | | | | | | | | | Completes phase 2 — the index already carried strings (KIND_STRING), the palette just didn't offer the toggle. StringsPalette now mirrors SymbolPalette: F2 flips between this binary and the project, project rows are prefixed with their binary, and choosing a literal in another binary switches to it and jumps. Ranking matches the symbol side: the trigram index guarantees the match, so ordering is earliest-match then shortest, with a (binary, addr) tiebreak — the same tie that crashed symbol search when two binaries shared a name, avoided here by construction. Project scope caps at 60 like symbols. _results is now (binary, addr, text) tuples in both palettes, so _on_string_chosen takes the same (binary, addr) choice and routes through _switch_then_goto. test_project_ui.py: local scope stays single-binary, F2 spans >=2 binaries (23 checks). Suite 191/0.
* projects: open the Ctrl+O switcher on the binary you're already inblasty2026-07-251-1/+4
| | | | | | | | It always highlighted row 0, so switching away and back meant hunting for your current position in the list. Preselect the entry marked active instead; if a filter excludes it, fall back to the first row as before. Locked in test_project_ui.py (21 checks).
* palette: cap project-scope results at 60 (mitigation for sluggish arrowing)blasty2026-07-251-3/+8
| | | | | | | | | | | | | | | | | | | | | | | Reported: arrowing through project-scope results is sluggish, and switching back to this-binary scope is instantly fast again. Everything I can measure here says the palette is fine, so this is a hypothesis-driven mitigation, not a proven fix. Ruled out by measurement: * per-option render + highlight move: 0.80 ms (local) vs 0.82 ms (project) at 200 options each — identical and fast * wrapping from the binary-name prefix: none, 1.00 lines/option in both * option count driving render cost: flat from 20 to 400 options * an app-level OptionHighlighted handler doing work: none exists * scroll animation: Textual's scroll_to_highlight already passes animate=False, immediate=True The synthetic pilot harness has a ~62 ms floor per press (Textual awaits a full refresh cycle), so end-to-end key timing there is meaningless. What remains plausible is simply LIST LENGTH in a real terminal: project scope saturates its cap because every binary contributes, while a local filter usually returns a handful — which matches the symptom exactly. So project scope now caps at 60 rather than 200 (and fetches 3x that from the index). 60 is plenty for a search palette; typing narrows further.
* fix: project-wide search crashed on a name shared by two binariesblasty2026-07-251-1/+4
| | | | | | | | | | | | | | | | | | TypeError: '<' not supported between instances of 'Hit' and 'Hit'. The rank tuple built for project scope ended with the Hit itself, so when two binaries contain the SAME symbol name the first three fields (match position, length, text) tied and sort() fell through to comparing Hit dataclasses, which aren't orderable. Shared names — main, textdomain, the whole libc surface — are the norm in a project, so this fired almost immediately. Sort on an explicit key that stops at the orderable fields and breaks ties on (binary, addr), which also makes the ordering deterministic instead of input-order dependent. Regression test in test_project_ui.py, where 'main' exists in both echo and cat: widen to project scope and assert the shared name is found in both binaries — it crashed before the fix. 20/20 there, suite 191/0.
* palette: cut the per-keystroke cost of project-wide symbol searchblasty2026-07-251-8/+13
| | | | | | | | | | | | | | | | | | | | | | | | | | | Reported as sluggish up/down navigation in project scope. Measuring it turned up two things: * The arrow keys themselves aren't the problem, and it isn't list length: a synthetic down-key benchmark reads a flat ~62 ms/key from 20 to 400 options in BOTH scopes. That is a fixed floor in pilot.press() (Textual awaits a full refresh cycle per synthetic press), so the harness can't isolate real key latency — the number says nothing about the app. * What IS measurably worse in project scope is the work done per typed character: 29.5 ms vs 17.8 ms locally. Fix the part that's real. The trigram index already guarantees every hit contains the query, so ranking only has to ORDER them — an exact-substring rank (match position, then name length) costs one find() per row instead of a full fuzzy pass, and fetching 3x the display limit instead of 10x cuts the candidate set. As a bonus the highlight spans come straight from the match position. Per-keystroke _apply over a 30k-entry, 6-binary index, typing "m"->"main_": before project ~29.5 ms after project 10.8 / 11.2 / 11.9 / 13.2 / 14.2 ms (local, unchanged, for reference: 13.8 / 14.3 / 15.2 / 16.2 / 38.8 ms) Project scope is now cheaper per keystroke than local scope, whose fuzzy pass over every function is the remaining hot spot. Suite 191/0.
* projects: project-wide symbol search over a SQLite FTS5 index (phase 2)blasty2026-07-251-21/+109
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-251-1/+105
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* ui: drop the footer cheatsheet; F1 opens a key reference insteadblasty2026-07-251-3/+101
| | | | | | | | | | | | | | | | | | | | | | | | | | 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-251-2/+16
| | | | | | | | | | | | | | | | | 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-251-6/+36
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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-251-5/+238
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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).
* split: propagate pure scrolls (wheel/scrollbar) to the companion paneblasty2026-07-251-9/+49
| | | | | | | | | | | | | | | | | | | | | | | | | 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-251-2/+19
| | | | | | | | | | | | | | | | | | 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.
* strings: browse every string in the binary and jump to it (IDA's Shift+F12)blasty2026-07-251-0/+138
| | | | | | | | | | | | | | | | | | | | | | | 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-251-2/+40
| | | | | | | | | | | | | 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-251-2/+17
| | | | | | | | | | | | 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-241-5/+37
| | | | | | | | | | | | | | | | | | | | | 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-241-12/+45
| | | | | | | | | | | | | | | | | | | | | 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-241-2/+88
| | | | | | | | | | | | | | | | | | | | 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-241-0/+2
| | | | | | | | 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-241-1/+58
| | | | | | | | | | | | | | | | | | | | | 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-241-0/+4
| | | | | | | | | | | 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-241-1/+20
| | | | | | | | | | | | | | | | 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-241-0/+79
| | | | | | | | | | | | | | | | | | 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-241-14/+21
| | | | | | | | | | | | 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-241-2/+27
| | | | | | | _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-241-0/+20
| | | | | | | | | | | | 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-241-0/+33
| | | | | | | | | | | | | 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.
* xrefs: show fine-grained kind (call/jump/read/write/offset) in the dialogblasty2026-07-241-1/+2
| | | | | | | | | | | | | | | | | | 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.
* mcp: collapse app + launcher to worker-onlyblasty2026-07-241-108/+15
| | | | | | | | | | | | | | | | | | | | 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-241-1/+2
| | | | | | | | | | | | | 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.
* app: --backend {mcp,worker} — run the TUI on our idalib worker (migration ↵blasty2026-07-241-52/+87
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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).
* robust: survive a lost server connection instead of crashing; auto-reconnectblasty2026-07-241-2/+95
| | | | | | | | | | | | | | | | | | | | | | | | | Leaving the TUI idle could let the analysis server go away (idle exit, killed, box slept). The next action's worker then raised IDAConnectionError, which Textual escalated to a fatal app exit (exit_on_error) — the whole TUI crashed with a traceback. Now IdaTui._handle_exception intercepts a WorkerFailed whose cause is IDAConnectionError and, instead of dying, runs a reconnect: * show a "connection lost — reconnecting…" overlay; * _reconnect() restarts the supervisor if it's down (_ensure_server), makes a fresh client, re-opens the binary (open_path) or re-resolves the sole session, restarts the keepalive, and swaps in the new client/Program; * _after_reconnect rebuilds the function index and refreshes the current view with the new program; on failure it leaves a message and retries on the next action. A re-entry guard avoids stacking attempts when many in-flight calls fail at once. Any other exception still crashes as before. Also harden _status() to never throw (query_one can miss #status during a screen transition), so a status update can't take down a worker. Verified: injecting IDAConnectionError into a nav worker -> no crash, overlay shown, new Program swapped in, overlay cleared, post-reconnect calls work. Full pilot suite green.
* nav: baseline horizontal scroll at 0 when jumping to a target columnblasty2026-07-241-4/+5
| | | | | | | | | | | | | | | | | DecompView.goto (repositioning within an already-loaded function, e.g. an xref jump inside the current function) derived the horizontal scroll from the CURRENT offset and kept it when the target column fell inside the (stale) viewport. So a jump could land the cursor on the right column but leave the pane still scrolled right from wherever you were, showing the line shifted/off. Baseline the horizontal offset at 0 before computing: reset to 0, then scroll right only if the target column is beyond the viewport width. The derive branch is jump-only; back/forward restores pass an explicit scroll_x and are untouched. Verified: goto with a stale scroll_x=19 -> scroll_x=0 after (col fits); scroll_restore + decomp_nav/decomp_follow_self/follow_xrefs/xref_labels/ view_toggle/disasm_nav/search/mouse/listing_view/continuous_view/func_banners green.
* ux: block with a "finding xrefs…" overlay while gathering xrefsblasty2026-07-241-6/+71
| | | | | | | | | | | | | | | | | | | | Gathering xrefs (xrefs_to + function_of over every site) runs in a worker, so the UI stayed live and a second 'x' (or other action) fired into a half-finished operation — duplicate requests, dialogs stacking, generally confusing behavior. Add a small blocking BusyScreen modal shown the instant 'x' is pressed: * re-entry guard (_xref_active) ignores a second 'x' while one gather is in flight, and the modal captures input anyway; * _present_xrefs dismisses it and opens the dialog when results are ready; * Esc cancels — a worker that finishes after the cancel won't pop a stale dialog (the _xref_active flag gates it); * every exit path clears it (no-subject case, and a try/except around the worker's slow calls) so it can't get stuck. Verified: 1st 'x' -> BusyScreen (xref_active=True); 2nd 'x' ignored (stack unchanged); results -> dialog + busy gone; Esc -> cancelled, no late dialog. follow_xrefs/xref_labels/decomp_nav/decomp_follow_self/view_toggle/disasm_nav/ search/mouse/listing_view/continuous_view/func_banners/startup green.
* nav: snap an xref-into-pseudocode jump to the line that holds the symbolblasty2026-07-241-5/+40
| | | | | | | | | | | | | | | | | Hex-Rays can attribute an address to a pseudocode line a step off from where the referenced symbol actually appears, so an xref jump could land on a line that doesn't contain the token (cursor at column 0, wrong line). Add _decomp_locate: anchor on the /*0xEA*/ marker line for the address, but when a token is known, snap to the whole-word occurrence NEAREST the anchor (preferring the anchor line, then the line just below). This fixes the marker/ symbol line discrepancy and disambiguates a symbol that appears multiple times by picking the occurrence closest to the address. The mid-function decomp-jump branch now uses it. Verified: real xref (sub_2060 from sub_20DD) still lands on 'sub_2060(a2);' cursor_x=4; synthetic discrepancy (marker on a line without the symbol) snaps to the nearest call-site line, not the far unrelated occurrence. Full suite green.
* nav: xref jump into pseudocode lands on the referenced symbol, not column 0blasty2026-07-241-1/+5
| | | | | | | | | | | | | | | | | The decompiler-jump column fix used fn.name (the target's containing function), which is correct when jumping to a function's own prototype but wrong for an xref jump to a call SITE: the token on that line is the symbol xrefs was invoked on, not the enclosing function. So x-pane jumps landed at column 0. Use focus_name (the xref's subject, already threaded through _goto_ea) as the token for a mid-function target; keep fn.name for the ea == fn.addr (prototype) case. Verified: xrefs to sub_2060 -> pick the caller site -> lands in sub_20DD's pseudocode on ' sub_2060(a2);' with cursor_x=4 = 'sub_2060'. Full suite (disasm_nav/view_toggle/search/follow_xrefs/xref_labels/mouse/decomp_nav/ decomp_follow_self/rename/rename_history/listing_view/continuous_view/ func_banners) green.
* nav: land the decompiler cursor on the function name, not column 0blasty2026-07-241-5/+11
| | | | | | | | | | | | | | | | | | Jumping to a function in the decompiler (follow/xref into pseudocode) parked the cursor at column 0 of the prototype line. Now it lands on the function's name in the prototype instead, mirroring the listing's ref-column behavior. _do_navigate's prefer_decomp branch: when the target is the function itself (ea == fn.addr) it uses the prototype line (0) and computes the name's column via _decomp_col_for; a mid-function target uses the ea-matched line (name column when present, else 0). The column rides through _open_decomp_entry -> NavEntry .dec_cursor_x -> _apply_decomp's view.show(cursor_x=...). Verified: following sub_2060 from pseudocode lands on line 0, cursor_x=28, which is exactly 'sub_2060' in "unsigned __int64 __fastcall sub_2060(int a1)". decomp_nav/decomp_follow_self/xref_labels/view_toggle/disasm_nav/search/mouse/ listing_view/continuous_view/func_banners/rename green (follow_xrefs is a pre-existing ordering flake; passes in isolation).