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.

View File

@@ -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.

View File

@@ -157,11 +157,30 @@ pub struct App {
pub lane_cols: HashMap<LaneId, (usize, bool)>,
/// 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<AgentPopup>,
/// 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<usize>,
/// 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<Search>,
/// 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<usize>,
/// 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<LaneId> {
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<LaneId> {
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<LaneId> {
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<LaneId>, 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<LaneId>, 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<usize> = 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<crate::sessions::DiskSession>) {
@@ -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<usize>,
/// 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]

297
src/keymap.rs Normal file
View File

@@ -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<Self> {
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::<u8>().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);
}
}

View File

@@ -1,5 +1,6 @@
mod ansi;
mod app;
mod keymap;
mod markdown;
mod proxy;
mod reload;

View File

@@ -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<Turn>,
/// Records before/outside any turn (mode, the caveat record, …).

1844
src/ui.rs

File diff suppressed because it is too large Load Diff