| Commit message (Collapse) | Author | Age | Files | Lines |
| ... | |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The permanent Footer spent a screen row on a truncated, always-visible key list.
Remove it (the status line now owns the bottom row) and put the full cheatsheet
behind F1 — grouped by task (Navigate / Views / Move / Edit / Search) rather than
by widget, which is what makes it readable. Esc, F1 or q closes it; there's also
a "Keyboard shortcuts" command-palette entry.
F1 rather than '?' because '?' is already search-backwards in the code views.
Textual leaves F1 unbound (App only claims ctrl+q/ctrl+c), so it traps cleanly.
Gotcha worth recording: the helper that builds the cheatsheet was first called
_render, which collides with Widget._render — Textual invoked ours internally and
got a rich Text where it wanted a Visual, so the whole screen failed to paint
("'Text' object has no attribute 'render_strips'"). Renamed to _cheatsheet.
The search scenario's "rendered above the footer" check now asserts the search
input owns the bottom row instead. New `help` scenario: footer gone, F1 opens it,
groups + real bindings present, Esc closes (5 checks).
Suite note: view_toggle fails in a full run ONLY as collateral from the standing
`filter` flake, which runs immediately before it — when filter leaves the table
empty, view_toggle's open_biggest has nothing to open. Verified: view_toggle
passes alone (13/13) and directly after `help` (18/18), and reproduces as a
cascade with --only filter,view_toggle. Not a regression from this change.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The pseudocode view still had the old mapping (home -> goto_top, end ->
goto_bottom), so <End> jumped you to the bottom of the function instead of the
end of the line. Bring it in line with the listing view:
home start of line ctrl+home top of the function
shift+home first non-blank ctrl+end/G bottom of the function
end end of line
shift+home skips the C indentation — the pseudocode analogue of the listing's
skip-the-address-gutter.
Verified live and locked in view_toggle (5 checks): <end> stays on the line with
the scroll unchanged, <home> hits column 0, <shift+home> lands on the indent
width, and ctrl+home/ctrl+end still reach top/bottom. Full suite 179/2-flake.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
In the decompiler, 'y' on a local variable already worked (func_types -> lvars ->
set_lvar_type), but a GLOBAL fell through every case and silently retyped the
ENCLOSING FUNCTION'S PROTOTYPE — worse than not working, since the prompt said
"prototype" while you thought you were typing a variable.
* server/patch_server.py: new data_type tool — {addr,name,type,size,is_func} for
a data item, so the prompt can prefill the current type and the caller can tell
a global from a function.
* domain: Program.data_type() + set_data_type() (set_type with kind="global").
* app: _prepare_retype gains the data case between "function" and the
current-function fallback, with a size-based prefill when the global is still
untyped; _do_retype routes kind="data" to set_data_type.
Classification verified on echo/main: 'v3' -> lvar (prefill 'char *'), 'stdout'
-> data (prefill 'FILE *'), 'main' -> func prototype, an unresolvable token ->
the enclosing prototype (unchanged fallback).
Also fixes a latent crash found while probing this: on_listing/decomp_view_
cursor_moved called self.query_one(ListingView), but App.query_one searches the
TOP screen — a cursor-moved message landing while any modal is up (loading
overlay, project switch) raised NoMatches out of a message handler and killed the
app. Both handlers now go through _try_view().
Pilot `retype` extended to 9 checks covering all three flavours, each asserting
the other targets are left alone. Two of the new checks needed settles: applying
a retype recompiles asynchronously, so scanning/indexing the pseudocode without
waiting reads text that's about to be replaced (this also cut the scenario from
30s to 2.6s of previously-wasted timeout). Full suite 174/2-flake.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The art rendered flush against the left of the box (inset 3, with 9 to spare on
the right). #loading-box's align-horizontal couldn't fix it: the 1fr title/note
siblings make the child group span the full content width, so container
alignment has nothing left to centre. Text justify="center" doesn't do it either
— no_wrap bypasses the justification pass.
Wrap the logo in rich.align.Align.center and give the widget width: 100%.
Measured: the strip is now the full 66-cell content width with the 60-wide art
padded 3/3.
|
| |
|
|
|
|
|
|
|
| |
`align: center middle` was set on SymbolPalette only, but StringsPalette and
ProjectPalette reuse the same #pal-box without a centring rule of their own — so
both rendered clamped against the top-left corner. Apply the rule to all three.
Measured on a 140x44 screen: every palette now has equal left/right (22/22) and
top/bottom margins. Palette scenarios still green (13/13).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Wires the project model + worker pool into the app. Project mode is ADDITIVE —
without --project the app is exactly the single-binary tool it was, which is what
keeps the 167-check pilot meaningful.
* IdaTui(project=...) builds a WorkerPool and opens the project's first binary;
_open_worker_client asks the pool instead of spawning directly.
* BinaryState snapshots what a switch leaves behind (program, func_index, nav,
cur, view prefs, filter). Switching reuses the _after_reconnect shape: swap
client+program, rebuild the index, reopen the entry. A still-resident binary
restores instantly (Program + index are in memory); an evicted one gets a fresh
worker but keeps its nav history, which is just addresses.
* ProjectPalette (Ctrl+O, + a "Switch binary…" palette command): the project's
binaries with resident/analysed/pinned/active state and memory, filterable.
* launch.py --project FILE, creating the project when binaries are also given;
stages everything up front so the source tree is never written to.
Two bugs found while testing:
* _did_auto_land is app-wide, but landing is per-binary: after the first binary
landed, a cold switch never landed at all AND left the switch overlay up
forever. Reset it per switch.
* PRE-EXISTING: the status Static had Textual markup enabled, so a single-word
bracket marker parses as a style tag and is silently eaten — [listing] and
[pseudocode] have never actually rendered (only [split · listing] survived,
because the · makes it an invalid tag). Status is plain text with brackets and
symbol names, so markup=False.
tests/test_project_ui.py: end-to-end pilot on two real binaries (18 checks) —
boot, switcher contents, switch, per-binary index, both workers resident,
switch back with state intact, return-to-where-you-were, and the promise that
the source tree stays pristine while every artifact lands in the sidecar.
Full single-binary suite unchanged at 167/2 (the standing flakes).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
idatui/pool.py — WorkerPool keeps one live worker per project binary:
* lazy spawn on first use; staging + a scratch sweep happen first, so a database
wedged by a previously hard-killed worker reopens instead of crash-looping.
* residency is bounded by a MEMORY BUDGET (default project.memory_pct of RAM),
not a worker count — a count is the wrong knob when one project holds a 50KB
helper and a 6MB crypto lib. Cost is measured per worker from
/proc/<pid>/smaps_rollup (PSS, which splits shared pages so summing means
something).
* over budget -> evict least-recently-used, never the active or pinned binary,
and give up rather than thrash when nothing is evictable. Eviction calls
idb_save first, so returning to a binary is a DB load, not a re-analysis.
* status() feeds the switcher UI (resident/pinned/active/analysed/memory).
worker_client: fix the wedge-file bug this depends on. close() sent
__shutdown__ and then IMMEDIATELY SIGTERMed, killing the worker mid
close_database() — which is what leaves the unpacked .id0/.id1/... behind and
makes the .i64 refuse to reopen. Now it waits out a grace period (the worker
returns from serve() on __shutdown__ and closes the DB in its finally) and only
escalates if it's genuinely stuck. Also expose .pid for memory accounting.
Verified: a pilot run that used to leave echo.id0/.id1/.id2/.nam/.til now leaves
only echo.i64, and teardown is no slower (14 checks in 5s).
tests/test_pool.py: 22 checks with an injected fake client — LRU order, budget
eviction, active/pinned protection, save-before-close, thrash avoidance,
status(), teardown. Pure stdlib, no idalib.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
First slice of multi-binary projects (docs/PROJECTS.md): the on-disk model, with
no runtime wiring yet.
idatui/project.py (stdlib-only, like domain/worker):
* Project.load/create/save — an explicit JSON project file listing binaries;
paths resolve relative to it, labels default to the basename and are
disambiguated on collision (they name files).
* A sidecar dir beside the project file (<stem>.idatui.d/) holds bin/ (staged
binaries), their .i64 + scratch, and idx/ for phase 2. IDA opens the STAGED
file, so nothing lands in the source tree — today targets/ carries ~244MB of
IDA litter around ~13MB of binaries, much of it stale wedge files.
* stage() copies rather than hardlinks. A hardlink is free but makes source and
staged one inode, so an in-place rebuild (cp over the path truncates instead of
replacing) would silently swap the bytes under an analysed DB with nothing to
detect it. The unit test caught exactly that. A copy also leaves the sidecar
self-contained once the sources are gone.
* Re-staging a changed source drops its now-stale DB; sweep_scratch() clears the
unpacked working files a hard-killed worker leaves behind (never the .i64).
tests/test_project.py: 27 checks, pure stdlib (no IDA/textual/worker), <1s.
docs/PROJECTS.md: the full design — the one-worker-per-DB constraint with
measured costs (bash worker = 126MB RSS/117MB PSS; libcrypto's DB is 72MB, so
residency is budgeted by MEMORY, not a worker count), the switch between
"switching needs a live worker" and "searching doesn't (cached index)", and
phases 1-4.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The sync only ran off CursorMoved, so a wheel-scroll or scrollbar drag — which
moves the viewport but never the cursor — left the other pane behind.
* ListingView/DecompView post a new Scrolled message from watch_scroll_y when the
rounded scroll changes; the app re-syncs on it (guarded to the active pane, so
a companion's align() can't feed back).
* _split_anchor(view): the sync now anchors on the driver's cursor while it is
visible, else on the top visible row. Cursor moves behave exactly as before
(key-nav always scrolls the cursor into view); once a pure scroll takes the
cursor off-screen the viewport itself becomes the anchor, so the companion
keeps following what you're actually looking at — including re-decompiling as
you scroll across function boundaries.
Pilot split_view gains a pure-scroll check (viewport moves, cursor doesn't, the
companion still moves): 20/20. Full suite 167/2-flake.
Three existing checks were setting .cursor without scrolling it into view, which
the anchor correctly treats as "cursor not visible"; they now scroll like real
key-nav. The multi-region check also had to make the decomp the ACTIVE pane
before a decomp-driven sync — otherwise the companion's align() fires Scrolled
and re-syncs listing-driven, clobbering the link (impossible in real usage, where
the companion is by definition not the active pane).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Scrolling either pane left the other one wherever it happened to be: the
companion only scrolled when the linked row went off-screen (reveal()), so the
link could sit at the bottom edge while the driver's cursor was mid-viewport —
visually incoherent, your eye had to hunt for it.
Add ListingView/DecompView.align(row, screen_row): scroll so the linked row lands
at the SAME viewport offset as the driver's cursor, and use it in _sync_split for
both directions. The two panes now track each other line-for-line, so the eye
reads straight across. Best-effort at the ends (can't scroll above line 0, nor
past the end when the pseudocode is shorter than the viewport).
Pilot split_view: deterministic alignment check — park the listing cursor at a
known viewport offset deep in the function, sync, and assert the decomp's scroll
top is exactly the aligned value (accounting for both clamps). 19/19; full suite
166/2-flake.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
ida-pro-mcp exposes no full strings list (only a filtered/capped "interesting"
survey), so this is a new injected tool plus a filterable browser.
* server/patch_server.py: list_strings(offset,count,min_len,refresh) — every
literal from idautils.Strings() as {addr,text,len,type}, paginated, with a
module-level cache keyed by min_len (rebuilding is O(n) and the browser pages
the whole list).
* domain: StrLit dataclass + Program.strings() — pages the full list once and
caches it.
* app: StringsPalette modal (mirrors SymbolPalette) — case-insensitive substring
filter with the match highlighted, addr/len/text columns, ↑↓/Enter/Esc.
Bodies are sanitized to one printable line (\n/\r/\t escaped, non-printables
dropped, long strings clipped) so control chars can't break the layout;
display strings are pre-rendered+pre-lowered once since filtering runs per
keystroke. Enter jumps to the literal in the unified listing via _goto_ea.
Bound to '"' and Shift+F12, plus a "Strings…" command-palette entry.
Verified on echo: 150 strings listed with addr/len/text, filtering 'usage'
narrows to 2 (case-insensitive), Enter lands the listing cursor on the literal.
Pilot `strings` scenario 6/6; full suite 165/2-flake.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
The unified listing spans many functions, but the decomp pane was pinned to the
one function it was opened on — scrolling the listing cursor past that function's
bounds stopped syncing. Now _sync_split tracks the decompiled function's ea span
(_split_range, from decomp_map) and, when the listing cursor leaves it, re-points
the decomp pane to the function under the cursor: _resync_decomp (exclusive
worker) -> function_of(ea) -> _apply_resync re-decompiles + reloads the region
map, or just drops the band over data/undefined (keeping the last function).
Pilot split_view: moving the listing cursor into another function re-syncs the
decomp (18/18).
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The sync direction follows the FOCUSED pane, but that was only discoverable via
Tab. Add on_descendant_focus: in split, focusing a pane (Tab or a mouse click)
makes it the leading/driver pane and re-syncs — so clicking into the pseudocode
and cursoring around now drives the listing, matching intuition (previously a
click focused the widget but left _active — and thus the sync direction —
pointing at the other pane). The split status now ends with "(Tab/click: drive
<other pane>)" so it's obvious.
Pilot split_view: clicking the pseudocode pane makes it the driver (17/17).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Polish for the split view:
* _split_status(): status line now reads "name @ ea [split · pseudocode line N
↔ K insn]" / "[split · listing]" from the focused pane, instead of the
single-view [pseudocode]/[listing] labels clobbering it. Wired into both
cursor-moved handlers and _apply_decomp.
* min-width gate: action_toggle_split refuses to enter split below
_SPLIT_MIN_WIDTH (100 cols) so two usable code panes always have room.
* navigation keeps both panes on the same function: verified (not fixed —
goto/follow in split already routes _open_entry -> _show_active split branch,
reloading the listing + decomp + region map for the new function).
Review turned two roadmap items into non-issues: split is a persistent mode
orthogonal to nav entries (back/forward just navigate within split), and search
is already per-focused-pane.
Pilot split_view gains: split-aware status, goto-in-split navigates, and both
panes reload on navigation (16/16). Full suite 157/2-flake. Split view is
feature-complete (phases 1-4).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Ghidra "region band": moving the pseudocode cursor now lights up EVERY
instruction that C line owns, not just one.
* server/patch_server.py: new decomp_map tool — sweeps cfunc.get_line_item across
each pseudocode line's columns and collects the ea from each item's dstr()
('EA: desc', matching the /*ea*/ marker source so it aligns with the display
lines). Returns {addr, lines:[{ea, eas:[...]}]}. (First tried item.get_ea(),
which reports a different ea and didn't align — dstr() is the right source.)
* domain: Program.decomp_map(ea) -> per-line ea lists, cached by name-gen.
* app: _load_split_map fetches it off-thread into _split_eamap/_split_ea2line;
_sync_split bands the full instruction region for a C line (decomp drives) and
uses the exact ea->line inverse (listing drives), falling back to the single
marker until the map lands. Maps cleared on leaving split.
Pilot split_view gains: decomp_map returns/aligns with the markers, and a
multi-instruction C line bands >1 listing row (13/13). Full suite 154/2-flake.
The idalib spike ran on the pilot's own worker (the standalone worker kept
getting reaped in this sandbox).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Ghidra sync: in split, the focused pane drives and the companion shows a
subtle band (_S_LINK) on the linked location + scrolls it into view. The
companion's cursor never moves (band + scroll only), so there's no echo/
ping-pong and no guard is needed.
* _sync_split(source): listing drives -> DecompView.line_for_ea (largest /*ea*/
marker <= cursor ea) bands the covering C line; decomp drives ->
ListingModel.ensure_ea bands the covering instruction row.
* wired into on_listing/decomp_view_cursor_moved (gated on the focused pane),
the Tab focus-switch (re-link from the new driver), _apply_decomp (link once
the pseudocode loads) and _apply_enter_split; bands cleared on leaving split.
* ListingView/DecompView gain _link_rows/_link_line + set_link/reveal, a
render_line apply_style(_S_LINK) band, and DecompView.line_for_ea.
Still single-ea per line (one instruction highlighted) — the full instruction
range is phase 3 (the decomp_map tool). Pilot split_view now covers both sync
directions + that the band actually paints (10/10); full suite 151/2-flake.
|
| |
|
|
|
|
|
|
| |
Two small correctness tidies before phase 2:
* action_toggle_split now no-ops while a search/rename/... prompt owns the
keyboard (parity with the other app actions; a stray 's' can't split mid-edit).
* the pilot reset() clears app._split so split state can't leak into the next
scenario if one crashes mid-run.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Ghidra-style dual view, layout + toggle (no cursor sync yet — that's phase 2).
's' (and a "Split view" palette command) toggles a _split mode where the listing
(left) and pseudocode (right) show together, divided by a keyline, one focused.
Entering split loads the listing + decompiles the current function into both
panes; Tab/F5 switches the focused pane; any single-view target (hex, etc.) or 's'
again collapses back to the focused pane.
* app: _split flag; _show_active gains a split branch (both panes, load decomp,
focus active, #panes.split class); action_toggle_split + _enter_split worker;
action_toggle_view switches panes when split; 's' binding + palette entry;
#panes.split ListingView divider CSS.
* docs/SPLIT_VIEW.md: the full design + phased roadmap (the hard part — line<->EA
set mapping via a sweep of cfunc.get_line_item — is scoped for phase 3).
* tests: split_view scenario (enter/load/tab-focus/exit, 5 checks). Also fix
disasm_nav's stale goto-bottom (press ctrl+end; plain 'end' is end-of-line now).
Verified live over RPC (both panes render, Tab flips focus, 's' exits) and pilot
split_view 5/5.
|
| |
|
|
|
|
|
|
|
|
|
| |
The F5-from-listing overlay fix raises dec.loading=True before _show_active, but
_show_active only clears it via _load_decomp when the function needs
(re)decompiling. F5 on a function already shown in the pseudocode pane
(dec.loaded_ea == cur.ea) took the else branch, which never cleared loading -> the
spinner stayed up forever. Clear dec.loading=False in that branch.
Pilot view_toggle gains a check: F5 the same cached function again and assert the
overlay clears (8/8).
|
| |
|
|
|
|
|
|
|
| |
Replace the "― decompiling… ―" ASCII-bar label with a small animated widget
(_DecompLoading): an amber braille spinner (matching the app's #b58900 accent)
+ a muted italic "decompiling…", dim over the grayed pseudocode. Spins at 12fps
via set_interval; initial frame set in __init__ so the cover has content
immediately (the pilot reads it synchronously). CSS keeps the dim panel bg +
centering; per-glyph colors come from the Rich Text now.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The F5/Tab-from-listing path decompiled the function inside _decomp_from_listing
(via _decomp_line_for) in a background thread with no overlay, THEN _show_active
re-decompiled it -- but that second call hit the cache and returned instantly, so
dec.loading was set and cleared within a frame and the overlay only flashed. The
long wait (the real decompile) happened with nothing on screen.
Raise the pseudocode pane + loading overlay synchronously in action_toggle_view
before launching the background decompile, so the wait is covered. Non-function
F5 restores the listing via _decomp_from_listing_failed.
Pilot view_toggle now drives the real path (action_toggle_view from the listing)
and asserts the overlay is raised synchronously; F5 paths (decomp_fallback,
continuous_view, region_define) all still pass (20/20 + view_toggle 7/7).
|
| |
|
|
|
|
|
|
|
| |
The extra `CommandPalette CommandList { width: 80% }` rule set the results list to
80% of the already-80% container -> ~64%, so the results were visibly narrower
than the input/modal. Drop it and instead pin the overlay `#--results` to 100% of
the palette Vertical, so the results span the full modal width. Verified the
input/results width relationship now matches the stock palette exactly (results =
modal width, input 2 cols narrower from its border) at 80% scale.
|
| |
|
|
|
|
|
|
|
|
| |
The stock Ctrl+P palette spans the full terminal width, which looks off on wide
terminals. Constrain CommandPalette > Vertical (and the #--results/CommandList
overlay) to 80% width capped at 120 cols; it's already center-aligned, so this
leaves symmetric left/right margins.
Verified via pilot: input-row width 78/110/118 for terminals 100/140/200 (vs
full width before), and the palette scenarios still pass (7/7).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The Ctrl+P palette was Textual's stock system commands (change theme / take
screenshot / quit) -- useless for RE. Add an IdaCommands command Provider and set
IdaTui.COMMANDS = {IdaCommands} so the palette lists real actions instead:
goto, find symbol, follow, xrefs, toggle disasm/pseudocode, continuous listing,
hex, rename, retype, comment, define code/func, make data/string, undefine,
toggle opcodes, structs editor, filter, names pane, save, quit -- each with its
keybind as help text, fuzzy-searchable.
App-level actions run directly; cursor-scoped ones (rename/xrefs/comment/...) are
dispatched to the active code view via IdaTui._palette_action (focus + run the
view's action_*), so a palette pick does exactly what the key does.
Verified live over RPC: 'hex' switches view, 'goto' opens the prompt, 'struct'
opens the StructEditor modal, and the stock 'theme' command is gone. Pilot
command_palette scenario: Ctrl+P opens it and a command executes (7/7).
|
| |
|
|
|
|
|
|
|
|
|
| |
The logo.ans art is 33 rows tall; on a short/narrow terminal the auto-height
loading box would overflow and clip both the art and the title/note/help
(center-aligned, so the useful text scrolls off top and bottom). LoadingScreen
now only mounts the logo when app.size fits it plus the chrome (height >= lines+9
and width >= 64); otherwise it falls back to the text-only overlay.
Verified across sizes: 90x44 -> shown, 90x30 -> omitted (too short), 50x44 ->
omitted (too narrow), 120x50 -> shown.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Replace the clamp-to-nearest-edge follow with a true screen-position freeze:
watch_scroll_y now shifts the byte cursor by the exact scroll delta on a user
scroll (wheel/scrollbar), so it points at a new byte but stays on the same screen
row. Cursor-driven scrolls (_scroll_to_cursor via _apply_scroll) are marked with
_internal_top and skipped, so key-nav/click don't double-move the cursor. All
scroll sources funnel through the one watch point.
Pilot `hex` scenario: put the cursor mid-viewport, scroll 30 rows, assert its
screen row is unchanged (+ clicks still land on the exact byte). 10/10 pass.
|
| |
|
|
|
|
|
| |
_load_logo() reads logo.ans (repo root) once, strips the cursor show/hide escapes
so Rich sees only SGR, and parses it with Text.from_ansi (no_wrap). LoadingScreen
renders it above the title, centered in the box; missing/unreadable logo degrades
to no splash art. Verified: 33x60 art mounts in the modal and renders in 256-color.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Override watch_scroll_y so a viewport scroll drags the byte cursor along: when
the rounded scroll changes, clamp the cursor's row into the visible range
[top, top+height) keeping its column, so it rides the nearest edge instead of
being left off-screen. No feedback loop -- key-nav's _scroll_to_cursor already
puts the cursor in view (clamp is then a no-op), and HexView.Moved only updates
the status line. Pairs with the click-to-place support.
Pilot `hex` scenario updated: after a scroll the cursor must now be within the
visible rows (was: asserted it stayed put). 9/9 hex checks pass.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
HexView subclasses ScrollView directly and had no mouse handler, so clicking
never moved the byte cursor -- after a wheel-scroll the cursor was stuck off
screen with no way to reposition it by mouse. Add on_click: map the content
offset (scroll_offset + click, past the 1-col padding) to a row, and the x column
to a byte 0..15 across both the hex cells (3 cols each, +1 gap before byte 8) and
the ascii pane. Double-click jumps to code, mirroring Enter.
Pilot scenario extends `hex`: scroll the viewport (cursor goes off-screen), then
click in the hex pane and the ascii pane and assert the cursor lands on the exact
clicked row+byte. 9/9 hex checks pass.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
ida-pro-mcp's xref_query only classifies xrefs as code/data (xr.iscode). Add an
injected `xref_types` tool (server/patch_server.py) that mirrors xref_query's
query/envelope shape but derives a fine `kind` from the IDA xref type:
call/jump/flow for code (fl_CF/CN/JF/JN/F), read/write/offset/text/info for data
(dr_R/W/O/T/I). The worker self-injects it on startup like the other custom tools.
* domain: Xref gains a `kind` field; _parse_xrefs reads it; xrefs_to() now calls
xref_types (falling back to xref_query if absent). xrefs_from is unchanged.
* app: the `x` dialog shows the kind as an aligned column after the address
(`000034F4 read sub_34F0+0x4`).
* rpc: the structured xrefs_to read carries `kind` too.
Verified live over the worker/RPC harness on targets/echo: sub_2C00 callers ->
call; __progname -> offset (GOT), read (sub_34F0), write (sub_3500); stdout ->
read/offset.
|
| |
|
|
|
|
|
|
|
|
|
| |
<home> goes to the true start of line (the address); shift+home now lands on the
mnemonic, skipping the address + opcode-bytes gutter. _insn_col() mirrors
_line_plain()'s prefix (address + indent + opcode field + member indent + name
prefix) so the column matches what's rendered; funchdr/label land after the
address, sep after the indent.
Verified live over RPC: on a code line, <home> -> col 0 (word '0000249A', the
address), shift+home -> col 40 (word 'push', the mnemonic).
|
| |
|
|
|
|
|
|
|
| |
Mirror the <end> change: in the unified ListingView, `home` now maps to col_home
(start of current line) instead of goto_top. Top/bottom move to the conventional
ctrl+home / ctrl+end (G still does bottom too), so no navigation is lost.
Verified live over RPC: <home> moves col 47->0 on the SAME line, scroll unchanged;
ctrl+home lands at line 0, ctrl+end/G at the last line.
|
| |
|
|
|
|
|
|
|
|
|
| |
In the unified ListingView, `end` was bound (with `G`) to goto_bottom, so it
triggered a vertical scroll. Rebind `end` -> col_end (same as `$`) so it moves the
column cursor to the end of the current line; `G` still does goto_bottom, and
`home`/`0` are unchanged.
Verified live over RPC: with the cursor forced to col 0, <end> moves col 0->47 on
the SAME line with scroll_y unchanged (no vertical jump); `G` still lands at the
last line.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The idalib worker is the only backend now, so remove the dead HTTP/supervisor
surface entirely (~2200 lines):
* deleted idatui/client.py (the IDAClient HTTP/JSON-RPC transport + session
manager), idatui/tui.py (the old mcp TUI entry, superseded by launch.py),
spawn.sh, and systemd/ (the supervisor unit).
* deleted the mcp-only tests (stress_client, smoke_client, test_keepalive,
stress_paging, rpc_smoke, serverctl.sh, pane_smoke, test_domain) -- the worker
pilot (tests/test_scenarios.py) supersedes them.
* migrated the tmux RPC harness (idatui/pane.py) to the worker: it spawns
`idatui.launch <binary> --rpc <sock>` instead of the mcp `idatui.tui`, drops
the supervisor auto-start/ensure machinery, and reaps our own worker
(idatui/worker.py) instead of ida_pro_mcp.idalib_server. --db/--url/--no-
ensure-server are gone; --open is required.
* __init__ / __main__ / domain no longer import client (exceptions come from
errors.py, the domain client hint is WorkerClient); pyproject points both
console scripts at idatui.launch; README + ida-tui header describe the
worker-only flow.
What stays (by design): the ida_pro_mcp *package* (the worker reuses its @tool
functions in-process) and server/patch_server.py (the worker injects its custom
tools on startup). Verified: whole package imports + IdaTui constructs + pilot
lists 31 scenarios. The worker pilot (134 pass / 2 known flakes) is the E2E gate.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The idalib worker is now the sole backend for opening a binary, so remove the
ida-pro-mcp code paths from the hot path:
* app.py: IdaTui.__init__ drops url/db/ensure_server/backend (now just
open_path/keepalive/rpc_path/ttl); _connect calls _open_worker_client directly;
_open_mcp_client deleted; _reconnect respawns the worker only; self.client and
_after_reconnect typed WorkerClient. No more IDAClient import.
* launch.py: rewritten worker-only -- validate the binary, sweep stale locks,
spawn the TUI (which starts the private worker behind its overlay). The whole
supervisor dance (_ensure_server/_start_supervisor/_open_binary/
_existing_session) is gone; `ida-tui foo.elf` is the one usage.
* tests/test_scenarios.py: pilot is worker-only (run(binary); binary via
positional/--worker/--binary, defaults to targets/echo).
Verified: app + launch + pilot import and construct; pilot lists 31 scenarios.
The mcp modules (client.py/pane.py/tui.py/spawn.sh) still exist as dead code and
are deleted in the next commit. Worker pilot (134/2-known-flakes) still the gate.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
The IDAError/IDAConnectionError/IDAToolError/... exceptions and the Session
dataclass were defined in client.py (the ida-pro-mcp HTTP client), but the idalib
worker path (worker_client/domain/app) needs them without the HTTP transport.
Move them to a transport-agnostic errors.py; client.py re-exports them so the
deprecated mcp tooling and stress tests are unchanged (verified:
errors.IDAToolError IS client.IDAToolError, so cross-module `except` still works).
worker_client, domain (TYPE_CHECKING-guarded IDAClient hint), app, and __init__
now import the shared types from errors.py. This decouples the worker path from
client.py at runtime -- the prerequisite for deleting the mcp transport.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
The worker reuses ida-pro-mcp's @tool functions, but our custom tools
(heads/read_raw/resolve_names/func_types/del_type/set_lvar_type) only exist
because server/patch_server.py injected them into the installed api_types.py --
which historically only ran via spawn.sh. On a fresh box (or worker-only setup
with spawn.sh gone) those tools would be missing and heads/listing would break.
_ensure_tools_injected() now runs patch_server.main() (idempotent, IDA-free) at
the top of _open_and_register, before ida_pro_mcp.ida_mcp is imported, so the
worker guarantees its own tool surface. Verified under /usr/bin/python: injection
runs and heads/read_raw/resolve_names/func_types land in api_types.py. This is
the prerequisite for deleting spawn.sh from the worker path.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Opening a binary now defaults to our own idalib worker; the ida-pro-mcp HTTP
supervisor path is deprecated (kept only for --db/attach and --backend mcp).
* launch.py: --backend default resolves to worker for a fresh binary open, mcp
for the attach modes (--db / bare `ida-tui`, which have no worker equivalent);
explicit --backend or IDATUI_BACKEND still wins. Logs a deprecation notice when
the mcp path is used.
* Deprecation markers on client.py and server/patch_server.py; the ida-tui shell
header and README now describe the worker as primary and note
$IDATUI_WORKER_PYTHON. TODO tracks the removal checklist.
No code deleted yet — the mcp fallback stays until the worker is proven on a box
where idalib can spawn (pilot against --backend worker is the gate). Backend
resolution matrix verified: `ida-tui bash`->worker, bare/`--db`->mcp, explicit
flag/env honored.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The MCP server sets structuredContent = result if isinstance(result, dict) else
{"result": result} (zeromcp mcp.py:832), and domain.py parses that exact shape —
e.g. function_of() reads payload["result"] from lookup_funcs, which returns a
bare list. Our worker returned the raw list, so function_of got a non-dict and
returned None: hence "F5 — cursor is not inside a defined function" on Tab.
Replicate the rule in the worker's dispatch: dict passes through, anything else
(list/scalar) is wrapped as {"result": ...}. Fixes function_of and every other
list-returning tool (resolve_names, xref_query, list_funcs) in one shot.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Root cause of "ModuleNotFoundError: No module named 'ida_pro_mcp'": the worker was
spawned with sys.executable — the TUI's python (~/ida-venv) which has idalib +
textual but NOT ida_pro_mcp. The package split on this box:
/usr/bin/python : idapro + ida_pro_mcp (the "IDA python")
~/ida-venv/python : idapro + textual (the "TUI python", runs the app)
Fix: WorkerClient now auto-detects a python that can import ida_pro_mcp
(IDATUI_WORKER_PYTHON override, else /usr/bin/python[3], else sys.executable) and
runs worker.py as a SCRIPT rather than `-m idatui.worker`, so it doesn't import
the textual-dependent idatui package __init__ under a python that has no textual.
worker.py itself is pure stdlib at load; idapro/ida_pro_mcp are imported at
runtime (both present in the IDA python).
Verified: detection returns /usr/bin/python; worker.py loads clean there.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The worker's stderr was swallowed by the TUI, so an open failure showed only
"worker exited during startup (code 1)". Now:
* WorkerClient captures the worker's stdout+stderr to /tmp/idatui-worker-*.log
and, on a startup exit, surfaces the last meaningful line in the error (the
worker prints a clean 'WORKER-FATAL: ...' marker; _log_tail prefers it).
* worker.py wraps main() to print that marker + traceback before exiting 1, and
gives an ACTIONABLE open error: "failed to open <bin>: the .i64 is likely held
by a running ida-mcp worker (pkill -f idalib) or wedged (delete .id0/.id1/
.id2/.nam/.til)". Also calls ida_auto.auto_wait() after open to fully match
ida-mcp's session manager (open_database + auto_wait).
Root cause of the reported failure is almost certainly a leftover ida-mcp worker
still holding bash's .i64 from earlier --backend mcp runs: idalib can't open a
database another process has locked. Fix: pkill -f idalib, then retry
--backend worker; the error message now says so instead of "code 1".
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
step 3)
Wires WorkerClient into the app as a selectable backend, so you can launch:
ida-tui --backend worker /path/to/binary # or IDATUI_BACKEND=worker
* IdaTui gains a `backend` param. _connect is refactored into _open_mcp_client()
(the existing ida-pro-mcp path, unchanged) and _open_worker_client() (spawns a
private idalib worker via WorkerClient that opens+analyzes THIS binary and
streams progress into the loading overlay). The common tail (health, keepalive,
Program, load_functions) is shared, so domain.py and every view are untouched.
* _reconnect branches the same way: a dropped worker (segfault → closed socket)
respawns a fresh WorkerClient — the connection-loss recovery already built
works verbatim for the worker.
* launch.py adds --backend (env IDATUI_BACKEND); the worker path skips all the
supervisor/server plumbing (it owns a private worker) and just sweeps stale
locks + requires a binary. keepalive is a no-op for the worker (it never idles
out). mcp remains the default — nothing changes unless you opt in.
Verified without idalib: all four files parse; --backend is in --help; IdaTui
constructs for both backends with both openers present; WorkerClient covers the
full client surface. The idalib E2E (experiments/worker_smoke.py, and actually
launching --backend worker) still can't run in this sandbox — it now reaps every
idalib spawn before a byte is written — but the mcp default is untouched and the
worker path reuses proven pieces (the unix protocol benched at ~50us/call; the
worker dispatches the same tool functions the HTTP path does).
To validate on a real box: ida-tui --backend worker targets/echo (or run
experiments/worker_smoke.py for the headless read-path check).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
step 1
First concrete step off the mcp HTTP transport. Instead of reimplementing ~25
tools, reuse ida-pro-mcp's tool *functions* verbatim and replace only the
transport + process management:
* idatui/worker.py — opens ONE database in-process on the main thread (as idalib
requires), imports ida_pro_mcp (which registers every stock + our patched-in
custom tool against MCP_SERVER), then serves MCP_SERVER.tools.methods[name]
(**args) over a unix socket with length-prefixed pickle. Serial on the main
thread (idalib is single-threaded; tools run inline through execute_sync).
Session-management tools (idb_open/idb_save/server_health/idb_list) are shimmed
since the worker *is* the single session.
* idatui/worker_client.py — WorkerClient exposes the exact surface the app/domain
use on the client (call/call_envelope/connect/set_db/resolve_db/list_sessions/
health/keepalive/close) and returns byte-identical payloads (the worker calls
the same functions IDAClient.call ultimately hits). So domain.py and the app
are UNCHANGED — you just construct a WorkerClient instead of an IDAClient.
Calls are serialized under a lock over one socket; keepalive is a no-op (the
worker is ours and never idles out).
Not wired into the app yet — the mcp path is fully intact.
Verified without idalib: pickle framing round-trips arbitrary payloads incl raw
bytes; WorkerClient has full IDAClient surface; call_envelope produces the
result.structuredContent shape domain.decompile() reads. The idalib E2E
(experiments/worker_smoke.py drives the real domain.Program read path through the
worker) is written but couldn't run here — this sandbox has degraded to reaping
any idalib spawn; the underlying unix-socket protocol already ran clean in the
inproc_spike bench (~50us/call), and the worker dispatches the same tool
functions the HTTP path does, so shapes match by construction.
Next: stand up progress reporting during analysis, then flip _connect/_reconnect
to build a WorkerClient behind a flag and run the pilot suite against it.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Leaving the TUI idle could let the analysis server go away (idle exit, killed,
box slept). The next action's worker then raised IDAConnectionError, which
Textual escalated to a fatal app exit (exit_on_error) — the whole TUI crashed
with a traceback.
Now IdaTui._handle_exception intercepts a WorkerFailed whose cause is
IDAConnectionError and, instead of dying, runs a reconnect:
* show a "connection lost — reconnecting…" overlay;
* _reconnect() restarts the supervisor if it's down (_ensure_server), makes a
fresh client, re-opens the binary (open_path) or re-resolves the sole session,
restarts the keepalive, and swaps in the new client/Program;
* _after_reconnect rebuilds the function index and refreshes the current view
with the new program; on failure it leaves a message and retries on the next
action. A re-entry guard avoids stacking attempts when many in-flight calls
fail at once. Any other exception still crashes as before.
Also harden _status() to never throw (query_one can miss #status during a screen
transition), so a status update can't take down a worker.
Verified: injecting IDAConnectionError into a nav worker -> no crash, overlay
shown, new Program swapped in, overlay cleared, post-reconnect calls work. Full
pilot suite green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
DecompView.goto (repositioning within an already-loaded function, e.g. an xref
jump inside the current function) derived the horizontal scroll from the CURRENT
offset and kept it when the target column fell inside the (stale) viewport. So a
jump could land the cursor on the right column but leave the pane still scrolled
right from wherever you were, showing the line shifted/off.
Baseline the horizontal offset at 0 before computing: reset to 0, then scroll
right only if the target column is beyond the viewport width. The derive branch
is jump-only; back/forward restores pass an explicit scroll_x and are untouched.
Verified: goto with a stale scroll_x=19 -> scroll_x=0 after (col fits);
scroll_restore + decomp_nav/decomp_follow_self/follow_xrefs/xref_labels/
view_toggle/disasm_nav/search/mouse/listing_view/continuous_view/func_banners
green.
|
| |
|
|
|
|
|
|
| |
The "auto-analyzing…" note crammed the "first open of a big binary can take a
while" caveat onto one line that overflowed the fixed-width splash box (it showed
truncated as "…first open of a big binary can"). Move the caveat to the splash's
static help line, keep the dynamic note short, and (in app.py, already committed)
widen the box to 72 and let the note/help wrap (height: auto) as a safety net.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Gathering xrefs (xrefs_to + function_of over every site) runs in a worker, so the
UI stayed live and a second 'x' (or other action) fired into a half-finished
operation — duplicate requests, dialogs stacking, generally confusing behavior.
Add a small blocking BusyScreen modal shown the instant 'x' is pressed:
* re-entry guard (_xref_active) ignores a second 'x' while one gather is in
flight, and the modal captures input anyway;
* _present_xrefs dismisses it and opens the dialog when results are ready;
* Esc cancels — a worker that finishes after the cancel won't pop a stale dialog
(the _xref_active flag gates it);
* every exit path clears it (no-subject case, and a try/except around the
worker's slow calls) so it can't get stuck.
Verified: 1st 'x' -> BusyScreen (xref_active=True); 2nd 'x' ignored (stack
unchanged); results -> dialog + busy gone; Esc -> cancelled, no late dialog.
follow_xrefs/xref_labels/decomp_nav/decomp_follow_self/view_toggle/disasm_nav/
search/mouse/listing_view/continuous_view/func_banners/startup green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Hex-Rays can attribute an address to a pseudocode line a step off from where the
referenced symbol actually appears, so an xref jump could land on a line that
doesn't contain the token (cursor at column 0, wrong line).
Add _decomp_locate: anchor on the /*0xEA*/ marker line for the address, but when
a token is known, snap to the whole-word occurrence NEAREST the anchor
(preferring the anchor line, then the line just below). This fixes the marker/
symbol line discrepancy and disambiguates a symbol that appears multiple times by
picking the occurrence closest to the address. The mid-function decomp-jump
branch now uses it.
Verified: real xref (sub_2060 from sub_20DD) still lands on 'sub_2060(a2);'
cursor_x=4; synthetic discrepancy (marker on a line without the symbol) snaps to
the nearest call-site line, not the far unrelated occurrence. Full suite green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The decompiler-jump column fix used fn.name (the target's containing function),
which is correct when jumping to a function's own prototype but wrong for an
xref jump to a call SITE: the token on that line is the symbol xrefs was invoked
on, not the enclosing function. So x-pane jumps landed at column 0.
Use focus_name (the xref's subject, already threaded through _goto_ea) as the
token for a mid-function target; keep fn.name for the ea == fn.addr (prototype)
case.
Verified: xrefs to sub_2060 -> pick the caller site -> lands in sub_20DD's
pseudocode on ' sub_2060(a2);' with cursor_x=4 = 'sub_2060'. Full suite
(disasm_nav/view_toggle/search/follow_xrefs/xref_labels/mouse/decomp_nav/
decomp_follow_self/rename/rename_history/listing_view/continuous_view/
func_banners) green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Jumping to a function in the decompiler (follow/xref into pseudocode) parked the
cursor at column 0 of the prototype line. Now it lands on the function's name in
the prototype instead, mirroring the listing's ref-column behavior.
_do_navigate's prefer_decomp branch: when the target is the function itself
(ea == fn.addr) it uses the prototype line (0) and computes the name's column via
_decomp_col_for; a mid-function target uses the ea-matched line (name column when
present, else 0). The column rides through _open_decomp_entry -> NavEntry
.dec_cursor_x -> _apply_decomp's view.show(cursor_x=...).
Verified: following sub_2060 from pseudocode lands on line 0, cursor_x=28, which
is exactly 'sub_2060' in "unsigned __int64 __fastcall sub_2060(int a1)".
decomp_nav/decomp_follow_self/xref_labels/view_toggle/disasm_nav/search/mouse/
listing_view/continuous_view/func_banners/rename green (follow_xrefs is a
pre-existing ordering flake; passes in isolation).
|