aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
* loading: ask how to load an unrecognised file, like IDA doesblasty3 days7-21/+441
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Last commit let you SAY how to load a blob. This one notices when you should have. Interactive IDA pops a dialog when no loader matches; we silently loaded as x86 at 0 and analysed to nothing, so the flag only helped people who already knew they needed it — which is exactly the people who don't need help. formats.sniff() recognises the formats IDA definitely handles (ELF, PE, Mach-O, dex, wasm, ar, COFF, Intel HEX, S-records). Anything else gets LoadOptionsScreen: a filterable processor list with human labels, a load-address field, Enter to accept, Esc to load it the way IDA would have anyway. Deliberate asymmetry: the sniff only claims formats it is sure about. A false "unknown" costs one dismissible dialog; a false "known" is the silent wrong answer this exists to kill. Esc is always an escape hatch. The list offers 20 processors, not IDA's 73 — most of the rest are museum pieces, and a name typed into the filter that matches nothing is taken literally so nothing is actually unreachable. Endianness is spelled out (arm vs armb) because getting it backwards is the most common route to zero functions. Asked only when nobody has answered yet: not with --processor, not in project mode (entries carry their own), and not when a database exists — the .i64 already records how the image was loaded. Textual trap worth recording: the screen stored the file size in self._size, which is Widget's own backing field for outer_size. Assigning an int to it crashes layout with "'int' object has no attribute 'region'" from deep inside _set_dirty, nowhere near the cause. Same family as the _render collision. The paragraph conversion now lives in exactly one place (formats.load_args); BinaryRef and launch both call it. Verified on a real AArch64 blob: dialog appears, filtering to "arm" leaves two entries, base 0x8000000 accepted -> "-parm -b800000" -> 35 functions at 0x8002440. An ELF never asks, and neither does a blob that already has a .i64. tests: new tests/test_formats.py (21) and a load_options scenario asserting the dialog stays out of the way for a recognised binary. 199/0 scenarios, 39/0 project, 30/0 project UI, 36/0 index, 27/0 pool, 21/0 formats.
* loading: say how to read a headerless blob (processor, base)blasty3 days9-23/+239
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A raw firmware dump has no format to detect, so IDA fell back to x86 at address 0. It doesn't fail — it opens, analyses, and finds nothing. An AArch64 image loaded this way gave 0 functions; told the truth it gives 35. ida-tui fw.bin --processor arm --base 0x8000000 and per binary in a project, which is what a multi-image firmware actually needs: {"path": "app.bin", "processor": "arm", "base": "0x8000000"} idapro.open_database() already accepted IDA command-line switches; nothing was passing any. Plumbed BinaryRef -> WorkerPool -> WorkerClient -> worker argv, plus a load_args for the single-binary path that has no project ref. base is written the way people say it (0x8000000, int or string, any base). IDA's -b is in PARAGRAPHS — -b1000 loads at 0x10000 — so BinaryRef.load_args converts, and a base that isn't 16-byte aligned is refused rather than silently landing 16x off. ida_args passes anything else through. Two bugs found by testing the whole path rather than the happy one: * Project.load() whitelisted path/label when normalising entries, so the load options were dropped the first time a project was reopened — set a processor, come back tomorrow, it's gone. * Re-passing the switches to an EXISTING database makes IDA refuse the open (rc != 0, no functions). The .i64 already records how the image was loaded, so the worker skips them once a database exists. My first guard checked splitext(path) + ".i64" and never fired, because IDA names it "<file>.i64" — keeping the extension. It checks both spellings now. Verified on a real AArch64 blob: fresh load 35 functions based at 0x8002440, reopen 35 again, and the CLI rejects an unaligned or non-numeric --base. tests: +6 project (options recorded, paragraph conversion, file round-trip, add() takes them, an ELF passes nothing, hex-string base). 39/0 project, 195/0 scenarios, 30/0 project UI, 36/0 index, 27/0 pool.
* xrefs: show project binaries that import this exportblasty4 days3-8/+101
| | | | | | | | | | | | | | | | | | | | | | | | | | | | xrefs_to only ever sees the current database, so an exported function looks unused from the inside even when the rest of the project calls it. The import side of the phase-3 linkage index already knew better; nothing surfaced it. _foreign_importers appends those callers to the xrefs dialog. From libc's strrchr, with echo in the project: 0000C318 import [echo] strrchr Read from the on-disk index, so a caller appears whether or not its worker is resident. Choosing one carries a (binary, addr) payload instead of a bare address; _on_xref_chosen routes that through _switch_then_goto — the same path a project search hit takes — so it records a hop and Esc comes back. Only fires for a symbol this binary actually EXPORTS. A local name that happens to collide with some other binary's import is not a caller of ours, and without that check every common name (main, read, error) would sprout fictional callers. Names are compared after link_name(), so ELF versioning doesn't hide the match. Verified on a real echo+libc project: the dialog lists echo's call site, and selecting it switches to echo, lands on 0xc318 and leaves hops=['libc.so.6']. tests: +3 project UI — a symbol we don't export gets no cross-binary callers, the (binary, addr) payload jumps to the other binary, and it records the hop. 195/0 scenarios, 30/0 project UI.
* projects phase 4: cross-binary back, linkage-guided pre-warm, project verbsblasty4 days8-12/+264
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three things, all following from phase 3 making cross-binary jumps ordinary. **A cross-binary jump was a one-way door.** Nav history is per-binary, so arriving in another binary — a project search hit, or now following an import into the library that implements it — landed you in an empty history with nothing to take you back. _switch_then_goto records the binary it came FROM, and action_back falls through to that hop once local history is spent: Esc walks back through the function you were in, then the binary you were in. Manual Ctrl+O switching records nothing, because that isn't navigation. **Pre-warm follows the linkage graph, not list order.** _prewarm_provider warms the binary providing the most of this one's imports — where a follow is most likely to go, so its startup is paid before you ask for it. "Next in the list" would have been arbitrary; phase 3 gave us something better to ask. pool.prewarm() refuses rather than making room. Evicting a binary the user visited to speculatively load one they haven't is a straight downgrade, and it throws away that binary's caches as well; at a tight budget pre-warm just does nothing. The cost of a worker that doesn't exist yet can only be estimated, so it uses the largest resident one (same program, different database) — and if that estimate proves wrong, the speculative worker is the one evicted, never a chosen one. **Driving a project.** pane spawn --project FILE [--open BIN]; `binaries` lists the inventory (active / resident / indexed / where Esc returns to) and `switch {binary,addr?}` makes another active — with an address it takes the search-hit path, so it records a hop. state gains `binary` and `hops`, which it should have had the moment project mode existed. Verified on real sessions: drive binaries/switch against an echo+cat project pane; Esc crossing back from a switch; and prewarm on echo+libc picking libc (provider of echo's imports) and warming it after an evict. tests: +5 pool (prewarm warms, no-ops when resident, refuses at budget, evicts nothing when refusing, ignores unknown labels) and +4 project UI (jump records the hop, Esc crosses back, hop consumed). Confirmed the Esc-back checks fail with the branch removed. 195/0 scenarios, 27/0 project UI, 36/0 index, 27/0 pool, 33/0 project. Left open: project-level persistence across sessions.
* projects phase 3: follow an import into the binary that implements itblasty4 days6-18/+290
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Following a call to strcmp reached the PLT/extern entry and stopped there — Hex-Rays has nothing to decompile, because the code lives in a library this binary only references. With the other binary open in the same project we already had everything needed to cross that gap; we just weren't indexing it. Index each binary's imports and exports (KIND_IMPORT / KIND_EXPORT) alongside its functions and strings. On a follow, _import_stub asks whether the target address is one of this binary's import stubs; if so _cross_binary_impl asks the index who exports that name, and we switch there instead of landing on the thunk. Verified end to end on a real echo + libc project: Enter on `strrchr(a1, 47)` in echo's pseudocode switches to libc.so.6 and lands on strrchr at 0xaf960. Three things it turns on: * ELF symbol versioning. The importer sees strrchr@@GLIBC_2.2.5 while the provider may export any of three spellings, so raw names resolve almost nothing. domain.link_name() cuts at the first '@'; Linkage.raw keeps what IDA reported, which is what the listing shows. * Exact match, not substring — ProjectIndex.exact(), so `read` doesn't bind to pread/read_line/thread_start. It also answers below the 3-char trigram floor, and plenty of real exports are that short. * Resolution reads the on-disk index, so a provider resolves while its worker is evicted. That's what the index was for. When nothing in the project provides the symbol _follow_import declines and the normal navigation runs: landing on the stub is still the honest answer, and a single-binary session is unchanged. The PLT-stub PRESENTATION item stays open — an unprovided import should say "imported, provider not in project" rather than show a decompiler error. server/patch_server.py gains list_linkage (idautils.Entries + enum_import_names); a worker without it degrades to no linkage rather than failing. tests: index join +8 (exact vs substring, short names, exclude-self, reverse join, kind isolation, forget unresolves) and link_name +4. 36/0 index, 195/0 scenarios, 23/0 project UI, 33/0 project, 22/0 pool.
* nav: one notion of "which pane you're in" — delete _prefblasty4 days3-7/+29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | The app carried three overlapping ideas of the current pane: _active, _pref, and Textual focus. 190e28b tied focus to _active in split; this removes _pref, which turns out never to have been a variable at all. _pref was assigned "listing" in __init__ and "listing" on a project binary switch. Nothing else ever wrote it. But _code_view() branched on it: return self.query_one(DecompView if self._pref == "decomp" else ListingView) so it always returned the listing, whatever you were reading. Its two callers put focus back after the goto prompt closes — so cancelling `g` while in the pseudocode focused the HIDDEN listing, and the pane you were looking at stopped answering the keyboard. Arrows did nothing until you clicked. (It also explains why routing follow through _code_view() earlier made Enter a dead key: the helper had been quietly lying the whole time.) _code_view() now returns the active code pane. _pref is gone from the app, BinaryState and the RPC snapshot keeps "pref" for wire compat, sourced from _code_mode() — the one place that answers "which code view do we return to from hex", and a constant by design in the unified layout. Verified on a live pane both ways: g then Esc in pseudocode, then two Downs. Fixed, the cursor moves 0 -> 2; with the old lookup restored it sits at 0 with focus=ListingView. Same check added to the view_toggle scenario, and it fails without the fix. 195/0, project UI 23/23.
* nav: don't stack a history entry for the place you're already standing onblasty4 days2-3/+42
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Walking back with Esc ended in a dead keypress: nav_depth dropped but the screen didn't change. Measured on a live pane, four Escs after one follow: Esc #1 -> sub_3500 L7 (where we followed from) good Esc #2 -> main L54 decomp (where we followed from) good Esc #3 -> main L344 listing good Esc #4 -> main L344 listing -- nav 2 -> 1, nothing moved The app auto-lands on main at startup, and opening main again from Ctrl+N appended a second, identical entry. Every "navigate to where you already are" did this; the extra Esc it bought is invisible except that it does nothing, which is exactly what "back is broken" feels like from the keyboard. Both push sites now go through _push_nav, which replaces the top entry instead of appending when the target is the same (ea, view, line) — the newer entry's metadata still wins. _same_spot compares dec_cursor for pseudocode and cursor for the listing. Note what is NOT a duplicate: the first follow after Tab-to-pseudocode still pushes twice (nav 1 -> 3). That's deliberate — Tab leaves _cur transient and off the stack, so the follow records the pseudocode position first, which is why Esc #1 above returns to main L54 rather than dropping you into the listing. Verified that subsequent follows push exactly one each. tests: the palette scenario re-opens the function it is already on and asserts nav depth is unchanged. Fails without the fix (nav 2 -> 3). 194/0.
* palette: match case-insensitively in BOTH directionsblasty4 days2-3/+18
| | | | | | | | | | | | | | | | | | | | | Ctrl+N found nothing on libcrypto. Typing "PEM_read_bio" returned 0 of 10093 functions while the backend resolved the very same name to 0x1d6290. _fuzzy lowercased the NAME but not the QUERY, then walked the query's characters through the lowered name. One capital letter and the subsequence walk fails at the first character, so the match is not merely worse — it is None, and the palette shows nothing at all. Invisible on the test binary because C symbols there are lowercase (main, strlen, error) and every existing palette check typed a lowercase query. Fatal on any library that capitalises: OpenSSL, most SDKs, Windows binaries. The paging index was the obvious suspect and was innocent — all_loaded() had all 10093. Verified on a live libcrypto pane: "PEM_read_bio" now returns 34 hits, exact match ranked first. tests: the palette scenario now types "MAIN" and expects "main". Confirmed it fails without the fix (results=[]) and passes with it. 193/0.
* highlight: bring pseudocode onto the measured paletteblasty4 days1-11/+20
| | | | | | | | | | | | | | | | | | | | | | | | | highlight.py was still carrying VS Code Dark+ (#c586c0 keywords, #4ec9b0 types, #dcdcaa identifiers) — an entirely different colour system from the listing, which moved to a measured palette in d1cfab2. Reading a function in split view meant reading two unrelated themes side by side, with the same thing coloured differently in each pane. Same palette in both panes now, so one hue means one thing everywhere: a string is #9ece6a and a symbol #7aa2f7 whether you're in disassembly or pseudocode. Contrast against the app background #12161c, signal 7:1+ and structure 3-4:1: keyword (control) #e8ecf2 15.3 operator/body #c3cad3 11.0 string #9ece6a 9.9 number #d8a657 8.2 type/builtin #93aee0 8.1 name #7aa2f7 7.2 comment #7c8b9e 5.2 punctuation #626c7a 3.4 Control keywords take the brightest NEUTRAL rather than a hue, mirroring the mnemonic column: they're the skeleton you scan for, and a hue there would claim a meaning the rest of the palette already assigns. Punctuation drops from grey70 to 3.4:1 — it was competing with the code it delimits. Suite 192/0.
* split: don't overwrite "decompiling…" with the idle statusblasty4 days1-3/+11
| | | | | | | | | | | | | | | | | | | | | | Travelling the history stack in split mode showed no feedback at all, so Esc still read as a no-op whenever the target needed decompiling — the exact thing cc44f5c was supposed to fix. That commit set the message in action_back, but the split branch of _show_active ends with _status_for_cur("split"), which unconditionally wrote the idle status straight over it. The spinner overlay was already being raised correctly; only the words were lost. The split branch now keeps the in-flight message while it has just kicked off a decompile, and on_descendant_focus (added in 190e28b, and firing exactly when focus lands on the restored pane) no longer restatuses while dec.loading. Caveat on verification: I could not observe this on targets/echo. Going back always lands on a function the decompiler has already cached, so the busy branch completes without a visible frame — sampling the status bar every 60ms across the whole Esc showed no intermediate state. What's verified here is that the busy branch sets the message and that nothing downstream clobbers it; the visible effect needs a target slow enough to decompile, i.e. a real binary. Suite 192/0.
* split: keep _active in step with focus, so Enter follows the pane you're inblasty4 days1-0/+21
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported as "Esc in the decompiler needs two presses". It was never Esc. In split mode Tab sets _active AND focus together, but focus also moves on its own — a click, or a pane focusing itself after a load — and _active was left naming the pane you are NOT in. Everything downstream trusts _active, so with the pseudocode on screen and the listing still "active", Enter resolved the word under the LISTING's cursor: it followed something unrelated (typically the outer function itself, landing on line 0 col 19 — its own name in the prototype) and pushed history for it. The first Esc was then spent undoing that bogus entry and looked like a no-op; the second did what you meant. on_descendant_focus now syncs _active to the focused code view while split, the same pair of steps Tab already does (set _active, re-link the band, restatus). Verified on a live pane driven through tmux, which is the only harness in this session that told the truth — the in-process pilot never exercised follow-then-Esc in split, which is why four earlier attempts "passed" while the bug sat there: before: Enter on sub_3500 -> fn main, L0 (never left the function) after: Enter on sub_3500 -> fn sub_3500, L0 then Esc -> main L54, one press Suite 192/0. Known remainder, deliberately not fixed here: a follow still pushes two entries (nav 2 -> 4), so the stack is deeper than it should be. Landing is correct at every step, so it isn't visible as the reported bug. The obvious dedupe in _open_decomp_entry (skip appending src when the top entry is the same place) is inert — the duplicate comes from somewhere else, and I'd rather leave it open than commit a fix I can't show working.
* Revert "nav: bind back to backspace as well — a bare Esc isn't reliable in ↵user4 days2-34/+1
| | | | | | a terminal" This reverts commit baf599e143dbf23f33886ba03a60c5d9f3c19078.
* nav: bind back to backspace as well — a bare Esc isn't reliable in a terminalblasty4 days2-1/+34
| | | | | | | | | | | | | | | | | | | | | | | | | | | Esc-to-go-back needed two presses. It looked like the decomp pane was swallowing the first one, but the RPC state dump before/after a single Esc settles it: nav_depth 3 -> 3 action_back never ran cursor.col 19 -> 20 +1 column, i.e. `right` modal null nothing was up to eat the key The keypress arrived as a right-arrow. \x1b is both Escape and the lead byte of every arrow/function-key sequence (ESC [ C is right), so a terminal or tmux that merges a lone Esc with what follows delivers something else entirely. Nothing in the nav path touches cursor_x and there is no widget-level escape binding on the code views, so this was never application logic — I spent two commits looking in the wrong place before asking for the dump. Bind back to "escape,backspace". Backspace is unambiguous, so going back never depends on a byte the terminal can reinterpret; Esc still works wherever it's delivered intact. tmux users generally want `set -sg escape-time 10` regardless. docs/TEXTUAL_NOTES.md records the symptom and, more usefully, the triage: check nav_depth in the state dump first — if the action never ran, it's the input layer, not the logic. Verified: backspace in the pseudocode pane takes nav 3 -> 2 and reloads the previous function. Suite 192/0.
* nav: tell the user something is happening when Esc needs a decompileblasty4 days1-1/+6
| | | | | | | | | | | | | Going back to a function that isn't currently decompiled has to decompile it first, and until that lands both panes still show where you were — so Esc read as a no-op and people press it again. action_back now sets a status the moment it pops ("◂ back to <name>…"), which _show_active then upgrades to "<name> — decompiling…" with the spinner overlay up. Verified: immediately after Esc, with no pause at all, the status says decompiling and dec.loading is True; it settles on the target a moment later. Suite 192/0.
* decomp: drop stale navigation results so Esc isn't silently undoneblasty4 days1-3/+18
| | | | | | | | | | | | | | | | | | | | | | | Esc in the pseudocode view appeared to jump back and then not update until you pressed something else. It wasn't a repaint problem (my previous commit guessed that and fixed a different, real, but unrelated staleness): the Esc WAS being undone. _do_navigate decompiles on a worker thread and hands the result to _open_decomp_entry, which applied it unconditionally. Decompiling a large function takes a moment, so pressing Esc while it's in flight meant the late result landed afterwards and re-applied the forward navigation — putting you back where you'd just left. The next keypress then made things look "correct" again, which is exactly the reported symptom. Add a navigation sequence number: _do_navigate captures it when it starts, action_back bumps it, and _open_decomp_entry drops a result whose sequence no longer matches. Verified both ways on a slow target (sub_3720): Esc pressed 20ms into the navigation now stays back at main:60, and removing just the guard reproduces the bug exactly (cursor=0, loaded=0x3720 — the function being left). Suite 192/0, project UI 23/23.
* decomp: repaint on a same-viewport jump (Esc back looked like it did nothing)blasty4 days1-0/+7
| | | | | | | | | | | | | | | | | | | | | | | Esc in the pseudocode view navigated correctly but the pane kept showing the old cursor line until you pressed something else. The listing view was fine because its path goes through load(), which relayouts and repaints. DecompView.goto() sets `cursor`, which is reactive(repaint=False), and then leaves redrawing to _apply_scroll — but that only repaints via call_after_refresh, which needs a refresh to actually happen. Jump somewhere inside the viewport already on screen (exactly what Esc-back within a function does) and the scroll doesn't change, so nothing asked the widget to redraw: the old highlight stayed and the status line had already updated, which is why it looked like the jump half-worked. _move() doesn't hit this because it repaints the old and new rows via _refresh_lines. goto() now refreshes explicitly. Verification note: a render_line-level probe CANNOT see this bug — render_line reads self.cursor live, so it always reports the new position. The staleness is in Textual's compositor cache, which only a repaint request clears. Verified by spying on the widget's refresh instead: a same-viewport goto now requests one (it requested none before). Suite 192/0.
* projects: don't duplicate a binary that's already in the projectblasty4 days3-6/+71
| | | | | | | | | | | | | | | | | | | | | | | | | | | | `--project fw.json a.elf b.elf` where those are already listed appended them again, so the project grew a second copy on every launch — each duplicate then staged its own file, opened its own worker and got its own index entries. Dedupe on the RESOLVED SOURCE PATH, which is the only identity that's actually correct here: two different foo.elf from different directories are different binaries and must both be accepted (the label disambiguator already gives them foo.elf and foo.elf_2), while ./a.elf, /abs/a.elf and a symlink to it are all the same file and must collapse to one entry. * Project.by_source(path) — lookup by realpath. * Project.add() returns the existing entry instead of appending a duplicate. * Project.create() drops repeats on one command line too. * launch.py reports what it did: "added N binary(ies)" / "N already in the project (matched by path) — left alone", and only rewrites the file when something actually changed. Not deduped in _build_refs on load: remove() maps refs to entries by index, so collapsing there would desync them, and a hand-edited duplicate still works (labels disambiguate). tests/test_project.py +8 checks: re-add is a no-op, so are a relative spelling, a messy ../ path and a symlink; a same-named file from another directory IS added and gets a distinct label; create() drops repeats. 33/33. Verified on the real CLI: re-running the reported command leaves the project at 3 entries.
* theme: rebuild the code palette on measured contrast, not tasteblasty4 days1-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 paletteblasty4 days1-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 cardsblasty4 days2-17/+75
| | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* docs: refresh PROJECTS.md phase 2/3 to match what shippedblasty4 days1-8/+28
| | | | | | | | | | | | | | * Phase 2: strings has the F2 toggle now, so drop the "still needs it" note. Record the 60-row project cap and the reason for it, and the ranking rule — including why the (binary, addr) tiebreak is load-bearing (a name shared by two binaries ties on every other field and Python then compares hit objects, raising TypeError; that shipped as a crash and was fixed). * Phase 3: spell out precisely how it relates to the PLT/import-stub backlog item. An export index turns "follow strcmp -> extrn strcmp:near, dead end" into a real jump into libc WHEN that library is in the project. It doesn't retire the item: a single-binary session, or a provider outside the project, still lands on a stub and still wants it recognised and presented as an import rather than a decompiler error.
* strings: F2 widens the strings browser to the whole projectblasty4 days3-26/+90
| | | | | | | | | | | | | | | | | | 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 inblasty4 days2-2/+12
| | | | | | | | 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)blasty4 days1-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 binariesblasty4 days2-1/+27
| | | | | | | | | | | | | | | | | | 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 searchblasty4 days1-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)blasty4 days5-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 changesblasty4 days2-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/0blasty4 days1-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 insteadblasty4 days2-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/bottomblasty4 days2-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 localsblasty4 days4-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 dialogblasty4 days1-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)blasty4 days1-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)blasty4 days4-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)blasty4 days3-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)blasty4 days3-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 paneblasty4 days2-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 cursorblasty4 days2-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 paletteblasty4 days1-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)blasty4 days4-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 boundsblasty4 days3-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 statusblasty4 days2-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 navblasty4 days3-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 highlightblasty4 days5-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 highlightblasty5 days3-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 resetblasty5 days2-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 layoutblasty5 days3-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 functionblasty5 days2-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 spinnerblasty5 days1-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.