Put every app key behind one prefix

The keybindings had no red thread because there were three focus states
and every key had to ask "is this mine or the child's" — hence ctrl-q,
because plain q was forwarded. Replace the whole model: unprefixed keys
belong to the embedded claude, always, and everything cloak owns sits
behind ctrl-space (CT_PREFIX to change), tmux-style, in one table that
the which-key popup, the footer hint and the dispatch all read.

Esc is the only key with a rule of its own, and it is about reachability
rather than modes: it closes the topmost overlay, and with nothing open
it goes to the child, so interrupt and Esc-Esc rewind keep working. View
state — filters, the lane the feed shows — is a setting, not a mode, and
is deliberately not escapable.

That removes the focus model entirely, which lets the two feeds collapse
into one: the feed renders whichever lane feed_lane names, at full
width, and the stream picker chooses it. Subagents used to live in a
modal popup holding a second draw_feed with its own cache and scroll
model, rendering the same thing twice. The sessions panel loses its half
of the screen the same way. Both pickers become bottom strips with the
feed readable above them, so walking the list previews each row — which
is the view-without-resuming that /resume cannot do, and frees enter to
attach the pane.

Adds a find bar with in-place highlighting, scrolling to the matching
line rather than the containing entry, and drops the two keys that
served the old layout.
This commit is contained in:
Jonas H
2026-09-10 14:16:34 +02:00
parent e9e224f856
commit 15b20a8af1
7 changed files with 2129 additions and 933 deletions

387
CLAUDE.md
View File

@@ -60,6 +60,11 @@ src/app.rs Arc<Mutex<App>> 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 `<usage>` 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 <uuid>` 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: <hex>`), 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 `<task-notification>` 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 `<task-notification>` 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 `<task-notification>`) or no traffic at all (a lane read
@@ -393,7 +516,8 @@ agentId: <hex>`), 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: <hex>`), 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/<cwd with / → ->/*.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 <uuid>`
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 <uuid>`
(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: <hex>`), 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: <hex>`), 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: <hex>`), 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: <hex>`), 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: <hex>`), 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: <hex>`), 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: <hex>`), 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: <hex>`), 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: <hex>`), 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
`<task-notification>` 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: <hex>`), 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.