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:
387
CLAUDE.md
387
CLAUDE.md
@@ -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.
|
||||
|
||||
54
README.md
54
README.md
@@ -40,13 +40,43 @@ proxy isn't running.
|
||||
|
||||
## Keys
|
||||
|
||||
| key | action |
|
||||
Everything you type goes to the embedded `claude`. There is no focus model and
|
||||
no key you have to think about — `q`, `j` and `Esc` all reach Claude Code,
|
||||
because the app's own keys live behind a **prefix**, tmux-style.
|
||||
|
||||
Press **ctrl-space** and a which-key popup shows what is available. The keys
|
||||
work immediately; you never have to wait for the popup.
|
||||
|
||||
| `^space` + | action |
|
||||
|---|---|
|
||||
| `q` / `Esc` | quit |
|
||||
| `Tab` / `Shift-Tab` | switch session |
|
||||
| `j`/`k`, arrows, PgUp/PgDn | scroll (disables follow) |
|
||||
| `f` / `G` / `End` | follow live tail |
|
||||
| `g` / `Home` | jump to top |
|
||||
| `s` | sessions — `j`/`k` previews each one in the feed, `enter` opens it in claude, `Esc` keeps reading it. Turn tree with `space`, branch with `b` |
|
||||
| `a` | streams — `j`/`k` previews the main chain, each subagent, each hosted tool call. Sized to the list, capped at a third |
|
||||
| `n` | new session (model picker) |
|
||||
| `c` | continue the most recent session |
|
||||
| `f` | filter which entry kinds show |
|
||||
| `/` | find bar — matches highlight in place, `enter`/`↓` next, `↑` prev, with a `3/12` counter |
|
||||
| `]` `[` | next / previous user prompt |
|
||||
| `.` | back to the live main chain, tailing it — undoes a picked session, a picked lane, and a parked scroll |
|
||||
| `z` `Z` | zoom the pane / zoom the feed |
|
||||
| `r` | hot reload |
|
||||
| `q` | quit |
|
||||
|
||||
Repeatable keys (`]` `[`) keep the popup open, so `^space ]]]` walks.
|
||||
`^space ^space` sends a literal prefix to the child.
|
||||
|
||||
Two keys are the app's without a prefix, for the same reason a terminal keeps
|
||||
them for itself: the **wheel** and **shift**+PgUp/PgDn scroll the feed (or the
|
||||
pane's own scrollback while the pane is zoomed). They keep working while a
|
||||
list is open, so you can read what you highlighted before committing to it.
|
||||
|
||||
**Esc closes the topmost overlay. With nothing open it goes to Claude Code**, so
|
||||
interrupt and Esc-Esc rewind keep working. Filters and the stream you picked are
|
||||
settings, not modes — Esc never resets them.
|
||||
|
||||
`CT_PREFIX` changes the prefix (`CT_PREFIX=ctrl-b`, `ctrl-]`, `f1`, …), for the
|
||||
case where your Claude Code wants ctrl-space for itself. `CT_DEBUG_KEYS=1` shows
|
||||
what your terminal actually delivers. ctrl-q quits from anywhere, as a way out
|
||||
if the prefix never arrives.
|
||||
|
||||
## Display
|
||||
|
||||
@@ -63,18 +93,18 @@ concurrent requests (subagents) tap independently.
|
||||
|
||||
## Hot reload
|
||||
|
||||
Swap a running instance onto a newly built binary with **ctrl-r** — without
|
||||
Swap a running instance onto a newly built binary with **`^space r`** — without
|
||||
losing the proxy port, the embedded `claude` pane, or the live feed.
|
||||
|
||||
```sh
|
||||
cargo build # in any terminal, whenever you like
|
||||
# then press ctrl-r in each running instance
|
||||
# then press ^space r in each running instance
|
||||
```
|
||||
|
||||
claude-cloak never builds anything itself and watches no files. You rebuild the
|
||||
way you always would; ctrl-r says "run that one now".
|
||||
way you always would; the key says "run that one now".
|
||||
|
||||
ctrl-r execs the same **path** the instance was started from, so a debug
|
||||
It execs the same **path** the instance was started from, so a debug
|
||||
instance reloads onto a rebuilt debug binary and a release instance onto a
|
||||
rebuilt release one. It does not cross profiles.
|
||||
|
||||
@@ -88,8 +118,8 @@ backlog and are served by the new code. Nothing is refused and nothing is
|
||||
truncated.
|
||||
|
||||
The footer shows `⟳ reloading…` while it drains, then `· reload #1` once the new
|
||||
code is running. A failed exec — ctrl-r pressed while the linker still had the
|
||||
file open — reports `⚠ reload failed: …` and changes nothing; press it again.
|
||||
code is running. A failed exec — pressed while the linker still had the file
|
||||
open — reports `⚠ reload failed: …` and changes nothing; press it again.
|
||||
|
||||
A state snapshot the new types no longer fit costs the feed only — never the
|
||||
port and never the pane.
|
||||
|
||||
478
src/app.rs
478
src/app.rs
@@ -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
297
src/keymap.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod ansi;
|
||||
mod app;
|
||||
mod keymap;
|
||||
mod markdown;
|
||||
mod proxy;
|
||||
mod reload;
|
||||
|
||||
@@ -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, …).
|
||||
|
||||
Reference in New Issue
Block a user