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)); }