From 15b20a8af1f5e505593edce34bd5465745b7787e Mon Sep 17 00:00:00 2001 From: Jonas H Date: Thu, 10 Sep 2026 14:16:34 +0200 Subject: [PATCH] Put every app key behind one prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keybindings had no red thread because there were three focus states and every key had to ask "is this mine or the child's" — hence ctrl-q, because plain q was forwarded. Replace the whole model: unprefixed keys belong to the embedded claude, always, and everything cloak owns sits behind ctrl-space (CT_PREFIX to change), tmux-style, in one table that the which-key popup, the footer hint and the dispatch all read. Esc is the only key with a rule of its own, and it is about reachability rather than modes: it closes the topmost overlay, and with nothing open it goes to the child, so interrupt and Esc-Esc rewind keep working. View state — filters, the lane the feed shows — is a setting, not a mode, and is deliberately not escapable. That removes the focus model entirely, which lets the two feeds collapse into one: the feed renders whichever lane feed_lane names, at full width, and the stream picker chooses it. Subagents used to live in a modal popup holding a second draw_feed with its own cache and scroll model, rendering the same thing twice. The sessions panel loses its half of the screen the same way. Both pickers become bottom strips with the feed readable above them, so walking the list previews each row — which is the view-without-resuming that /resume cannot do, and frees enter to attach the pane. Adds a find bar with in-place highlighting, scrolling to the matching line rather than the containing entry, and drops the two keys that served the old layout. --- CLAUDE.md | 387 +++++++--- README.md | 54 +- src/app.rs | 478 ++++++++---- src/keymap.rs | 297 ++++++++ src/main.rs | 1 + src/sessions.rs | 1 + src/ui.rs | 1844 ++++++++++++++++++++++++++++++----------------- 7 files changed, 2129 insertions(+), 933 deletions(-) create mode 100644 src/keymap.rs diff --git a/CLAUDE.md b/CLAUDE.md index 1616f83..52398b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,11 @@ src/app.rs Arc> shared state; Tap = one in-flight tapped request, `task_note_line`), which lifts Claude Code's task notifications out of the user prompt and turns each into a one-line Kind::TaskNote +src/keymap.rs the one binding table: `Act` (what a key does), `Menu`/`Bind` + (the tree the which-key popup renders) and `Prefix` (which key + opens it, `CT_PREFIX`-overridable). The popup, the footer hint + and `ui::run_act` all read this table, so a binding cannot exist + in one and not the others. See the prefix invariant src/ansi.rs self-contained SGR parser (no dependency): CSI `…m` → ratatui Style; every other escape (other CSI finals, OSC/DCS/APC, nF charset designation, two-char) is stripped. `ui::sanitize`/ @@ -67,11 +72,19 @@ src/ansi.rs self-contained SGR parser (no dependency): CSI `…m` → ratatui `ansi::strip`/`strip_multiline`, so dropping the ESC byte no longer leaves `[1m` behind as literal text — nor the `B` of the `ESC ( B` that rustfmt and `git diff` write after every newline -src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed +src/ui.rs ratatui rendering @ ~30fps; **one** full-width feed (FeedCache: per-entry rendered lines + wrapped heights, only changed entries re-render; the viewport window of lines is handed to ratatui so scroll state is usize end-to-end). - Focus accent is orange (`ACCENT` = indexed 208): borders, the + `draw_feed` renders whichever lane `App::feed_lane` names — + main chain, a subagent, a nested server-tool call — at full + width; the stream picker chooses it. There is no second feed + and no sessions panel: both are overlays now, and both are + bottom strips (`draw_sessions` / `draw_streams` in `list_rect`, + `draw_search` in `search_rect`, `draw_menu` in `bottom_rect`), + which is what freed the whole width. Nothing is a centred box — + `popup_rect` is gone. + Accent is orange (`ACCENT` = indexed 208): borders, the scroll thumb and the user-prompt blocks all use it when focused, dim grey when not. User prompts render as full-width filled rectangles padded to exactly the inner width (`wrap_words` + @@ -91,31 +104,21 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed ExitPlanMode) and web tool families, with the generic `key: value` dump kept as the fallback. A `Kind::Meta` whose content holds `\n` renders one dim row per line (a `\n` inside a - single ratatui `Line` is not a row break). The - feed's right border doubles as a prompt - minimap: `*` markers show where each user message sits in the - whole conversation, with the scroll thumb drawn on top where they - coincide. - Subagents never touch this feed: it renders lane - `MAIN_LANE` only, at full width, whatever the agents are doing. - They live in the `A` popup (`popup_rect` = 80% of the *feed* - rect, centred): `draw_agent_list` is the picker, - `draw_feed` the chosen agent's own stream — same function as the - main feed, own FeedCache from the `FeedCaches` pool, own - scroll/follow from `App::lane_cols`, so it follows its own tail - and the border title carries the identity (`⟳ Explore · find the - retry helper · sonnet · out 2.1k · 2/3`). Picker rows and that - border title prefer a notification's `` totals - (`lane_tokens` / `lane_dur`) over the wire-counted `out …`. See - the subagent-popup invariant. - Sessions panel is a uniform 50% of the main area: each session - is a multi-line item — full white title (Claude Code's own name - for the session via `App::cc_title`, live rows and stubs alike; + single ratatui `Line` is not a row break). The feed's right + border doubles as a prompt minimap: `*` markers show where each + user message sits in the whole conversation, with the scroll + thumb drawn on top where they coincide — main lane only (a + subagent has no user prompts). + `run_act` is the single dispatch point for `keymap::ROOT`; + `search_key` / `filter_key` / `streams_key` / `sessions_key` + are the per-overlay handlers. `draw_sessions` is the old + half-width panel, unchanged in content and moved into an + overlay: full white title (Claude Code's own name for the + session via `App::cc_title`, live rows and stubs alike; `live_title` only covers a live session the transcript has not - named yet — word-wrapped by - `wrap_words`) over a dimmed id·model meta row; expanded turn - rows are indented past the title and `truncate_str`'d to one - line each. + named yet — word-wrapped by `wrap_words`) over a dimmed + id·model meta row; expanded turn rows are indented past the + title and `truncate_str`'d to one line each. src/markdown.rs wraps tui-markdown: renders GFM tables itself (box-drawing, width-fitted wrapped columns) and strips heading `#` markers — the pinned tui-markdown 0.3.5 does neither @@ -164,10 +167,10 @@ src/term.rs embedded claude pane: spawns `claude --session-id ` in a *alternate* screen — an `$EDITOR` (nvim, `git commit`, a pager) Claude Code launched into the same pty — which auto-fullscreens the pane; see the alt-screen invariant -src/reload.rs hot reload: ctrl-r `execve`s the binary now on disk *into this +src/reload.rs hot reload: `prefix r` `execve`s the binary now on disk *into this process* — same pid, so the listener socket, the `claude` child and (via a JSON snapshot) the live feed all cross over. Builds - nothing itself: you rebuild outside and press ctrl-r. See the + nothing itself: you rebuild outside and press the key. See the hot-reload invariant ``` @@ -176,14 +179,59 @@ UI thread redraws on its own tick (no channel; just the mutex). ## Key invariants +- **Unprefixed keys belong to the embedded `claude`. Always.** There is no + focus model left — no ctrl-↑/ctrl-↓, no "which panel has the keyboard", no + key you have to think about before pressing. `q`, `j` and Esc reach Claude + Code because nothing else can claim them; everything cloak owns sits behind + one prefix (`keymap::Prefix`, ctrl-space by default, `CT_PREFIX` to change + it), tmux-style. `keymap::ROOT` is the whole app surface, and it is a *table*: + `ui::draw_menu` renders it, the footer summarises it and `ui::run_act` + dispatches it, so a binding cannot exist in one and not the others — which is + what stops the keymap drifting apart again. Four supporting rules: + 1. **Two things are ours without a prefix, because a real terminal also keeps + them for itself rather than forwarding**: the **wheel** and + **shift**+PgUp/PgDn. Both scroll the feed, or the pane's own scrollback + while the pane is fullscreen (the pane-scroll invariant is unchanged; it + just lost its `eui.focused()` gate). Feed scrolling is continuous, so + putting it behind a prefix would be the one genuinely bad trade. + 2. **Esc is about reachability, not modes.** It closes the topmost overlay; + with nothing open it goes to the child, because Claude Code interrupts on + Esc and rewinds on Esc-Esc. The test is literally *is the pane reachable?* + — which is why the two zooms answer differently: `prefix Z` (zoom feed) + **hides** the pane, so Esc leaves it, while `prefix z` (fullscreen) is + nothing *but* the pane, so Esc passes through and interrupts. + `App::close_overlay` is the one implementation and returns false when + there was nothing to close. + 3. **View state is not escapable.** Filters and `App::feed_lane` are + settings, not modes: resetting them on a stray Esc would be a surprise, + not a rescue. Coming back is an *action*: `prefix .` (`Act::FollowLive`), + which undoes **all four** kinds of pinning at once — a picked session, a + picked lane, an expanded turn tree, and the scroll position a search or a + prompt jump parked. That last one is the easy one to forget: without + re-arming the tail (`scroll_col_end(MAIN_LANE, true)`) the feed sits where + you left it and never catches up, even after the turn ends, which reads as + the key doing nothing. This is also why there is no `prefix Esc`. + 4. **Nothing traps you, and the menu has to be *visible* to prove it.** Any + unbound key closes it, the repeatable leaves (`]`/`[`) keep it open so + `prefix ]]]` walks, and `prefix prefix` sends a literal prefix to the + child. Overlays normally anchor to the bottom of the *feed*, which puts + them just above the pane — but in fullscreen the feed area is the single + row the layout reserves, so a strip drawn into it is invisible. That made + `prefix z` read as a trap: the menu saying `z` gets you back out was being + rendered one row tall behind the pane. `draw` therefore anchors overlays + to the screen (everything above the footer) whenever + `pane_view == PaneView::Full`. ctrl-q stays bound globally for one + reason only: a terminal that delivers no ctrl-space would otherwise leave + the app with no way out. It is not a focus workaround — there is no focus. + - **A hot reload is an `execve` of ourselves, never a restart.** `reload.rs` builds nothing and watches nothing: you rebuild however you normally would, - then **ctrl-r** swaps each running instance onto the binary now at + then **`prefix r`** swaps each running instance onto the binary now at `App::exe`. Separating the two is the point — a build is the user's business, and a running instance must not decide on its own when to become different code. So there is no watcher thread, no `cargo` subprocess and no env var to - arm; ctrl-r is simply always live, in a debug build and a release one alike. - ctrl-r execs the same **path** it started from, so the reload follows the + arm; the key is simply always live, in a debug build and a release one alike. + It execs the same **path** it started from, so the reload follows the file, not the profile: a debug instance reloads onto a rebuilt debug binary, a release instance onto a rebuilt release one. `execve` keeps the pid, the open fds and the child processes, @@ -194,7 +242,7 @@ UI thread redraws on its own tick (no channel; just the mutex). `term::PtyHandoff`/`EmbeddedTerm::adopt`), and the **feed** (a JSON snapshot in `$TMPDIR`, pointed to by `CT_RELOAD_HANDOFF`). Four rules keep it honest: 1. **Resolve the exe path at startup, never lazily.** The file is *expected* - to have been replaced by the time ctrl-r is pressed, and a linker's rename + to have been replaced by the time the key is pressed, and a linker's rename unlinks the inode we are running from — after which `/proc/self/exe` reads `…/claude-cloak (deleted)`. `reload::exe_path` runs once, in `App::new`, and strips that suffix defensively. @@ -222,7 +270,7 @@ UI thread redraws on its own tick (no channel; just the mutex). 4. **Nothing is dropped on the way out.** An exec runs no destructors, which is exactly why `EmbeddedTerm::drop` does not fire and SIGHUP the child — so `try_reload` *borrows* the pane and the app rather than taking them, - and a failed exec (ctrl-r landing mid-link is the realistic case) leaves + and a failed exec (the key landing mid-link is the realistic case) leaves the old code running with everything intact, `close_on_exec` having put the FD_CLOEXEC flags back. Two things the exec breaks that have to be repaired by hand: crossterm caches @@ -358,32 +406,107 @@ agentId: `), and the real completion is injected into the parent's next search render through one hit renderer (`push_search_result`) rather than two that can drift. The `other =>` fallback stays the net for block types nobody has seen, and still sets no `self.cur`. -- **Subagents live in a popup; they never share the feed.** The popup is no - longer strictly agents — a lane is a subagent *or* a nested server-tool call — - so the footer leads with `A streams (N)`, the picker title reads - `streams · X of Y running`, and `ui::lane_mark` returns `⚙` for - `Lane::is_server_tool()`, replacing the `⟳`/`✓`/`·` three-way for those lanes - (a server-tool lane is one request, and no `` will ever - confirm it; liveness still shows through the accent styling and the - running-first sort). The main feed - always renders `MAIN_LANE` at full width, so how many agents run changes - nothing about reading the main chain — no split, no rows, no reserved space, - no interleaved entries (`draw_feed` filters `e.lane == args.lane`). `A` - (`App::toggle_agent_popup` → `App::agent_popup`) opens the one place they are - shown: `AgentPopup::List` picks an agent, `AgentPopup::Feed` gives one agent - the whole popup (80% of the feed rect, `ui::popup_rect`). Opening takes the - shortest path — a lone agent goes straight to its stream, several land on the - picker with the first *running* one preselected — and `A` closes whatever is - open. The popup is **modal**: while it is up it takes every key (and the - wheel), which is why it needs no focus/column model at all. Esc unwinds one - layer (feed → picker → closed), `[`/`]` step between agents from inside a - feed. `App::agent_list_of` orders it: running first (`Lane::running`), then - idle/finished, each group in spawn order — but **every** lane is listed, - disk lanes included, because this popup is the only way to read a finished - agent's output. State is session-local: `draw` clears `agent_popup` and - `lane_cols` when the displayed session changes, and `validate_agent_popup` - drops a popup whose lane the displayed session doesn't have (a rebuilt - on-disk view), so the render path never sees a dangling `LaneId`. +- **One feed, one lane; the picker chooses which.** The feed always renders + exactly one lane at full width (`draw_feed` filters `e.lane == args.lane`) — + never interleaved, which is the part of the old design that stands. What is + gone is the *second* feed: subagents used to live in a modal popup holding + their own `draw_feed`, their own `FeedCache` and their own scroll model, + rendering the same thing twice. Now `App::feed_lane` names the lane and + `prefix a` picks it, so reading an agent is the same act as reading the main + chain rather than a different mode with different keys. + `App::stream_list_of` is that picker's order: `MAIN_LANE` **first** — the way + back has to be in the same list as the way out — then `agent_list_of` + (running first via `Lane::running`, then idle/finished, each group in spawn + order). *Every* lane is listed, disk lanes included, because this is the only + way to read a finished agent's output. A session with no side lanes still + gets a picker showing `main`, so the key never dead-ends. + `ui::lane_mark` still returns `⚙` for `Lane::is_server_tool()` (one request, + and no `` will ever confirm it), and the row the feed is + on carries the same `▶` the sessions overlay gives the pane's session. + Scroll state is per lane: `App::lane_col_of`/`set_lane_col` resolve + `MAIN_LANE` to `scroll`/`follow` (the pair the reload snapshot carries) and + everything else to `lane_cols`, so each stream keeps its place and follows + its own tail. `validate_lanes` runs in `draw` before the feed borrow and + falls a dangling `feed_lane` back to `MAIN_LANE`, so the render path never + indexes `Session::lanes` out of bounds. State is session-local: `draw` clears + `lane_cols`, `feed_lane` and the picker when the displayed session changes. + +- **A list overlay never covers the feed it steers, and moving the highlight + *is* the pick.** Both pickers — `prefix s` and `prefix a` — share one shape + (full width, flush with the bottom of the feed area, `LIST_PCT` (a third) + tall with a `LIST_MIN` floor) and one interaction: j/k walks the list and the + feed above shows each row as you land on it. A centred box would hide the one + thing the movement is *for*, which is why `popup_rect` no longer exists. + `list_rect` is that shape; `streams_rect` **shrinks it to what the list + holds** and keeps `list_rect` only as the cap. Only streams can do that — a + lane is exactly `STREAM_ROWS` (2) rows and a turn often fans out to two or + three, so a fixed third is mostly blank space taken from the feed, whereas a + session item wraps to an unknown number of rows and there are usually dozens. + Past the cap the list scrolls (`ListState` keeps the highlight in view). + Three consequences: + 1. **Preview is the interaction; there is no separate commit.** For streams + that leaves Enter with nothing to do, so Enter and Esc both just close, + keeping what you walked to (`streams_move` does the work). Sessions keeps + an Enter because it has something preview cannot do — attach the pane. + 2. **Esc keeps what you were looking at.** Nothing here is an edit, so there + is nothing to cancel; `prefix .` is the way back and the footer says + `⇤ pinned` until you take it. + 3. **The feed stays scrollable underneath.** `shift`+PgUp/PgDn is handled + *before* the overlay block, not after, and the **wheel keeps scrolling the + feed** rather than moving the highlight — the same carve-out as always, + now with no exception, because nothing covers the feed any more. + +- **Search is per *entry*, and it only finds what is on screen.** + `App::search_run` matches a lowercased substring against three things per + entry — `Entry::content`, the tool *name* of a `Kind::Tool`, and + `ToolResult::content` — because a tool result is rendered and should + therefore be findable. It is **not** a grep over rendered text: markdown + markers, the box-drawing of a table and the system prompt (only its *size* is + stored) are not searchable, and a hit is an entry, not a line. Two rules keep + it honest: + 1. **Filtered-out entries are skipped.** They have no row in the rendered + feed, so scrolling to one would land somewhere arbitrary and read as a + wrong answer. What you can see is what you can find. + 2. **It is scoped to `feed_lane`**, like everything else about the feed. + Switching streams re-scopes the same query. + The UI is a browser find bar, not a mode with a separate commit: `prefix /` + opens a three-row `search_rect` strip (same bottom-anchored full-width shape + as the sessions overlay — a query living only in the footer reads as nothing + happening), typing re-runs from the top, Enter/↓/Tab walk forward and ↑ back, + and the strip shows `3/12`. Esc leaves with the position you landed on. + Matches are **painted** by `ui::highlight_lines`, which post-processes the + rendered spans rather than teaching each renderer about search — `entry_lines` + fans out into markdown, a dozen tool renderers and the ANSI parser, and a + match has to light up the same way in all of them. Splitting a span keeps its + own style and overrides only the colours, so bold/dim/italic survive. The + query is therefore part of the render fingerprint (`ui::query_hash`, FNV-1a): + without that, a cached entry would keep serving lines from before the query. + Highlighting lasts exactly as long as the bar is open, so there is no stale + paint and no `:nohlsearch` to remember. + **The jump is line-granular, not entry-granular** (`ui::match_row`): a hit + hundreds of rows into a long tool result would otherwise pin that entry's + *top* and show no match at all, which reads as "nothing found" — the reported + papercut. `match_row` reads the highlight the render pass already applied + rather than re-running the query, so one definition of "this line matched" + drives both the paint and the scroll, and it leaves `MATCH_CONTEXT` rows above + so you land with the tool header in view. An entry that matched only through + its *clipped* `ToolResult` has no painted row to aim at; that falls back to + the entry top, which is the honest answer. `scroll_to_match` is what keeps + the turn-tree jump on the old behaviour — it is pinning a prompt, whose match + is its first line anyway. A span whose lowercase form differs + in *length* from the original (ß, İ) is left alone — the two byte offsets no + longer agree, and a wrong slice is worse than a missed highlight. + +- **The feed tails the pane, and pinning is visible.** `App::follow_pane` + (default on) keeps the selection on `App::embed_session`, `tail -f` style — + but only while no overlay is open and no turn tree is expanded, because those + are deliberate navigation and yanking the selection out from under them is + exactly what this rule exists to avoid. Picking another session with `enter` + turns it off; `prefix .`, attaching the pane, and spawning one turn it back + on. Whenever the feed is *not* on the live main chain the footer leads with + `⇤ pinned · prefix . to follow`, because nothing else on screen says "you are + not looking at what the pane is doing". + - **`Lane::running` is a sort key, never a gate**: streaming (`active > 0`), or no finish signal and quiet for less than `LANE_IDLE_MAX` (60s); a `finished_at` (the ``) or no traffic at all (a lane read @@ -393,7 +516,8 @@ agentId: `), and the real completion is injected into the parent's next a `⟳`/`·` mark — never a hidden stream, which is what the old row-collapse timers could do. - **Only the main lane drives the pane and the session header.** `embed_grow`, - the ctrl-l wipe scheduled in `Tap::drop`, the prompt minimap, `n`/`N` and + the ctrl-l wipe scheduled in `Tap::drop`, the prompt minimap, the prompt + jumps (`prefix ]`/`[`) and `Session::model`/context are gated on `MAIN_LANE`; `last_system_len` and `last_tools_sig` live per lane (a subagent's system prompt and restricted tool set differ, so session-wide state re-emitted both lines on every @@ -410,32 +534,46 @@ agentId: `), and the real completion is injected into the parent's next (and strips it before forwarding), and `Tap::new` *binds* `App::embed_session` to whatever id that tagged request actually carries (`App::bind_embed_session` rebinds + renames a provisional resume row if they differ). Selection policy - follows: the embed jumps the selection only on first bind; a brand-new - *external* session auto-jumps so a fresh `/clear` is visible **unless** - `App::pane_focused` (mirrored from the UI each frame) — never steal the - selection from a pane the user is driving. This is what made an `a`-spawned - session stream into the wrong row before. + follows from `App::follow_pane` (see the tailing invariant): the embed jumps + the selection on first bind, and a brand-new *external* session auto-jumps so + a fresh `/clear` is visible **unless** `App::pane_focused` (mirrored from the + UI each frame — now "the pane is taking keys", i.e. no overlay is up) — never + steal the selection from a pane the user is typing in. This is what made an + `a`-spawned session stream into the wrong row before. - **One app instance = one proxy port = at most one embedded claude** (`EmbedUi::term` / `App::embed_token` → learned `App::embed_session`). `kill_current_embed` is the single teardown path and `bind_new_pane` the single registration path, so pane identity + grow/clear flags can't drift across the spawn/replace call sites. Every other live session is an external - claude pointed at our port: observable, never attachable. The pane stays - visible while it holds keyboard focus even if the selection isn't on its - session yet (its id is still being learned); only an intentional ctrl-↑ / - tab-away hides it. + claude pointed at our port: observable, never attachable. The pane is drawn + whenever it exists and is not hidden — **not** gated on the feed selection any + more. That coupling only existed to keep keyboard focus and the visible + session in step; with the pane always holding the keyboard there is nothing to + keep in step, and decoupling them is the point: the feed can show a past + session, or a subagent's lane, while the pane keeps running the live one. + `prefix p` hides it, `prefix Z` covers it, nothing else. - The session list merges live sessions (first, indices stable) with this directory's past sessions from `~/.claude/projects//*.jsonl` as dimmed stubs (deduped by uuid — a live session's file is on disk too). - **Tab is viewing only, never a process operation**: selecting a stub - lazy-loads its transcript into `App::history`; tabbing off the embedded - session hides the pane without killing the child (instant to come back). - ctrl-↓ is the commit point that attaches the pane to the selection: - reveal+focus if it's the embedded session, `claude --resume ` + The list lives in the `prefix s` overlay. **Viewing there is the hover + state, not a key**: j/k re-points the feed live as you walk the list (the + overlay is a bottom strip, so the feed is right there above it), which is the + view-without-resuming that Claude Code's own `/resume` picker cannot do — and + the reason this overlay still exists at all, since `/resume` is a process op + and resuming a live external session forks its transcript. That frees + **`enter` to be the commit** — the thing you almost always want — attaching + the pane to the selection: reveal if it's the embedded session, + `claude --resume ` (kill + respawn) for disk stubs and dead embeds, fresh `--session-id` - spawn when there's nothing. Live *external* sessions are guarded — their + spawn when there's nothing. `Esc` is the other way out and it **keeps what + you were reading** (`close_overlay` pins `follow_pane` to whether the + selection is the pane's own session): there is nothing to cancel — the feed + pointer is a view setting, not an edit — and snapping back would throw away + the only thing walking the list produced. `prefix .` returns to the pane, and + the footer says `⇤ pinned` whenever you are not on it. + Live *external* sessions are guarded — their instance may still run elsewhere and a second `--resume` would fork the - transcript — but a second ctrl-↓ within 3s forces it (liveness is + transcript — but a second `enter` within 3s forces it (liveness is unknowable: an idle claude sends no traffic; `EmbedUi::past_embeds` skips the guard for sessions whose instance we killed ourselves). `--session-id` cannot be combined with `--resume` (CLI rejects it without `--fork-session`); @@ -488,11 +626,14 @@ agentId: `), and the real completion is injected into the parent's next (`ANTHROPIC_MODEL`, then local/project/user `settings.json`). With no default configured either, the spawn passes no `--model` at all and `claude` picks both. -- **Turn tree / branching** (lazygit/yazi-style, all in the sessions panel): +- **Turn tree / branching** (lazygit/yazi-style, all inside the `prefix s` + overlay — which is the only home it has left, and why collapsing sessions + into a plain `/resume` was not an option): `space` (or `→`/`l`) expands the selected session's turn tree — one row per real user prompt, abandoned rewind branches indented `⑂` under their fork point, trunk continuing below. j/k/↑/↓ walk sessions *and* turns (they - never scroll the feed; the wheel and PgUp/PgDn/g/G do that). Highlighting + never scroll the feed; the wheel and shift+PgUp/PgDn do that, and they keep + working while the overlay is up). Highlighting a turn switches the feed to the on-disk transcript along the path through that turn and pins the turn's prompt to the viewport top (HistoryView caches per uuid, rebuilt when the leaf changes; FeedCache keys on @@ -501,7 +642,7 @@ agentId: `), and the real completion is injected into the parent's next file** — chain root→turn, or exactly the visual range stitched together (sessionId rewritten, each turn's head re-parented onto the previous turn's tail, our own ai-title record gives it the `⑂ …` label) — injected - as the selected top stub. Branching never touches a process: ctrl-↓ stays + as the selected top stub. Branching never touches a process: `enter` stays the only spawn/kill commit point. - The tap drives pane behavior: grows it for AskUserQuestion / ExitPlanMode (sized from the question's option count) before Claude Code @@ -568,8 +709,8 @@ agentId: `), and the real completion is injected into the parent's next scrolled away (its row does not index that window). 2. **Only `PaneView::Full` scrolls.** The cropped views frame Claude Code's input box, which is always at the live bottom, so `draw` calls - `follow_live` for them — one place, instead of at each of ctrl-f / - ctrl-↑ / F2 / session-switch. + `follow_live` for them — one place, instead of at each of + `prefix z` / `prefix Z` / `prefix p` / session-switch. 3. **Any key snaps back to live** (xterm's scroll-on-key) before it is forwarded, so typing can never leave you reading history while the child answers off-screen. `shift`+PgUp/PgDn is the exception: a real terminal @@ -578,7 +719,7 @@ agentId: `), and the real completion is injected into the parent's next 4. The **ctrl-l wipe is cancelled while fullscreen**, because there the pane is not prompt-only — its transcript is the whole context, and the thing being scrolled. Cancelled rather than deferred: firing it later would - delete that history the moment ctrl-f dropped out of fullscreen. A wipe + delete that history the moment `prefix z` dropped out of fullscreen. A wipe that already ran *before* you went fullscreen is gone for good though — Ink redraws only the live frame, so fullscreen shows history from that point on. @@ -593,20 +734,22 @@ agentId: `), and the real completion is injected into the parent's next draws). `ui::sync_alt_screen` therefore fullscreens the pane for as long as the editor lasts and puts it back after. Four rules: 1. **Edge-triggered, never re-asserted per frame**, which is what leaves - ctrl-f in charge: a manual toggle mid-edit sticks instead of being undone + `prefix z` in charge: a manual toggle mid-edit sticks instead of being undone on the next draw, and it clears the restore flag (`EmbedUi::alt_fullscreen`) so quitting the editor doesn't reverse it. A pane that was *already* fullscreen stays fullscreen afterwards — only a fullscreen we entered ourselves is undone. - 2. **Gated on pane focus**, keeping fullscreen ⇔ focused: ctrl-↑ hands the - screen back to the feed mid-edit, ctrl-↓ returns it to the editor. + 2. **Gated on the pane actually taking keys**: opening an overlay mid-edit + hands the screen back to the feed, closing it returns to the editor. The + gate used to be pane *focus*; with focus gone, "no overlay is up" is the + same condition expressed in the only terms left. 3. **The ctrl-l wipe is cancelled** while the alternate screen is up, for a different reason than fullscreen's: that keystroke is meant for Claude Code's input box, and sending it into nvim is not ours to do. The scroll view also resets on both edges — the two screens index stable rows differently, and the alternate one holds no scrollback at all. 4. **A hot reload reads the flag off the adopted child** before the first - draw, so an editor still open at ctrl-r raises no edge and the + draw, so an editor still open at reload time raises no edge and the snapshotted `PaneState::alt_fullscreen` stays meaningful. - Tool input streams as raw JSON fragments; pretty-printed only on @@ -720,16 +863,20 @@ agentId: `), and the real completion is injected into the parent's next API stream) aren't expanded in the compact pane — consistent with the known "permission prompts aren't detected" limit. - Keybindings avoid Alt entirely: on layouts like dk_mac_fixed, Alt composes - characters (alt-c = ©) and never reaches the app as a modifier. Pane keys: - F2 toggle, ctrl-↓ attach pane to selected session (resume/spawn/focus), - ctrl-↑ focus feed, ctrl-f fullscreen toggle (only while the pane is - focused), shift+PgUp/PgDn page the pane's scrollback while it is fullscreen - (any other key snaps back to live), ctrl-q quit (global; needed while the - pane is focused, where plain `q` is forwarded to the child), c attach - most-recent past session. - List keys: j/k/↑/↓ move the session/turn highlight, space/→/← expand/ - enter/leave the turn tree, a opens the model picker popup and spawns a - brand-new `claude --session-id … [--model …]` (kills any current pane — + characters (alt-c = ©) and never reaches the app as a modifier. That is now a + small constraint, because there is only one unprefixed key left to place: the + prefix itself (`keymap::Prefix`, ctrl-space, `CT_PREFIX` to change). Its + default has to match three spellings — terminals send ctrl-space as `Null`, + as ctrl-`@` or as a real ctrl-modified space, and which one you get is not + knowable in advance — so `Prefix::matches` accepts all three. That is also + why the prefix is configurable at all: the only hard requirement is that the + installed Claude Code does not want the key, which is a property of the + child's version, not of ours. + The bindings themselves are `keymap::ROOT` and are documented there, not + here — duplicating the list is exactly the drift the table exists to stop. + What is worth recording is what the actions reach: + `prefix n` opens the model picker and spawns a brand-new + `claude --session-id … [--model …]` (kills any current pane — `show_embed_new`; saves resume-then-/clear to get a fresh chat). The picker list is `Models::choices()` — one row per model, at the window `Models::arg` gives it (`sonnet (1M context)` → `sonnet[1m]`, `haiku` → @@ -748,31 +895,30 @@ agentId: `), and the real completion is injected into the parent's next `opus`/`sonnet`/`fable` and a set of full ids, *not* `haiku` or `mythos`. `[1m]` needs no shell quoting: the pane spawns via `CommandBuilder` argv, not a shell. - Tab/BackTab cycle sessions (`p` no longer - mirrors BackTab). v visual range, b branch, Esc unwinds (visual → tree → - quit). n/N jump the feed scroll to the next/previous user prompt - (`App::prompt_jump`, applied in `draw` where entry heights are cached). The - feed scrolls only via wheel / PgUp / PgDn / g / G / n / N. - `A` toggles the subagent popup (the footer leads with `A agents (N)` when - the displayed session has any — it is the only route to them). Inside it: - j/k move the picker or scroll the agent feed one line, enter/→ opens the - highlighted agent, `[`/`]` step to the previous/next agent, PgUp/PgDn/g/G - scroll, Esc goes feed → picker → closed, `A`/`q` closes outright. Being modal - it also owns the wheel (`ui::wheel`), so no pointer hit-testing is involved. - Switching the displayed session clears `App::lane_cols` and closes the popup, - so a lane id can't inherit another session's scroll offset. - ctrl-r hot-reloads onto the binary now on disk (you rebuild outside; this - swaps the running instance onto it) — global like ctrl-q, because it has to - work while the pane holds focus. + Inside the `prefix s` overlay (a bottom-anchored third of the screen — see + its invariant): j/k/↑/↓ move the session/turn highlight, + space/→/← expand/enter/leave the turn tree, Tab/BackTab cycle sessions, v + visual range, b branch, enter attaches the pane. Inside `prefix a`: j/k + previews each lane, enter/esc close on the one you walked to. Inside + `prefix f`: space toggles, `a` all, `n` none. `prefix /` opens the find bar + (see its invariant). Every overlay is modal for the *keyboard*, which is why + none of them needs a focus model — but none of them takes the wheel, because + none of them covers the feed. + `prefix ]`/`[` jump the feed to the next/previous user prompt + (`App::prompt_jump`, applied in `draw` where entry heights are cached); + `prefix >`/`<` step between streams. All four are sticky, so the menu stays + up and `prefix ]]]` walks. `CT_DEBUG_KEYS=1` shows raw key *and* mouse events in the status bar (`mouse: ScrollUp … fullscreen=true back=0`) — the wheel's two silent failure modes look identical on screen otherwise: no event delivered at all (an outer tmux without `set -g mouse on` swallows them) versus an event - delivered to a pane with no scrollback above the live screen. -- Mouse is captured: the wheel scrolls the feed regardless of focus — except + delivered to a pane with no scrollback above the live screen. It is also how + you find out what your terminal sends for a candidate prefix. +- Mouse is captured: the wheel scrolls the feed (or moves an open picker's + highlight — an overlay is modal, so it owns the wheel) — except while the pane is fullscreen, where the feed is off screen and the wheel scrolls the child's scrollback instead (`EmbedUi::scroll_pane`, the same - `WHEEL_ROWS` step either side of ctrl-f). Left-drag selects screen text, + `WHEEL_ROWS` step either side of `prefix z`). Left-drag selects screen text, copied on release via OSC 52 (like Claude Code). Native terminal selection therefore needs shift held. - Bracketed paste is enabled on the outer terminal (`EnableBracketedPaste`): @@ -838,7 +984,8 @@ agentId: `), and the real completion is injected into the parent's next ## Not yet handled (known MVP limits) - Hot reload is unix-only (`execve`, fd inheritance, `TIOCSWINSZ`), and only - the UI path offers it — `--headless` has no event loop to press ctrl-r in. + the UI path offers it — `--headless` has no event loop to press + `prefix r` in. It follows the *path* the instance was started from, so it cannot cross profiles: reloading a debug instance onto a release build means starting the release binary instead. `App::history` (viewed disk transcripts), the render @@ -850,15 +997,15 @@ agentId: `), and the real completion is injected into the parent's next - Non-streaming requests pass through untapped (e.g. `count_tokens`), and so does a **2xx** non-SSE response. A non-2xx now surfaces as a `Kind::Error`. -- Subagent popup: no per-lane prompt minimap (a subagent has no user prompts). +- Subagent lanes: no per-lane prompt minimap (a subagent has no user prompts). A disk lane *does* now carry the agent's own run totals (`subagent_tokens`/`tool_uses`/`duration_ms`) whenever its `` was recorded — a transcript records no *API* usage, but the notification blocks carry Claude Code's own numbers. What a disk lane still lacks is per-turn `input_tokens`/`output_tokens` (SSE-only), and a lane whose notification we never saw falls back to the wire-counted `out …`. - Only one agent is readable at a - time (a modal popup, by design: the alternative was the split feed this + One lane is readable at a time, by design — the feed shows one lane and + `prefix a` / `prefix >` picks it (the alternative was the split feed this replaced). A lane is never closed, only marked `finished`: a background agent (`x-app: cli-bg`) can wake up again long after its launch result landed, and `SendMessage` can revive a finished @@ -893,7 +1040,7 @@ agentId: `), and the real completion is injected into the parent's next local control endpoint). - Materialized branch files satisfy our own parser (round-trip tested) but Claude Code's loader tolerance is only verified empirically by resuming - one — if a CC update changes the JSONL schema, retest `b` + ctrl-↓. The + one — if a CC update changes the JSONL schema, retest `b` + `enter`. The tree itself isn't refreshed while expanded (collapse/re-expand re-reads the file), and a highlighted turn of a *live* session views its on-disk transcript, which lags the in-memory feed by however much CC buffers. diff --git a/README.md b/README.md index 00fe6b4..3a3f61b 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,43 @@ proxy isn't running. ## Keys -| key | action | +Everything you type goes to the embedded `claude`. There is no focus model and +no key you have to think about — `q`, `j` and `Esc` all reach Claude Code, +because the app's own keys live behind a **prefix**, tmux-style. + +Press **ctrl-space** and a which-key popup shows what is available. The keys +work immediately; you never have to wait for the popup. + +| `^space` + | action | |---|---| -| `q` / `Esc` | quit | -| `Tab` / `Shift-Tab` | switch session | -| `j`/`k`, arrows, PgUp/PgDn | scroll (disables follow) | -| `f` / `G` / `End` | follow live tail | -| `g` / `Home` | jump to top | +| `s` | sessions — `j`/`k` previews each one in the feed, `enter` opens it in claude, `Esc` keeps reading it. Turn tree with `space`, branch with `b` | +| `a` | streams — `j`/`k` previews the main chain, each subagent, each hosted tool call. Sized to the list, capped at a third | +| `n` | new session (model picker) | +| `c` | continue the most recent session | +| `f` | filter which entry kinds show | +| `/` | find bar — matches highlight in place, `enter`/`↓` next, `↑` prev, with a `3/12` counter | +| `]` `[` | next / previous user prompt | +| `.` | back to the live main chain, tailing it — undoes a picked session, a picked lane, and a parked scroll | +| `z` `Z` | zoom the pane / zoom the feed | +| `r` | hot reload | +| `q` | quit | + +Repeatable keys (`]` `[`) keep the popup open, so `^space ]]]` walks. +`^space ^space` sends a literal prefix to the child. + +Two keys are the app's without a prefix, for the same reason a terminal keeps +them for itself: the **wheel** and **shift**+PgUp/PgDn scroll the feed (or the +pane's own scrollback while the pane is zoomed). They keep working while a +list is open, so you can read what you highlighted before committing to it. + +**Esc closes the topmost overlay. With nothing open it goes to Claude Code**, so +interrupt and Esc-Esc rewind keep working. Filters and the stream you picked are +settings, not modes — Esc never resets them. + +`CT_PREFIX` changes the prefix (`CT_PREFIX=ctrl-b`, `ctrl-]`, `f1`, …), for the +case where your Claude Code wants ctrl-space for itself. `CT_DEBUG_KEYS=1` shows +what your terminal actually delivers. ctrl-q quits from anywhere, as a way out +if the prefix never arrives. ## Display @@ -63,18 +93,18 @@ concurrent requests (subagents) tap independently. ## Hot reload -Swap a running instance onto a newly built binary with **ctrl-r** — without +Swap a running instance onto a newly built binary with **`^space r`** — without losing the proxy port, the embedded `claude` pane, or the live feed. ```sh cargo build # in any terminal, whenever you like - # then press ctrl-r in each running instance + # then press ^space r in each running instance ``` claude-cloak never builds anything itself and watches no files. You rebuild the -way you always would; ctrl-r says "run that one now". +way you always would; the key says "run that one now". -ctrl-r execs the same **path** the instance was started from, so a debug +It execs the same **path** the instance was started from, so a debug instance reloads onto a rebuilt debug binary and a release instance onto a rebuilt release one. It does not cross profiles. @@ -88,8 +118,8 @@ backlog and are served by the new code. Nothing is refused and nothing is truncated. The footer shows `⟳ reloading…` while it drains, then `· reload #1` once the new -code is running. A failed exec — ctrl-r pressed while the linker still had the -file open — reports `⚠ reload failed: …` and changes nothing; press it again. +code is running. A failed exec — pressed while the linker still had the file +open — reports `⚠ reload failed: …` and changes nothing; press it again. A state snapshot the new types no longer fit costs the feed only — never the port and never the pane. diff --git a/src/app.rs b/src/app.rs index 14ce8de..c8ce669 100644 --- a/src/app.rs +++ b/src/app.rs @@ -157,11 +157,30 @@ pub struct App { pub lane_cols: HashMap, /// Session key `lane_cols` currently belongs to. pub cols_session: String, - /// Subagent popup (`A`), the *only* place a subagent's stream is shown. - /// `None` = closed, and then agents cost no layout space at all: the main - /// feed always owns the full width and never interleaves agent entries. - /// Reset when the displayed session changes — a lane id is session-local. - pub agent_popup: Option, + /// Stream picker overlay (`prefix a`): `Some(row)` while it is open. It + /// lists *every* lane of the displayed session — the main chain included — + /// because picking one is now the only thing it does: it points + /// `feed_lane` at that lane. There is no second feed any more. + pub streams_popup: Option, + /// Which lane the one feed renders. `MAIN_LANE` unless a stream was picked. + /// Session-local, so a session switch resets it (see `cols_session`). + /// + /// This is the collapse the overhaul is built on: the feed always shows + /// exactly one lane at full width, and the picker chooses which. The old + /// arrangement — main feed plus a modal popup holding a *second* feed with + /// its own cache and scroll model — rendered the same thing twice. + pub feed_lane: LaneId, + /// The feed tails the embedded pane's session, like `tail -f`. Picking a + /// different session in the overlay turns it off; picking the pane's row + /// (or `prefix .`) turns it back on. There is deliberately no "escape to + /// default" key — see `keymap`'s note on Esc. + pub follow_pane: bool, + /// Incremental search over the displayed lane (`prefix /`). + pub search: Option, + /// Scroll the feed so this entry index sits at the viewport top, once, on + /// the next draw. Shared by the turn-tree jump and search — both know an + /// entry index but not its pixel offset, which lives in the render cache. + pub scroll_entry: Option, /// Session UUID of the embedded `claude` pane (src/term.rs). This is now /// *learned* from the pane's first tagged request (see `embed_token`), /// not assumed from the `--session-id` we spawned with — Claude Code does @@ -292,7 +311,11 @@ impl App { prompt_jump: None, lane_cols: HashMap::new(), cols_session: String::new(), - agent_popup: None, + streams_popup: None, + feed_lane: MAIN_LANE, + follow_pane: true, + search: None, + scroll_entry: None, embed_session: None, embed_token: None, pane_focused: false, @@ -425,7 +448,10 @@ impl App { pub fn after_restore(&mut self, key: Option<&str>) { self.lane_cols.clear(); self.cols_session.clear(); - self.agent_popup = None; + self.streams_popup = None; + self.feed_lane = MAIN_LANE; + self.search = None; + self.scroll_entry = None; self.filter_popup = None; self.model_popup = None; self.expanded = None; @@ -434,6 +460,56 @@ impl App { } } + /// Is a modal overlay up? While one is, it takes every key and the wheel, + /// and the pane reads as unfocused — that plus Esc is the entire "mode" + /// model. View state (filters, `feed_lane`) is deliberately not in here: + /// it is a setting, not a mode. + pub fn overlay_open(&self) -> bool { + self.show_sessions + || self.streams_popup.is_some() + || self.filter_popup.is_some() + || self.model_popup.is_some() + || self.search.is_some() + } + + /// Esc: close the topmost overlay. Returns false when there was nothing to + /// close, which is the caller's cue to hand the key to the child (Claude + /// Code interrupts on Esc and rewinds on Esc-Esc — eating it would break + /// both). + pub fn close_overlay(&mut self) -> bool { + // Innermost first: the turn tree and its visual range live *inside* + // the sessions overlay, so they unwind before it closes. + if let Some(e) = self.expanded.as_mut() + && self.show_sessions + { + if e.visual.is_some() { + e.visual = None; + return true; + } + self.expanded = None; + return true; + } + if self.search.take().is_some() { + return true; + } + if self.filter_popup.take().is_some() || self.model_popup.take().is_some() { + return true; + } + if self.streams_popup.take().is_some() { + return true; + } + // Leaving the sessions overlay *keeps* the session you were reading. + // There is nothing to cancel — the feed pointer is a view setting, not + // an edit — and snapping back would throw away the only thing walking + // the list produced. `prefix .` is how you go back to the pane, and the + // footer says so whenever you are pinned. + if std::mem::take(&mut self.show_sessions) { + self.follow_pane = self.selected_key() == self.embed_session; + return true; + } + false + } + /// Length of the merged selection list: live sessions first (indices /// stay stable — entries/sessions are append-only), then disk stubs. pub fn merged_len(&self) -> usize { @@ -672,7 +748,7 @@ impl App { /// The session the feed currently displays: the on-disk view when a turn /// is highlighted or a past-session stub is selected (mirroring `draw`), /// otherwise the live session. - fn displayed_session(&self) -> Option<&Session> { + pub fn displayed_session(&self) -> Option<&Session> { let key = self.selected_key()?; let on_disk = self.on_turns() || self.selected >= self.sessions.len(); if on_disk && let Some(h) = self.history.get(&key) { @@ -702,126 +778,118 @@ impl App { .unwrap_or_default() } - /// `A`: open or close the subagent popup. Opening takes the shortest path - /// to a stream — a lone agent opens its feed directly, several land on the - /// picker with the first *running* agent preselected (`agent_list` order). - pub fn toggle_agent_popup(&mut self) { - if self.agent_popup.is_some() { - self.agent_popup = None; - self.status = "agent view closed".into(); - return; - } - let lanes = self.agent_list(); - self.agent_popup = match lanes.len() { - 0 => { - self.status = "no subagents in this session".into(); - None - } - 1 => { - self.status = self.lane_status(lanes[0]); - Some(AgentPopup::Feed(lanes[0])) - } - n => { - self.status = picker_status(n); - Some(AgentPopup::List(0)) - } - }; + /// `prefix a`: open the stream picker. Every lane of the displayed + /// session is listed — the main chain first, then subagents and nested + /// server-tool calls in `agent_list_of` order (running first) — because + /// picking a lane is the only navigation the feed has, and the way back to + /// the main chain has to be in the same list as the way out of it. + /// + /// Opening on a session with no side lanes is not an error: the picker + /// still shows `main`, so the key never dead-ends. + pub fn open_streams(&mut self) { + let n = self.stream_list().len().saturating_sub(1); + let cur = self.feed_lane; + let row = self.stream_list().iter().position(|&l| l == cur).unwrap_or(0); + self.streams_popup = Some(row); + self.status = picker_status(n); } - /// Lane whose feed the popup shows; `None` while it shows the picker or is - /// closed. This is the scroll target of the paging keys and the wheel while - /// the popup is up — the popup is modal, so it takes them all. - pub fn agent_popup_lane(&self) -> Option { - match self.agent_popup { - Some(AgentPopup::Feed(l)) => Some(l), - _ => None, - } + /// Lanes in picker order: `MAIN_LANE`, then `agent_list_of`. + pub fn stream_list_of(s: &Session) -> Vec { + let mut v = vec![MAIN_LANE]; + v.extend(Self::agent_list_of(s)); + v } - /// Move the picker highlight by `delta`, wrapping. - pub fn agent_popup_move(&mut self, delta: isize) { - let n = self.agent_list().len(); - if let Some(AgentPopup::List(sel)) = self.agent_popup - && n > 0 + /// `stream_list_of` for the session the feed displays. Empty only when + /// there is no session at all. + pub fn stream_list(&self) -> Vec { + self.displayed_session() + .map(Self::stream_list_of) + .unwrap_or_default() + } + + /// Move the picker highlight by `delta`, wrapping, **and show that lane**. + /// + /// Live preview, exactly like the sessions overlay: the picker is a bottom + /// strip, so the feed above is right there and moving the highlight is the + /// whole interaction. That leaves the picker with no separate commit — + /// enter and Esc both just close, keeping whatever you walked to. + pub fn streams_move(&mut self, delta: isize) { + let lanes = self.stream_list(); + if let Some(sel) = self.streams_popup + && !lanes.is_empty() { - let next = (sel as isize + delta).rem_euclid(n as isize) as usize; - self.agent_popup = Some(AgentPopup::List(next)); + let next = (sel as isize + delta).rem_euclid(lanes.len() as isize) as usize; + self.streams_popup = Some(next); + self.show_lane(lanes[next]); } } - /// Picker → that agent's feed. - pub fn agent_popup_enter(&mut self) { - if let Some(AgentPopup::List(sel)) = self.agent_popup - && let Some(&l) = self.agent_list().get(sel) - { - self.agent_popup = Some(AgentPopup::Feed(l)); - self.status = self.lane_status(l); - } + /// Point the feed at `lane` and say so in the status line. + pub fn show_lane(&mut self, lane: LaneId) { + self.feed_lane = lane; + self.status = self.lane_status(lane); } - /// Status line for the agent whose feed the popup opened. + /// Status line for the lane the feed just switched to. fn lane_status(&self, lane: LaneId) -> String { + if lane == MAIN_LANE { + return "main chain".into(); + } self.displayed_session() .and_then(|s| s.lanes.get(lane as usize)) .map_or_else(String::new, |l| format!("watching {}", l.title())) } - /// Esc / ← inside the popup: a feed steps back to the picker when there is - /// a choice to make, otherwise the popup closes. - pub fn agent_popup_back(&mut self) { - let lanes = self.agent_list(); - self.agent_popup = match self.agent_popup { - Some(AgentPopup::Feed(l)) if lanes.len() > 1 => { - self.status = picker_status(lanes.len()); - Some(AgentPopup::List( - lanes.iter().position(|&x| x == l).unwrap_or(0), - )) - } + /// Keep the feed and the picker pointed at lanes that exist. A session + /// switch or a rebuilt on-disk view can invalidate either, and the render + /// path indexes `Session::lanes` directly — so this runs in `draw` before + /// the feed borrow, once, instead of a bounds check at every use site. + pub fn validate_lanes(&mut self) { + let lanes = self.stream_list(); + if !lanes.contains(&self.feed_lane) { + self.feed_lane = MAIN_LANE; + } + self.streams_popup = match self.streams_popup { + Some(sel) if !lanes.is_empty() => Some(sel.min(lanes.len() - 1)), _ => None, }; } - /// `[` / `]` on a popup feed: previous/next agent without a detour through - /// the picker. - pub fn agent_popup_cycle(&mut self, forward: bool) { - let lanes = self.agent_list(); - if let Some(AgentPopup::Feed(l)) = self.agent_popup - && !lanes.is_empty() - { - let cur = lanes.iter().position(|&x| x == l).unwrap_or(0) as isize; - let next = (cur + if forward { 1 } else { -1 }).rem_euclid(lanes.len() as isize); - let lane = lanes[next as usize]; - self.agent_popup = Some(AgentPopup::Feed(lane)); - self.status = self.lane_status(lane); + /// Scroll state of one lane: `(scroll, follow)`. `MAIN_LANE` keeps using + /// `scroll`/`follow` (the pair the reload snapshot carries); every other + /// lane owns an entry in `lane_cols`, so each stream follows its own tail + /// and switching back and forth keeps your place. + fn lane_col(&mut self, lane: LaneId) -> (&mut usize, &mut bool) { + if lane == MAIN_LANE { + (&mut self.scroll, &mut self.follow) + } else { + let e = self.lane_cols.entry(lane).or_insert((0, true)); + (&mut e.0, &mut e.1) } } - /// Keep the popup pointed at something that exists: a lane the displayed - /// session does not have (session switch, rebuilt on-disk view) closes it, - /// a stale picker index is clamped. Called from `draw` before the feed - /// borrow, so the render path never sees an impossible state. - pub fn validate_agent_popup(&mut self) { - let lanes = self.agent_list(); - self.agent_popup = match self.agent_popup { - Some(AgentPopup::Feed(l)) if lanes.contains(&l) => Some(AgentPopup::Feed(l)), - Some(AgentPopup::List(sel)) if !lanes.is_empty() => { - Some(AgentPopup::List(sel.min(lanes.len() - 1))) - } - _ => None, - }; + /// Read one lane's scroll state without creating an entry for it. + pub fn lane_col_of(&self, lane: LaneId) -> (usize, bool) { + if lane == MAIN_LANE { + (self.scroll, self.follow) + } else { + self.lane_cols.get(&lane).copied().unwrap_or((0, true)) + } } - /// Scroll one feed by `delta` rows (negative scrolls up). `None` is the - /// main feed (`scroll`/`follow`); an agent's popup feed keeps its state in - /// `lane_cols`, so every agent follows its own tail. + pub fn set_lane_col(&mut self, lane: LaneId, scroll: usize, follow: bool) { + let (s, f) = self.lane_col(lane); + *s = scroll; + *f = follow; + } + + /// Scroll one feed by `delta` rows (negative scrolls up). `None` targets + /// whichever lane the feed is currently showing. pub fn scroll_col(&mut self, lane: Option, delta: isize) { - let (scroll, follow) = match lane { - None => (&mut self.scroll, &mut self.follow), - Some(l) => { - let e = self.lane_cols.entry(l).or_insert((0, true)); - (&mut e.0, &mut e.1) - } - }; + let lane = lane.unwrap_or(self.feed_lane); + let (scroll, follow) = self.lane_col(lane); *follow = false; *scroll = if delta < 0 { scroll.saturating_sub(delta.unsigned_abs()) @@ -830,22 +898,84 @@ impl App { }; } - /// `g` / `G`: jump one column to the top (follow off) or the tail (follow + /// `g` / `G`: jump one lane to the top (follow off) or the tail (follow /// on, so it keeps streaming). pub fn scroll_col_end(&mut self, lane: Option, bottom: bool) { - let (scroll, follow) = match lane { - None => (&mut self.scroll, &mut self.follow), - Some(l) => { - let e = self.lane_cols.entry(l).or_insert((0, true)); - (&mut e.0, &mut e.1) - } - }; + let lane = lane.unwrap_or(self.feed_lane); + let (scroll, follow) = self.lane_col(lane); *follow = bottom; if !bottom { *scroll = 0; } } + /// `prefix /`: run the query over the displayed lane and point the feed at + /// the first hit at or after the current scroll position. Case-insensitive + /// substring over the entry's own text *and* its tool result, so a search + /// finds what is on screen rather than what is in the stream. + pub fn search_run(&mut self, forward: bool) { + let Some(q) = self.search.as_ref().map(|s| s.query.to_lowercase()) else { + return; + }; + if q.is_empty() { + if let Some(sr) = self.search.as_mut() { + sr.hits = 0; + sr.pos = 0; + sr.at = None; + } + return; + } + let lane = self.feed_lane; + // Filtered-out entries are skipped: they have no row in the rendered + // feed, so "jumping" to one would scroll somewhere arbitrary and look + // like the search was wrong. What you can see is what you can find. + let filters = self.filters; + let idx: Vec = self + .displayed_session() + .map(|s| { + s.entries + .iter() + .enumerate() + .filter(|(_, e)| { + e.lane == lane + && filters[filter_index(&e.kind)] + && entry_matches(e, &q) + }) + .map(|(i, _)| i) + .collect() + }) + .unwrap_or_default(); + if idx.is_empty() { + if let Some(sr) = self.search.as_mut() { + sr.hits = 0; + sr.pos = 0; + sr.at = None; + } + self.status = format!("no match for \"{q}\""); + return; + } + let cur = self.search.as_ref().and_then(|s| s.at); + let hit = match cur { + None => idx[0], + Some(c) if forward => *idx.iter().find(|&&i| i > c).unwrap_or(&idx[0]), + Some(c) => *idx.iter().rev().find(|&&i| i < c).unwrap_or(&idx[idx.len() - 1]), + }; + if let Some(sr) = self.search.as_mut() { + sr.at = Some(hit); + sr.hits = idx.len(); + sr.pos = idx.iter().position(|&i| i == hit).map_or(0, |p| p + 1); + } + self.scroll_entry = Some(hit); + self.follow_off(); + } + + /// Stop tailing whichever lane the feed shows (a jump pins the viewport). + fn follow_off(&mut self) { + let lane = self.feed_lane; + let (_, follow) = self.lane_col(lane); + *follow = false; + } + /// Swap in a fresh scan result, keeping a stub selection pointed at the /// same session even if the list reordered (scanner thread calls this). pub fn set_disk_sessions(&mut self, list: Vec) { @@ -976,22 +1106,41 @@ pub fn seed_server_tool_seq(next: u64) { SRVTOOL_SEQ.fetch_max(next, std::sync::atomic::Ordering::Relaxed); } -/// Footer message while the agent picker is up. +/// Status line while the stream picker is up. Says what the list *is* — +/// the keys are the footer's job. fn picker_status(n: usize) -> String { - format!("{n} streams — enter to open, esc to close") + format!("{n} streams in this session") } -/// State of the subagent view (`A`). It is a *modal popup over the feed*, not a -/// region of the layout: a subagent's entries never appear in the main feed -/// (`draw_feed` filters by lane) and never take space from it, so watching the -/// main chain is unaffected by how many agents run. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum AgentPopup { - /// Picking which agent to watch; the index is a position in `agent_list`. - /// Skipped when the session has exactly one agent. - List(usize), - /// One agent's feed fills the popup (its own scroll/follow in `lane_cols`). - Feed(LaneId), +/// Incremental search state (`prefix /`). `at` is the entry *index* of the +/// current hit, which is what Up/Down step from — deliberately not a stored hit +/// list, so the query stays live against a feed that is still growing. +/// `hits`/`pos` are recomputed on every run purely so the overlay can show +/// `3/12`; nothing navigates by them. +#[derive(Default, Clone, Debug)] +pub struct Search { + pub query: String, + pub at: Option, + /// Matches in the displayed lane, and the 1-based position of `at` among + /// them. Both zero when the query is empty or matches nothing. + pub hits: usize, + pub pos: usize, +} + +/// Does this entry match a lowercased query? Its text and its tool result +/// both count: the result is on screen, so it should be findable. +fn entry_matches(e: &Entry, q: &str) -> bool { + if e.content.to_lowercase().contains(q) { + return true; + } + if let Kind::Tool { name } = &e.kind + && name.to_lowercase().contains(q) + { + return true; + } + e.result + .as_ref() + .is_some_and(|r| r.content.to_lowercase().contains(q)) } /// Identity Claude Code stamps on a subagent's requests. Both are its own @@ -3094,7 +3243,7 @@ mod tests { /// readable) with the running ones first, and `A` takes the shortest path /// to a stream. #[test] - fn agent_popup_lists_running_agents_first() { + fn stream_picker_lists_main_then_running_agents_first() { let mut s = Session::new("d".into(), "m".into()); let mut lane = |id: &str| { let l = s.add_lane( @@ -3118,45 +3267,74 @@ mod tests { s.lanes[l as usize].last_event = Some(Instant::now()); } assert_eq!(App::agent_list_of(&s), vec![a2, a3, a1]); + // The picker leads with the main chain: the way back has to be in the + // same list as the way out. + assert_eq!(App::stream_list_of(&s), vec![MAIN_LANE, a2, a3, a1]); let mut a = App::new(); a.sessions.push(s); a.selected = 0; - // `A` on three agents opens the picker on the first *running* one. - a.toggle_agent_popup(); - assert_eq!(a.agent_popup, Some(AgentPopup::List(0))); - a.agent_popup_move(1); - a.agent_popup_enter(); - assert_eq!(a.agent_popup, Some(AgentPopup::Feed(a3))); - assert_eq!(a.agent_popup_lane(), Some(a3), "paging targets that agent"); - // [ / ] switch agents inside the feed; esc steps back to the picker. - a.agent_popup_cycle(true); - assert_eq!(a.agent_popup, Some(AgentPopup::Feed(a1)), "wraps"); - a.agent_popup_back(); - assert_eq!(a.agent_popup, Some(AgentPopup::List(2))); - a.toggle_agent_popup(); - assert_eq!(a.agent_popup, None, "A closes whatever is open"); + // Opening highlights the lane the feed is on (main, to start). + a.open_streams(); + assert_eq!(a.streams_popup, Some(0)); + // Moving *is* the pick: the picker is a bottom strip, so the feed above + // shows each lane as you walk. Nothing is left for Enter to commit. + a.streams_move(1); + assert_eq!(a.feed_lane, a2, "the highlight previews that lane"); + a.streams_move(-1); + assert_eq!(a.feed_lane, MAIN_LANE, "and walking back previews main"); + a.streams_move(1); + a.streams_popup = None; + assert_eq!(a.feed_lane, a2, "closing keeps what you walked to"); + // Re-opening lands on the lane being shown, not on row 0. + a.open_streams(); + assert_eq!(a.streams_popup, Some(1)); - // One agent = no picker: `A` opens its feed directly. + // A session with no agents still has a picker — it shows `main`, so + // the key never dead-ends. let mut a = App::new(); let mut one = Session::new("d1".into(), "m".into()); let l = one.add_lane("x".into(), "oracle".into(), "job".into(), None, Some(MAIN_LANE), 1); one.entries.push(Entry::meta("output".into()).in_lane(l)); one.reindex_lanes(); a.sessions.push(one); - a.toggle_agent_popup(); - assert_eq!(a.agent_popup, Some(AgentPopup::Feed(l))); - // A session without agents can't open the popup at all. + assert_eq!(a.stream_list(), vec![MAIN_LANE, l]); a.sessions[0].lanes.truncate(1); a.sessions[0].entries.clear(); - a.agent_popup = None; - assert!(a.agent_list().is_empty()); - a.toggle_agent_popup(); - assert_eq!(a.agent_popup, None); - // A popup pointing at a lane the displayed session lost is dropped. - a.agent_popup = Some(AgentPopup::Feed(7)); - a.validate_agent_popup(); - assert_eq!(a.agent_popup, None); + assert_eq!(a.stream_list(), vec![MAIN_LANE]); + // A feed pointing at a lane the displayed session lost falls back to + // the main chain rather than indexing out of bounds. + a.feed_lane = 7; + a.validate_lanes(); + assert_eq!(a.feed_lane, MAIN_LANE); + } + + /// Search walks hits inside the displayed lane only, and wraps. + #[test] + fn search_steps_through_hits_in_the_displayed_lane() { + let mut s = Session::new("d".into(), "m".into()); + let l = s.add_lane("x".into(), "oracle".into(), "job".into(), None, Some(MAIN_LANE), 1); + s.entries.push(Entry::done(Kind::Text, "the retry helper".into())); + s.entries.push(Entry::done(Kind::Text, "unrelated".into())); + s.entries.push(Entry::done(Kind::Text, "retry again".into())); + s.entries.push(Entry::meta("retry in the agent".into()).in_lane(l)); + s.reindex_lanes(); + let mut a = App::new(); + a.sessions.push(s); + a.search = Some(Search { query: "RETRY".into(), ..Default::default() }); + a.search_run(true); + assert_eq!(a.scroll_entry, Some(0), "case-insensitive, first hit"); + a.search_run(true); + assert_eq!(a.scroll_entry, Some(2), "the agent's entry is a different lane"); + a.search_run(true); + assert_eq!(a.scroll_entry, Some(0), "wraps"); + a.search_run(false); + assert_eq!(a.scroll_entry, Some(2), "backwards wraps too"); + // Switching lanes re-scopes the same query. + a.feed_lane = l; + a.search = Some(Search { query: "retry".into(), ..Default::default() }); + a.search_run(true); + assert_eq!(a.scroll_entry, Some(3)); } #[test] diff --git a/src/keymap.rs b/src/keymap.rs new file mode 100644 index 0000000..8ac9889 --- /dev/null +++ b/src/keymap.rs @@ -0,0 +1,297 @@ +//! The one binding table. +//! +//! Every app key lives here, once: the which-key popup renders this table, the +//! footer hint summarises it, and `ui::run_act` dispatches it. A binding that +//! is not in the table cannot be pressed, and one that is in it is documented +//! for free — which is what stops the keymap drifting apart again. +//! +//! # The rule the whole model rests on +//! +//! **Unprefixed keys belong to the embedded `claude`. Always.** There is no +//! focus model, no ctrl-↑/ctrl-↓ dance and no "is this key mine?" question: +//! `q`, `j` and Esc reach Claude Code because nothing else can claim them. +//! Everything cloak owns sits behind [`Prefix`], tmux-style. Two exceptions, +//! both of which a real terminal also keeps for itself rather than forwarding: +//! the **wheel** and **shift**+PgUp/PgDn. +//! +//! Esc is the one key with a rule of its own, and it is a rule about +//! reachability, not about modes: *Esc closes the topmost overlay; with nothing +//! open it goes to the child.* Claude Code uses Esc to interrupt and Esc-Esc to +//! rewind, so eating it unconditionally would break both. View state (a filter +//! set, the lane the feed shows) is deliberately **not** escapable — it is a +//! setting, not a mode, and resetting it on a stray Esc would be a surprise +//! rather than a rescue. + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Something a key does. `Copy`, so dispatch can match on it after the table +/// borrow ends. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Act { + /// Session picker overlay (also the home of the turn tree and `b`ranching). + Sessions, + /// Stream picker overlay: the main chain, every subagent, every nested + /// server-tool call. Picking one points the feed at that lane. + Streams, + /// Model picker → spawn a brand-new `claude --session-id …`. + NewSession, + /// Attach the pane to the most recent past session (`claude -c`). + Continue, + /// Entry-kind filter strip. + Filter, + /// Incremental search over the displayed lane. + Search, + /// Jump the feed to the next / previous user prompt. Repeatable: the menu + /// stays open so `]]]` walks. + NextPrompt, + PrevPrompt, + /// Pane fullscreen. Esc still reaches the child there — it is nothing *but* + /// the pane, so nothing is covering it. + ZoomPane, + /// Hide the pane, feed takes the screen. This *does* cover the pane, so Esc + /// leaves it. + ZoomFeed, + /// Back to the live main chain of the pane's session, tailing it. Undoes + /// every kind of pinning at once — a picked session, a picked lane, and a + /// scroll position parked by a search or a prompt jump. + FollowLive, + Reload, + Quit, +} + +impl Act { + /// Whether the popup marks this entry as leading somewhere — an overlay + /// that takes over input. Purely cosmetic (`▸`). + pub fn opens(self) -> bool { + matches!( + self, + Act::Sessions | Act::Streams | Act::NewSession | Act::Filter | Act::Search + ) + } + + /// Repeatable actions keep the menu up, so the key can be pressed again + /// without re-pressing the prefix. Everything else closes it. + pub fn sticky(self) -> bool { + matches!(self, Act::NextPrompt | Act::PrevPrompt) + } +} + +#[derive(Debug)] +pub struct Bind { + pub key: char, + pub label: &'static str, + pub act: Act, +} + +/// A menu level. There is one today (`ROOT`); the type exists because the +/// popup renders *a* level and `EmbedUi::menu` holds the open one, not because +/// nesting is planned. A submenu earns its place when a group of keys is both +/// large and rarely used, and no group is either right now. +#[derive(Debug)] +pub struct Menu { + pub title: &'static str, + pub binds: &'static [Bind], +} + +impl Menu { + pub fn find(&self, c: char) -> Option<&Bind> { + self.binds.iter().find(|b| b.key == c) + } +} + +/// The root menu, in reading order. The popup lays it out in columns. +pub static ROOT: Menu = Menu { + title: "", + binds: &[ + Bind { key: 's', label: "sessions", act: Act::Sessions }, + Bind { key: 'a', label: "streams", act: Act::Streams }, + Bind { key: 'n', label: "new", act: Act::NewSession }, + Bind { key: 'c', label: "continue", act: Act::Continue }, + Bind { key: 'f', label: "filter", act: Act::Filter }, + Bind { key: '/', label: "search", act: Act::Search }, + Bind { key: ']', label: "next prompt", act: Act::NextPrompt }, + Bind { key: '[', label: "prev prompt", act: Act::PrevPrompt }, + Bind { key: '.', label: "follow live", act: Act::FollowLive }, + Bind { key: 'z', label: "zoom pane", act: Act::ZoomPane }, + Bind { key: 'Z', label: "zoom feed", act: Act::ZoomFeed }, + Bind { key: 'r', label: "reload", act: Act::Reload }, + Bind { key: 'q', label: "quit", act: Act::Quit }, + ], +}; + +// --------------------------------------------------------------------------- +// The prefix +// --------------------------------------------------------------------------- + +/// The one key that opens the menu. Configurable because the *only* hard +/// requirement is that the embedded Claude Code does not want it, and that is +/// a property of the child's version, not of ours — so it must be changeable +/// without a rebuild. `CT_PREFIX=ctrl-b`, `CT_PREFIX=ctrl-]`, `CT_PREFIX=f1`. +/// +/// Default `ctrl-space`. Terminals disagree about what ctrl-space *is* on the +/// wire (NUL, ctrl-`@`, or a real ctrl-modified space), so that one spelling +/// matches all three — see `matches`. `CT_DEBUG_KEYS=1` shows what actually +/// arrives when a terminal delivers none of them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Prefix { + code: KeyCode, + mods: KeyModifiers, + /// True for the ctrl-space default, which needs the three-way match. + ctrl_space: bool, + pub label: String, +} + +impl Default for Prefix { + fn default() -> Self { + Self::parse("ctrl-space").expect("the default prefix parses") + } +} + +impl Prefix { + /// Read `CT_PREFIX`, falling back to the default on an unset or + /// unparseable value (a typo must not leave the app with no menu key). + pub fn from_env() -> Self { + std::env::var("CT_PREFIX") + .ok() + .and_then(|s| Self::parse(&s)) + .unwrap_or_default() + } + + pub fn parse(spec: &str) -> Option { + let spec = spec.trim(); + let mut mods = KeyModifiers::NONE; + let mut rest = spec; + loop { + let lower = rest.to_ascii_lowercase(); + let (m, tail) = if let Some(t) = lower.strip_prefix("ctrl-") { + (KeyModifiers::CONTROL, t.len()) + } else if let Some(t) = lower.strip_prefix("shift-") { + (KeyModifiers::SHIFT, t.len()) + } else if let Some(t) = lower.strip_prefix("alt-") { + (KeyModifiers::ALT, t.len()) + } else { + break; + }; + mods |= m; + rest = &rest[rest.len() - tail..]; + } + let low = rest.to_ascii_lowercase(); + let code = match low.as_str() { + "space" => KeyCode::Char(' '), + "tab" => KeyCode::Tab, + "esc" => KeyCode::Esc, + f if f.starts_with('f') && f[1..].parse::().is_ok() => { + KeyCode::F(f[1..].parse().ok()?) + } + _ => { + let mut it = rest.chars(); + let c = it.next()?; + if it.next().is_some() { + return None; + } + KeyCode::Char(c.to_ascii_lowercase()) + } + }; + let ctrl_space = code == KeyCode::Char(' ') && mods.contains(KeyModifiers::CONTROL); + Some(Self { + code, + mods, + ctrl_space, + label: pretty(mods, code), + }) + } + + /// Does this event open the menu? + /// + /// ctrl-space is three events depending on the terminal: `Char(' ')` with + /// CONTROL, `Char('@')` with CONTROL (the NUL byte decoded as its caret + /// spelling), and a bare `Null`. All three mean the same keypress, so all + /// three count. + pub fn matches(&self, k: &KeyEvent) -> bool { + if self.ctrl_space { + let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + return k.code == KeyCode::Null + || (ctrl && matches!(k.code, KeyCode::Char(' ') | KeyCode::Char('@'))); + } + // Compare only the modifiers the spec named: terminals add SHIFT of + // their own accord for capitals and for some ctrl combinations. + let want = self.mods & (KeyModifiers::CONTROL | KeyModifiers::ALT); + let got = k.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT); + let code = match k.code { + KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()), + other => other, + }; + code == self.code && got == want + } +} + +fn pretty(mods: KeyModifiers, code: KeyCode) -> String { + let mut s = String::new(); + if mods.contains(KeyModifiers::CONTROL) { + s.push('^'); + } + if mods.contains(KeyModifiers::ALT) { + s.push_str("alt-"); + } + match code { + KeyCode::Char(' ') => s.push_str("space"), + KeyCode::Char(c) => s.push(c), + KeyCode::Tab => s.push_str("tab"), + KeyCode::Esc => s.push_str("esc"), + KeyCode::F(n) => s.push_str(&format!("F{n}")), + other => s.push_str(&format!("{other:?}")), + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::KeyEventKind; + + fn ev(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: ratatui::crossterm::event::KeyEventState::NONE, + } + } + + /// The default has to survive all three ways a terminal spells ctrl-space, + /// because we cannot know which one the user's terminal picks. + #[test] + fn ctrl_space_matches_every_spelling_terminals_use() { + let p = Prefix::default(); + assert!(p.matches(&ev(KeyCode::Char(' '), KeyModifiers::CONTROL))); + assert!(p.matches(&ev(KeyCode::Char('@'), KeyModifiers::CONTROL))); + assert!(p.matches(&ev(KeyCode::Null, KeyModifiers::NONE))); + assert!(!p.matches(&ev(KeyCode::Char(' '), KeyModifiers::NONE)), "plain space is the child's"); + assert_eq!(p.label, "^space"); + } + + #[test] + fn prefix_specs_parse_and_reject() { + let p = Prefix::parse("ctrl-b").unwrap(); + assert!(p.matches(&ev(KeyCode::Char('b'), KeyModifiers::CONTROL))); + assert!(!p.matches(&ev(KeyCode::Char('b'), KeyModifiers::NONE))); + assert_eq!(p.label, "^b"); + assert!(Prefix::parse("f1").unwrap().matches(&ev(KeyCode::F(1), KeyModifiers::NONE))); + assert!(Prefix::parse("ctrl-]").unwrap().matches(&ev(KeyCode::Char(']'), KeyModifiers::CONTROL))); + assert!(Prefix::parse("").is_none()); + assert!(Prefix::parse("ctrl-nope").is_none()); + } + + /// Every key in the table is unique per menu, or one of them is dead. + #[test] + fn no_menu_binds_a_key_twice() { + fn check(m: &Menu) { + let mut seen = Vec::new(); + for b in m.binds { + assert!(!seen.contains(&b.key), "{} binds {:?} twice", m.title, b.key); + seen.push(b.key); + } + } + check(&ROOT); + } +} diff --git a/src/main.rs b/src/main.rs index 4ca6c4a..611a658 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod ansi; mod app; +mod keymap; mod markdown; mod proxy; mod reload; diff --git a/src/sessions.rs b/src/sessions.rs index a95c669..0dd9724 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -129,6 +129,7 @@ pub struct Turn { /// A session's turns as a tree. Built from `uuid`/`parentUuid` chains: /// uuid-less records (mode, file-history-snapshot, last-prompt…) attach to /// the turn of the record preceding them in the file. +#[derive(Default)] pub struct TurnTree { pub turns: Vec, /// Records before/outside any turn (mode, the caveat record, …). diff --git a/src/ui.rs b/src/ui.rs index 97163c2..0faeb50 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,7 +1,8 @@ use crate::app::{ - AgentPopup, App, Entry, FILTER_LABELS, Kind, Lane, LaneId, MAIN_LANE, Session, SharedApp, + App, Entry, FILTER_LABELS, Kind, Lane, LaneId, MAIN_LANE, Search, Session, SharedApp, ToolResult, filter_index, fmt_tokens, }; +use crate::keymap::{Act, Menu, Prefix, ROOT}; use crate::term::{EmbeddedTerm, PaneView}; use ratatui::Frame; use ratatui::crossterm::cursor::SetCursorStyle; @@ -28,12 +29,18 @@ use std::time::{Duration, Instant}; struct EmbedUi { term: Option, visible: bool, - /// Keyboard focus is on the claude pane (vs. the feed above it). - /// Directional: ctrl-↓ moves focus into the pane, ctrl-↑ back to the feed. - claude_focused: bool, - /// Pane takes (nearly) the whole screen. Toggled with ctrl-f while the - /// pane has focus; cleared when focus leaves it or it is hidden. + /// The feed takes the screen and the pane is hidden (`prefix Z`). This + /// *covers* the pane, which is exactly why Esc leaves it — unlike + /// `fullscreen`, which is nothing but the pane and therefore passes Esc + /// through to the child. + zoom_feed: bool, + /// Pane takes (nearly) the whole screen (`prefix z`). fullscreen: bool, + /// Which-key menu level currently on screen; `None` = closed. UI-thread + /// only, so it never touches `App` or the reload snapshot. + menu: Option<&'static Menu>, + /// The key that opens that menu. Everything unprefixed goes to the child. + prefix: Prefix, /// The focused child was on the **alternate screen** last frame /// (`EmbeddedTerm::alt_screen`): an editor it launched — nvim, a /// `git commit`, a pager — owns the terminal, so the pane is fullscreen and @@ -113,13 +120,21 @@ struct Selection { } impl EmbedUi { - /// Pane is visible, child alive, and holds keyboard focus → it gets keys. - fn focused(&self) -> bool { + /// The pane is on screen with a live child. There is no focus flag any + /// more: an on-screen pane always has the keyboard, which is the rule the + /// whole keymap rests on (see `keymap`). + fn alive(&self) -> bool { self.visible - && self.claude_focused + && !self.zoom_feed && self.term.as_ref().is_some_and(|t| !t.exited()) } + /// Does the child get this keystroke? Only an overlay or the menu can take + /// it away, and both are transient and Esc-closable. + fn takes_keys(&self, overlay: bool) -> bool { + self.alive() && !overlay && self.menu.is_none() + } + /// Scroll target while the pane is fullscreen: the pane's own scrollback, /// exactly as a plain terminal running `claude` would scroll (Claude Code /// grabs no mouse, so nothing is forwarded to the child — see @@ -222,7 +237,7 @@ struct FeedCache { } struct CachedEntry { - fingerprint: (usize, bool, usize, bool, bool), + fingerprint: (usize, bool, usize, bool, bool, u64), lines: Vec>, /// Rows after wrapping to `FeedCache::width` (incl. trailing separator). height: usize, @@ -234,7 +249,7 @@ impl CachedEntry { /// so a placeholder is never re-examined). fn blank() -> Self { Self { - fingerprint: (usize::MAX, false, usize::MAX, false, false), + fingerprint: (usize::MAX, false, usize::MAX, false, false, 0), lines: Vec::new(), height: 0, } @@ -252,13 +267,104 @@ impl CachedEntry { /// bool folds in feed focus, but *only* for `Kind::User` entries (their block /// color tracks focus): toggling focus then re-renders just the user blocks, /// not the whole transcript. -fn fingerprint(e: &Entry, focused: bool) -> (usize, bool, usize, bool, bool) { +fn fingerprint(e: &Entry, focused: bool, query: u64) -> (usize, bool, usize, bool, bool, u64) { let (rlen, rerr) = e .result .as_ref() .map_or((usize::MAX, false), |r| (r.content.len(), r.is_error)); let focus_bit = matches!(e.kind, Kind::User) && focused; - (e.content.len(), e.done, rlen, rerr, focus_bit) + (e.content.len(), e.done, rlen, rerr, focus_bit, query) +} + +/// Hash of the active search query, folded into the render fingerprint. +/// +/// Highlighting is applied *after* an entry is rendered, so a cached entry from +/// before the query would keep serving unhighlighted lines. Editing the query +/// therefore has to invalidate the cache, and this is the cheapest thing that +/// does it without storing the string per entry. FNV-1a: no dependency, and +/// collisions cost a missed re-render on one keystroke, not correctness. +fn query_hash(q: Option<&str>) -> u64 { + let Some(q) = q else { return 0 }; + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in q.as_bytes() { + h ^= u64::from(*b); + h = h.wrapping_mul(0x1000_0000_01b3); + } + // Never 0 for a real query — that is "no search". + h | 1 +} + +/// Rows of context left above a search hit when scrolling to it, so the line +/// lands *in* the viewport rather than flush against its top edge. +const MATCH_CONTEXT: usize = 2; + +/// Wrapped rows above the first painted search match in `lines`, or `None` +/// when the entry holds no match at all (it can be the *result* text that +/// matched, which is rendered clipped — then there is nothing to aim at and +/// the entry's top is the honest answer). +/// +/// Reads the highlight the render pass already applied rather than re-running +/// the query: one definition of "this line matched", used for both the paint +/// and the scroll. +fn match_row(lines: &[Line<'static>], width: u16) -> Option { + let k = lines + .iter() + .position(|l| l.spans.iter().any(|sp| sp.style.bg == Some(MATCH_BG)))?; + Some(wrapped_height(&lines[..k], width)) +} + +/// Background of a search match. Yellow reads as "found" in every terminal +/// theme, and `color_on` picks the foreground so it stays legible. +const MATCH_BG: Color = Color::Indexed(226); + +/// Paint every case-insensitive occurrence of `q` in already-rendered lines. +/// +/// Post-processing the spans rather than teaching each renderer about search is +/// deliberate: `entry_lines` fans out into markdown, a dozen tool renderers and +/// the ANSI parser, and a match has to light up the same way in all of them. +/// Splitting a span keeps whatever style it already had and only overrides the +/// colours, so bold/italic/dim survive the highlight. +fn highlight_lines(lines: &mut Vec>, q: &str) { + if q.is_empty() { + return; + } + let hl = Style::new().bg(MATCH_BG).fg(color_on(MATCH_BG)); + for line in lines.iter_mut() { + let mut out: Vec> = Vec::with_capacity(line.spans.len()); + for sp in line.spans.drain(..) { + let hay = sp.content.to_lowercase(); + if !hay.contains(q) { + out.push(sp); + continue; + } + // Match on the lowercased copy but slice the original, so the text + // keeps its own casing. Both are byte-aligned only while + // lowercasing is 1:1 in length; when it is not (ß, İ) the offsets + // can disagree, so fall back to leaving the span alone. + if hay.len() != sp.content.len() { + out.push(sp); + continue; + } + let text = sp.content.into_owned(); + let mut at = 0usize; + while let Some(rel) = hay[at..].find(q) { + let start = at + rel; + let end = start + q.len(); + if !text.is_char_boundary(start) || !text.is_char_boundary(end) { + break; + } + if start > at { + out.push(Span::styled(text[at..start].to_string(), sp.style)); + } + out.push(Span::styled(text[start..end].to_string(), sp.style.patch(hl))); + at = end; + } + if at < text.len() { + out.push(Span::styled(text[at..].to_string(), sp.style)); + } + } + line.spans = out; + } } /// Detach a `Line` from the text it borrows so it can outlive the app lock. @@ -342,7 +448,12 @@ fn indexed_rgb(i: u8) -> (u8, u8, u8) { /// Render one entry to owned lines, including the blank separator row that /// follows every entry in the feed. `focused` only affects user-prompt blocks. -fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec> { +fn entry_lines( + e: &Entry, + width: u16, + focused: bool, + query: Option<&str>, +) -> Vec> { let mut lines: Vec> = Vec::new(); match &e.kind { // Meta is one dim row — except a citation source list, which is a @@ -509,6 +620,9 @@ fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec> { } } } + if let Some(q) = query { + highlight_lines(&mut lines, q); + } lines.push(Line::default()); lines } @@ -565,7 +679,9 @@ pub fn run( let on_alt = adopted.as_ref().is_some_and(EmbeddedTerm::alt_screen); let mut eui = EmbedUi { visible: pane_ui.visible && has_pane, - claude_focused: pane_ui.focused && has_pane, + zoom_feed: false, + menu: None, + prefix: Prefix::from_env(), fullscreen: pane_ui.fullscreen && has_pane, alt_screen: on_alt, alt_fullscreen: pane_ui.alt_fullscreen && has_pane, @@ -646,7 +762,7 @@ fn try_reload(app: &SharedApp, eui: &mut EmbedUi, terminal: &mut ratatui::Defaul let pane = eui.term.as_ref().filter(|t| !t.exited()).and_then(|t| t.handoff()); let pane_ui = crate::reload::PaneState { visible: eui.visible, - focused: eui.claude_focused, + focused: eui.visible, fullscreen: eui.fullscreen, alt_fullscreen: eui.alt_fullscreen, past_embeds: eui.past_embeds.iter().cloned().collect(), @@ -661,28 +777,14 @@ fn try_reload(app: &SharedApp, eui: &mut EmbedUi, terminal: &mut ratatui::Defaul let mut a = app.lock().unwrap(); let e = crate::reload::exec_into(&exe, &a, pane, &pane_ui); - // Only reachable when the exec failed — most likely ctrl-r landed while the - // linker had the file half-written. Put the terminal back, keep serving, - // and say so; pressing ctrl-r again is safe. + // Only reachable when the exec failed — most likely the key landed while + // the linker had the file half-written. Put the terminal back, keep + // serving, and say so; pressing it again is safe. let _ = ratatui::crossterm::terminal::enable_raw_mode(); let _ = terminal.clear(); a.reload = Status::Failed(format!("{e:#}")); } -fn toggle_embed(eui: &mut EmbedUi, app: &SharedApp) { - if eui.visible { - eui.visible = false; - eui.claude_focused = false; - eui.fullscreen = false; - // A dead child is dropped on hide so the next toggle respawns. - if eui.term.as_ref().is_some_and(|t| t.exited()) { - kill_current_embed(eui, app); - } - return; - } - show_embed_pane(eui, app); -} - /// Tear down the current embedded child (if any): dropping it kills the /// process (`EmbeddedTerm::drop`), the learned session id is retired into /// `past_embeds` (so a later `--resume` of it skips the liveness guard — we @@ -719,6 +821,9 @@ fn bind_new_pane(eui: &mut EmbedUi, app: &SharedApp, t: EmbeddedTerm, session: O a.select_key(key); } a.follow = true; + // A pane you just started is what you want the feed on: re-arm the tail. + a.follow_pane = true; + a.feed_lane = MAIN_LANE; a.clear_turn_focus(); drop(a); eui.term = Some(t); @@ -769,10 +874,11 @@ fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) { if let Some(key) = a.embed_session.clone() { a.select_key(&key); a.follow = true; + a.follow_pane = true; } } eui.visible = true; - eui.claude_focused = true; + eui.zoom_feed = false; } /// `a` model picker: always spawn a *fresh* `claude --session-id ` @@ -793,7 +899,7 @@ fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, pick: &str) { } } eui.visible = true; - eui.claude_focused = true; + eui.zoom_feed = false; } /// Spawn (or replace) the embedded pane resuming a past session by UUID. @@ -840,13 +946,16 @@ fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) { } } eui.visible = true; - eui.claude_focused = true; + eui.zoom_feed = false; } /// ctrl-↓ / `c`: attach the embedded pane to the *selected* session. The /// cheap cases (reveal/focus the live pane, spawn the first instance) are /// instant; only attaching to a different session kills + respawns claude. -fn attach_selected(eui: &mut EmbedUi, app: &SharedApp) { +/// Returns false when nothing was attached because the liveness guard armed +/// instead — the caller must then leave the overlay open, or the warning it +/// just wrote is invisible and the confirming second press has nowhere to go. +fn attach_selected(eui: &mut EmbedUi, app: &SharedApp) -> bool { enum Plan { Fresh, Reveal, @@ -891,7 +1000,7 @@ fn attach_selected(eui: &mut EmbedUi, app: &SharedApp) { Plan::Fresh => show_embed_pane(eui, app), Plan::Reveal => { eui.visible = true; - eui.claude_focused = true; + eui.zoom_feed = false; } Plan::Resume(key) => { eui.force_resume = None; @@ -908,11 +1017,13 @@ fn attach_selected(eui: &mut EmbedUi, app: &SharedApp) { } else { eui.force_resume = Some((key, Instant::now())); app.lock().unwrap().status = - "session may be live in another claude instance — ctrl-↓ again to resume anyway" + "session may be live in another claude instance — enter again to resume anyway" .into(); + return false; } } } + true } fn event_loop( @@ -978,7 +1089,8 @@ fn event_loop( // when the pane has focus (no submit on embedded newlines). Nowhere // else accepts text input, so ignore it otherwise. if let Event::Paste(text) = &ev { - if eui.focused() + if eui.alive() + && !app.lock().unwrap().overlay_open() && let Some(et) = &eui.term { et.paste(text); @@ -1055,245 +1167,157 @@ fn event_loop( app.lock().unwrap().status = format!("key: {:?} mods={:?} kind={:?}", k.code, k.modifiers, k.kind); } - // Pane controls, available in every state (alt-keys are out: - // they compose characters on some keyboard layouts): - // F2 show/hide the claude pane - // ctrl-↓ focus the claude pane (showing it if hidden) - // ctrl-↑ focus the feed - // ctrl-f toggle pane fullscreen (only while the pane is focused) - let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); - // ctrl-q quits from anywhere — in particular while the claude pane - // is focused, where plain `q` is forwarded to the child. - if ctrl && k.code == KeyCode::Char('q') { + // --------------------------------------------------------- + // Key routing. One rule: **unprefixed keys are the child's**. + // Everything cloak owns sits behind the prefix (see `keymap`). + // --------------------------------------------------------- + if k.kind != KeyEventKind::Press { + continue; + } + // 1. The which-key menu owns every key while it is up. + if let Some(level) = eui.menu { + // Prefix twice = send a literal prefix through, tmux-style. + if eui.prefix.matches(&k) { + eui.menu = None; + if let Some(et) = &eui.term { + et.follow_live(); + et.key(k); + } + continue; + } + let act = match k.code { + KeyCode::Char(c) => level.find(c).map(|b| b.act), + _ => None, + }; + match act { + Some(act) => { + // Repeatable leaves keep the menu up so `]]]` walks. + if !act.sticky() { + eui.menu = None; + } + if run_act(act, eui, &app) == Flow::Quit { + return Ok(()); + } + } + // Esc and any unbound key close it. A menu you cannot + // leave by mashing a key is a trap. + None => eui.menu = None, + } + continue; + } + // 2. The prefix opens it. + if eui.prefix.matches(&k) { + eui.menu = Some(&ROOT); + continue; + } + // ctrl-q is the one global outside the prefix, and it is there for + // exactly one reason: if a terminal does not deliver the prefix at + // all, the app would otherwise have no way out. Not a focus + // workaround — there is no focus model left. + if k.modifiers.contains(KeyModifiers::CONTROL) && k.code == KeyCode::Char('q') { return Ok(()); } - if k.code == KeyCode::F(2) { - if k.kind == KeyEventKind::Press { - toggle_embed(eui, &app); + // 3. shift+PgUp/PgDn is the terminal's own scroll key, so it is + // ours the same way the wheel is — and it is handled *before* + // the overlays, not after: the sessions overlay only takes the + // bottom third of the screen, so the feed it is steering is + // still on screen and still worth scrolling. + if k.modifiers.contains(KeyModifiers::SHIFT) + && matches!(k.code, KeyCode::PageUp | KeyCode::PageDown) + { + let dir = if k.code == KeyCode::PageUp { -1 } else { 1 }; + let page = eui.term.as_ref().map_or(20, |t| t.page_rows()); + if !eui.scroll_pane(dir * page) { + app.lock().unwrap().scroll_col(None, dir * 20); } continue; } - if ctrl && k.code == KeyCode::Down { - if k.kind == KeyEventKind::Press { - attach_selected(eui, &app); - } - continue; - } - if ctrl && k.code == KeyCode::Up { - eui.claude_focused = false; - // The feed would be invisible behind a fullscreen pane. - eui.fullscreen = false; - continue; - } - // ctrl-r hot-reloads onto the binary now on disk — you rebuild - // outside, this swaps the running instance onto it. Global, like - // ctrl-q: it has to work while the pane holds focus, where plain - // keys belong to the child. - if ctrl && k.code == KeyCode::Char('r') { - if k.kind == KeyEventKind::Press { - start_reload(&app); - } - continue; - } - if ctrl && k.code == KeyCode::Char('f') && eui.focused() { - if k.kind == KeyEventKind::Press { - eui.fullscreen = !eui.fullscreen; - // An explicit choice outranks the alternate-screen - // restore: don't undo it when the editor exits. - eui.alt_fullscreen = false; - } - continue; - } - // While the claude pane has focus, everything else belongs to it — - // except the terminal-level scroll keys in fullscreen, which a real - // terminal also keeps for its own scrollback instead of sending to - // the app. Every other key snaps the view back to the live screen - // first (xterm's scroll-on-key), so typing can never leave you - // reading history while the child answers off-screen. - if eui.focused() { - if k.modifiers.contains(KeyModifiers::SHIFT) - && matches!(k.code, KeyCode::PageUp | KeyCode::PageDown) - { - let page = eui.term.as_ref().map_or(1, |t| t.page_rows()); - let dir = if k.code == KeyCode::PageUp { -1 } else { 1 }; - if eui.scroll_pane(dir * page) { + // 4. Overlays are modal: while one is up it takes every key. + { + let mut a = app.lock().unwrap(); + if a.overlay_open() { + if k.code == KeyCode::Esc { + a.close_overlay(); continue; } + if a.search.is_some() { + search_key(&mut a, k.code); + continue; + } + if a.filter_popup.is_some() { + filter_key(&mut a, k.code); + continue; + } + if a.streams_popup.is_some() { + streams_key(&mut a, k.code); + continue; + } + if let Some(msel) = a.model_popup { + let choices = a.models.choices(); + let n = choices.len().max(1); + match k.code { + KeyCode::Up | KeyCode::Char('k') => { + a.model_popup = Some((msel + n - 1) % n) + } + KeyCode::Down | KeyCode::Char('j') => { + a.model_popup = Some((msel + 1) % n) + } + KeyCode::Enter => { + a.model_popup = None; + let model = choices.get(msel).map(|c| c.1.clone()); + drop(a); + if let Some(model) = model { + show_embed_new(eui, &app, &model); + } + } + _ => {} + } + continue; + } + // The sessions overlay: the turn tree and branching live + // in here now, which is the only home they have left. + if sessions_key(&mut a, k.code) { + continue; + } + drop(a); + // Enter: point the *pane* at the highlighted session. Only + // close on success — an armed liveness guard needs its + // warning on screen and a second press to land. + if attach_selected(eui, &app) { + let mut a = app.lock().unwrap(); + a.show_sessions = false; + a.follow_pane = true; + a.show_lane(MAIN_LANE); + } + continue; } + } + // 4b. `prefix Z` is the one non-overlay state that *covers* the + // pane, so it answers to Esc for the same reason an overlay + // does: the test is "is the pane reachable?", not "is this a + // mode?". Fullscreen (`prefix z`) is the opposite case — + // nothing is covering the pane there, so Esc goes through to + // the child and interrupts, which is what it is for. + if k.code == KeyCode::Esc && eui.zoom_feed { + eui.zoom_feed = false; + continue; + } + // 5. The child. Any key snaps its scrollback back to live first + // (xterm's scroll-on-key), so typing can never leave you + // reading history while the child answers off screen. + if eui.alive() { if let Some(et) = &eui.term { et.follow_live(); et.key(k); } continue; } - if k.kind != KeyEventKind::Press { - continue; - } + // 6. No pane on screen (none spawned yet, or `prefix Z`): the feed + // takes the keys, so the app is still usable with no child. let mut a = app.lock().unwrap(); - let nsess = a.merged_len(); - if k.code == KeyCode::Char('c') && ctrl { - return Ok(()); - } - // Filter popup captures input while open. - if let Some(sel) = a.filter_popup { - let n = FILTER_LABELS.len(); - match k.code { - KeyCode::Char(' ') => a.filters[sel] = !a.filters[sel], - KeyCode::Up | KeyCode::Char('k') => a.filter_popup = Some((sel + n - 1) % n), - KeyCode::Down | KeyCode::Char('j') => a.filter_popup = Some((sel + 1) % n), - KeyCode::Char('f') | KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { - a.filter_popup = None - } - _ => {} - } - continue; - } - // Model picker (opened with `a`): j/k move, Enter spawns a fresh - // session with the chosen model, esc/a/q cancels. - if let Some(msel) = a.model_popup { - let choices = a.models.choices(); - let n = choices.len().max(1); - match k.code { - KeyCode::Up | KeyCode::Char('k') => a.model_popup = Some((msel + n - 1) % n), - KeyCode::Down | KeyCode::Char('j') => a.model_popup = Some((msel + 1) % n), - KeyCode::Esc | KeyCode::Char('a') | KeyCode::Char('q') => a.model_popup = None, - KeyCode::Enter => { - a.model_popup = None; - let model = choices.get(msel).map(|c| c.1.clone()); - drop(a); - if let Some(model) = model { - show_embed_new(eui, &app, &model); - } - } - _ => {} - } - continue; - } - // Agent popup (`A`): modal, so it takes every key while open — - // the picker navigates, an agent feed scrolls. Esc steps back one - // layer (feed → picker → closed), `A`/`q` closes outright. - if a.agent_popup.is_some() { - match k.code { - KeyCode::Up | KeyCode::Char('k') => match a.agent_popup { - Some(AgentPopup::List(_)) => a.agent_popup_move(-1), - _ => { - let t = a.agent_popup_lane(); - a.scroll_col(t, -1); - } - }, - KeyCode::Down | KeyCode::Char('j') => match a.agent_popup { - Some(AgentPopup::List(_)) => a.agent_popup_move(1), - _ => { - let t = a.agent_popup_lane(); - a.scroll_col(t, 1); - } - }, - KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Right | KeyCode::Char('l') => { - a.agent_popup_enter() - } - KeyCode::Esc | KeyCode::Left | KeyCode::Char('h') => a.agent_popup_back(), - KeyCode::Char('A') | KeyCode::Char('q') => a.agent_popup = None, - // Switch agents without a detour through the picker. - KeyCode::Char('[') => a.agent_popup_cycle(false), - KeyCode::Char(']') => a.agent_popup_cycle(true), - KeyCode::PageUp => { - let t = a.agent_popup_lane(); - a.scroll_col(t, -20); - } - KeyCode::PageDown => { - let t = a.agent_popup_lane(); - a.scroll_col(t, 20); - } - KeyCode::Home | KeyCode::Char('g') => { - let t = a.agent_popup_lane(); - a.scroll_col_end(t, false); - } - KeyCode::End | KeyCode::Char('G') => { - let t = a.agent_popup_lane(); - a.scroll_col_end(t, true); - } - _ => {} - } - continue; - } match k.code { - KeyCode::Char('q') => return Ok(()), - // Esc unwinds one layer: visual mode → turn tree → quit. - KeyCode::Esc => match a.expanded.as_mut() { - Some(e) if e.visual.is_some() => e.visual = None, - Some(_) => a.expanded = None, - None => return Ok(()), - }, - KeyCode::Char('f') => a.filter_popup = Some(0), - // a → pick a model, then spawn a brand-new session (no need to - // resume + /clear just to get a fresh chat). - KeyCode::Char('a') => a.model_popup = Some(0), - // n / N → jump the feed scroll to the next / previous user - // prompt (honoured on the next draw, where entry heights live). - KeyCode::Char('n') => { - a.follow = false; - a.prompt_jump = Some(true); - } - KeyCode::Char('N') => { - a.follow = false; - a.prompt_jump = Some(false); - } - KeyCode::Char('s') => a.show_sessions = !a.show_sessions, - // c → attach the most recent past session (like `claude -c`): - // select it (the scanner keeps disk_sessions newest-first), - // then run the normal attach logic. - KeyCode::Char('c') => { - match a.disk_sessions.first().map(|d| d.uuid.clone()) { - None => a.status = "no past sessions found for this directory".into(), - Some(uuid) => { - a.select_key(&uuid); - a.clear_turn_focus(); - drop(a); - attach_selected(eui, &app); - } - } - continue; - } - // Turn tree (sessions panel): space toggles the selected - // session's tree, →/l expands / steps into the turns, ←/h - // steps out / collapses (yazi-style), v anchors a visual - // range, b materializes a new decoupled session from the - // highlighted turn (its chain) or the visual selection. - KeyCode::Char(' ') => a.toggle_expand(), - KeyCode::Right | KeyCode::Char('l') => a.tree_right(), - KeyCode::Left | KeyCode::Char('h') => a.tree_left(), - KeyCode::Char('v') => a.toggle_visual(), - KeyCode::Char('b') => match a.branch_selected() { - Ok(u) => { - a.status = format!( - "branched → {} (ctrl-↓ to start it)", - u.chars().take(8).collect::() - ); - } - Err(e) => a.status = e, - }, - // Tab / BackTab cycle sessions. `a` is the new-session picker; - // `n`/`N` jump between user prompts; `p` is no longer a - // back-tab mirror. - KeyCode::Tab if nsess > 0 => { - a.selected = (a.selected + 1) % nsess; - a.follow = true; - a.clear_turn_focus(); - } - KeyCode::BackTab if nsess > 0 => { - a.selected = (a.selected + nsess - 1) % nsess; - a.follow = true; - a.clear_turn_focus(); - } - // j/k/↑/↓ drive the session/turn highlight, never the feed — - // the feed scrolls with the wheel (or PgUp/PgDn/g/G). - KeyCode::Up | KeyCode::Char('k') => a.nav(false), - KeyCode::Down | KeyCode::Char('j') => a.nav(true), - // A → the subagent popup: one agent opens straight into its - // feed, several show the picker first. Subagents are shown - // nowhere else, so this is also how a finished agent's output - // is read back. - KeyCode::Char('A') => a.toggle_agent_popup(), + KeyCode::Up | KeyCode::Char('k') => a.scroll_col(None, -1), + KeyCode::Down | KeyCode::Char('j') => a.scroll_col(None, 1), KeyCode::PageUp => a.scroll_col(None, -20), KeyCode::PageDown => a.scroll_col(None, 20), KeyCode::Home | KeyCode::Char('g') => a.scroll_col_end(None, false), @@ -1304,6 +1328,181 @@ fn event_loop( } } +/// What a menu action wants the event loop to do next. +#[derive(PartialEq, Eq)] +enum Flow { + Done, + Quit, +} + +/// Run one binding from the table. The single dispatch point: the popup, the +/// footer hint and this function all read `keymap::ROOT`, so a binding cannot +/// exist in one and not the others. +fn run_act(act: Act, eui: &mut EmbedUi, app: &SharedApp) -> Flow { + match act { + Act::Quit => return Flow::Quit, + Act::Reload => start_reload(app), + Act::ZoomPane => { + eui.fullscreen = !eui.fullscreen; + // An explicit choice outranks the alternate-screen restore. + eui.alt_fullscreen = false; + } + Act::ZoomFeed => { + eui.zoom_feed = !eui.zoom_feed; + if eui.zoom_feed { + eui.fullscreen = false; + } + } + Act::NewSession => app.lock().unwrap().model_popup = Some(0), + // The footer already spells the overlay's keys out; a status line + // repeating them just says the same thing twice on one row. + Act::Sessions => app.lock().unwrap().show_sessions = true, + Act::Streams => app.lock().unwrap().open_streams(), + Act::Filter => app.lock().unwrap().filter_popup = Some(0), + Act::Search => { + let mut a = app.lock().unwrap(); + a.search = Some(Search::default()); + } + Act::NextPrompt | Act::PrevPrompt => { + let mut a = app.lock().unwrap(); + let lane = a.feed_lane; + let at = a.lane_col_of(lane).0; + a.set_lane_col(lane, at, false); + a.prompt_jump = Some(act == Act::NextPrompt); + } + // "Live" means all four things at once, because being pinned is + // never just one of them: a picked session, a picked lane, a scroll + // position parked by a search or a prompt jump, and an expanded turn + // tree all read as "the feed stopped moving". Re-arming the tail is + // the part that was missing — without it the feed sits where you left + // it and never catches up, even once the turn ends. + Act::FollowLive => { + let mut a = app.lock().unwrap(); + a.follow_pane = true; + a.expanded = None; + a.show_lane(MAIN_LANE); + if let Some(k) = a.embed_session.clone() { + a.select_key(&k); + } + a.scroll_col_end(Some(MAIN_LANE), true); + a.status = "following the live main chain".into(); + } + Act::Continue => { + let mut a = app.lock().unwrap(); + match a.disk_sessions.first().map(|d| d.uuid.clone()) { + None => a.status = "no past sessions found for this directory".into(), + Some(uuid) => { + a.select_key(&uuid); + a.clear_turn_focus(); + drop(a); + attach_selected(eui, app); + } + } + } + } + Flow::Done +} + +/// Keys of the search bar — a browser find bar, which is the interaction +/// everyone already knows: typing re-runs the query from the top so the feed +/// tracks what you type, and Enter/Down/Up walk the matches in place. +/// +/// Enter is deliberately *next match*, not "commit": the bar is where you walk +/// hits, and Esc is how you leave with the position you landed on. +fn search_key(a: &mut App, code: KeyCode) { + match code { + KeyCode::Char(c) => { + if let Some(s) = a.search.as_mut() { + s.query.push(c); + // Restart from the top of the feed on every edit, so the first + // hit of the new query is the one you see. + s.at = None; + } + a.search_run(true); + } + KeyCode::Backspace => { + if let Some(s) = a.search.as_mut() { + s.query.pop(); + s.at = None; + } + a.search_run(true); + } + KeyCode::Down | KeyCode::Enter | KeyCode::Tab => a.search_run(true), + KeyCode::Up | KeyCode::BackTab => a.search_run(false), + _ => {} + } +} + +fn filter_key(a: &mut App, code: KeyCode) { + let Some(sel) = a.filter_popup else { return }; + let n = FILTER_LABELS.len(); + match code { + KeyCode::Char(' ') | KeyCode::Enter => a.filters[sel] = !a.filters[sel], + KeyCode::Up | KeyCode::Char('k') => a.filter_popup = Some((sel + n - 1) % n), + KeyCode::Down | KeyCode::Char('j') => a.filter_popup = Some((sel + 1) % n), + KeyCode::Char('a') => a.filters = [true; FILTER_LABELS.len()], + KeyCode::Char('n') => a.filters = [false; FILTER_LABELS.len()], + _ => {} + } +} + +/// Keys of the stream picker. j/k already shows the lane it moves to, so there +/// is nothing left for Enter to commit — it and Esc both just close, keeping +/// whatever you walked to. Same shape as the sessions overlay, minus the one +/// thing that overlay's Enter does that this one has no equivalent for +/// (attaching the pane). +fn streams_key(a: &mut App, code: KeyCode) { + match code { + KeyCode::Up | KeyCode::Char('k') => a.streams_move(-1), + KeyCode::Down | KeyCode::Char('j') => a.streams_move(1), + KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => a.streams_popup = None, + _ => {} + } +} + +/// Keys of the sessions overlay. Returns false for Enter — the one key that +/// needs the `EmbedUi` the caller holds — and true for everything else. +/// +/// **Viewing needs no key at all.** The overlay is a bottom strip, so j/k +/// already re-points the feed live as you walk the list; that is the +/// view-without-resuming that `/resume` cannot do, and it happens by hovering +/// rather than by committing. So Enter is free to be the thing you almost +/// always want — attach the pane — and Esc keeps whatever you were reading +/// (see `App::close_overlay`). +fn sessions_key(a: &mut App, code: KeyCode) -> bool { + let nsess = a.merged_len(); + match code { + // Enter is the commit and the only one: it attaches the pane. Handled + // by the caller, which holds the `EmbedUi`. + KeyCode::Enter => return false, + KeyCode::Up | KeyCode::Char('k') => a.nav(false), + KeyCode::Down | KeyCode::Char('j') => a.nav(true), + KeyCode::Char(' ') => a.toggle_expand(), + KeyCode::Right | KeyCode::Char('l') => a.tree_right(), + KeyCode::Left | KeyCode::Char('h') => a.tree_left(), + KeyCode::Char('v') => a.toggle_visual(), + KeyCode::Char('b') => match a.branch_selected() { + Ok(u) => { + a.status = format!( + "branched → {} (enter to start it)", + u.chars().take(8).collect::() + ); + } + Err(e) => a.status = e, + }, + KeyCode::Tab if nsess > 0 => { + a.selected = (a.selected + 1) % nsess; + a.clear_turn_focus(); + } + KeyCode::BackTab if nsess > 0 => { + a.selected = (a.selected + nsess - 1) % nsess; + a.clear_turn_focus(); + } + _ => {} + } + true +} + fn draw( f: &mut Frame, app: &SharedApp, @@ -1321,35 +1520,39 @@ fn draw( // sized from the question's option count when it could estimate it, 75% of // the screen as fallback. const EMBED_MIN: u16 = 7; // MIN_COMPACT_INNER + borders - // The pane is drawn only while the feed selection is on the embedded - // session: tabbing away hides it (the child keeps running — hide, don't - // kill), tabbing back reveals it instantly. ctrl-↓ attaches elsewhere. - let selected_is_embed = - a.embed_session.is_some() && a.selected_key().as_deref() == a.embed_session.as_deref(); - // The pane stays visible while it holds keyboard focus even if the - // selection isn't (yet) on its session: its real session id is learned - // from the first request, and an involuntary selection move (another - // session's traffic) must never yank focus out of a pane the user is - // typing in. Intentional navigation still hides it — ctrl-↑ drops focus, - // after which the selection rule applies and tabbing away hides the pane. - let show_embed = - eui.visible && eui.term.is_some() && (selected_is_embed || eui.claude_focused); + // The pane is drawn whenever it exists and is not hidden — it is no longer + // gated on the feed selection. That coupling only existed to keep keyboard + // focus and the visible session in step; with the pane always holding the + // keyboard there is nothing to keep in step, and decoupling them is the + // point: the feed can show a past session, or a subagent's lane, while the + // pane keeps running the live one. + let show_embed = eui.visible && !eui.zoom_feed && eui.term.is_some(); if !show_embed { - // An invisible pane must not swallow keystrokes (the selection can - // move under us, e.g. a new session auto-jump). - eui.claude_focused = false; eui.fullscreen = false; } + // The feed tails the pane, `tail -f` style. Only while nothing is open and + // no turn tree is expanded: those are deliberate navigation, and yanking + // the selection out from under them is what this rule exists to avoid. + let overlay = a.overlay_open(); + if a.follow_pane + && !overlay + && a.expanded.is_none() + && let Some(k) = a.embed_session.clone() + && a.selected_key().as_deref() != Some(k.as_str()) + { + a.select_key(&k); + } // Mirror focus into shared state so the off-thread tap can avoid stealing // the selection from a pane the user is actively driving. - a.pane_focused = eui.focused(); + let pane_keys = eui.takes_keys(overlay); + a.pane_focused = pane_keys; // An editor Claude Code launched (nvim, `git commit`, a pager) takes the // child's screen over via the alternate buffer, which Claude Code never // does itself. So there is no input box left to frame — give the pane the // whole screen for as long as the editor lasts, and put it back after. - // Gated on focus, keeping fullscreen ⇔ focused: ctrl-↑ hands the screen - // back to the feed, ctrl-↓ returns it to the editor. - let on_alt = eui.focused() && eui.term.as_ref().is_some_and(EmbeddedTerm::alt_screen); + // Gated on the pane actually taking keys, so an overlay opened mid-edit + // hands the screen back to the feed and closing it returns to the editor. + let on_alt = pane_keys && eui.term.as_ref().is_some_and(EmbeddedTerm::alt_screen); if sync_alt_screen(on_alt, &mut eui.alt_screen, &mut eui.fullscreen, &mut eui.alt_fullscreen) && let Some(et) = &eui.term { @@ -1403,18 +1606,17 @@ fn draw( Style::new().dark_gray() } }; - let feed_focused = !eui.focused(); + let feed_focused = !pane_keys; let [main, embed_area, footer] = Layout::vertical([ Constraint::Min(1), Constraint::Length(embed_h), Constraint::Length(1), ]) .areas(f.area()); - // Uniform: the sessions panel is always half the width, so titles have - // room to read the same whether or not a turn tree is expanded. - let left_width = if a.show_sessions { main.width / 2 } else { 0 }; - let [left, right] = Layout::horizontal([Constraint::Length(left_width), Constraint::Min(10)]) - .areas(main); + // One region, full width. The sessions panel is gone: it is an overlay + // now (`prefix s`), so the feed never gives up half the screen to a list + // you look at for a few seconds at a time. + let right = main; let live_n = a.sessions.len(); let stubs = a.visible_stubs(); @@ -1427,139 +1629,8 @@ fn draw( if a.cols_session != sel_key.clone().unwrap_or_default() { a.cols_session = sel_key.clone().unwrap_or_default(); a.lane_cols.clear(); - a.agent_popup = None; - } - - // Session list: live sessions first, then this directory's past sessions - // as dimmed stubs (kept fresh by the scanner thread — no I/O here). The - // expanded session's turn rows render directly under its row, abandoned - // branches indented one level under their fork point (⑂). - if a.show_sessions { - // Inner content width (panel minus its border): titles wrap to this, - // turn labels truncate to it. - let inner_w = left.width.saturating_sub(2).max(1) as usize; - let mut items: Vec = Vec::new(); - let mut flat_sel = 0usize; - let vis_range = a.expanded.as_ref().and_then(|e| { - let (av, p) = (e.visual?, e.sel?); - Some((av.min(p), av.max(p))) - }); - let white = Style::new().fg(Color::White); - // The session whose `claude` child we spawned and is still alive: the - // one running instance this app owns (cleared the moment the pane - // exits). It gets a bright accent marker + accent title so it reads as - // "running here" at a glance; external live sessions only show a green - // dot while they're actively streaming (their instance may have ended — - // liveness is unknowable), and disk stubs stay dimmed. - let embed_key = a.embed_session.clone(); - for m in 0..live_n + stubs.len() { - // Every session is a multi-line item: the full title (white, - // wrapped to the panel width — continuation rows aligned under - // it) followed by a dimmed meta row (status dot + id + model for - // live sessions, just the id for disk stubs). - let (uuid, lead, title, meta, title_style) = if m < live_n { - let s = &a.sessions[m]; - let is_embed = embed_key.as_deref() == Some(s.key.as_str()); - let lead = if is_embed { - "▶ ".fg(ACCENT).bold() - } else if s.active > 0 { - "● ".green() - } else { - "○ ".dark_gray() - }; - let id: String = s.key.chars().take(8).collect(); - let meta = if is_embed { - format!("{id} · {} · running", short_model(&s.main().model)) - } else { - format!("{id} · {}", short_model(&s.main().model)) - }; - let title_style = if is_embed { - Style::new().fg(ACCENT).bold() - } else { - white - }; - // Claude Code's own name for the session, exactly as its - // `/resume` picker reads it; the feed's first prompt only - // covers the gap before the transcript names it. - let title: String = a - .cc_title(&s.key) - .map(str::to_string) - .unwrap_or_else(|| live_title(s)); - (s.key.clone(), lead, title, meta, title_style) - } else { - let d = &a.disk_sessions[stubs[m - live_n]]; - let id: String = d.uuid.chars().take(8).collect(); - // ⑂N = subagent transcripts recorded next to this session. - let meta = if d.agents > 0 { - format!("{id} · ⑂{}", d.agents) - } else { - id - }; - ( - d.uuid.clone(), - "· ".dark_gray(), - d.label(), - meta, - white, - ) - }; - let mut rows: Vec = Vec::new(); - for (i, w) in wrap_words(&sanitize(&title), inner_w.saturating_sub(2)) - .into_iter() - .enumerate() - { - if i == 0 { - rows.push(Line::from(vec![lead.clone(), Span::styled(w, title_style)])); - } else { - rows.push(Line::from(Span::styled(format!(" {w}"), title_style))); - } - } - rows.push(Line::from(format!(" {meta}")).dark_gray()); - - let on_sel_row = m == sel; - let turn_hl = a - .expanded - .as_ref() - .filter(|e| e.uuid == uuid) - .and_then(|e| e.sel); - if on_sel_row && turn_hl.is_none() { - flat_sel = items.len(); - } - items.push(ListItem::new(rows)); - if let Some(e) = a.expanded.as_ref().filter(|e| e.uuid == uuid) { - for (p, &t) in e.tree.display.iter().enumerate() { - let turn = &e.tree.turns[t]; - let bullet = if turn.depth > 0 { "⑂" } else { "❯" }; - // Indent turns past the title gutter, then by tree depth; - // labels truncate (never wrap) so one row = one turn. - let prefix = format!(" {}{bullet} ", " ".repeat(turn.depth.min(6))); - let avail = inner_w.saturating_sub(prefix.chars().count()); - let txt = format!("{prefix}{}", truncate_str(&turn.label, avail)); - let line = if vis_range.is_some_and(|(lo, hi)| p >= lo && p <= hi) { - Line::from(txt).style(Style::new().bg(USER_BG).fg(color_on(USER_BG))) - } else { - Line::from(txt).dark_gray() - }; - if on_sel_row && turn_hl == Some(p) { - flat_sel = items.len(); - } - items.push(ListItem::new(line)); - } - } - } - let mut ls = ListState::default(); - if !items.is_empty() { - ls.select(Some(flat_sel)); - } - f.render_stateful_widget( - List::new(items) - .block( - Block::bordered().title(" sessions ").border_style(border_style(feed_focused)), - ) - .highlight_style(ratatui::style::Style::new().reversed()), - left, - &mut ls, - ); + a.streams_popup = None; + a.feed_lane = MAIN_LANE; } // What the feed shows: a highlighted turn views the on-disk transcript @@ -1612,10 +1683,17 @@ fn draw( // `n`/`N`: jump to the next/previous user prompt. Taken once, here, before // the feed borrow so we can clear it (offset computed below from the cache). let prompt_jump = a.prompt_jump.take(); - // Keep the agent popup pointed at a lane that exists (a session switch or - // a rebuilt on-disk view can invalidate it). Done before the feed borrow, - // which freezes `a`. - a.validate_agent_popup(); + // Keep the feed (and the picker) pointed at a lane that exists: a session + // switch or a rebuilt on-disk view can invalidate either. Done before the + // feed borrow, which freezes `a`. + a.validate_lanes(); + // Two things ask for a scroll by entry index, and they want different + // landings: a turn jump pins the prompt's top, a search hit wants the + // matching row. Only `scroll_entry` is set by search, so the source is the + // flag. Either way the offset needs heights that only the render cache has. + let search_jump = a.scroll_entry.take(); + let scroll_to_match = search_jump.is_some(); + let scroll_target = search_jump.or(scroll_target); let (feed_session, feed_leaf): (Option<&Session>, Option) = match &hist_view { Some((u, _)) => { let h = a.history.get(u); @@ -1624,86 +1702,57 @@ fn draw( None => (a.sessions.get(sel), None), }; let feed_live = hist_view.is_none(); - // Scroll state written back after the feed borrow ends: the main feed keeps - // using App::scroll/follow, the popup's agent owns an entry in - // App::lane_cols so it can follow its own tail independently. - let mut main_col = (a.scroll, a.follow); - let mut lane_writeback: Option<(LaneId, usize, bool)> = None; - let agent_popup = a.agent_popup; + // Scroll state written back after the feed borrow ends. One feed, one + // lane: `lane_col_of` resolves `MAIN_LANE` to `scroll`/`follow` and every + // other lane to its own `lane_cols` entry, so each stream keeps its place. + let lane = a.feed_lane; + let mut col = a.lane_col_of(lane); + // Matches light up only while the search overlay is up: that overlay *is* + // the search, and walking hits happens inside it (Up/Down/Enter). Leaving + // keeps the position you landed on and drops the paint, so there is no + // stale highlight to clear and no `:nohlsearch` to remember. + let query: Option = a + .search + .as_ref() + .map(|s| s.query.to_lowercase()) + .filter(|q| !q.is_empty()); if let Some(s) = feed_session { - // The main feed always gets the whole area: a subagent never takes - // space from it (nor interleaves into it — draw_feed filters by lane). - // Agents live in the popup drawn on top, below. + // The one feed, full width, showing exactly one lane. Which lane is a + // filter (`draw_feed` matches `e.lane == args.lane`), never an + // interleave — that part of the old design is unchanged; what is gone + // is the second feed that used to render agents in a popup. + let lane = if (lane as usize) < s.lanes.len() { lane } else { MAIN_LANE }; + let title = if lane == MAIN_LANE { + main_title(s) + } else { + let list = App::stream_list_of(s); + let pos = list.iter().position(|&x| x == lane).map(|i| (i + 1, list.len())); + lane_title(&s.lanes[lane as usize], pos) + }; let out = draw_feed( f, - caches.get(&s.key, MAIN_LANE), + caches.get(&s.key, lane), &FeedArgs { s, - lane: MAIN_LANE, + lane, area: right, - focused: feed_focused && agent_popup.is_none(), + focused: feed_focused && !overlay, filters, live: feed_live, leaf: feed_leaf, - scroll: main_col.0, - follow: main_col.1, + scroll: col.0, + follow: col.1, scroll_target, prompt_jump, - title: main_title(s, a.show_sessions), - minimap: true, + search: query.as_deref(), + scroll_to_match, + title, + // A subagent's stream has no user prompts, so no minimap and + // no prompt jumps to mark. + minimap: lane == MAIN_LANE, }, ); - main_col = (out.scroll, out.follow); - // The subagent popup: 80% of the feed area, centred, drawn over the - // main feed. Either the agent picker, or one agent's whole stream. - match agent_popup { - None => {} - Some(AgentPopup::List(sel)) => { - draw_agent_list(f, popup_rect(right), s, &App::agent_list_of(s), sel); - } - // `validate_agent_popup` already dropped a lane this session does - // not have; the bound check makes any disagreement between it and - // the session actually rendered here a blank frame instead of a - // panic (the UI thread holds the mutex the proxy tap needs). - Some(AgentPopup::Feed(lane)) if (lane as usize) < s.lanes.len() => { - let area = popup_rect(right); - // `2/3` in the title: which agent of the session this is, so - // `[`/`]` has somewhere to walk from. - let list = App::agent_list_of(s); - let pos = list - .iter() - .position(|&x| x == lane) - .map(|i| (i + 1, list.len())); - let (scroll, follow) = a.lane_cols.get(&lane).copied().unwrap_or((0, true)); - f.render_widget(Clear, area); - let out = draw_feed( - f, - caches.get(&s.key, lane), - &FeedArgs { - s, - lane, - area, - // The popup is modal: it is where the keys go, so it - // always reads as focused. - focused: true, - filters, - live: feed_live, - leaf: feed_leaf, - scroll, - follow, - // A subagent's stream has no user prompts and no turn - // tree, so neither the minimap nor the turn/prompt - // jumps apply to it. - scroll_target: None, - prompt_jump: None, - title: lane_title(&s.lanes[lane as usize], pos), - minimap: false, - }, - ); - lane_writeback = Some((lane, out.scroll, out.follow)); - } - Some(AgentPopup::Feed(_)) => {} - } + col = (out.scroll, out.follow); } else { f.render_widget( Paragraph::new(format!( @@ -1715,14 +1764,10 @@ fn draw( right, ); } - a.scroll = main_col.0; - a.follow = main_col.1; - if let Some((lane, scroll, follow)) = lane_writeback { - a.lane_cols.insert(lane, (scroll, follow)); - } + a.set_lane_col(lane, col.0, col.1); // Embedded claude pane - let embed_focused = eui.focused(); + let embed_focused = pane_keys; // Cursor shape the pane wants this frame (None unless it draws a cursor). let mut want_cursor: Option = None; if show_embed { @@ -1790,43 +1835,43 @@ fn draw( } eui.cursor_shape = want_cursor; + // Footer. The hint is derived from the binding table, not written out + // beside it: the whole point of one table is that the hint cannot drift + // from what the keys actually do. let visual_on = a.expanded.as_ref().is_some_and(|e| e.visual.is_some()); - let keys = if a.filter_popup.is_some() { - "space toggle · j/k move · f/esc close" + let px = &eui.prefix.label; + let keys = if a.search.is_some() { + "enter/↓ next · ↑ prev · esc close".into() + } else if a.filter_popup.is_some() { + "space toggle · a all · n none · j/k move · esc close".into() } else if a.model_popup.is_some() { - "enter new session · j/k move · esc cancel" - } else if matches!(a.agent_popup, Some(AgentPopup::List(_))) { - "enter open · j/k move · esc/A close" - } else if a.agent_popup.is_some() { - "j/k · PgUp/PgDn · g/G scroll · [/] agent · esc back · A close" - } else if embed_focused && pane_view == PaneView::Full { + "enter new session · j/k move · esc cancel".into() + } else if a.streams_popup.is_some() { + "j/k preview · enter/esc close".into() + } else if visual_on { + "j/k extend · b branch selection · esc cancel".into() + } else if a.show_sessions { + "enter open in claude · space tree · v visual · b branch · esc keep viewing".into() + } else if pane_view == PaneView::Full { // Fullscreen owns the wheel: the feed is off screen, and scrolling // walks the child's own scrollback instead. - "wheel/shift-PgUp scroll claude · ctrl-f exit fullscreen · ctrl-q quit" - } else if embed_focused { - "ctrl-↑ feed · ctrl-f fullscreen · ctrl-q quit · F2 hide claude" - } else if visual_on { - "j/k extend · b branch selection · esc cancel" - } else if a.on_turns() { - "j/k turns · v visual · b branch · ←/space close · wheel scrolls feed" - } else if show_embed { - "ctrl-↓ claude · a add session · q quit · j/k move · space tree · f filter · F2 hide" + format!("{px} z exit zoom · wheel/shift-PgUp scroll claude · {px} menu") } else { - "q quit · n new · j/k move · space/→ tree · f filter · c continue · ctrl-↓ attach" + format!("{px} menu · wheel/shift-PgUp scroll feed · keys go to claude") }; - // `A` only matters when the displayed session has side lanes at all — and - // it is the *only* way to see one, so the hint leads the footer instead of - // trailing it, where the long key list gets cut off on narrow terminals. - // Not while the pane has focus: there every key belongs to the child. - // - // "streams", not "agents": a lane is a subagent *or* a nested server-tool - // call (a hosted web search), and the popup lists both. + // Two things that are worth knowing at a glance and are not keys: how many + // side streams the session has (the only route to them is `prefix a`), and + // whether the feed is showing something other than the live main chain — + // because nothing else on screen says "you are not looking at the pane". let n_lanes = a.agent_list().len(); - let keys = if a.agent_popup.is_some() || embed_focused || n_lanes == 0 { - keys.to_string() - } else { - format!("A streams ({n_lanes}) · {keys}") - }; + let mut lead = String::new(); + if !a.follow_pane || a.feed_lane != MAIN_LANE { + lead.push_str(&format!("⇤ pinned · {px} . to follow · ")); + } + if n_lanes > 0 && !a.overlay_open() { + lead.push_str(&format!("{px} a streams ({n_lanes}) · ")); + } + let keys = format!("{lead}{keys}"); // A rebuild in flight outranks the proxy line: it is the only thing in // the app the user is actively waiting on. let status = match a.reload.note(a.in_flight()) { @@ -1838,17 +1883,33 @@ fn draw( footer, ); - // Filter popup + // Overlays. They normally anchor to the bottom of the *feed*, which puts + // them just above the pane — but in fullscreen the feed area is the one + // row the layout reserves, and a strip drawn into it is invisible. That is + // what made `prefix z` look like a trap: the menu telling you `z` gets you + // back out was being rendered one row tall behind the pane. So fullscreen + // anchors overlays to the screen instead (everything above the footer). + let overlay_area = if pane_view == PaneView::Full { + Rect { height: f.area().height.saturating_sub(1), ..f.area() } + } else { + main + }; + if a.show_sessions { + draw_sessions(f, list_rect(overlay_area), &a, live_n, sel); + } + if let Some(sr) = a.search.as_ref() { + draw_search(f, overlay_area, sr); + } + if let Some(sel) = a.streams_popup + && let Some(sess) = a.displayed_session() + { + let lanes = App::stream_list_of(sess); + draw_streams(f, streams_rect(overlay_area, lanes.len()), sess, &lanes, sel); + } if let Some(fsel) = a.filter_popup { - let w = 26u16.min(main.width); - let h = (FILTER_LABELS.len() as u16 + 2).min(main.height); - let area = Rect { - x: main.x + (main.width.saturating_sub(w)) / 2, - y: main.y + (main.height.saturating_sub(h)) / 2, - width: w, - height: h, - }; - f.render_widget(Clear, area); + let w = 26u16.min(overlay_area.width); + let h = (FILTER_LABELS.len() as u16 + 2).min(overlay_area.height); + f.render_widget(Clear, centred(overlay_area, w, h)); let items: Vec = FILTER_LABELS .iter() .enumerate() @@ -1861,24 +1922,17 @@ fn draw( ls.select(Some(fsel)); f.render_stateful_widget( List::new(items) - .block(Block::bordered().title(" filter ")) + .block(Block::bordered().title(" filter ").border_style(Style::new().fg(ACCENT))) .highlight_style(ratatui::style::Style::new().reversed()), - area, + centred(overlay_area, w, h), &mut ls, ); } - - // Model picker popup (n → choose a model → fresh session). if let Some(msel) = a.model_popup { let choices = a.models.choices(); - let w = 30u16.min(main.width); - let h = (choices.len() as u16 + 2).min(main.height); - let area = Rect { - x: main.x + (main.width.saturating_sub(w)) / 2, - y: main.y + (main.height.saturating_sub(h)) / 2, - width: w, - height: h, - }; + let w = 30u16.min(overlay_area.width); + let h = (choices.len() as u16 + 2).min(overlay_area.height); + let area = centred(overlay_area, w, h); f.render_widget(Clear, area); let items: Vec = choices .iter() @@ -1888,12 +1942,21 @@ fn draw( ls.select(Some(msel)); f.render_stateful_widget( List::new(items) - .block(Block::bordered().title(" new session ")) + .block( + Block::bordered() + .title(" new session ") + .border_style(Style::new().fg(ACCENT)), + ) .highlight_style(ratatui::style::Style::new().reversed()), area, &mut ls, ); } + // The which-key popup is drawn last and lowest: it is a hint, not a + // window, so it sits over the pane rather than over what you are reading. + if let Some(level) = eui.menu { + draw_menu(f, overlay_area, level, &eui.prefix.label); + } drop(a); // release the app lock before the mouse-selection pass @@ -1918,9 +1981,7 @@ fn draw( let contains = |r: Rect, p: (u16, u16)| { p.0 >= r.left() && p.0 < r.right() && p.1 >= r.top() && p.1 < r.bottom() }; - let outer = if left.width > 0 && contains(left, s.start) { - left - } else if embed_h > 0 && contains(embed_area, s.start) { + let outer = if embed_h > 0 && contains(embed_area, s.start) { embed_area } else { right @@ -1988,31 +2049,314 @@ fn draw( } } -/// Share of the feed area the subagent popup covers, per axis. -const POPUP_PCT: u32 = 80; +/// The sessions overlay (`prefix s`): live sessions first, then this +/// directory's past sessions as dimmed stubs, with the selected session's turn +/// tree expanded under it (abandoned rewind branches indented `⑂` under their +/// fork point). +/// +/// This was a permanent half-width panel. It is an overlay because that is +/// what it always behaved like — you look at it for a few seconds, pick +/// something, and go back to reading — and because `enter` here *views* a +/// session without touching a process, which is the one thing Claude Code's +/// own `/resume` picker cannot do. +fn draw_sessions(f: &mut Frame, area: Rect, a: &App, live_n: usize, sel: usize) { + f.render_widget(Clear, area); + let stubs = a.visible_stubs(); + // Inner content width (panel minus its border): titles wrap to this, + // turn labels truncate to it. + let inner_w = area.width.saturating_sub(2).max(1) as usize; + let mut items: Vec = Vec::new(); + let mut flat_sel = 0usize; + let vis_range = a.expanded.as_ref().and_then(|e| { + let (av, p) = (e.visual?, e.sel?); + Some((av.min(p), av.max(p))) + }); + let white = Style::new().fg(Color::White); + // The session whose `claude` child we spawned and is still alive: the + // one running instance this app owns (cleared the moment the pane + // exits). It gets a bright accent marker + accent title so it reads as + // "running here" at a glance; external live sessions only show a green + // dot while they're actively streaming (their instance may have ended — + // liveness is unknowable), and disk stubs stay dimmed. + let embed_key = a.embed_session.clone(); + for m in 0..live_n + stubs.len() { + // Every session is a multi-line item: the full title (white, + // wrapped to the panel width — continuation rows aligned under + // it) followed by a dimmed meta row (status dot + id + model for + // live sessions, just the id for disk stubs). + let (uuid, lead, title, meta, title_style) = if m < live_n { + let s = &a.sessions[m]; + let is_embed = embed_key.as_deref() == Some(s.key.as_str()); + let lead = if is_embed { + "▶ ".fg(ACCENT).bold() + } else if s.active > 0 { + "● ".green() + } else { + "○ ".dark_gray() + }; + let id: String = s.key.chars().take(8).collect(); + let meta = if is_embed { + format!("{id} · {} · running", short_model(&s.main().model)) + } else { + format!("{id} · {}", short_model(&s.main().model)) + }; + let title_style = if is_embed { + Style::new().fg(ACCENT).bold() + } else { + white + }; + // Claude Code's own name for the session, exactly as its + // `/resume` picker reads it; the feed's first prompt only + // covers the gap before the transcript names it. + let title: String = a + .cc_title(&s.key) + .map(str::to_string) + .unwrap_or_else(|| live_title(s)); + (s.key.clone(), lead, title, meta, title_style) + } else { + let d = &a.disk_sessions[stubs[m - live_n]]; + let id: String = d.uuid.chars().take(8).collect(); + // ⑂N = subagent transcripts recorded next to this session. + let meta = if d.agents > 0 { + format!("{id} · ⑂{}", d.agents) + } else { + id + }; + ( + d.uuid.clone(), + "· ".dark_gray(), + d.label(), + meta, + white, + ) + }; + let mut rows: Vec = Vec::new(); + for (i, w) in wrap_words(&sanitize(&title), inner_w.saturating_sub(2)) + .into_iter() + .enumerate() + { + if i == 0 { + rows.push(Line::from(vec![lead.clone(), Span::styled(w, title_style)])); + } else { + rows.push(Line::from(Span::styled(format!(" {w}"), title_style))); + } + } + rows.push(Line::from(format!(" {meta}")).dark_gray()); -/// The subagent popup's rect: `POPUP_PCT` of the *feed* area on both axes, -/// centred over it. Deliberately not the whole main area — the sessions panel -/// keeps its half, and the ring of main feed left visible around the popup -/// says "this is an overlay, not the conversation you were reading". Clamped -/// to `area`, so a tiny terminal simply gets all of it. -fn popup_rect(area: Rect) -> Rect { - let pct = |v: u16, floor: u16| -> u16 { - let scaled = (u32::from(v) * POPUP_PCT / 100) as u16; - scaled.max(floor.min(v)) - }; - let (w, h) = (pct(area.width, 24), pct(area.height, 6)); + let on_sel_row = m == sel; + let turn_hl = a + .expanded + .as_ref() + .filter(|e| e.uuid == uuid) + .and_then(|e| e.sel); + if on_sel_row && turn_hl.is_none() { + flat_sel = items.len(); + } + items.push(ListItem::new(rows)); + if let Some(e) = a.expanded.as_ref().filter(|e| e.uuid == uuid) { + for (p, &t) in e.tree.display.iter().enumerate() { + let turn = &e.tree.turns[t]; + let bullet = if turn.depth > 0 { "⑂" } else { "❯" }; + // Indent turns past the title gutter, then by tree depth; + // labels truncate (never wrap) so one row = one turn. + let prefix = format!(" {}{bullet} ", " ".repeat(turn.depth.min(6))); + let avail = inner_w.saturating_sub(prefix.chars().count()); + let txt = format!("{prefix}{}", truncate_str(&turn.label, avail)); + let line = if vis_range.is_some_and(|(lo, hi)| p >= lo && p <= hi) { + Line::from(txt).style(Style::new().bg(USER_BG).fg(color_on(USER_BG))) + } else { + Line::from(txt).dark_gray() + }; + if on_sel_row && turn_hl == Some(p) { + flat_sel = items.len(); + } + items.push(ListItem::new(line)); + } + } + } + let mut ls = ListState::default(); + if !items.is_empty() { + ls.select(Some(flat_sel)); + } + f.render_stateful_widget( + List::new(items) + .block( + Block::bordered() + .title(" sessions ") + .border_style(Style::new().fg(ACCENT).bold()), + ) + .highlight_style(ratatui::style::Style::new().reversed()), + area, + &mut ls, + ); +} + +/// Share of the feed a list overlay takes. A third leaves the feed readable +/// above it, which is the whole point of anchoring these low: moving the +/// highlight re-points the feed, and you have to be able to *see* that. +const LIST_PCT: u16 = 33; + +/// Floor for that strip — border plus two multi-line items. Below this the +/// list shows one row and is useless. +const LIST_MIN: u16 = 8; + +/// The search bar's rect: the same full-width bottom strip as the sessions +/// overlay, three rows tall. It has to be *visible* — a query that lives only +/// in the footer reads as nothing happening at all. +fn search_rect(area: Rect) -> Rect { + bottom_rect(area, 3) +} + +/// A full-width strip `h` rows tall, flush with the bottom of `area`. +fn bottom_rect(area: Rect, h: u16) -> Rect { + let h = h.min(area.height); Rect { - x: area.x + (area.width.saturating_sub(w)) / 2, - y: area.y + (area.height.saturating_sub(h)) / 2, + x: area.x, + y: area.bottom().saturating_sub(h), + width: area.width, + height: h, + } +} + +/// A list overlay's rect: full width, bottom-anchored, a third of the feed +/// tall. Both pickers use it, and neither is a centred box on purpose — a +/// centred box would cover the feed it is steering. +fn list_rect(area: Rect) -> Rect { + let h = (area.height * LIST_PCT / 100).max(LIST_MIN.min(area.height)); + bottom_rect(area, h) +} + +/// Rows one stream row occupies: a head line and a dimmed meta line. +const STREAM_ROWS: usize = 2; + +/// The stream picker shrinks to what it holds, capped at `list_rect`. +/// +/// The session list cannot do this — its items wrap to an unknown number of +/// rows and there are usually dozens — but a stream list is exactly two rows +/// per lane and a turn often fans out to two or three, so a fixed third of the +/// screen is mostly blank space taken from the feed above it. Growing past the +/// cap is what scrolls (`ListState` keeps the highlight in view). +fn streams_rect(area: Rect, lanes: usize) -> Rect { + let want = (lanes * STREAM_ROWS + 2) as u16; + bottom_rect(area, want.min(list_rect(area).height)) +} + +/// A `w`×`h` rect centred in `area`, clamped to it. +fn centred(area: Rect, w: u16, h: u16) -> Rect { + let w = w.min(area.width); + let h = h.min(area.height); + Rect { + x: area.x + (area.width - w) / 2, + y: area.y + (area.height - h) / 2, width: w, height: h, } } -/// Title of the main feed: id (unless the sessions panel already shows it), -/// the main chain's model and its token counters. -fn main_title(s: &Session, show_sessions: bool) -> String { +/// The search bar: the query on the left, `3/12` on the right. A block caret +/// after the text rather than a real terminal cursor, so this never argues +/// with the pane's own DECSCUSR mirroring. +fn draw_search(f: &mut Frame, area: Rect, sr: &Search) { + let rect = search_rect(area); + f.render_widget(Clear, rect); + let inner_w = rect.width.saturating_sub(2) as usize; + let count = if sr.query.is_empty() { + String::new() + } else if sr.hits == 0 { + "no matches".into() + } else { + format!("{}/{}", sr.pos, sr.hits) + }; + let count_style = if sr.hits == 0 && !sr.query.is_empty() { + Style::new().red() + } else { + Style::new().dark_gray() + }; + let left = format!(" /{}", sr.query); + let pad = inner_w + .saturating_sub(left.chars().count() + 1 + count.chars().count()) + .max(1); + let line = Line::from(vec![ + Span::styled(left, Style::new().fg(Color::White)), + // Caret: a reversed space, so it reads as an input field. + Span::styled(" ", Style::new().add_modifier(Modifier::REVERSED)), + Span::raw(" ".repeat(pad)), + Span::styled(count, count_style), + ]); + f.render_widget( + Paragraph::new(line).block( + Block::bordered() + .title(" search ") + .border_style(Style::new().fg(ACCENT).bold()), + ), + rect, + ); +} + +/// Rows the which-key popup needs for `n` bindings at `cols` columns. +fn menu_rows(n: usize, cols: usize) -> u16 { + n.div_ceil(cols.max(1)) as u16 +} + +/// The which-key popup: the binding table, laid out in columns, anchored to +/// the bottom of `area` so it covers the pane rather than what you are reading. +/// +/// It is a *hint*, not a mode: the keys work the moment the prefix is pressed, +/// whether or not you wait for this to appear. Any unbound key closes it, so +/// it can never trap you. +fn draw_menu(f: &mut Frame, area: Rect, level: &'static Menu, prefix: &str) { + // Widest label decides the column width; 3 columns unless the terminal is + // too narrow for them. + let cell = level + .binds + .iter() + .map(|b| b.label.chars().count() + 6) + .max() + .unwrap_or(12); + let cols = ((area.width.saturating_sub(2) as usize) / cell.max(1)).clamp(1, 4); + let rows = menu_rows(level.binds.len(), cols); + let rect = bottom_rect(area, rows + 2); + f.render_widget(Clear, rect); + let mut lines: Vec = Vec::new(); + for r in 0..rows as usize { + let mut spans: Vec = vec![Span::raw(" ")]; + for c in 0..cols { + // Column-major, so a menu reads down then across. + let Some(b) = level.binds.get(c * rows as usize + r) else { + continue; + }; + let mut cellw = cell; + let label = if b.act.opens() { + format!("{} ▸", b.label) + } else { + b.label.to_string() + }; + spans.push(Span::styled( + b.key.to_string(), + Style::new().fg(ACCENT).bold(), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled(label.clone(), Style::new().fg(Color::White))); + cellw = cellw.saturating_sub(label.chars().count() + 2); + spans.push(Span::raw(" ".repeat(cellw))); + } + lines.push(Line::from(spans)); + } + let title = if level.title.is_empty() { + format!(" {prefix} ") + } else { + format!(" {prefix} {} ", level.title) + }; + f.render_widget( + Paragraph::new(Text::from(lines)) + .block(Block::bordered().title(title).border_style(Style::new().fg(ACCENT))), + rect, + ); +} + +/// Title of the main feed: session id, the main chain's model and its token +/// counters. +fn main_title(s: &Session) -> String { let m = s.main(); let tokens = format!( "{} · in {} · out {} ", @@ -2020,12 +2364,8 @@ fn main_title(s: &Session, show_sessions: bool) -> String { fmt_tokens(m.input_tokens), fmt_tokens(m.output_tokens) ); - if show_sessions { - format!(" {tokens}") - } else { - let id: String = s.key.chars().take(8).collect(); - format!(" {id} · {tokens}") - } + let id: String = s.key.chars().take(8).collect(); + format!(" {id} · {tokens}") } /// Title of an agent's popup feed: activity mark, agent type · description, @@ -2091,7 +2431,7 @@ fn lane_mark(l: &Lane) -> &'static str { /// two lines per agent — mark + `type · description` over a dimmed /// model/tools/tokens row — in `App::agent_list_of` order, so the running ones /// come first and read bright while finished ones stay reachable below. -fn draw_agent_list(f: &mut Frame, area: Rect, s: &Session, lanes: &[LaneId], sel: usize) { +fn draw_streams(f: &mut Frame, area: Rect, s: &Session, lanes: &[LaneId], sel: usize) { f.render_widget(Clear, area); let inner_w = area.width.saturating_sub(3) as usize; let items: Vec = lanes @@ -2103,7 +2443,13 @@ fn draw_agent_list(f: &mut Frame, area: Rect, s: &Session, lanes: &[LaneId], sel } else { Style::new().fg(Color::White) }; - let head = format!(" {} {}", lane_mark(l), l.title()); + // No "currently shown" marker: the highlight *is* that, because + // moving it shows the lane (`App::streams_move`). + let head = if i == MAIN_LANE { + " · main chain".to_string() + } else { + format!(" {} {}", lane_mark(l), l.title()) + }; // Same substitution as `lane_tokens`: a notification's `` // counted the whole run, our own tally only what we saw. let meta = format!( @@ -2119,9 +2465,11 @@ fn draw_agent_list(f: &mut Frame, area: Rect, s: &Session, lanes: &[LaneId], sel ]) }) .collect(); + // `main` is never counted as running: it is not a side stream, and the + // header answers "how many agents are still going". let running = lanes .iter() - .filter(|&&i| s.lanes[i as usize].running()) + .filter(|&&i| i != MAIN_LANE && s.lanes[i as usize].running()) .count(); let mut ls = ListState::default(); ls.select(Some(sel.min(lanes.len().saturating_sub(1)))); @@ -2129,7 +2477,10 @@ fn draw_agent_list(f: &mut Frame, area: Rect, s: &Session, lanes: &[LaneId], sel List::new(items) .block( Block::bordered() - .title(format!(" streams · {running} of {} running ", lanes.len())) + .title(format!( + " streams · {running} of {} running ", + lanes.len().saturating_sub(1) + )) .border_style(Style::new().fg(ACCENT).bold()), ) .highlight_style(Style::new().reversed()), @@ -2138,8 +2489,9 @@ fn draw_agent_list(f: &mut Frame, area: Rect, s: &Session, lanes: &[LaneId], sel ); } -/// Everything one feed needs. Bundled because a feed is drawn from two places -/// (the main chain, and one agent inside the popup) with only these differing. +/// Everything one feed needs. There is exactly one feed now — which lane it +/// shows is `lane`, chosen in the stream picker — but the bundle stays because +/// the argument list is long and mostly stable. struct FeedArgs<'a> { s: &'a Session, lane: LaneId, @@ -2152,6 +2504,16 @@ struct FeedArgs<'a> { follow: bool, /// Pin a turn's first entry to the top (main feed only). scroll_target: Option, + /// Active search query, already lowercased: every occurrence is painted in + /// the rendered lines (`highlight_lines`) and it is part of the cache + /// fingerprint, so editing the query re-renders. + search: Option<&'a str>, + /// `scroll_target` came from a search hit, so scroll to the matching + /// *line* inside that entry rather than to the entry's top. A turn jump + /// wants the top (it is pinning a prompt, whose match is its first line); + /// a search hit can sit hundreds of rows into a long tool result, where + /// pinning the top shows no match at all and reads as "nothing found". + scroll_to_match: bool, /// `n`/`N` prompt jump (main feed only). prompt_jump: Option, title: String, @@ -2180,6 +2542,7 @@ fn draw_feed(f: &mut Frame, cache: &mut FeedCache, args: &FeedArgs) -> FeedOut { }; } let feed_width = args.area.width.saturating_sub(2); + let qhash = query_hash(args.search); // (In)validate the render cache: a width change, session switch, or // a different transcript view of the same session (live vs on-disk, // another tree path) invalidates everything, otherwise only entries @@ -2205,9 +2568,9 @@ fn draw_feed(f: &mut Frame, cache: &mut FeedCache, args: &FeedArgs) -> FeedOut { } continue; } - let fp = fingerprint(e, args.focused); + let fp = fingerprint(e, args.focused, qhash); if cache.entries.get(i).is_none_or(|c| c.fingerprint != fp) { - let lines = entry_lines(e, feed_width, args.focused); + let lines = entry_lines(e, feed_width, args.focused, args.search); let height = wrapped_height(&lines, feed_width); let ce = CachedEntry { fingerprint: fp, @@ -2239,13 +2602,25 @@ fn draw_feed(f: &mut Frame, cache: &mut FeedCache, args: &FeedArgs) -> FeedOut { args.scroll.min(max_scroll) }; if let Some(target) = args.scroll_target { - // Pin the highlighted turn's first entry to the viewport top. - new_scroll = visible + // Rows above the target entry. + let mut off = visible .iter() .take_while(|&&i| i < target) .map(|&i| cache.entries[i].height) - .sum::() - .min(max_scroll); + .sum::(); + // A search hit refines that to the matching row *inside* the entry: + // the highlight pass already marked which lines matched, so the line + // index falls out of the rendered cache rather than needing its own + // bookkeeping. `MATCH_CONTEXT` rows are left above it so you land with + // the tool header / preceding prose in view instead of flush at the top. + if args.scroll_to_match + && args.search.is_some() + && let Some(ce) = cache.entries.get(target) + && let Some(row) = match_row(&ce.lines, feed_width) + { + off = (off + row).saturating_sub(MATCH_CONTEXT); + } + new_scroll = off.min(max_scroll); new_follow = false; } if let Some(down) = args.prompt_jump { @@ -2386,15 +2761,15 @@ fn draw_feed(f: &mut Frame, cache: &mut FeedCache, args: &FeedArgs) -> FeedOut { /// Otherwise the main feed scrolls, wherever the pointer sits. fn wheel(app: &SharedApp, dir: isize) { let mut a = app.lock().unwrap(); - match a.agent_popup { - Some(AgentPopup::List(_)) => a.agent_popup_move(dir), - // follow re-engages automatically when draw() clamps the scroll to - // the bottom. - _ => { - let target = a.agent_popup_lane(); - a.scroll_col(target, dir * WHEEL_ROWS); - } - } + // An overlay is modal, so it owns the wheel too — no pointer hit-testing + // is involved anywhere. A list moves its highlight; everything else + // scrolls the feed's current lane. follow re-engages automatically when + // draw() clamps the scroll to the bottom. + // Both list overlays are bottom strips with the feed still readable above + // them, so the wheel keeps scrolling that feed; j/k moves the list. Nothing + // covers the feed any more, so there is no case left where the wheel + // belongs to a list. + a.scroll_col(None, dir * WHEEL_ROWS); } /// Copy text to the system clipboard via the OSC 52 escape sequence (works @@ -3131,13 +3506,14 @@ fn sanitize_md(s: &str) -> String { mod tests { use super::{ SHRINK_DELAY, base64, color_on, entry_lines, fmt_ms, lane_dur, lane_mark, lane_title, - lane_tokens, mcp_header, popup_rect, sanitize_md, smooth_compact, sync_alt_screen, - truncate_str, user_block_style, wrap_words, + lane_tokens, mcp_header, menu_rows, sanitize_md, smooth_compact, + sync_alt_screen, truncate_str, user_block_style, wrap_words, }; - use crate::app::{Entry, Kind, Lane, MAIN_LANE, ToolResult}; + use crate::app::{App, Entry, Kind, Lane, MAIN_LANE, ToolResult}; + use crate::keymap::ROOT; use ratatui::layout::Rect; - use ratatui::style::{Color, Modifier}; - use ratatui::text::Line; + use ratatui::style::{Color, Modifier, Style}; + use ratatui::text::{Line, Span}; use std::time::Instant; /// A finished tool entry with its input JSON and (optionally) the @@ -3183,33 +3559,199 @@ mod tests { /// The subagent popup covers 80% of the *feed* area, centred — never the /// sessions panel, and never so small that a tiny terminal loses the view. + /// A stream list of two lanes should not take a third of the screen just + /// to show four rows of content. #[test] - fn agent_popup_covers_four_fifths_of_the_feed() { - // Feed area offset inside the screen (sessions panel to its left). - let feed = Rect::new(40, 1, 60, 30); - let r = popup_rect(feed); - assert_eq!((r.width, r.height), (48, 24), "80% on both axes"); - // Centred over the feed, so the main feed shows around every edge. - assert_eq!(r.x, 40 + 6); - assert_eq!(r.y, 1 + 3); - assert!(r.x >= feed.x && r.right() <= feed.right()); - assert!(r.y >= feed.y && r.bottom() <= feed.bottom()); - - // Tiny feed: the popup takes all of it rather than collapsing to a - // couple of unusable rows. - let tiny = Rect::new(0, 0, 20, 5); - let r = popup_rect(tiny); - assert_eq!((r.width, r.height), (20, 5)); - // A wide terminal must not overflow the percentage arithmetic. - let wide = popup_rect(Rect::new(0, 0, u16::MAX, 100)); - assert_eq!(wide.width, 52428); + fn stream_picker_shrinks_to_its_contents_but_never_past_the_cap() { + let main = Rect { x: 0, y: 0, width: 120, height: 30 }; + let cap = super::list_rect(main).height; + // Two lanes = two 2-row items + borders. + let r = super::streams_rect(main, 2); + assert_eq!(r.height, 6); + assert_eq!(r.width, main.width, "still full width"); + assert_eq!(r.bottom(), main.bottom(), "still bottom-anchored"); + assert!(r.height < cap, "and smaller than the session list"); + // One lane (a session with no agents still lists `main`). + assert_eq!(super::streams_rect(main, 1).height, 4); + // Many lanes stop at the cap and scroll inside it. + assert_eq!(super::streams_rect(main, 50).height, cap); + // A tiny terminal is bounded by the screen, not by the cap arithmetic. + let tiny = Rect { x: 0, y: 0, width: 80, height: 5 }; + assert!(super::streams_rect(tiny, 50).height <= 5); + } + + /// Both list overlays are the same bottom strip: full width, a third of + /// the feed, feed readable above. A centred box would cover the thing they + /// steer. + #[test] + fn both_list_overlays_are_the_same_bottom_strip() { + let main = Rect { x: 0, y: 0, width: 120, height: 30 }; + let r = super::list_rect(main); + assert_eq!(r.width, main.width, "full width"); + assert_eq!(r.bottom(), main.bottom(), "flush with the bottom"); + assert_eq!(r.height, 9, "a third of the feed"); + assert!(r.top() > main.top() + main.height / 2, "feed stays visible above"); + // A short terminal gets the floor rather than a one-row list. + let tiny = Rect { x: 0, y: 0, width: 80, height: 12 }; + assert_eq!(super::list_rect(tiny).height, super::LIST_MIN); + // …and never more than there is. + let squashed = Rect { x: 0, y: 0, width: 80, height: 5 }; + assert_eq!(super::list_rect(squashed).height, 5); + // The search bar is the short one. + assert!(super::search_rect(main).height < r.height); + } + + #[test] + fn search_scroll_targets_the_matching_row_not_the_entry_top() { + let width = 40u16; + let mut lines: Vec> = (0..12) + .map(|i| Line::from(format!("filler row {i}"))) + .collect(); + lines.push(Line::from(vec![Span::styled( + "cargo", + Style::new().bg(super::MATCH_BG), + )])); + lines.push(Line::from("after")); + assert_eq!( + super::match_row(&lines, width), + Some(12), + "twelve unwrapped rows sit above the hit" + ); + // A line that wraps counts as the rows it really occupies. + let wrapped = vec![ + Line::from("x".repeat(width as usize * 3)), + Line::from(vec![Span::styled("cargo", Style::new().bg(super::MATCH_BG))]), + ]; + assert_eq!(super::match_row(&wrapped, width), Some(3)); + // No painted match (the *result* matched, and it renders clipped): + // fall back to the entry top rather than guessing a row. + assert_eq!(super::match_row(&[Line::from("nothing here")], width), None); + } + + /// Matches must light up wherever they land, whatever renderer produced + /// the line, and the surrounding styling must survive. + #[test] + fn search_highlights_every_occurrence_and_keeps_the_style() { + let mut lines = vec![Line::from(vec![ + Span::styled("the Retry helper RETRYs", Style::new().add_modifier(Modifier::BOLD)), + ])]; + super::highlight_lines(&mut lines, "retry"); + let spans = &lines[0].spans; + assert_eq!( + spans.iter().map(|s| s.content.as_ref()).collect::>(), + vec!["the ", "Retry", " helper ", "RETRY", "s"], + "case-insensitive match, original casing kept" + ); + let hit: Vec<&Span> = spans.iter().filter(|s| s.style.bg == Some(super::MATCH_BG)).collect(); + assert_eq!(hit.len(), 2, "both occurrences painted"); + assert!( + hit.iter().all(|s| s.style.add_modifier.contains(Modifier::BOLD)), + "the span's own styling survives the highlight" + ); + // An empty query is a no-op, not a span explosion. + let mut plain = vec![Line::from("untouched")]; + super::highlight_lines(&mut plain, ""); + assert_eq!(plain[0].spans.len(), 1); + } + + /// The query is part of the render fingerprint, or a cached entry keeps + /// serving lines from before the search. + #[test] + fn editing_the_query_invalidates_the_render_cache() { + let e = Entry::done(Kind::Text, "hello".into()); + let a = super::fingerprint(&e, false, super::query_hash(Some("he"))); + let b = super::fingerprint(&e, false, super::query_hash(Some("hel"))); + let none = super::fingerprint(&e, false, super::query_hash(None)); + assert_ne!(a, b, "a keystroke re-renders"); + assert_ne!(a, none, "so does clearing the search"); + assert_eq!(super::query_hash(None), 0); + assert_ne!(super::query_hash(Some("")), 0, "an empty query is still a search"); + } + + /// The bar is a short bottom strip, like the sessions list but three rows. + #[test] + fn search_bar_is_a_visible_strip_not_just_a_footer_note() { + let main = Rect { x: 0, y: 0, width: 100, height: 30 }; + let r = super::search_rect(main); + assert_eq!((r.width, r.height), (100, 3)); + assert_eq!(r.bottom(), main.bottom()); + assert!(r.height < super::list_rect(main).height, "shorter than the session list"); + } + + #[test] + fn esc_unwinds_overlays_then_belongs_to_the_child() { + let mut a = App::new(); + a.filters[0] = false; + a.feed_lane = 3; + assert!(!a.close_overlay(), "nothing open → the key is the child's"); + + a.show_sessions = true; + a.expanded = Some(crate::app::Expanded { + uuid: "u".into(), + tree: crate::sessions::TurnTree::default(), + sel: Some(0), + visual: Some(0), + }); + assert!(a.close_overlay()); + assert!(a.expanded.as_ref().unwrap().visual.is_none(), "visual range first"); + assert!(a.close_overlay()); + assert!(a.expanded.is_none(), "then the turn tree"); + assert!(a.close_overlay()); + assert!(!a.show_sessions, "then the overlay itself"); + assert!(!a.close_overlay()); + + // Leaving the sessions overlay keeps what you were reading rather than + // snapping back to the pane — walking the list is how you view a + // session without resuming it, so throwing that away on the way out + // would waste the whole trip. + a.show_sessions = true; + a.embed_session = Some("pane".into()); + a.close_overlay(); + assert!(!a.follow_pane, "pinned on the session you were looking at"); + + // Filters and the shown lane are settings, not modes — Esc never + // resets them, which is the whole reason `prefix Esc` does not exist. + assert!(!a.filters[0]); + assert_eq!(a.feed_lane, 3); + } + + /// Every root binding dispatches, and an unbound key closes the menu + /// rather than trapping you in it. + #[test] + fn the_menu_is_the_binding_table_and_is_never_a_trap() { + assert!(ROOT.find('s').is_some()); + assert!(ROOT.find('§').is_none()); + // Only the repeatable prompt jumps hold the menu open. + let sticky: Vec = ROOT + .binds + .iter() + .filter(|b| b.act.sticky()) + .map(|b| b.key) + .collect(); + assert_eq!(sticky, vec![']', '[']); + } + + /// The popup is laid out column-major, so it must reserve a row per item + /// per column — a miscount would clip the last binding off the table. + #[test] + fn menu_reserves_a_row_for_every_binding() { + assert_eq!(menu_rows(ROOT.binds.len(), 3), ROOT.binds.len().div_ceil(3) as u16); + assert_eq!(menu_rows(ROOT.binds.len(), 1), ROOT.binds.len() as u16); + assert_eq!(menu_rows(0, 3), 0); + assert_eq!(menu_rows(7, 3), 3, "a partial last column still gets its rows"); + // Column-major indexing must reach every entry. + let (cols, rows) = (3usize, menu_rows(ROOT.binds.len(), 3) as usize); + let mut seen = 0; + for r in 0..rows { + for c in 0..cols { + if ROOT.binds.get(c * rows + r).is_some() { + seen += 1; + } + } + } + assert_eq!(seen, ROOT.binds.len()); } - /// The compact pane grows on the frame the prompt gets taller, but a - /// smaller reading is held back until it has been stable for SHRINK_DELAY — - /// so the per-frame wobble during subagent turns / menu filtering doesn't - /// resize the PTY (which would make Claude Code repaint and flicker). A - /// transient `None` reading keeps the last height. #[test] fn compact_height_grows_fast_shrinks_slow() { let mut applied = 7u16; @@ -3251,7 +3793,7 @@ mod tests { #[test] fn feed_text_strips_control_chars() { let e = Entry::done(Kind::Text, "```\n\tif self.queued:\n\t\treturn\n```".into()); - let lines = entry_lines(&e, 60, false); + let lines = entry_lines(&e, 60, false, None); for line in &lines { for span in &line.spans { assert!( @@ -3307,7 +3849,7 @@ mod tests { r#"{"command":"cargo fmt --check"}"#, Some("\u{1b}[31m- app.lock()\u{1b}[0m\nplain tail"), ); - let lines = entry_lines(&e, 80, false); + let lines = entry_lines(&e, 80, false, None); let t = text(&lines); assert!(t.contains("- app.lock()"), "{t}"); assert!(!t.contains("[31m"), "escape leaked as literal text: {t}"); @@ -3325,7 +3867,7 @@ mod tests { "Set model to \u{1b}[1mOpus 5\u{1b}[22m" .into(), ); - let lines = entry_lines(&e, 80, false); + let lines = entry_lines(&e, 80, false, None); let t = text(&lines); assert!(t.contains("Set model to Opus 5"), "{t}"); assert!(!t.contains("[1m"), "{t}"); @@ -3348,7 +3890,7 @@ mod tests { fn user_prompt_drops_ansi_colour_but_keeps_bold() { let raw = "Set model to \u{1b}[1;31mSonnet 5\u{1b}[22m and saved as your default"; let e = Entry::done(Kind::User, raw.into()); - let lines = entry_lines(&e, 40, true); + let lines = entry_lines(&e, 40, true, None); let t = text(&lines); assert!(t.contains("Sonnet 5"), "{t}"); assert!(!t.contains("[1m") && !t.contains("[22m"), "{t}"); @@ -3387,7 +3929,7 @@ mod tests { r#"{"subject":"Add ring-buffer retroactive capture to InteractionRecorder","description":"Keep last N seconds in memory","activeForm":"Adding ring-buffer capture"}"#, Some("Task #1 created successfully: Add ring-buffer retroactive capture"), ); - let t = text(&entry_lines(&e, 90, false)); + let t = text(&entry_lines(&e, 90, false, None)); assert!(t.contains("⚙ Task + Add ring-buffer retroactive capture"), "{t}"); assert!(t.contains("Keep last N seconds in memory"), "{t}"); assert!(!t.contains("activeForm") && !t.contains('{'), "raw JSON: {t}"); @@ -3397,7 +3939,7 @@ mod tests { r#"{"taskId":"1","status":"in_progress"}"#, Some("Task #1 updated"), ); - let lines = entry_lines(&e, 80, false); + let lines = entry_lines(&e, 80, false, None); let t = text(&lines); assert!(t.contains("⚙ Task #1 → ◐ in_progress"), "{t}"); assert!(!t.contains("taskId"), "raw JSON: {t}"); @@ -3405,7 +3947,7 @@ mod tests { assert_eq!(fg_of(&lines, "in_progress"), Some(Color::Yellow)); let e = tool("TaskUpdate", r#"{"taskId":"7","status":"completed"}"#, None); - let lines = entry_lines(&e, 80, false); + let lines = entry_lines(&e, 80, false, None); assert!(text(&lines).contains("⚙ Task #7 → ☑ completed")); assert_eq!(fg_of(&lines, "completed"), Some(Color::Green)); } @@ -3419,7 +3961,7 @@ mod tests { r#"{"command":"until grep -q done log; do sleep 1; done\necho ok","description":"world skin bench phase transitions","timeout_ms":1200000,"persistent":false}"#, Some("Monitor started (task b8s2gso3a, timeout 1200000ms).\nYou will be notified."), ); - let lines = entry_lines(&e, 100, false); + let lines = entry_lines(&e, 100, false, None); let t = text(&lines); assert!( t.contains("⚙ Monitor world skin bench phase transitions"), @@ -3443,7 +3985,7 @@ mod tests { r#"{"questions":[{"question":"How should the pane be framed?","header":"Framing","multiSelect":false,"options":[{"label":"Measure the box","description":"Frame from the top border down"},{"label":"Fixed offsets","description":"Crop a constant row count"}]}]}"#, Some("Measure the box"), ); - let t = text(&entry_lines(&e, 70, false)); + let t = text(&entry_lines(&e, 70, false, None)); assert!(t.contains("⚙ AskUserQuestion"), "{t}"); assert!(t.contains("[Framing]"), "{t}"); assert!(t.contains("How should the pane be framed?"), "{t}"); @@ -3467,7 +4009,7 @@ mod tests { "{\"plan\":\"## Plan\\n\\n1. Size the PTY to the screen\\n2. Measure what Ink drew\"}", None, ); - let t = text(&entry_lines(&e, 70, false)); + let t = text(&entry_lines(&e, 70, false, None)); assert!(t.contains("⚙ ExitPlanMode"), "{t}"); assert!(t.contains("Plan") && !t.contains("## Plan"), "{t}"); assert!(t.contains("Size the PTY to the screen"), "{t}"); @@ -3486,7 +4028,7 @@ mod tests { "Line style is patched by each span's own style." ); let e = tool("WebSearch", r#"{"query":"ratatui line style"}"#, Some(res)); - let t = text(&entry_lines(&e, 110, false)); + let t = text(&entry_lines(&e, 110, false, None)); assert!(t.contains("⚙ WebSearch \"ratatui line style\""), "{t}"); assert!(t.contains("⎿ Line in ratatui::text"), "{t}"); assert!(t.contains("https://ratatui.rs/concepts/text"), "{t}"); @@ -3495,7 +4037,7 @@ mod tests { assert!(!t.contains("{\"title\""), "raw JSON: {t}"); let e = tool("WebSearch", r#"{"query":"q"}"#, Some("Links: not json\nprose")); - let t = text(&entry_lines(&e, 100, false)); + let t = text(&entry_lines(&e, 100, false, None)); assert!(t.contains("Links: not json"), "falls back raw: {t}"); } @@ -3516,7 +4058,7 @@ mod tests { let r = crate::app::server_tool_result(Some(&block)); assert!(!r.is_error); let e = tool("web_search", r#"{"query":"ratatui scrollbar"}"#, Some(&r.content)); - let t = text(&entry_lines(&e, 110, false)); + let t = text(&entry_lines(&e, 110, false, None)); assert!(t.contains("⚙ WebSearch \"ratatui scrollbar\""), "{t}"); assert!(t.contains("⎿ Ratatui docs"), "{t}"); assert!(t.contains("https://ratatui.rs/examples"), "{t}"); @@ -3535,7 +4077,7 @@ mod tests { result: None, lane: MAIN_LANE, }; - let lines = entry_lines(&e, 80, false); + let lines = entry_lines(&e, 80, false, None); let t = text(&lines); assert!(t.starts_with("▸ 2 sources\n"), "{t}"); assert!(t.contains("· Ratatui docs — https://ratatui.rs\n"), "{t}"); @@ -3581,7 +4123,7 @@ mod tests { r#"{"url":"https://docs.rs/ratatui","prompt":"How does Line combine with Span styles?"}"#, Some("## Answer\n\nThe line style is applied first."), ); - let t = text(&entry_lines(&e, 80, false)); + let t = text(&entry_lines(&e, 80, false, None)); assert!(t.contains("⚙ WebFetch https://docs.rs/ratatui"), "{t}"); assert!(t.contains("How does Line combine with Span styles?"), "{t}"); assert!(t.contains("The line style is applied first."), "{t}"); @@ -3595,12 +4137,12 @@ mod tests { r#"{"skill":"optimize-materialization"}"#, Some("Launching skill: optimize-materialization"), ); - let t = text(&entry_lines(&e, 80, false)); + let t = text(&entry_lines(&e, 80, false, None)); assert!(t.contains("⚙ Skill optimize-materialization"), "{t}"); assert!(t.contains("⎿ Launching skill"), "{t}"); let e = tool("ToolSearch", r#"{"query":"select:Monitor","max_results":1}"#, None); - let t = text(&entry_lines(&e, 80, false)); + let t = text(&entry_lines(&e, 80, false, None)); assert!(t.contains("⚙ ToolSearch \"select:Monitor\""), "{t}"); assert!(t.contains("max 1"), "{t}"); @@ -3609,13 +4151,13 @@ mod tests { r#"{"task_id":"b8s2gso3a","block":true}"#, Some("phase 3 done\nphase 4 done"), ); - let t = text(&entry_lines(&e, 80, false)); + let t = text(&entry_lines(&e, 80, false, None)); assert!(t.contains("⚙ TaskOutput b8s2gso3a (block)"), "{t}"); assert!(t.contains("phase 4 done"), "the output isn't clipped: {t}"); assert!(!t.contains("task_id"), "raw JSON: {t}"); let e = tool("TaskStop", r#"{"task_id":"b8s2gso3a"}"#, None); - assert!(text(&entry_lines(&e, 80, false)).contains("⚙ TaskStop b8s2gso3a")); + assert!(text(&entry_lines(&e, 80, false, None)).contains("⚙ TaskStop b8s2gso3a")); } #[test] @@ -3625,7 +4167,7 @@ mod tests { r#"{"probe":"world_skin","frames":120}"#, None, ); - let t = text(&entry_lines(&e, 80, false)); + let t = text(&entry_lines(&e, 80, false, None)); assert!(t.contains("⚙ MCP pistdio/probe_run"), "{t}"); assert!(t.contains("probe: world_skin"), "{t}"); assert!(t.contains("frames: 120"), "{t}"); @@ -3658,11 +4200,11 @@ mod tests { ("▸ Monitor event: bench", Color::Cyan), ("· task zz9", Color::DarkGray), ] { - let lines = entry_lines(¬e(line), 80, false); + let lines = entry_lines(¬e(line), 80, false, None); assert_eq!(fg_of(&lines, line), Some(want), "{line}"); } // A monitor payload / an unattachable report rides along dimmed. - let lines = entry_lines(¬e("▸ Monitor event: bench\n BENCH phase=traverse"), 80, false); + let lines = entry_lines(¬e("▸ Monitor event: bench\n BENCH phase=traverse"), 80, false, None); assert_eq!(fg_of(&lines, "BENCH phase"), Some(Color::DarkGray)); }