aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/app.py (unfollow)
Commit message (Collapse)Author
2026-09-25Offer a writable database location instead of failing the openblasty
`ida-tui /bin/ls` asked IDA to create /bin/ls.i64, which a normal user cannot write, and what came back was the backend's account of it: idalib worker launcher 2425195 exited with status 1 [ida-nexus] Failed to open database /usr/bin/ls after a full analysis wait, naming neither the file it could not create nor the reason. Nothing in the message points at permissions, and there was no way forward from it. idatui/idbpath.py answers the question before anything is opened: where would the database go (mirroring ida_nexus's own derivation), can it go there (os.access only — the pre-flight check must not create files), and where instead. The proposal is stable per binary (~/.local/share/idatui/db/<name>-<sha1(realpath)[:8]>.i64) so reopening the same binary resumes the same names and comments, and hashed so two busyboxes from different directories cannot collide. DATA_HOME rather than CACHE_HOME on purpose: an .i64 holds the whole session, and cache directories are something cleaners may empty. The answer becomes NexusClient(output_database=...) — the option project mode already uses to keep everything in its sidecar. `--idb PATH` (also forwarded by `pane spawn`) says where without being asked. An RPC-driven TUI is never asked: `pane spawn` polls for ready and would sit out its whole timeout in front of a modal nobody can answer, so it relocates and says so in the status line. A failed open re-offers relocation after a real create probe, since os.access does not know about ACLs, a lying network mount or a full disk. tests/test_idbpath.py (pure, 32 checks) pins the path arithmetic, the two properties of the proposal and that neither check touches the user's directory; tests/test_idb_relocate_ui.py (textual, no worker, 24 checks) drives the dialog, Esc, an edited path, the untouched common case, --idb, the RPC path, and that output_database actually reaches the client. Verified end to end against a real /bin/ls: 400 functions, and a rename saved in one session comes back in the next.
2026-09-25Fix parsing of pseudocode tagsDuncan Ogilvie
2026-08-21reformat: ruff format + import sort, mechanically (see ruff.toml)blasty
No behavior. Listed in .git-blame-ignore-revs (next commit).
2026-08-21splash: give the logo a placement id so re-anchoring replaces, not stacksblasty
A kitty placement is identified by (image id, placement id); an a=p with no p= key is anonymous and every one stacks another copy. The splash re-anchors on every progress note (~5/s), so a 60s load ended with ~300 placements of an RGBA logo alpha-compositing over each other -- soft edges creeping to solid, and the terminal re-rendering all of them per frame. Same pair every time (LOGO_PLACEMENT) = the terminal REPLACES the placement, so re-anchoring is free and atomic. That is also why on_resize no longer clear()s first (that showed a hole for a frame), and why a shrunk-to-nothing region now drops the placement instead of leaving a stale one anchored. tests/test_kittygfx.py pins the escapes (pure, stdlib); experiments/splash_place_count.py counts what a real splash sends.
2026-08-20Switch to ida-nexusDuncan Ogilvie
2026-08-20Adopt idb_events and remote module features from ida-codemodeDuncan Ogilvie
2026-08-17Add Ctrl+R to refresh all viewsDuncan Ogilvie
2026-08-10Build the listing index in one call: boot 9.3s -> 1.25s, 911 calls -> 4blasty
The listing used to learn its shape by fetching it. Even after skeleton pages that was 457 round trips and 227k rows for a 1.2MB bash, to end up knowing how many rows there are and where each one is. segment_index(detail=True) now returns exactly that -- every row's address, kind and size as packed arrays, plus the page boundaries -- from one walk that builds no rows and renders no text. ListingModel.build_from_index() decodes it straight into _heads/_head_eas/_row_at/_by_ea/_page_*, marks every row _SKELETON_GEN, and declares itself complete. _grow has nothing left to stream. Nothing else in the model changed, because a row without text is a state it already had: the FIRST read of a page materialises it through the same _ensure_text/_ensure_page path a rename uses. That is why this is a ~90 line change to a core view rather than a rewrite. bash boot: 911 calls / 9.26s -> 4 calls / 1.25s 7.4x whole census (boot + 9 UI actions): 933 calls -> 30 Two things had to be exactly right, and both are tested rather than argued: * the ROW COUNT, or the scrollbar lies. Verified equal to a fully streamed model, and every row's ea/kind/size equal too, 228,659 of them, zero mismatches. * the PAGE BOUNDARIES, or _ensure_page refetches a page that does not line up, fails its structure check and triggers a full rebuild. heads() pages on PHYSICAL rows; anchoring every N LOGICAL rows looks identical (the two only diverge once a segment holds an undefined run) and would have been a lurking bug on .bss. Anchors now carry [logical_row, ea, head_index] taken at the real boundary, and are asserted equal to the streamer's own. Transport note: the packed arrays are base64, not raw bytes. _PACK_EPILOGUE serialises with json.dumps(default=str), which turns bytes into their repr -- 2.97MB arrived as 11.26MB of unparseable text before that was spotted. The new test builds both models back to back and compares every internal array. An earlier version compared against the app's long-lived model and was off by one row, because scenarios before it rename and define things: that model describes the database at boot, not now. Full gate: 1063 passed, twice.
2026-08-10Skeleton pages: stop rendering 227k rows to count them (3x boot)blasty
ListingView._grow streams the entire segment in the background for one reason: to learn how many rows it has, so the scrollbar and paging are right. It did that by rendering every row in full -- 227,500 rows of a 1.2MB bash, 911 backend calls, 9.3 seconds -- essentially none of which is ever looked at. generate_disasm_line is 22x the cost of the walk around it, so heads() gains text=False: a SKELETON page with the same rows at the same addresses with the same kinds and sizes, and no rendered text. Measured identical structurally (rows, addresses, kinds, sizes and cursor all match a real page) which is what makes one swappable for the other later. It also skips the digest (nothing to go stale) and lets the client skip the bulk opcode read, so a page costs ONE round trip instead of two. Client side is deliberately tiny, because the machinery already existed: a skeleton page is just a page whose text is stale. It is marked with a sentinel generation no _text_gen can equal, and the FIRST read of it goes through the same _ensure_text/_ensure_page path a rename uses -- which already refetches a page by address, verifies the structure still lines up and splices it in. Two staleness gates learn to fire for _skeleton as well as _renamed; that is the whole integration. bash boot: 911 calls / 9.26s -> 456 calls / 3.12s, 3.0x. The trade is that a page you actually display is fetched twice (3.3ms + 9.4ms vs 9.4ms), paid only for what is shown. _prime still loads real pages, so the viewport you land on is never a skeleton. The failure mode is BLANK ROWS, not an exception, and nothing in the suite scrolled far enough to see one: _prime renders the first ~1000 rows for real, so a test that pages down a few screens passes against a completely broken implementation. The new scenario reads deep rows through both the model and the render path, and asserts materialising changes neither the row count nor the walk. Verified by reverting the two gates: it fails with text=''. Full gate: 1050 passed.
2026-08-09PgUp/PgDn page the list overlays, not just the code viewsblasty
The help screen has always advertised 'PgDn / PgUp - page down / up', but only the four code views implemented it. In the palettes the keys did nothing at all: those screens focus a filter Input, so the OptionList's own pageup/pagedown bindings never fire -- every key goes to the Input, and an unhandled one is silently dropped. Adds OptionListNav, a mixin carrying the forwarding actions, and puts the six Input+OptionList overlays on it: symbols, search, strings, registers, load options, projects. They already held six BYTE-IDENTICAL copies of action_cursor_down/up, so this removes more than it adds. Paging delegates to the widget's own action_page_up/down instead of moving by a guessed N: those know the live viewport height, skip disabled options and clamp at both ends -- and it keeps the forwarded panes behaving exactly like the ones that page natively. Two panes deliberately stay off the mixin: * XrefsScreen focuses its list, so Textual already pages it. Now covered by a test so nobody 'fixes' it into double-stepping. * StructEditor binds ctrl+n to 'new type', so it cannot take NAV_BINDINGS; it gets page actions through its existing filter-focused guard instead. BINDINGS do not merge from a plain mixin (Textual only merges them from DOMNode subclasses), so every screen splats *NAV_BINDINGS explicitly -- the same trap SearchMixin documents. Tests gate on scrollable_content_region.height >= 1 first: paging is geometry, and before layout the page size is 0, so every check would pass against a no-op. Verified by removing the bindings again -- 3 checks fail with highlighted=0, which is the exact silent failure being fixed. Full gate: 1040 passed, 0 failed.
2026-08-09Graph: fix the triskel fallbacks, and say why when it still falls backblasty
"engine triskel -> native+triskel-failed" was unreadable and, worse, gave no reason: the cause went to a logger a TUI user never sees. Dumped all 400 functions of bin/ls (echo's 128 were not enough) and swept them at three zoom levels: 6 of 1200 layouts fell back, all of them my own _verify tripping over a detour that could not be placed. - the detour jumped to the nearest side of the FIRST box in the way, which in a dense layout is usually inside the next box along. It now collects every box the run passes and picks the nearest genuinely free line. - it skipped the first and last segments because they carry the port and the arrowhead. But that is exactly where the failures were: triskel is happy to park a block directly above its successor and drive the final approach straight through it. Those segments may now move ALONG their own box's border, which is free almost every time. - repairs are swept to a fixed point: moving one segment stretches its neighbours, which can push those into a box. 0/1200 fallbacks after that. Then the bigger corpus turned up a second, genuinely upstream defect: superimposing SESE regions can leave two blocks a couple of columns into each other (2 of ls's 400 functions, in float space, before rounding). Cosmetic in a PNG; here the boxes are made of text, so one block's disassembly overwrites another's. _verify now checks it and falls back, which is the right trade. Reporting, so this is never mute again: - stats["engine_error"] carries the reason, the status line shows it, and the label is "native (triskel failed)". - the corpus test asserts fallbacks are rare AND explained, rather than asserting they never happen. Also lowered AUTO_TRISKEL_MAX_BLOCKS 250 -> 180. Layout runs on every zoom keypress and triskel knees hard past ~175 blocks (174: 66ms, 233: 489ms, 329: 555ms). The old cap allowed a 489ms stall. The corpus timing check now measures only sizes `auto` can actually reach, plus a 5s ceiling so nothing blows up quadratically when forced.
2026-08-09Graph: name the interpreter when pytriskel is missingblasty
./ida-tui runs $IDATUI_PYTHON (default ~/ida-venv), which is not the repo .venv the tests use -- so the graph view can report pytriskel as missing while tests/test_graph.py is exercising both engines. "not installed" on its own sends you to check the wrong python; the status now names sys.executable, and docs/GRAPH_VIEW.md says to install into both.
2026-08-09Graph: a second layout engine, triskel's SESE decompositionblasty
`e` in graph mode cycles auto -> native -> triskel, and `auto` prefers triskel where it is installed and the function is at most 250 blocks. Why: our layered engine draws wide-and-short pictures with a lot of crossings on anything branchy. Triskel splits the CFG into Single-Entry Single-Exit regions first and lays each out on its own, which on the 128-function corpus means fewer crossings on 12 functions, equal on 9, worse on 3 -- and the wins are the hairballs (sub_5CA0 41 -> 6, sub_2C90 32 -> 7, sub_2C00 12 -> 0). It also routes loop edges around the side of the graph the way IDA does, which was a known gap here. It is not free: ~2x slower at 87 blocks, 10x at 424, hence the cap. The library needed a fork (~/dev/triskel, branch idatui) before it could be used from Python at all -- its get_waypoints() threw on every published version, an empty graph segfaulted the interpreter, and its spacing constants were pixels baked in at compile time. Making those settable is what makes this integration cheap: we hand it CELLS, so its output is integral and two edge lanes can never round onto the same row. The feared quantisation problem measured out backwards -- cells claimed by more than one edge: native 131, triskel 35. Not trusted with degenerate input, all handled before the call: self-loops and disconnected components make it throw, and one corpus edge comes back routed through a block, which we detour and re-verify. A triskel failure is never fatal; it falls back to native. Two things the second engine flushed out of the existing code: - the canvas was sized from boxes alone, which is exact only because native's dummy nodes reserve the space. Triskel routes outside that bounding box and the edges were being clipped. - arrowhead placement read e.back, conflating "this is a loop edge" (style) with "this polyline runs against control flow" (geometry). Now Edge.flipped, which is also a latent fix for residual-cycle edges whose succ/pred were being reported backwards. tests/test_graph.py runs its whole suite once per available engine (943 checks); new graph_engine scenario covers the live toggle.
2026-08-07splash: scale the logo to the pane instead of dropping itblasty
Reported as "the splash logo stopped rendering". It had not stopped: the splash asks for the artwork's NATURAL size and shows nothing when that does not fit, and the artwork needs 31 rows plus 10 of box chrome. A pane in a split zellij window is 31 rows — one row short of the 41 it wanted — so the logo silently disappeared. Traced with $IDATUI_KITTY_LOG in the real session: compose: supported=True app.size=Size(width=159, height=31) cells=60x23 fits=False The terminal scales an image into whatever cell box it is placed in (`c=`/`r=` on the placement), so there was never a reason for all-or-nothing. `logo_cells(max_rows)` now fits the art to the room left after the box's furniture, and the same number reserves the cells and sizes the placement, so a resize needs no relayout. In that same 31-row pane it now draws 55x21 instead of nothing. Two things fixed on the way: * The chrome constant was one row optimistic (`rows + 9` where the box measures 10: border 2, padding 2, art margin 1, title 1, note 1+1, help 1+1). At exactly the old threshold the help line was clipped off the bottom. * `_fits` conflated "is the terminal big enough" with "is the artwork the right size", which is what made the image path inherit the block art's all-or-nothing behaviour. The block art genuinely cannot scale (it is half-block cells, 26 rows) and still falls back to the text splash; the image no longer does. `splash_scaling` pins it at 31, 30 and 44 rows: the logo is drawn, it is scaled to the room, the box is never clipped, and a big pane still gets the natural size. 905 passed, 0 failed.
2026-08-07Centre modals with a rule about modals, not a list of themblasty
SearchPalette opened pinned to the top of the screen: the CSS named the screens that centre (`SymbolPalette, StringsPalette, ProjectPalette, …`) and a new dialog is not on a list nobody remembers to edit. The comment sitting above that rule — "every #pal-box palette centres, not just the symbol one" — was the *first* time this happened. `ModalScreen { align: center middle; }` matches subclasses, so every dialog inherits it and the next one is centred for free; the eight per-screen rules that only repeated it are gone. Textual's own Ctrl+P CommandPalette is a ModalScreen too and wants its stock top alignment, so it opts out in one visible line rather than by omission. The `modal_centering` scenario checks both halves: that centring is expressed as a rule, and that it actually reaches a dialog's laid-out region (above/below and left/right within a cell). 894 passed, 0 failed.
2026-08-07Ctrl+F: search the whole database, by text or by bytesblasty
`/` only ever searched the lines of the view you were in. This adds the search you actually need on a binary: over the entire database, either through the rendered disassembly or through the image. * **text** matches the line as displayed, whitespace-normalised, so `call cs:` finds `call cs:getenv_ptr` (IDA's column padding is not something anyone types). Smartcase; `regex` available over RPC. * **bytes** is IDA's own `find_bytes`, so the pattern language people already know works unchanged: hex pairs, `?` wildcards for a whole byte or one nibble (`48 8? ?? 24`), quoted literals (`"Hello", 0`). Commas, no separators (`488B05C3`) and ragged spacing all normalise. **Which mode you meant is guessed, and the guess is biased on purpose.** `dead`, `add`, `cafe` and `ff` are valid hex AND ordinary things to search for, so a bare hex-looking word stays TEXT; nobody types `48 8b ?? c3` meaning prose. `hex:`/`text:` prefixes and F2 override it. The subtle case is a *typo* in a byte pattern. `48 zz c3` first fell through to a text search and reported "no match" — indistinguishable from "those bytes are not in this binary", which is the most misleading answer a search can give. Now any query whose tokens are all byte-sized is treated as bytes, and a bad token is refused BY NAME. IDA does the same thing quietly (find_bytes answers a malformed pattern with zero hits and no error), so the validation lives in Program.search, not just in the UI. Enter searches, then Enter opens the highlighted hit; the title says which it will do, because a database-wide scan is far too slow to run on every keystroke like the other palettes. Navigation goes to the item head — a byte match can start mid-instruction — and the status names the exact address. Also: the `find` RPC verb and `drive find`, which is the one an agent wants (`drive find '48 8b ?? c3'`). idatui/search.py holds the classification and is pure, so the whole question of "what did they mean" is tested offline: tests/test_search.py, 35 checks, 0.1s. Pilot scenario db_search covers the UI end to end. Full suite: 890 passed, 0 failed, 51.3s.
2026-08-07Export findings as markdown (Ctrl+E), and the journal that makes it trueblasty
The output of an RE session is what you worked out, and it was locked in a .i64 that only IDA can read. Ctrl+E (or `drive export`, or the `export` RPC verb) writes it out: your comments grouped by function with the line each annotates, the names and prototypes you set, the types you declared. **The hard part was provenance, and it needed a mechanism, not a filter.** A database does not record WHO wrote a comment or a name. IDA's analyzer sets `; switch 73 cases` and `; s1` with the same `set_cmt` a person uses, and the ELF loader sets `elf_gnu_hash_nbuckets` and `File class: 64-bit` the same way. Four probes, all negative: the FF_COMM flag is identical, `get_cmt` returns them all, `generate_disasm_line` tags every one of them COLOR_REGCMT (not COLOR_AUTOCMT), and they survive with auto-comments switched off. A first cut filtered by shape and produced a report whose first screen was ELF header trivia and `; jumptable ... case 99`. So idatui journals its own edits (idatui/journal.py) into a netnode in the database: it rides along in the .i64, it is still there next session, and the report is then exactly what was done here -- 2 findings out of a database carrying 693 other annotations. Recorded at the choke points in edit_ctl (rename, name-address, comment, retype) and in the struct editor; flushed on save, on export and on quit, so no edit pays a round trip. Without a journal (a database worked on in the IDA GUI, or predating this) the report falls back to filtering by shape -- dummy names, imports, loader segments, the analyzer's stereotyped switch/jumptable strings -- and says so in the document rather than claiming authorship it cannot prove. idatui/findings.py splits gather (needs IDA) from render (does not), so the formatting, grouping, sorting, escaping and the empty cases are tested offline: tests/test_findings.py, 32 checks, no worker, 0.1s. The pilot scenario covers the round trip that matters -- edit through the UI, export, find it in the file, and reload the journal from the .i64. Full suite: 842 passed, 0 failed, 51.2s.
2026-08-07Struct editor: '/' fuzzy-filters the struct listblasty
2026-08-07Modal chrome: thin round borders, titles in the borderblasty
2026-08-07Syntax-highlight the struct editor's C definitionsblasty
2026-08-07Rebase MISTER EXO's ida-codemode port onto the current treeblasty
Mechanical part of the port: the 27-file patch was cut against a base ~148 commits behind us, so it did not apply. Resolved 11 conflicts (all of them diff drift, not semantic clashes) and the three file deletions: - app.py: the patch re-inserted _do_rename/_do_name_addr/_seek_split etc. as "theirs" because our tree moved them to edit_ctl.py/trace_ctl.py. Kept ours and applied the real intent (WorkerClient->CodeModeClient, .call->.invoke, _open_worker_client->_open_database_client) at their current homes. - domain.py: kept Head as a NamedTuple -- the patch reverted it to a frozen dataclass, which the perf work measured at 2.9us vs 1.9us per row on a quarter-million-row walk. Dropped _fetch_output (no download_url under Code Mode) and its now-dead urllib/json imports. - pane.py: the patch's deletion swallowed our zellij support along with the worker-reaping block it meant to remove. Kept zellij, removed the reaping. - test_scenarios.py: the idb_save->save_database teardown hunk belongs to tests/_fixtures.py in our tree; applied it there and kept our pc_num_format scenario that the drift landed on. Three defects in the patch itself, fixed here: - It made "import idatui" hard-require ida_codemode, so every offline suite died at import -- including the pure ones (graph/index/trace) that are the house rule for "tests/run.py --fast". The import is now deferred and gated on the binding, which is also what lets the port's own contract tests inject a fake DatabaseHandle. - project.stage() inlined an ida_codemode.registry import and treated "library not installed" as "someone owns this database", which broke IDA-free project staging. Ownership lookup moved to codemode_client.database_owner(). - tests/test_codemode_client.py had no NEEDS_IDA marker, which tests/run.py rejects outright. Offline suite: 301 passed, 0 failed. Against master's 344 the whole delta is accounted for: -40 worker_client (module deleted), -18 launch sweep checks (behaviour deliberately removed) +3 guarding that it stays removed, +2 pool (GUI-save semantics), +13 new codemode_client contract tests. NOT yet done, and the port is not functional without it: the adapter is missing five operations our tree grew since the patch's base (flowchart, op_format, pc_nums, pc_num_format, survey_binary) and its "heads" predates back-walking and digest/expect.
2026-08-07Highlight ranges are computed per line on demand instead of for every match. ↵blasty
Searching one character over bash matches 177k lines at 310k places, and all but the forty on screen were built and thrown away. _MatchRanges keeps the line SET eagerly and works out the offsets when a line is painted or the cursor lands on it; the blob scan now also skips to the next line after a hit. Result: {"status":"keep","total_ms":17829.9,"lg_boot_ms":724.4,"lg_decomp_ms":2353.5,"lg_graph_ms":1041,"lg_hex_ms":434.9,"lg_index_ms":95.5,"lg_listing_cold_ms":545.6,"lg_listing_warm_ms":407.9,"lg_nav_ms":6922,"lg_palette_ms":4.8,"lg_render_ms":218.1,"lg_search_ms":884.3,"pure_graph_ms":213.6,"sm_boot_ms":432,"sm_decomp_ms":1250.2,"sm_graph_ms":687.4,"sm_hex_ms":457.5,"sm_index_ms":2.6,"sm_listing_cold_ms":265.4,"sm_listing_warm_ms":288.8,"sm_nav_ms":303.2,"sm_palette_ms":0.3,"sm_render_ms":253.9,"sm_search_ms":43,"fails":0}
2026-08-07Three targeted cuts: the graph's transposition pass counts keep and swap in ↵blasty
one pass over the neighbour pairs (was four _pair_cross calls); the barycentre median answers degree 1 and 2 without sorting; and the search body is built from windowed model reads instead of one locked row lookup per line. Result: {"status":"keep","total_ms":17944.4,"lg_boot_ms":703.7,"lg_decomp_ms":2453.7,"lg_graph_ms":1000,"lg_hex_ms":478,"lg_index_ms":96,"lg_listing_cold_ms":453,"lg_listing_warm_ms":411.2,"lg_nav_ms":6555.9,"lg_palette_ms":4.9,"lg_render_ms":218.4,"lg_search_ms":1308.8,"pure_graph_ms":212.3,"sm_boot_ms":432.4,"sm_decomp_ms":1267.8,"sm_graph_ms":742.7,"sm_hex_ms":440,"sm_index_ms":2.5,"sm_listing_cold_ms":264.3,"sm_listing_warm_ms":289.5,"sm_nav_ms":305.4,"sm_palette_ms":0.3,"sm_render_ms":258.6,"sm_search_ms":45,"fails":0}
2026-08-07Re-run of #18 (_CellRow slice assignment, one-string box borders, memoised ↵blasty
Style sum), confirming it: 17590 -> 17501. lg_graph averages 951 over the two runs against 1136 before. Result: {"status":"keep","total_ms":17501.4,"lg_boot_ms":711,"lg_decomp_ms":2484.8,"lg_graph_ms":1017.2,"lg_hex_ms":431.9,"lg_index_ms":98.7,"lg_listing_cold_ms":528,"lg_listing_warm_ms":409.2,"lg_nav_ms":6488.2,"lg_palette_ms":4.7,"lg_render_ms":214.8,"lg_search_ms":1473.5,"pure_graph_ms":244.3,"sm_boot_ms":451.4,"sm_decomp_ms":596,"sm_graph_ms":691.3,"sm_hex_ms":444.5,"sm_index_ms":0,"sm_listing_cold_ms":263,"sm_listing_warm_ms":284.2,"sm_nav_ms":374.7,"sm_palette_ms":0.3,"sm_render_ms":252.1,"sm_search_ms":37.5,"fails":0}
2026-08-07Re-run of #16 (keep the joined search body across a cancelled search and ↵blasty
across navigation inside the same segment), confirming it. total 17784 -> 17590; lg_search 1917 -> 1400, sm_search 70 -> 48. Also lands .auto/check_search.py in the checks gate: it compares both search fast paths against the plain per-line loop for every typed prefix. Result: {"status":"keep","total_ms":17589.8,"lg_boot_ms":710.6,"lg_decomp_ms":2404.9,"lg_graph_ms":1136.1,"lg_hex_ms":425.6,"lg_index_ms":103.4,"lg_listing_cold_ms":417.2,"lg_listing_warm_ms":508.1,"lg_nav_ms":6553.8,"lg_palette_ms":4.8,"lg_render_ms":215.7,"lg_search_ms":1400.3,"pure_graph_ms":239.3,"sm_boot_ms":443.2,"sm_decomp_ms":668.4,"sm_graph_ms":698.1,"sm_hex_ms":443.6,"sm_index_ms":0,"sm_listing_cold_ms":267.3,"sm_listing_warm_ms":286,"sm_nav_ms":356.6,"sm_palette_ms":0.3,"sm_render_ms":258.6,"sm_search_ms":47.9,"fails":0}
2026-08-07HexView.render_line emits style RUNS instead of one Segment per byte cell ↵blasty
(35 -> 7 segments per row), and Head is a NamedTuple rather than a frozen dataclass (tuple.__new__ 1.9us vs a dataclass __init__ 2.9us, and it is built once per listing row walked). Result: {"status":"keep","total_ms":17784.1,"lg_boot_ms":680.8,"lg_decomp_ms":2344,"lg_graph_ms":1106.5,"lg_hex_ms":576.4,"lg_index_ms":96.5,"lg_listing_cold_ms":552.9,"lg_listing_warm_ms":431.3,"lg_nav_ms":6256.4,"lg_palette_ms":4.8,"lg_render_ms":224.1,"lg_search_ms":1916.5,"pure_graph_ms":241.3,"sm_boot_ms":442.6,"sm_decomp_ms":591.3,"sm_graph_ms":663.9,"sm_hex_ms":419.2,"sm_index_ms":0,"sm_listing_cold_ms":258.2,"sm_listing_warm_ms":281.8,"sm_nav_ms":374.4,"sm_palette_ms":0.3,"sm_render_ms":251.1,"sm_search_ms":69.8,"fails":0}
2026-08-07Search the whole segment as ONE joined string. Every line is concatenated ↵blasty
once (with a start-offset table) so finding a term is a C-level str.find walk instead of a python loop that rebuilds and case-folds 224k lines per keystroke. Falls back to the per-line loop if case-folding changes the string's length. Result: {"status":"keep","total_ms":18618.9,"lg_boot_ms":689.6,"lg_decomp_ms":2560,"lg_graph_ms":1035.1,"lg_hex_ms":684.2,"lg_index_ms":100.4,"lg_listing_cold_ms":551.1,"lg_listing_warm_ms":397.8,"lg_nav_ms":6687.8,"lg_palette_ms":4.7,"lg_render_ms":211.9,"lg_search_ms":1858.2,"pure_graph_ms":238.5,"sm_boot_ms":431.4,"sm_decomp_ms":671.2,"sm_graph_ms":728.1,"sm_hex_ms":552,"sm_index_ms":0,"sm_listing_cold_ms":257.4,"sm_listing_warm_ms":281,"sm_nav_ms":365.9,"sm_palette_ms":0.3,"sm_render_ms":242.8,"sm_search_ms":69.6,"fails":0}
2026-08-07Only re-apply a scroll after the next refresh when it actually clamped. Both ↵blasty
_apply_scroll implementations unconditionally scheduled a deferred scroll_to + refresh(layout=True) — a whole-screen re-arrange on every scroll — as a workaround for scrolling before the view's size is computed. Now the deferred pass runs only when scroll_offset didn't reach the target. Result: {"status":"keep","total_ms":19005.8,"lg_boot_ms":788.9,"lg_decomp_ms":2752.5,"lg_graph_ms":899,"lg_hex_ms":565.2,"lg_index_ms":71.9,"lg_listing_cold_ms":416.1,"lg_listing_warm_ms":402.3,"lg_nav_ms":6756.3,"lg_palette_ms":4.7,"lg_render_ms":217.1,"lg_search_ms":2335.8,"pure_graph_ms":235.3,"sm_boot_ms":537.4,"sm_decomp_ms":600.9,"sm_graph_ms":693.1,"sm_hex_ms":549.9,"sm_index_ms":0,"sm_listing_cold_ms":280.9,"sm_listing_warm_ms":261.2,"sm_nav_ms":309,"sm_palette_ms":0.3,"sm_render_ms":249.3,"sm_search_ms":78.7,"fails":0}
2026-08-07Two hot-path fixes found by profiling the plain-line builder: the ↵blasty
opcode-bytes column used a per-byte f-string generator where bytes.hex(' ').upper() does it in one C call (12x), and ListingModel._phys/_head_index_at re-imported bisect on every call. _line_plain 2.64 -> 1.57 us/row. Result: {"status":"keep","total_ms":19476.3,"lg_boot_ms":756.3,"lg_decomp_ms":2495.1,"lg_graph_ms":946,"lg_hex_ms":905.4,"lg_index_ms":72,"lg_listing_cold_ms":547.5,"lg_listing_warm_ms":410.4,"lg_nav_ms":6723.4,"lg_palette_ms":5,"lg_render_ms":218.8,"lg_search_ms":2312.4,"pure_graph_ms":239.5,"sm_boot_ms":535.3,"sm_decomp_ms":616.4,"sm_graph_ms":682.2,"sm_hex_ms":824.9,"sm_index_ms":0,"sm_listing_cold_ms":258.9,"sm_listing_warm_ms":261,"sm_nav_ms":339.5,"sm_palette_ms":0.3,"sm_render_ms":248.7,"sm_search_ms":77.3,"fails":0}
2026-08-07Incremental search narrows instead of rescanning. Typing a character onto ↵blasty
the term can only remove lines (a line holding "mov" holds "mo"), so _compute_matches rescans the previous hit list when the term grew and nothing else moved. Keyed on (term, case-fold, row count, line-source id) so a listing still streaming rows in behind the search falls back to a full scan. Result: {"status":"keep","total_ms":20835.7,"lg_boot_ms":754.4,"lg_decomp_ms":2593.7,"lg_graph_ms":929.5,"lg_hex_ms":1080.2,"lg_index_ms":76.1,"lg_listing_cold_ms":526.7,"lg_listing_warm_ms":410.1,"lg_nav_ms":6674.8,"lg_palette_ms":5,"lg_render_ms":225.2,"lg_search_ms":3310.3,"pure_graph_ms":239.3,"sm_boot_ms":537.9,"sm_decomp_ms":630.1,"sm_graph_ms":734.3,"sm_hex_ms":869.9,"sm_index_ms":0,"sm_listing_cold_ms":260.8,"sm_listing_warm_ms":262.3,"sm_nav_ms":334.8,"sm_palette_ms":0.3,"sm_render_ms":268.4,"sm_search_ms":111.6,"fails":0}
2026-08-07app: the view mode is a type, and 'disasm' is goneblasty
_active was a bare string with 49 comparisons across four modules and a fifth value nobody meant to keep. "disasm" was assigned on exactly one path -- a decompile that failed with nowhere to return to -- and named the same widget as "listing". Four sites understood it; five compared against "listing" alone and silently took the wrong branch: * Tab out of a failed decompile set "listing" instead of "decomp", so the first press appeared to do nothing. * rpc.py carried a workaround for a mode change that never arrived, keyed on being ALREADY in the ghost state -- so it fired in the rare case and not in the common one. Now keyed on LISTING, which is the case that happens. * drive.py asked the socket to show it "disasm", a value the app will now never report, and would have toggled twice and given up. ViewMode is a StrEnum on purpose: _active goes straight to drivers as cursor.kind and the pilot compares it to plain strings, so members being strings keeps every payload and comparison working. What it buys is one place that says which modes exist, and an AttributeError instead of silence on a typo. Read it through is_listing/is_decomp/is_hex/is_graph/in_code rather than ==. The bare comparisons are what let the ghost hide, and they are what the next mode would have to hunt down -- adding "graph" already cost one crash that way (_active_code_view returning None when a prompt closed). view_modes_all_handled walks the enum and asks the app the questions it asks itself. Verified it bites: adding a fifth unhandled member fails it twice. 746 checks, 142.3s.
2026-08-06app: lift database edits out of IdaTui, and one prompt abstractionblasty
Second cut at the 4000-line class, and the biggest: 702 contiguous lines of rename/comment/retype/make-data/literal-format/define, now EditController. What stays on IdaTui is what Textual insists on owning -- on_<Message> handlers, which it dispatches by name on the DOMNode, and @work entry points, whose worker machinery wants a DOMNode host. Both are one-line delegates. Underneath them was the duplication that made this worth doing rather than just moving lines. Opening a prompt (hide the status bar, set placeholder, can_focus, display, value, focus) was written out five times; closing it four; and Esc was a six-branch ladder in on_key with one copy of the same four lines per prompt. They had drifted -- search cancelled its highlight, goto restored focus, the edit prompts did neither consistently. Prompt/PromptBar own that discipline once, and close() HANDS BACK the context it was holding, so it can't be read twice or go stale: the listing's rename had to capture _rename_addr by hand before _end_rename cleared it, or the name went to address 0. _line_ea_for stays on the app -- rpc.py and the goto readback ask the same question, so it was never an edit helper.
2026-08-06app: lift trace navigation out of IdaTuiblasty
First cut at the 4000-line class. Trace is the cleanest seam: 348 contiguous lines, one coherent job (where we are in time and everything that moves us), and two suites already covering it. TraceController owns the state now -- the trace, the timestamp, the trail maps. IdaTui keeps the keys, because Textual only merges BINDINGS from DOMNode subclasses and a mixin's would be silently dropped, and it keeps the @work entry points, because the worker machinery wants a DOMNode host. Both are one-line delegates. _trace/_t/_trail_map/_trail_map_ea/_trail_line_of stay readable on the app as properties: the pilot suite and rpc.py read the position by those names, and a property means one owner rather than a copy that can drift. rpc.py itself now goes through the controller. The parallel line-map that _apply_split_map used to poke into five attributes is now one adopt_map() call -- same single shared index, but the sharing is stated rather than implied by two places assigning the same fields. 731 checks, unchanged.
2026-08-06splash: blasty's transparent logo, and stop guessing the cell aspectblasty
The artwork is now a proper transparent PNG with soft edges (24% of its pixels carry partial alpha) instead of opaque art on black with a stray full-width scan line along the bottom. Cropped to its content and resized 1024 -> 768px, which halves the file and costs nothing visible; logo-trans.png keeps the master for future re-renders. Two things the new art exposed, both wrong before it: fit() assumed cells were 1:2. This terminal reports 9x22, i.e. 1:2.44. The old logo was 474x516 -- close enough to the assumption that nobody noticed -- but a square image at the hardcoded 60x33 would have been visibly stretched. The graphics query now asks for the cell size too (CSI 16 t rides along in the same round trip, before the DA1 that already synchronises it) and fit() uses the answer. The footprint was a constant. logo_cells() derives it from the artwork and the measured cell size, so the art can be replaced without anyone remembering to edit a number. logo.ans was stale: the block-art fallback for terminals that can't draw an image was still the OLD artwork, scan line included. tools/make_logo_ans.py regenerates it from logo.png so the two cannot drift again. It understands alpha -- a transparent cell emits no colour and lets the terminal background through, and a cell with only one opaque half uses the matching half block so the pixel lands on the correct side.
2026-08-06help: reach the cheatsheet with H as well as F1blasty
F1 is swallowed before it ever reaches us on at least one setup here -- the app's own binding fires when the key is injected directly, zellij has no F1 binding of its own, and every common F1 encoding written straight into the pane (SS3 ESC O P, CSI ESC [11~, CSI-u ESC [1;1P) opens it. So the key is being eaten by something upstream, which is not ours to fix, and a cheatsheet reachable only through a function key is fragile anyway: terminals and multiplexers claim them routinely. H opens and closes it too. '?' stays with the incremental search, which is what it has always done in the code views.
2026-08-06graph: navigate to blocks, not to coordinatesblasty
Clicking the minimap panned to the exact coordinate under the pointer and moved the cursor only if a block happened to sit there. Since one minimap cell covers many canvas cells, "there" was almost always padding: you got a jump into empty space and the cursor stayed behind, so you had to click a block afterwards to actually go anywhere. Blocks cover a few percent of a laid-out graph -- 4.6% of an 87-block function, 0.8% of a 424-block one -- and the rest is the space that keeps edges apart. So coordinates are the wrong thing to navigate by here. The minimap now snaps to the nearest block and takes the cursor with it, and a drag scrubs from block to block. Distance is measured with the column halved, because cells are twice as tall as they are wide and otherwise "nearest" is not what looks nearest. A drag-pan or ctrl+d/pageup that ends with no block on screen at all now eases to the nearest one too, since an empty screen leaves nothing to navigate back by. It only fires when nothing is visible, so a deliberate pan is never fought.
2026-08-06graph: the minimap is clickable, and stops swallowing clicksblasty
Click it to jump the view to that part of the graph, drag to scrub. If the point you clicked is over a block the cursor lands in it, so the keyboard carries on from where you pointed instead of snapping back. This also fixes a real bug rather than only adding a feature. The minimap FLOATS over the canvas -- it is pinned to the viewport, not drawn into the graph -- so a click on it was being translated into canvas coordinates and dropping the cursor into whatever block happened to lie underneath. It has to be hit-tested before the canvas, which is what on_click now does. _minimap_rect() is the one source of truth for where it is: the renderer and the hit-test both take the position from it, so the two-column inset that keeps it clear of the ScrollView's scrollbar can't drift between them.
2026-08-06splash: draw the real logo on terminals that canblasty
logo.ans is 60x33 cells of half-blocks -- a 60x66 pixel image. logo.png is 474x516. On a terminal that speaks the kitty graphics protocol we now send the real thing, in the same cell footprint (fit() lands on exactly 60x33, so the layout is unchanged), and fall back to the block art everywhere else. Three findings, each of which cost a round of "it renders nothing": Support cannot be sniffed from the environment. Under a multiplexer that passes the protocol through, TERM is xterm-256color and KITTY_WINDOW_ID, TERM_PROGRAM and COLORTERM are all empty while the protocol answers OK -- detection by terminal name would disable graphics on exactly the terminal that supports them. So we ask: a 1x1 graphics query plus a Primary Device Attributes request, with DA1 as the sync point. Unicode placeholders are not usable. The tidy way to put an image in a TUI is a virtual placement plus U+10EEEE cells that the compositor clips and moves like text -- and it is what every Textual image library builds on -- but this terminal answers ENOTSUPPORTED for placeholders while supporting everything else. So the image is placed directly, anchored to screen cells Textual knows nothing about. The splash therefore owns its lifetime: place after layout, re-anchor when the note repaints (throttled; a placement is one short escape with no image data), delete on unmount, or a leftover would sit on top of the disassembly forever. The query and the upload go on OPPOSITE sides of the alternate screen. The query must run before Textual starts, which reads stdin on its own thread and would eat the reply. The image must be uploaded after Textual has switched to the alternate screen: an image uploaded to the primary screen cannot be placed from the alternate one, and the placement reports success while drawing nothing. That silent failure is why detection lives in launch.py and upload lives in the splash's on_mount. $IDATUI_KITTY_LOG traces the decisions, since none of this is visible to a test -- correct escape sequences and visible pixels are not the same thing here. Off-tty (the pilot suite, a pipe) detection returns False and the block art is used, so the tests are unaffected.
2026-08-06app: the graph viewblasty
Space swaps the code view for the function's basic blocks, IDA's own key. Boxes hold the SAME Head rows the listing renders, so IDA's colour tags, the word highlight, the execution trail and every editing verb work inside them for free. Nothing is pre-painted: each screen row is composed on demand from the edge index plus whichever boxes cover it, so cost tracks the viewport and not the graph. z cycles three zoom levels, m toggles a minimap, J/K walk edges, and the mode is sticky -- following a call lands in the callee's graph. Above 400 blocks it declines and says so, because nothing readable comes out at that size. Edges follow IDA's colours, and the ones touching the block under the cursor are brightened. Adding a value to _active means every consumer has to learn it: the one that was missed (_active_code_view returning None) crashed the app the first time a prompt closed in graph mode. A stale async load is also guarded now -- without it, a rename's queued rebuild landed later and dragged the user back into a graph they had already dismissed.
2026-08-06app: 'o' cycles the literal under the cursorblasty
IDA's own key, and that muscle memory is worth more than the opcode column's old claim on it -- the bytes column moves to B. The literal the cursor is on is MARKED, and that mark is what changes, because a line usually holds more than one. The cursor follows its literal across the edit: 48 <-> 0x30 reflows the line, and holding the column would put the next press on a neighbour. Land on a register -- something with no format of its own -- and it says so and names the operand that has one, rather than quietly reformatting a different one. Works in the pseudocode too, on Hex-Rays' separate number formats.
2026-07-31rpc: stop lying to the driver about renames, modals and teardownblasty
Five defects found while an agent drove a long RE session over the socket. Each one was reproduced on a live spawned pane first (an in-process pilot would not have shown any of them), then fixed: pane stop truncated the save. `stop` asked the app to quit, slept 400 ms, then unconditionally killed the pane. Quitting runs App.on_unmount, which writes every dirty database; a 90 MB .i64 takes tens of seconds, so the kill landed mid-write and a whole session's annotations went to /dev/null with a cheerful {"stopped": [...]} on stdout. Now it waits for the pane to actually exit (--timeout, default 600 s) and only force-kills on timeout, saying so. The quit verb bypassed the dirty check. It called app.exit() directly rather than the path a human gets, so the "unsaved changes" logic never ran. It now routes through _on_quit_choice and reports {saving, dirty}. Naming a function start from the listing never reached the function index. `goto <addr>` puts the cursor on the address token, so `n` takes the name-an-address path, which called bump_items() but left FunctionIndex holding the old name. Result: the rename response snapshot showed the section label, and functions()/names()/the palette all reported the rename had not happened — so a driver that trusts its readbacks redoes work it already did. Twice, in the session that prompted this. _do_name_addr now updates the index, the nav stack and the table cell when the address is a function start. A stripped binary with no entry function started up *inside a modal*. _auto_land pushed the symbol palette when main() was missing, while ping still answered ready:true. Every keystroke an RPC driver injected went into the palette's search box and was silently swallowed. It now lands on the first function instead and hints at Ctrl+N. Verbs that inject keystrokes now refuse when a modal is on top, naming it, instead of failing with "'goto' prompt did not open (word under cursor?)" — a message that blamed the cursor for what was always a focus problem. Also: `drive raw` passes k=v values through as strings, so `view lines=8` died with "'<' not supported between instances of 'int' and 'str'". Numeric params are now coerced centrally rather than at each call site. tests/test_scenarios.py: 212 passed, 0 failed.
2026-07-30trace: a stale navigation no longer drags the view backblasty
Fixes the bug found while building M3. Navigations run in workers and are applied when they land; the trace's OPENING seek goes to t=0, which for a normal binary is _start, and that navigation is slow. It arrived after later seeks and won, leaving the cursor and _cur on _start while the trace's pc was elsewhere — and it never settled, measured stable for 3+ seconds. Anything cursor-based done just after a seek (`>` asks about the address under the cursor) then acted on the wrong address. The decompiler path has had a staleness guard since 756589a; the listing path never got one. It has one now (_open_at_if_current), and a seek bumps _nav_seq so older in-flight navigations are dropped. Verified both directions on the exact reproduction: seek to the first execution of a repeated instruction, seek to the second, wait — cursor stays put with the guard, and with the guard removed it drifts to 0x34d0 (_start) exactly as reported. Scope, deliberately narrow. I first bumped _nav_seq in _goto_ea for EVERY navigation, which is the more general rule, and a full run then failed follow_xrefs — a follow can be dropped by whatever navigates next. That check has flaked before so it is not proof, but the mechanism is real and my evidence is only about seeks, so the bump lives in _seek. TODO records what would justify the general version and what test it needs. tests: +1 trace UI (39) — seek, seek again, wait 3s, and the cursor is still on the instruction the trace is at. Two consecutive full runs 212/0 after narrowing.
2026-07-29trace: seek verbs — next/previous execution, and who set this registerblasty
M3. Stepping walks time; seeking jumps to the next time THIS thing was touched, which is what makes a trace more than a very long single-step log. `>` / `<` — next/previous execution of whatever the focused view addresses. One pair of keys, two questions, because what's on screen already says which: * listing: the instruction under the cursor. "When else did this run?" * pseudocode: the whole C line, as the union of its instructions' executions. A line is not one address, and falling back to its single /*ea*/ marker would answer a narrower question — usually none at all, since most lines have no marker. * hex: the byte under the cursor, via memory_accesses. It says where you landed ("execution of 0x3160: 2 of 2 @ t=320") and, at either end, that you're AT the end rather than silently doing nothing — a key that does nothing is indistinguishable from a broken one. `W` — the registers with the instruction that set each to its current value, and the distance back. Enter seeks to that write, f seeks forward. Backward is the direction people want: you notice a bad value after it has been used. This is the question a trace exists to answer and it was already in the model (last_write/next_write), untested in anger until now. tests: +13 trace UI (38) — > and < move between the two executions of a repeated instruction, the status names which execution it is, both edges report instead of moving, W opens, and choosing a register lands on an instruction that REALLY wrote it (checked against the trace's own changed-set, not just the timestamp matching). Two things the tests taught me, both recorded: * focus() does not make a view active outside split mode — Tab does. My first seek test pressed > while _active was still "decomp", so it asked the pseudocode about a line with no instructions. * TODO gets a new entry: a stray late navigation to the entry function arrives after a seek and wins, leaving the cursor on 'start' while the pc is elsewhere. Same shape as the stale-decomp-result bug fixed in 756589a, which got a sequence guard the listing path never did. 212/0 scenarios, 35/0 model, 12/0 differential.
2026-07-29trace: memory at time T — stack in the dock, live bytes in hexblasty
M2. Trace.memory(addr, length, idx) reconstructs what memory held at a moment, returning the bytes AND a per-byte "known" mask. The mask is the point: a trace knows what it observed and nothing else, so a byte nobody read or wrote is genuinely unknown and must not be drawn as zero. That distinction is the whole reason to read memory from a trace instead of the database — the database has the file's bytes, the trace has what was actually there. Reads count as evidence, not just writes: an instruction reading a byte reveals what it held then. Indexed by ADDRESS (sorted once, bisect per query), because the question is "what was in this window at time t" and the accesses that matter are the few touching that window, not the tens of thousands in the trace. Where the memory actually is: measured, 0% of accesses in either real trace fall inside the image — every one is stack or heap. So the primary view is the STACK, in the dock, anchored at SP: stack (rsp) ▸7ffff6f99470 ???????????????? 7ffff6f99478 00007ffff6fb0b00 7ffff6f99488 00007ffff6fa94e5 The hex view overlays trace bytes on the file's contents (green = the trace saw this byte at this timestamp, grey = still the file's idea). Correct, and it will matter for a program that writes globals, but on these traces it shows nothing — which is why the stack pane is the deliverable and not a nice-to-have. One bug the work surfaced: MemOp.addr was having the image slide applied to it, which is nonsense for a stack address — it produced -0xc838. The slide relocates the IMAGE; stack and heap have no database counterpart. Memory op addresses now stay in trace space, and memory_raw() queries there, while memory() takes database addresses for the hex view. tests: +9 model (35) covering the known-mask, reads-as-evidence, partial coverage and the writers/accessors queries; +1 differential (12) checking reconstructed memory state against Tenet's own get_memory at sampled timestamps; +5 UI (30) for the stack pane — present, anchored at SP, marks unseen bytes, follows time. 212/0 scenarios.
2026-07-28app: one instruction map for the decompiled function, not twoblasty
Finishing the thing the last commit only worked around. The split view and the trace path each kept their own per-line instruction map of the same pseudocode — fetched separately, indexed separately, and keyed differently: the split one on _cur (the function the CURSOR is in), the trace one on the function the DECOMPILER has loaded. Those are not the same thing, which is precisely how the two ended up describing different functions and why I spent a commit chasing a "sparse decomp_map" that was never sparse. _apply_split_map now indexes once and both read it. Keyed on the decompiler's loaded function, and no longer conditional on split being on — the old guard dropped the result whenever _cur had moved while the fetch was in flight, which during stepping is almost always. Measured after: three decomp_map fetches across 28 steps (two for main, one for the function stepped into), the split map and the trace map are literally the same object, and both describe what is on screen. Process note, because this is the second time: my first attempt at this edit SILENTLY DID NOTHING — the pattern didn't match (a duplicated comment line I'd mangled), the old method stayed, and the new caller hit its `not self._split` guard, so the painting tests went from passing to "0 lines". Same failure mode as the key bindings that never got added. Structural edits now assert that the anchor was found and that the replacement is present before writing. 212/0 scenarios, 26/0 trace UI, 30/0 project UI, 20/0 split view.
2026-07-28trace: stop the decompiler thrashing during a step (and correct the record)blasty
I blamed decomp_map in the last commit. It was innocent: called directly it returns 769 lines, 475 with addresses, for exactly the function I said it returned four for. The four-line map belonged to a PLT stub the decompiler had momentarily switched to, and I sampled mid-bounce. The actual fault: _seek_split decided "has execution left the decompiled function?" from _split_range, which is maintained by a guarded async path (_apply_split_map drops its result if _cur moved while in flight) and therefore lags during stepping. A stale range made every step look like a function change, so the decompiler bounced main -> stub -> main, each bounce paying a synchronous 769-line map fetch on the UI thread. Now the decision comes from the map the trail painting already holds, keyed to what the decompiler currently HAS loaded. The bouncing is gone — three map fetches across twelve steps instead of one per step — and the pseudocode cursor follows every instruction the decompiler attributes to a line, including across a call into another function. What it does NOT do: guess. Roughly half of a function's instructions have no line attributed, and the obvious fallback (nearest mapped address at or before the pc) is unsound — C lines are not monotonic in address, and it put an instruction early in main on line 708, "sub_2040();", near the end. The cursor waits instead; the trail still marks where you are. tests: +1 trace UI (26) — over ~28 steps, every instruction that IS mapped is followed by the pseudocode cursor. 212/0 scenarios. TODO corrected: the entry blaming decomp_map now says what actually happened, including that _split_ea2line/_split_range are still fed by the laggy path and remain a latent issue for the split view's own sync.
2026-07-28trace: a step in split view moves the listing cursor to the pcblasty
Normal navigation moves one pane and gives the companion a band, never a cursor — that rule exists so the two can't chase each other. A trace step isn't navigation: time is a single global position and both panes are showing the same instant, so the cursor belongs on it in both. _seek_split places the listing cursor on the current instruction, then hands off to the existing _sync_split so the companion still gets its band and align() at the driver's screen row. The anchoring machinery is used, not bypassed. PARTIAL, and the shortfall is worth stating plainly: the LISTING cursor tracks the pc reliably (tested over consecutive steps). The PSEUDOCODE cursor only follows when decomp_map covers that address, and for cat's main it covers almost nothing — four entries for a 700-line function. That is not something this commit introduced and not something I could fix responsibly without understanding it; TODO has what I measured, including that dec.goto(96) left the cursor at 0 in the same run, which may or may not be the same bug. One real fix along the way: _place_decomp_at prefers the map the trail painting keeps (keyed to the decompiler's currently loaded function) over the split view's _split_ea2line. The latter is refreshed by a guarded async path that drops its result if _cur moved while in flight, and a burst of steps moves _cur constantly — so during stepping it is frequently a map of the function you just left. tests: +2 trace UI (25) — stepping in split moves the listing cursor onto the pc for six consecutive steps, and the trail marks it 'now' in both panes. 212/0 scenarios.
2026-07-28trace: stepping stays in the view you're readingblasty
Caught while demoing this in a live pane, not by a test: press Tab to read the pseudocode, press ] once, and you're back in the disassembly. _seek() follows the trace by navigating to the new PC, and navigating to an ADDRESS opens the listing unless the decompiler is explicitly preferred. So every step out of C dropped you out of C — the painting work of the last commit was unusable in the view it was built for, from the first keypress. _seek now passes prefer_decomp=(self._active == "decomp"), the same thing the xref handler already does for the same reason. Worth noting what it looks like when it works: stepping in pseudocode follows execution INTO a callee and the view switches to that function's C, which is what you want and what makes the decompiler painting worth having. tests: +2 trace UI (23) asserting the view survives a step in both directions. 212/0 scenarios.
2026-07-27status: name the open fileblasty
The status bar said "0x2490" without saying what it belonged to. Obvious once there are two panes open, or after switching binaries in a project — which already prefixed its label, so single-binary sessions were the odd ones out. [cat] .text @ 0x472b [listing] (c code · p func · u undefine · Enter follow) Uses the opened file's basename, not _module(): that one asks the worker over RPC and this runs on every status write. Kept in step when the path changes (project switch, reload). Three messages already carried the module name themselves and would have read "[echo] echo — 128 functions"; they don't say it twice now. tests: +3 scenarios (212) — the bar names the file, keeps naming it as you move (the idle status is not the only writer), and doesn't say it twice.
2026-07-27trace: paint the execution trail — including on the decompilerblasty
Both code views now show where you came from and where you're going: the instruction you're on ('now'), the ~96 steps behind it ('past', warm) and the ~96 ahead ('future', cool). A trail, not all of history. Painting every address the trace ever touched says almost nothing on a loop-heavy program; the last and next few dozen steps say how you GOT here. Where an address appears on both sides — a loop body, which is most of them — the nearer side wins, because that's the one explaining the step you just took or are about to. **The pseudocode is painted too**, which is the reason to build this here rather than use Tenet. A trace records instructions, so that's what Tenet paints. We already have decomp_map from the split-view work, saying which instructions each C line covers, so the same trail lands on the decompilation: line 46 now | v3 = getenv("POSIXLY_CORRECT"); line 47 future | v4 = (__int64)*a2; line 49 future | if ( v3 ) A C line covers many instructions, so it takes the strongest kind present: now beats past beats future — if the instruction you're standing on belongs to this line, this line is where you are. Two things kept cheap: the trail is recomputed per SEEK rather than per repaint (~200 lookups, and repaints vastly outnumber steps), and decomp_map is cached per function because it's an RPC and stepping is interactive. The colours sit deliberately under the code palette — the trail says "you came through here", the text still has to read as code. tests: +8 UI (21) — the listing carries now/past/future and it reaches the screen; pseudocode is painted; exactly ONE C line is 'now' and it is the line covering the current instruction (not merely some executed line, which is the mistake this check exists to catch). 209/0 scenarios, 27/0 model, 10/0 diff.