aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAge
* TODO.new: two bugs found while driving the TUI (symbol-list pageup/down, ↵HEADmainblasty2026-09-29
| | | | multiline comments)
* experiments: two pty harnesses the pilot cannot stand in forblasty2026-09-29
| | | | | | | | | | | | | | | Both exist because an in-process Textual pilot passes on things a real terminal does not do. type_bench.py times the semantic verbs at several delay_ms values through a real pty -- tests/_fixtures.fast_keys patches the per-key idle wait away, which is exactly the cost being measured, so the pilot is blind to it. This is where the ~80ms/char figure behind `press()` came from. click_pty.py injects SGR mouse bytes on stdin like a terminal emulator and reads the cursor back over the RPC. `pilot.click()` posts MouseDown/MouseUp/Click straight at a widget and bypasses Textual's own click synthesis, so it can pass while a real click does nothing.
* Type at the speed the driver asked forblasty2026-09-29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | `App._press_keys` waits on Textual's `wait_for_idle` twice per key -- a CPU-load heuristic that sleeps in 20ms granules until process time stops advancing. Measured through the RPC server on a real pty that is ~80ms per character, so `delay_ms=35` typed at ~115ms/char (a third of the pace it named) and `delay_ms=0` -- what `idatui.drive` asks for, and what an agent wants -- was no faster than a slow human. `press()` swaps the heuristic for a bare yield while our keys go in, the same trade tests/_fixtures.fast_keys already makes. What replaces it is not "nothing": every verb ends in settle() (pump drained, threaded workers finished), which is the real gate and strictly stronger than "the CPU looks idle", and `wait:<ms>` tokens still sleep for real so a delay that was asked for still renders frame by frame. It patches around the call rather than at import, so nothing else -- the pilot suite in particular -- changes behaviour if this module is imported there. With the floor gone, the default delay is the whole cost of a keystroke, which makes 35 dishonest as well as slow: TYPE_DELAY_MS is 20 (~50 chars/second, still visibly typed). docs/RPC.md says what delay_ms now means, since "0 is instant" only became true here. Also moves the last two `client.invoke("name", ...)` call sites in this file to `client.call(remote_ops.name, ...)`, which is what the rest of the tree uses since the ida-nexus port: the operation is a reference, not a string nobody checks.
* Put a spawned pane beside US, not beside whoever is lookingblasty2026-09-29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The slosh backend worked out "where do I live" from `panes[].focused`. Exactly one pane in a session is focused, it is always in the tab currently on screen, and it belongs to whoever is looking -- so a TUI spawned "beside the agent" landed in whatever tab the human had wandered into, and in a session running several agents it was reliably a stranger's. `apply-layout` jumping the view to the tab it built made it self-inflicted too: one spawn moved the focus the next spawn would read. Identity now comes from four sources, in order, none of which is "whoever has focus": $IDATUI_SLOSH_PANE (an operator saying so), $SLOSH_PANE (slosh telling the pane's program which pane it is), our own /proc ancestry matched against panes[].pid (the answer for an agent's shell-out, which has neither a scrubbed environment nor a controlling tty), and a purpose named by $IDATUI_ORIGIN_PURPOSE / $SLOSH_PANE_PURPOSE. If none of them can answer, the pane stays in the tab the layout built it in -- visible, findable by purpose, and nobody else's tab -- and says so, since the fix is one variable in the caller's environment. The id-addressed verbs are probed, not assumed, and the probe is a field rather than a verb: slosh ignores arguments it does not know, so `raw` with an `id` on an older build answers ok and writes the bytes into whatever pane has focus -- silently, into somebody's shell. panes[].pid arrived with those verbs, so its presence is the capability. Where they exist: `raw id=` writes without touching focus, `capture id=` reads a pane in any tab without hopping the view (the old snapshot-and-slice route is kept as the fallback, and is rude precisely because it moves what other people are looking at), `apply-layout focus=false` builds the tab without jumping to it, and `move-pane beside=` names our pane rather than letting slosh pick that tab's focused one. Detached is now the default and `--focus` is the opt-in. The other way round, every spawn ended by pulling the session's focus -- and so the viewed tab -- to a pane nobody asked to look at: fine when a human typed the command and is waiting for it, wrong for an agent opening a binary while somebody works two tabs away. `--detached` still parses, for callers that say it. tests/test_pane_origin.py (22 checks, pure stdlib, no IDA and no slosh: the decisions are made about a `panes` reply, so the reply is the input) pins the precedence order, the focus-is-not-identity rule, the ancestry walk and that an unanswerable session leaves the pane where it was built.
* Spawn TUI panes in slosh, the multiplexer we actually useblasty2026-09-29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `pane spawn` knew tmux and zellij, so under slosh every entry point answered "error: not inside tmux or zellij" — the agent skill described a workflow that could not run. slosh is now the first thing autodetected ($SLOSH, then $ZELLIJ, then $TMUX), and a slosh pane id is a bare integer, so a registry row written before this commit still routes its own stop/capture/keys. Everything goes through the control socket, always the JSON form ($SLOSH_BIN -s $SLOSH_SESSION cmd): the bare-verb alias answers unwrapped, and a parser written for one shape silently finds nothing in the other. $SLOSH_BIN rather than `slosh` because a session may have been started from a build tree that is not on $PATH. Spawning is the one place slosh differs in kind. Its `split` verb only ever makes a shell pane, and typing a command into somebody's shell is the thing not to do — a pane that was *given* a command is the one with alive/exit_code to poll and `rerun` to repeat. So spawn applies a one-tab layout whose pane carries command= and a declared purpose=idatui:<hex>, finds it by that purpose (declared purposes are locked, so the TUI cannot relabel itself out from under us, and titles change anyway), then move-panes it into the tab we were called from: it lands beside the caller with its pty and scrollback intact, and the layout's now-empty tab removes itself. --detached restores focus afterwards, since slosh focuses what it creates and moves. The rest follows the zellij precedent. A dead command pane stays on screen as a husk, which _pane_alive must read as dead or a launcher that died on startup would burn the whole 300s readiness timeout instead of reporting "pane exited during startup". `capture` cuts the pane's content_* rect out of `snapshot` (which is the whole composited session) and hops tabs if the pane is parked in another one, because only the active tab is composed at all. `keys` keeps taking tmux-flavoured names and turns them into the bytes a terminal sends: it focuses the target, writes with `raw`, and restores focus — `send` would be decoded as session input first, where C-a is slosh's own leader and never reaches the TUI. --size stays tmux-only and now says so for whichever mux is in play. The two RPC integration tests gated on $TMUX/$ZELLIJ, so they skipped silently under slosh; they accept $SLOSH now. Verified against the live session, not just in unit checks: a probe pane spawns, reports alive, captures its own output, receives real keystrokes and dies on close; `pane spawn --open targets/echo` reaches ready:true with 128 functions, `drive where` answers, `pane keys Tab` flips listing to pseudocode on screen, and `pane stop` leaves an empty registry and no husk. Env inheritance is unchanged from zellij (a pane gets the session server's environment, not the caller's) — the probe printed PROBE_VAR=[].
* Offer a writable database location instead of failing the openblasty2026-09-25
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `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.
* Merge PR #2 from mrexodia: fix pseudocode/listing tag parsing, ida-nexus 0.13.0blasty2026-09-25
|\ | | | | | | | | | | | | | | | | | | | | | | | | | | | | https://github.com/blasty/ida-tui/pull/2 — pseudocode address anchors are now split off only as the trailing /*0x…*/ our decompile tool appends, so a marker-shaped string literal or source comment survives display and cursor columns. The listing span walker asks the kernel (tag_skipcode) how long each colour code is instead of assuming two bytes, so semantic group tags with kind/TID headers stay invisible on kernels that emit them. Bumps ida-nexus to 0.13.0. Verified locally on IDA 9.4 + ida-nexus 0.13.0: full suite 974 passed (955 on main + 19 new pure checks). Old vs new span walker give identical output over 60k bash heads (no 0x36 tags on this kernel); the new walker costs 19.8 vs 12.3 us/line, next to 7.6 us for generate_disasm_line.
| * Fix parsing of pseudocode tagsDuncan Ogilvie2026-09-25
| |
| * Bump ida-nexus to 0.13.0Duncan Ogilvie2026-09-25
| |
* | README: friendlier rewrite; move IDA connection internals to CONTRIBUTINGblasty2026-09-25
|/ | | | | | | Plain-language intro, numbered getting-started steps, readable keys and feature tables, advanced usage folded into a details block. The lease / event-stream / remote-op details now live under 'How ida-tui talks to IDA' in CONTRIBUTING.md so nothing is lost.
* TODO: change notifications and discard-changes landed with PR #1blasty2026-08-21
|
* scenarios: cover the event->UI auto-refresh pathblasty2026-08-21
| | | | | | | | | | | | | | | PR #1's live refresh had client-level coverage only (the listener thread and debounce, in test_nexus_client); the app half -- _refresh_idb_events invalidating, reindexing, and reloading the active surface -- was untested. The scenario renames straight through the client so no UI cache hears about it (what another client's edit looks like from this process), hands the app a synthetic batch, and requires: the status says why the view is about to move, the listing rebuilds around the same cursor and viewport address, the function index carries the external name, pseudocode decompiled afterwards shows it, and a second batch landing while decomp is active reloads it in place. That second batch also reverts the rename, so the scenario is idempotent.
* blame: ignore the ruff reformat commitblasty2026-08-21
|
* reformat: ruff format + import sort, mechanically (see ruff.toml)blasty2026-08-21
| | | | No behavior. Listed in .git-blame-ignore-revs (next commit).
* adopt ruff: pinned formatter + import sorting, opt-in pre-commit hookblasty2026-08-21
| | | | | | | | | | | | | | | | | PR #1 arrived black-formatted, which forced the question. ruff's formatter is black's output, it is one static binary rather than a dependency chain, and it is fast enough that a hook is free. ruff.toml pins the version (a formatter whose output moves between releases turns 'formatted' into 'formatted by whoever committed last') and selects only import sorting on the lint side -- a hook that refused a commit over an unused variable would be a different feature. .githooks/pre-commit (install: git config core.hooksPath .githooks) formats exactly what is staged, refuses partly-staged files rather than quietly widening a commit built with git add -p, and prefers the pinned .venv ruff. Also drops the blanket '# ruff: noqa' PR #1 left in remote_ops.py -- it would have exempted that file from the only lint rule we enforce.
* tests: repair the two seams the ida-nexus port left behindblasty2026-08-21
| | | | | | | | | | | | | | | Two scenario monkeypatch sites still hooked client.invoke -- the rename to call() updated the call sites but not the patches, so reprime_is_free and split_view crashed instead of counting. call() takes the remote_ops declaration itself now, so both count by its __name__. remote_ops imported RemoteModule inside _bindings(), which made test_nexus_client (NEEDS_IDA = False) unrunnable under a stdlib-only python3 -- the house rule tests/run.py --fast depends on. The import moves to the same eagerly-if-present, bound-to-None-so-patchable contract nexus_client uses, and the test injects FakeRemoteModule/FakeRemoteError exactly like its other fakes: real names when the library is installed, strict fakes when it is not.
* Merge PR #1 from mrexodia: Windows support and auto-refresh on eventsblasty2026-08-21
|\ | | | | | | | | | | | | | | | | | | | | https://github.com/blasty/ida-tui/pull/1 — from the ida-nexus (né ida-codemode) maintainer: kitty-graphics fallback for platforms without termios, Ctrl+R view refresh, typed remote operations through ida_nexus RemoteModule, live auto-refresh from other clients' IDB events (upstream item 7), discard-without-save (item 6), and the ida-nexus 0.7.0 rename. tests/test_kittygfx.py is the union of both sides' files: our escape- construction checks plus the PR's cross-platform fallback checks.
| * Switch to ida-nexusDuncan Ogilvie2026-08-20
| |
| * Adopt idb_events and remote module features from ida-codemodeDuncan Ogilvie2026-08-20
| |
| * Add Ctrl+R to refresh all viewsDuncan Ogilvie2026-08-17
| |
| * Add kitty graphics fallback for Windows supportDuncan Ogilvie2026-08-17
| |
* | splash: give the logo a placement id so re-anchoring replaces, not stacksblasty2026-08-21
|/ | | | | | | | | | | | | | | | 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.
* docs: be honest that the triskel fork is unpublishedblasty2026-08-15
| | | | | | | | | The install instructions pointed at ~/dev/triskel, a path only I have, so a reader could not act on them. Say plainly that the patched pytriskel is not released yet and that native is the supported default. Also drops the .fastfeedback/SPEED.md references and refreshes a stale branch name and commit hash in bench_ops.py.
* docs: drop the triskel evaluation artifactsblasty2026-08-15
| | | | | | | | | TRISKEL_EVAL.md and graph_compare.py were intermediary working docs from choosing the SESE engine. Triskel now ships alongside the native layout engine, so the evaluation no longer needs to be in the tree; GRAPH_VIEW.md keeps the part that describes shipping behaviour. Also drops the local-only ~/dev/triskel/PATCHES.md pointer from pyproject.
* README: drop the ASCII mock and the LLM-agent sectionblasty2026-08-15
| | | | | | The mock terminal is a poor stand-in for a real demo; a gif/screenshots should go there instead. The agent section pointed at .agents/, which is no longer part of the repo.
* Add MIT licence and contributor guideblasty2026-08-15
| | | | | Also ignore the local-only working artifacts (.auto/, .agents/, plan/, rehearsed-engineer.md) so they stay on disk and out of the repo.
* Re-check the upstream findings report against 0.6.1blasty2026-08-14
| | | | | | | | | | | | | | | | | | | Items 4, 8 and 9 are fixed (loader switches on reopen, package exports, py.typed), so the report and the skill no longer claim 4-9 are all open. Item 7 is partial: DatabaseManager grew a DatabaseEventCallback, but there is still nothing an external caching client can observe, which was the point. 5 and 6 are untouched. The "are these files byte-identical" re-check recipe is removed: it named client.py/registry.py/resolver.py, which 0.5.x renamed, so it would have reported "unchanged" forever. The replacement is a diff of __all__ and the DatabaseOpenOptions fields, both of which the contract suite now asserts whenever the library is importable. Also records two things that cost time here: the loader-switch strip needs input_path != source, so an .i64 passed directly alongside load options still fatals; and an editable checkout does not enforce its own dependency floors, so a `git pull` to 0.6.1 leaves ida-domain/zeromcp silently too old.
* Port to the ida-codemode 0.5.3+ public APIblasty2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 0.5.x turned ida_codemode.__all__ into a real public API and hid the rest: client.py -> handle.py, registry.py -> _registry.py + instances.py, resolver.py -> _resolver.py. Every import we had was from a module that no longer exists, so the TUI could not attach at all. - handle.entry -> handle.instance (RegistryEntry -> DatabaseInstance) - 30 keyword-only options -> DatabaseOpenOptions(...) passed as options= - IdbBusy -> DatabaseBusyError - InstanceDisconnected/ClientError -> DatabaseDisconnected/CodeModeConnectionError - our scan_instances()+idb_key() ownership walks -> find_database_owner() - our FileLock poking (_wait_for_entry_release) -> wait_database_released() - registry.discover_instances() -> discover_databases() + InstanceState _database_exists() is deleted with it: upstream now drops the loader switches itself when reopening an existing IDB (_resolver._build_worker_command), which is the same fix we had client-side. See docs/CODEMODE_UPSTREAM.md section 4 for the one invocation that still slips through. The offline contract suite has to keep running under a stdlib-only python3, where every Code Mode name is bound to None -- so it now injects a strict fake DatabaseOpenOptions and a real DatabaseBusyError exception alongside the fake handle. Without the latter, `except DatabaseBusyError` is `except None`, and the TypeError it raises masks whatever actually failed inside the try. The loader-option names moved inside the options dataclass, so the guard that caught `loading_address` vs `image_base` moved with them (_option_fields_are_real). uv.lock pins 0.6.1; ~/ida-venv and .venv are on 0.6.1 with the ida-domain 0.5.1 / zeromcp 1.8.0 floors it requires. Full gate: 1065 passed.
* Fix: rebuilding the row index on every switch back to the listingblasty2026-08-10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported as "decompiler -> listing feels slow", and it was: ~900ms per Tab. The listing view re-primes every time it is shown, and priming now builds the whole row index. build_from_index() had no idempotence, so each switch back re-ran segment_index over the entire segment. Measured on bash, going back to the listing away from the primed viewport: press Tab: 904ms -> 9.7ms, and 5 backend calls -> 0 The index is a pure function of the database and the model is thrown away and rebuilt whenever anything moves the walk (stale_structure), so a model that is already indexed can return immediately. Nothing caught this because the listing was CORRECT the whole time -- only slow. Every structural assertion passed, boot still measured fast, and the suite has no notion of "how many calls did that keystroke cost". The new scenario counts backend calls across three view switches and asserts segment_index is not among them; with the guard removed again it fails. Latent and NOT fixed here: materialising a page the viewport reaches for the first time still happens inside render_line, i.e. an RPC (~20ms) on the UI loop. That predates this change -- it is how skeleton pages have always worked -- and is small enough not to read as a stall, but it is the same shape of bug and wants prefetching onto the worker that already exists for pages. Full gate: 1064 passed.
* Build the listing index in one call: boot 9.3s -> 1.25s, 911 calls -> 4blasty2026-08-10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* segment_index: the row total in one call instead of 458blasty2026-08-10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The listing streams a whole segment for one reason -- to know how many rows it has, so the scrollbar and paging are right. Even as skeletons that is 458 round trips and 227k rows for a 1.2MB bash, none of which is displayed. segment_index walks the same items and counts what heads() WOULD emit, building none of them, and returns the total plus [row, ea] anchors every 500 rows. Measured on bash, same process and database: segment_index : 228,659 rows, 1 call, 501ms streaming : 228,659 rows, 458 calls, 1836ms 3.7x Exactness is the whole point, so it mirrors _rows_for's arithmetic rather than approximating it: 3 banner rows at a function start, a label row for a named code head that is not one, the head row, struct members for data, 2 footer rows at a function end, and an undefined run counted as its byte length because the client presents one collapsed row as that many logical rows. A count that is off by a handful means the scrollbar lies and a seek lands on the wrong row, so the test compares against a fully streamed model in the same process rather than against a tolerance, and checks that every anchor names the address of the row it claims. Getting that right took a false alarm worth recording: the count first looked 35 rows short of a model built by the pilot, which turned out to be a DIFFERENT DATABASE (tests run on a pristine scratch copy). Against the same database it matches exactly, head for head, with zero differing addresses. Nothing consumes this yet. Spending it means teaching ListingModel to hold sparse pages seeked through the anchors instead of one dense array grown from the segment start, which is a real change to the core view and wants its own run at it. Full gate: 1054 passed.
* domain: build Head positionally, and drop a dead test from _as_intblasty2026-08-10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Head is the most-constructed object in the codebase -- 227k of them to stream one bash -- and from_raw was paying for two things it did not need. _as_int ended in a ternary whose arms were BOTH int(v, 16), so the isinstance+startswith in front of them decided nothing and ran on every address the client parses (55k times per 60 pages). Removed; the function is now the isinstance it always was. Head(...) was built by keyword, which makes the tuple match names against fields; positional is the same object with none of that. Address conversion is inlined for the same reason from_raw exists at all. Together 30% off Head construction (2.27 -> 1.59ms per 2003 rows, 0.34us a row), with output verified identical against the previous implementation over both skeleton and full rows. isinstance was kept over the 5%-faster "type(v) is int" because the two differ for bool and int subclasses, and that is not a trade worth making for 5% of 12% of a page. End to end this is ~2.5% of bash's boot (3117 -> 3065ms): Head construction was ~12% of the client half, and the client half is ~54% of what is left. Adds experiments/profile_client.py -- profile_remote.py's counterpart for the half of a page load that happens outside the database process, which is where the remaining time now is. Full gate: 1050 passed.
* remote_tools: item class by mask instead of is_code/is_data callsblasty2026-08-10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | The listing walk asked ida_bytes.is_code/is_data ~6 times per head (the row's kind, plus twice more via _is_unknown_f from _advance and _rows_for). Each is a python wrapper around a C call; the mask compare is the same question with no call at all. Equivalence was proven, not assumed: checked against is_code/is_data over all four classes x 16,020 synthetic flag values, and over every distinct flag in a real database (125 across 207,191 heads). Zero mismatches. Output is byte-identical over 3003 rows spanning all five row kinds. (The digest DOES differ run to run, which looked alarming until it turned out the same unmodified code differs too: hash() is seed-randomised per process and the digest is documented as per-process. Cross-process digest comparison proves nothing.) Worth 5.6% on a skeleton page (3.37 -> 3.18ms) and 2.7% on a full one -- the ~5% the profile predicted, not the 1.5-2x I guessed out loud. End to end it does NOT move bash's boot (3117 -> 3140ms, noise), because at 455 x 3.18ms the backend is now only ~1.45s of a 3.14s boot: the other ~54% is transport, Head construction and model bookkeeping on the client. Only the two hot sites changed. The four in the operand-format paths are not per-head and read better as is_code/is_data. Full gate: 1050 passed.
* Skeleton pages: stop rendering 227k rows to count them (3x boot)blasty2026-08-10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* remote_tools: delete a shadowed duplicate hiding an lru_cache landmineblasty2026-08-09
| | | | | | | | | | | | | | | | | | | | | | | | _idatui_head_row and _idatui_line_parts were each defined TWICE at module scope. Python keeps the last, so the first 74 lines were dead -- but the two copies of _idatui_line_parts were not equivalent: only the live one carries @lru_cache(maxsize=_IDATUI_LINE_CACHE). That makes the duplication worse than clutter. Anyone tidying it up by deleting the second copy would keep a byte-identical function and silently lose the most important cache in the backend: 10.4us -> 3.9us per listing row, ~2.7x on every row of every page. A comment now marks the spot and says which copy to keep. Found while profiling the digest; an AST duplicate-check reported the two as IDENTICAL because ast.get_source_segment on a FunctionDef excludes the decorator list. Compare decorators explicitly. Verified before deleting: the block defines only names redefined below, and nothing above it referenced them. Behaviour unchanged -- heads(500) still 9.3ms, digest still stable, and the expect/short-circuit contract still omits rows on a match and returns them on a miss. Full gate: 1042 passed.
* remote_tools: hoist the IDAPython imports to module scope (~3%)blasty2026-08-09
| | | | | | | | | | | | | | | | | | | | | | | | | _idatui_head_row ran 'import ida_bytes/ida_lines/ida_name' on every LISTING ROW -- 25,500 sys.modules lookups per 500-row page. 50 such imports across the file, all pointless: this module is never imported by the client (codemode_client reads it as TEXT and installs it in the database process), so the no-IDA house rule that forces function-local imports elsewhere does not apply here. Measured honestly, it is worth about 3%: heads(500) best-of-40 goes 9.37ms -> 9.07ms, median 9.73 -> 9.65. A microbenchmark of the bare import predicted 3.2ms/page and was 10x optimistic -- the A/B is what counts, and it is a small win, not the big one. ida_hexrays deliberately stays function-local: it is licence-dependent, and hoisting it would break the whole remote library for someone without the decompiler rather than failing only when they decompile. Verified by AST that every ida_* reference still resolves. Adds experiments/profile_remote.py, which ships cProfile INTO the database process -- the only way to see the split between IDA's own calls and our python, which no client-side timer can show. Full gate: 1042 passed.
* experiments: count backend round-trips per user actionblasty2026-08-09
| | | | | | | | | | | | | Answers 'are we batching or paying a round-trip per item' with numbers. Wraps CodeModeClient.invoke on a live pilot and reports calls + wall + ops for each action. The trap it encodes: ListingView._grow streams the whole segment on a worker thread, so it lands calls continuously regardless of what the user does. A naive census attributes that to whichever span is open and every action comes out at a near-identical '~1 call per 10ms of pause', which measures the pause and not the action. It now drains the grower first, reports it as its own line, and measures the rest against a quiet backend.
* PgUp/PgDn page the list overlays, not just the code viewsblasty2026-08-09
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* README: drop the Docs sectionblasty2026-08-09
| | | | | | | | | | | | | | | | | | docs/ is internal design and porting notes, not end-user documentation, so the README should not send readers there. Put it back when there are docs worth linking. Removed the section and the two inline pointers that fed the same files (-> docs/GRAPH_VIEW.md on Graph, -> docs/RPC.md on RPC), since the reason applies to those equally. No docs/ reference remains. Kept, deliberately, two links that are not end-user docs: * .agents/skills/idatui/SKILL.md -- promoted from a subsection of Docs to its own heading so removing the parent did not silently delete the only pointer to the skill we just shipped; * .fastfeedback/SPEED.md -- contributor-facing, sits inside Tests next to the commands it qualifies, and is accurate. Say the word if either should go too.
* README: cut the wall of text out of 'What's in it'blasty2026-08-09
| | | | | | | | | | | | | | | | | | | | The feature coverage was right, the prose was not: eleven paragraphs that explained MECHANISM in an intro document. The keys table right above it already proved that scannable beats thorough here. Every feature is now name + key + at most two lines. Longest block went from ~70 words to 35; the section from 80 lines to 53. What got cut was rationale, not information, and none of it is lost -- each one is already the docstring of the module that implements it: why findings has to journal edits (journal.py), how search guesses text vs bytes (search.py), trail painting and unseen bytes as ?? (trace.py), why terminal graphics support is asked for rather than sniffed (kittygfx.py). The README now says what a feature IS and links onward. Also folded hex/strings/palettes/projects/splash into one 'Also' line (all already in the keys table), and gave the same treatment to the Run prose and the agent-skill paragraph.
* Ship the idatui agent skill in-repo at .agents/skills/blasty2026-08-09
| | | | | | | | | | | | | | | | | | | | | | | | | Follows the Agent Skills standard (agentskills.io) rather than a contrib/ dir: harnesses discover .agents/skills/<name>/SKILL.md automatically from the repo root, and it is vendor-neutral (not .claude/ or .pi/ specific). Not a verbatim copy of the local one -- it was ~85% generic and the rest had to go before redistributing: * the backend is described as a normal pyproject dependency, with the editable-git-checkout setup demoted to a conditional note (that is one maintainer's layout, but the 'upstream pull silently swaps the backend' warning is worth keeping for anyone who does it); * personal agent tooling (bgrun/waitfor wrappers) replaced by the underlying lesson -- a backgrounded subshell holds the inherited stdout, so redirect all three fds -- which is true without those scripts; * maintainer commit authorship dropped; * absolute ~/dev paths made relative to the repo root; ~/ida-venv kept but marked as this project's assumption, matching the README. Also fixes the README's stale full-run count (788/~2m -> 1031/~50s), lists the docs that were missing from the index, and warns that a user-level skill of the same name shadows this one -- otherwise a personal copy silently wins and the in-repo copy rots.
* SPEED: replace the historical backend table with a real 0.3.1 vs 0.3.2 A/Bblasty2026-08-09
| | | | | | | | | | | | | | | | | | | | | | | | The worker-vs-Code-Mode table was measured before 0.3.2 and with both workarounds active, so it answered a question nobody asks any more. Replaced with three configurations measured on the same box, rolling both checkouts back and forward: A old client WITH workarounds on 0.3.1 -- what shipped B current client on 0.3.1 -- what the workarounds were for C current client on 0.3.2 -- now Headline: the real-world gain is ~1.4x geomean, NOT the 6.9x the empty round trip advertises, and the doc says so in those words -- because the tempting number to quote is the wrong one. The A->C vs B->C gap is the actual story: stock 0.3.1 was 5.4x slower, so the workarounds had already recovered nearly everything and upstream mostly bought us the right to delete them. Also records the three cost classes (payload- / round-trip- / IDA-dominated) so the next person optimising here knows which lever moves which op, and the ~10% run-to-run spread so a sub-1.2x 'regression' doesn't start a hunt. Old worker table kept below, labelled historical. experiments/bench_ops.py is the harness, with the copy-to-/tmp-before-checkout trick documented in it.
* Docs: re-check ALL nine upstream findings against 0.3.2blasty2026-08-09
| | | | | | | | | | | | | | | | | | | | | Items 1-2 were marked fixed; 3-9 were left 'not re-verified', which was a gap rather than a finding. Checked all seven. Item 3 is also FIXED, and by a lot: a same-box A/B (roll the editable checkout back to 4195f21, measure, roll forward) puts execute_python('result = 1') at 2.055ms on 0.3.1 and 0.294ms on 0.3.2 -- 7.0x, now about the cost of a bare HTTP GET. The 0.3.1 column reproduces the originally documented 2.025ms almost exactly, which is what makes the 0.3.2 column believable. So the execute_sync marshalling that was ~93% of the floor is gone, and the 'never make a call per row' rule is much weaker. Items 4-9 are open by construction: client/registry/resolver/server/ database/worker .py are byte-identical between 0.3.1 and 0.3.2, and all six items live in those files. Recorded that as the cheap re-check recipe for the next upstream pull. SPEED.md taught the obsolete 2ms floor as a design rule; corrected, with a warning not to go rewrite working batched code to be chatty.
* Docs: correct the stale --fast check counts (302/257 -> 380)blasty2026-08-09
|
* Docs: mark the two upstream perf findings fixed in 0.3.2blasty2026-08-09
| | | | | | | | CODEMODE_UPSTREAM items 1 (timeout_trace) and 2 (to_jsonable) both landed upstream; note it at the top, on each item and in the priority table, and flag that items 3-9 are not re-verified against 0.3.2. SPEED.md described the settrace workaround and its IDATUI_CODEMODE_TRACE knob as current -- both are gone, and its backend table predates 0.3.2.
* Drop the settrace workaround: ida-codemode 0.3.2 deleted the hookblasty2026-08-09
| | | | | | | | | | | | | | | | | | | | _script() ran every snippet with sys.settrace(None) to detach Code Mode's per-line trace hook, which returned itself and so enabled line tracing in every frame it saw -- 52x on ida_bytes.get_flags, and the whole residual gap against the old private worker. 0.3.2 removes that hook entirely (zero settrace refs); the deadline is now a C-level thread interrupt, runtime._interrupt_thread. Re-measured on a 200-row listing page of main in targets/bash: the workaround buys 0.99x. So it goes, and the caveat goes with it -- a pure-Python loop inside a snippet is once again bounded by its deadline. _PACK_EPILOGUE measures 0.97x for the same reason (dumps_json got a C fast path) but is kept for encoder determinism, with its stale 114x claim corrected. experiments/bench_pack_trace.py is the harness for both numbers. Full gate green: 1031 passed, 0 failed.
* Graph: never hand triskel a block its root cannot reachblasty2026-08-09
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | "EMPTY BL" in the status bar is triskel's own bracket-list assertion from its SESE pass, and it turned out to be the mild version of the problem. Triskel's graph root is whichever node was created FIRST, and every analysis walks out from it. Anything unreachable from that node is undefined behaviour. We were: - creating nodes in id order, so the root was the lowest-numbered block rather than the entry, and - splitting only WEAKLY connected components, which says nothing about reachability. A 7-block CFG whose entry has no successors -- IDA hands those out for thunks and for dead code it could not resolve -- SEGFAULTS the interpreter. That is unsurvivable: it takes the session down and there is no exception to fall back from. Now the entry is created first, orphan blocks are attached to it with phantom edges that steer placement but are never drawn (one edge usually adopts a whole orphan subgraph, attached at a node no other orphan reaches), and reachability is asserted in python BEFORE crossing into C++. This replaces the component splitting entirely: one layout instead of N stacked side by side, and triskel gets to place the orphans. The reproducer is now a test (t_unreachable_entry). Remaining fallbacks on the ls corpus are 8/1200 layouts, all the upstream box-overlap defect, all but one on 300-500 block functions.
* Graph: fix the triskel fallbacks, and say why when it still falls backblasty2026-08-09
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | "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.
* Graph: name the interpreter when pytriskel is missingblasty2026-08-09
| | | | | | | | | ./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.
* graph_triskel: name the detour target, drop the unused swapblasty2026-08-09
|