Compare commits
4 Commits
6bb7e7d424
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15b20a8af1 | ||
|
|
e9e224f856 | ||
|
|
ae454a3d2d | ||
|
|
043c99cdeb |
480
CLAUDE.md
480
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
|
||||
@@ -157,11 +160,17 @@ src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` in a
|
||||
portable-pty handles, which do not survive an exec).
|
||||
`scroll` / `follow_live` / `scrolled_rows` give the
|
||||
**fullscreen** pane the scrollback a plain terminal would —
|
||||
see the pane-scroll invariant
|
||||
src/reload.rs hot reload: ctrl-r `execve`s the binary now on disk *into this
|
||||
see the pane-scroll invariant. `shows_error` reports whether
|
||||
the compact frame is currently holding an error Claude Code
|
||||
printed, which is what stops the ctrl-l wipe from deleting it —
|
||||
see the pane-error invariant. `alt_screen` reports the
|
||||
*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: `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
|
||||
```
|
||||
|
||||
@@ -170,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,
|
||||
@@ -188,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.
|
||||
@@ -216,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
|
||||
@@ -352,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
|
||||
@@ -387,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
|
||||
@@ -404,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`);
|
||||
@@ -482,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
|
||||
@@ -495,14 +642,57 @@ 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
|
||||
renders the prompt, shrinks when the tool_result echoes back, and schedules
|
||||
a ctrl-l transcript wipe 400ms after each turn (the pane is prompt-only;
|
||||
the feed shows the context) — **except in fullscreen**, where that wipe is
|
||||
*cancelled*, not deferred (see the pane-scroll invariant).
|
||||
*cancelled*, not deferred (see the pane-scroll invariant), and **except
|
||||
while an error is framed** (see the pane-error invariant).
|
||||
- **A CLI error is pane-only, so the pane keeps it — a tool error is not.**
|
||||
The compact pane is prompt-only because the feed above shows the context.
|
||||
The one exception is an error the *CLI itself* produced (`● API Error:
|
||||
Connection lost mid-response.`, a retry exhaustion): that is Claude Code's
|
||||
own text, never in the API stream, so the feed cannot show it and the pane
|
||||
is the only place it will ever appear. So two things happen together:
|
||||
`compact_frame_ex` walks further up to take the message in (over the task
|
||||
panel too — the error came before it), and `ui::draw` **cancels** the
|
||||
scheduled ctrl-l wipe when `EmbeddedTerm::shows_error` reports one, for the
|
||||
same reason fullscreen cancels it: Ink redraws only the live frame, so
|
||||
wiping would delete the error 400ms after it appeared and nothing would ever
|
||||
bring it back. Cancelled, not deferred — firing it later loses the same
|
||||
rows. Three rules keep it bounded:
|
||||
1. **The `⎿` gutter disqualifies a row, and that is the whole test.** A
|
||||
gutter is a *tool* talking, and a failed tool is not a CLI error: its
|
||||
`tool_result` reaches the feed with the next request, so the feed already
|
||||
shows it and the pane has no reason to grow. Framing it too was the
|
||||
reported bug — tool errors bled through and held the pane open. So
|
||||
`text_is_error_row` rejects any `⎿` row (`⎿ Error: Exit code 3`
|
||||
included, deliberately), strips only the `●`/`⏺` bullet, then requires
|
||||
`Error:` / `API Error` / a `✗✘✖✕` glyph. **The colon is load-bearing**:
|
||||
`● Error handling lives in src/foo.rs` is ordinary prose. And it is
|
||||
**text shape, never colour** — verified on a real 2.1.26x child, the tool
|
||||
error renders palette 211 and the API error 220, both theme values.
|
||||
2. **The newest prompt row ends the error's relevance.** Once the user has
|
||||
typed something else the error belongs to a previous exchange, so
|
||||
`error_block_top` floors its scan just under the last `❯ …` transcript
|
||||
row. `ERROR_SCAN` (12 rows) alone is far too coarse for this — Claude
|
||||
Code's reply to an error is only two rows, so the error stayed inside the
|
||||
window and held the pane open through the whole *next* turn, which is
|
||||
what the second report was. Above the top rule a `❯` row can only be an
|
||||
echoed prompt; the box's own `❯` and a menu's `❯` marker are both below
|
||||
it, outside the scanned window.
|
||||
3. **`ERROR_SCAN` is the backstop bound**, for the case where no prompt
|
||||
follows: the error drifts out of the window as output arrives and the
|
||||
pane shrinks back on its own. No app state is involved — the wipe is
|
||||
simply re-scheduled by the next `Tap::drop` and fires once the screen is
|
||||
clean. It has to be this wide because Claude Code parks a blank row *and*
|
||||
its `✻ Worked…` row between the last transcript line and the box, so the
|
||||
error is never the context row. `error_block_top` takes the *highest*
|
||||
error row in the window, so a long message is framed from its first line;
|
||||
nothing is pulled in above it, because a CLI error names itself.
|
||||
- **In fullscreen the scroll is the pane's own scrollback, never a forwarded
|
||||
mouse event.** Claude Code enables no mouse tracking and never leaves the
|
||||
normal screen (verified on the wire: for 2.1.247 tmux reports every mouse
|
||||
@@ -519,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
|
||||
@@ -529,10 +719,39 @@ 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.
|
||||
|
||||
- **An editor on the child's alternate screen owns the whole pane.** ctrl-g
|
||||
opens the prompt in `$EDITOR` (so do `/memory` and a `git commit` a tool
|
||||
runs), and that program takes the pty over via the alternate buffer — which
|
||||
Claude Code itself never does (the pane-scroll invariant leans on the same
|
||||
fact). So `EmbeddedTerm::alt_screen` means exactly one thing: what is on
|
||||
screen is not a prompt to frame, and framing it can only crop it
|
||||
(`compact_frame` looks for the input box's two rules, which nvim never
|
||||
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
|
||||
`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 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 reload time raises no edge and the
|
||||
snapshotted `PaneState::alt_fullscreen` stays meaningful.
|
||||
|
||||
- Tool input streams as raw JSON fragments; pretty-printed only on
|
||||
`content_block_stop`. Streaming text re-renders markdown on every change
|
||||
(FeedCache fingerprints by content length + done + result), so partial
|
||||
@@ -595,9 +814,14 @@ agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
(and its `… +N pending` overflow line) directly above the input box, so
|
||||
walking up over that block — tolerating one blank line and single wrapped /
|
||||
activity rows, capped at `MAX_TASK_BLOCK` — makes task status visible with no
|
||||
extra app state. Priority when the pane can't hold everything: the panel is
|
||||
extra app state. Above *that* it walks up to an error the **CLI itself**
|
||||
printed (`text_is_error_row` / `error_block_top` — a `⎿` gutter means a tool
|
||||
and is skipped; see the pane-error invariant).
|
||||
Priority when the pane can't hold everything: the panel is
|
||||
dropped first (`CompactFrame::ess_top`, the one-context-row frame) so the
|
||||
line you're typing and an open menu never fall off screen. The framed region drives the pane height too:
|
||||
line you're typing and an open menu never fall off screen; an overflowing
|
||||
no-menu frame bottom-anchors anyway, so a report taller than the pane keeps
|
||||
its tail rather than pushing the box off screen. The framed region drives the pane height too:
|
||||
`compact_rows` (called from `ui::draw`) measures box-height + tail so the
|
||||
pane auto-expands as the prompt gains lines or a menu opens and shrinks back
|
||||
when idle (floor `MIN_COMPACT_INNER`, cap = screen − 6); `PTY_PAD` keeps the
|
||||
@@ -639,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` →
|
||||
@@ -667,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`):
|
||||
@@ -729,13 +956,21 @@ agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
`dev/fake_upstream.py`: it answers every request with canned SSE, so a **real
|
||||
`claude` child** can be made to render its client-side tool UIs on demand
|
||||
(`dev/.fake_scenario` =
|
||||
`ask | plan | todo | taskupdate | agent | websearch | ansi | text`, switchable
|
||||
`ask | plan | todo | taskupdate | agent | websearch | ansi | toolerror |
|
||||
basherror | apierror | text`, switchable
|
||||
mid-run) — this is how the pane's frame detector is developed against what
|
||||
Ink actually draws. `websearch` also answers the *nested* hosted-tool request
|
||||
Claude Code makes to run WebSearch (`server_tool_use` +
|
||||
`web_search_tool_result` + `citations_delta`), and `ansi` returns a `Bash`
|
||||
call whose output carries real SGR codes, so both paths are exercised by a
|
||||
genuine tool_result rather than a fixture. Its tool ids are minted from a
|
||||
genuine tool_result rather than a fixture.
|
||||
The three error scenarios are how the pane's error framing is developed
|
||||
against real Ink output, and two of them are *negative* fixtures: `toolerror`
|
||||
calls `Read` on a missing path (auto-approved, so no permission prompt) and
|
||||
`basherror` runs a failing `Bash`, both producing the `⎿ Error:` gutter that
|
||||
must **not** grow the pane. `apierror` answers the *turn* request with a
|
||||
non-retryable 400 — leaving the side/title calls alone — so Claude Code
|
||||
prints its own `● API Error: 400 …` row, which must. Its tool ids are minted from a
|
||||
session-wide counter: Claude Code resends the full history every request, so a
|
||||
**reused tool id makes an old tool_result re-attach to the newest call** — an
|
||||
artifact of the fake, not of the proxy. Note the `agent` scenario answers
|
||||
@@ -749,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
|
||||
@@ -761,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
|
||||
@@ -804,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.
|
||||
|
||||
@@ -8,7 +8,7 @@ ExitPlanMode, TodoWrite/Task*) on demand, so the pane's frame detector in
|
||||
`src/term.rs` can be developed against what Ink actually draws.
|
||||
|
||||
Scenario is picked per turn from `CT_FAKE_SCENARIO`
|
||||
(ask | plan | todo | taskupdate | agent | websearch | ansi | text).
|
||||
(ask | plan | todo | taskupdate | agent | websearch | ansi | toolerror | apierror | text).
|
||||
|
||||
`websearch` also answers the *nested* request Claude Code makes to run WebSearch:
|
||||
that call declares Anthropic's server-side `web_search` tool, so it is replied to
|
||||
@@ -162,6 +162,12 @@ ANSI_INPUT = {
|
||||
}
|
||||
|
||||
|
||||
# A tool call that *fails* on the child's side, so Claude Code prints its own
|
||||
# red error row (`⎿ Error: …`) above the input box. `Read` is auto-approved, so
|
||||
# this needs no permission prompt — the failure comes from the missing file.
|
||||
TOOL_ERROR_INPUT = {"file_path": "/nonexistent/claude-cloak/does-not-exist.txt"}
|
||||
|
||||
|
||||
ASK_INPUT = {"questions": [{
|
||||
"question": "The compact pane currently crops the top of this prompt. Which framing "
|
||||
"should the pane use when an interactive question is on screen, given that "
|
||||
@@ -269,6 +275,21 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
scenario = read_scenario()
|
||||
# `apierror`: a non-retryable upstream failure, so Claude Code prints
|
||||
# its own red `API Error: 400 …` row where the turn would have been.
|
||||
# Only the *turn* request fails — the side/title calls still answer, so
|
||||
# the pane behaves normally around the error.
|
||||
if scenario == "apierror" and tools and not hosted and not has_result:
|
||||
out = json.dumps({"type": "error", "error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": "fake upstream rejected this turn on purpose"}}).encode()
|
||||
self.send_response(400)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(out)))
|
||||
self.end_headers()
|
||||
self.wfile.write(out)
|
||||
return
|
||||
|
||||
# The nested hosted-tool request answers itself, whatever the scenario.
|
||||
if hosted:
|
||||
gen = stream_server_websearch()
|
||||
@@ -300,6 +321,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"Let me search for that.")
|
||||
elif scenario == "ansi":
|
||||
gen = stream_tool("Bash", ANSI_INPUT, "Printing coloured output.")
|
||||
elif scenario == "basherror":
|
||||
gen = stream_tool("Bash", {"command": "echo boom >&2; exit 3", "description": "Fail"}, "Running that.")
|
||||
elif scenario == "toolerror":
|
||||
gen = stream_tool("Read", TOOL_ERROR_INPUT, "Reading that file.")
|
||||
elif scenario == "taskupdate":
|
||||
gen = stream_tool("TaskUpdate", {"taskId": "1", "status": "in_progress"},
|
||||
"Starting the first task.")
|
||||
|
||||
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;
|
||||
|
||||
@@ -179,6 +179,10 @@ pub struct PaneState {
|
||||
pub visible: bool,
|
||||
pub focused: bool,
|
||||
pub fullscreen: bool,
|
||||
/// That fullscreen was entered for an editor on the child's alternate
|
||||
/// screen, so it is undone when the editor exits (`ui::sync_alt_screen`).
|
||||
/// Carried because the editor usually outlives the reload.
|
||||
pub alt_fullscreen: bool,
|
||||
pub past_embeds: Vec<String>,
|
||||
pub compact_inner: u16,
|
||||
}
|
||||
|
||||
@@ -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, …).
|
||||
|
||||
242
src/term.rs
242
src/term.rs
@@ -408,6 +408,21 @@ impl EmbeddedTerm {
|
||||
(term.screen().visible_row_to_stable_row(0) - top).max(0) as usize
|
||||
}
|
||||
|
||||
/// True while the child sits on the **alternate screen** — i.e. a
|
||||
/// full-screen program Claude Code launched (an `$EDITOR` like nvim, a
|
||||
/// `git commit` message, a pager) has taken the terminal over.
|
||||
///
|
||||
/// Claude Code itself never leaves the normal screen (verified on the wire:
|
||||
/// `alternate_on=0` on 2.1.247), so the flag means exactly one thing — what
|
||||
/// is on screen is not Claude Code's prompt. Framing it as one can only
|
||||
/// crop it: `compact_frame` locates the input box by its two rules, which
|
||||
/// an editor does not draw. The UI reads this to fullscreen the pane for as
|
||||
/// long as the editor lasts (`ui::sync_alt_screen`). The alternate screen
|
||||
/// also carries no scrollback, so `scroll` is a no-op there by itself.
|
||||
pub fn alt_screen(&self) -> bool {
|
||||
self.term.lock().unwrap().is_alt_screen_active()
|
||||
}
|
||||
|
||||
/// The child's current cursor shape (set via DECSCUSR). We mirror it onto
|
||||
/// the outer terminal so the pane shows a bar in insert mode and a block
|
||||
/// only when Claude Code's vim normal mode asks for one.
|
||||
@@ -456,7 +471,9 @@ impl EmbeddedTerm {
|
||||
/// region (one context row above the box, the input box itself however
|
||||
/// many lines it has grown to, and the statusLine — or a full `@`/`/`
|
||||
/// menu when one is open). The UI uses this to size the pane so the
|
||||
/// prompt auto-expands as you type and never scrolls out of view.
|
||||
/// prompt auto-expands as you type and never scrolls out of view, and so
|
||||
/// it grows over an active task panel or an error Claude Code just
|
||||
/// printed.
|
||||
/// Independent of the terminal's row count, so resizing the pane to this
|
||||
/// value can't feed back into the measurement.
|
||||
///
|
||||
@@ -471,6 +488,17 @@ impl EmbeddedTerm {
|
||||
Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
|
||||
}
|
||||
|
||||
/// True when the compact frame is currently taking in an error Claude Code
|
||||
/// printed (see `error_block_top`). The UI reads this to *cancel* the
|
||||
/// scheduled ctrl-l transcript wipe — for the same reason fullscreen
|
||||
/// cancels it: Ink redraws only the live frame, so a wipe would delete the
|
||||
/// error 400ms after it appeared and nothing would ever bring it back.
|
||||
/// Only called on the frame a wipe is actually due, so the screen scan
|
||||
/// costs nothing the rest of the time.
|
||||
pub fn shows_error(&self) -> bool {
|
||||
compact_frame_ex(&self.screen_rows()).is_some_and(|f| f.error)
|
||||
}
|
||||
|
||||
/// Inner rows the pane wants for the interactive prompt Claude Code draws
|
||||
/// for AskUserQuestion / ExitPlanMode (see `interactive_frame`). Measured
|
||||
/// from the child's screen for the same reason `compact_rows` is: the tap's
|
||||
@@ -703,6 +731,79 @@ fn task_block_top(rows: &[String], from: usize) -> Option<usize> {
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// How far above the input box an error still counts as "what just happened".
|
||||
/// The window has to reach well past the single context row — Claude Code
|
||||
/// parks a blank row and its `✻ Worked…` spinner row between the last
|
||||
/// transcript line and the box, and a long message wraps over several rows.
|
||||
/// It is the backstop bound on how far the frame grows for an error; a prompt
|
||||
/// the user has since sent ends the error's relevance sooner (see
|
||||
/// `error_block_top`).
|
||||
const ERROR_SCAN: usize = 12;
|
||||
|
||||
/// A row of Claude Code's *own* error reporting — an error the CLI itself
|
||||
/// produced, as it prints it above the input box:
|
||||
///
|
||||
/// ```text
|
||||
/// ● API Error: Connection lost mid-response. The response above may be
|
||||
/// incomplete.
|
||||
/// ```
|
||||
///
|
||||
/// **A `⎿` gutter disqualifies the row.** That gutter is a tool talking, and a
|
||||
/// failed tool is *not* a CLI error: its `tool_result` reaches the feed with
|
||||
/// the next request, so the feed above the pane already shows it and the pane
|
||||
/// has no reason to grow. Only the CLI's own errors are invisible to the feed
|
||||
/// (see the pane-error invariant), and they never carry a gutter — so the
|
||||
/// gutter is the whole test, and `⎿ Error: Exit code 3` is deliberately not
|
||||
/// a match.
|
||||
///
|
||||
/// The bullet *is* stripped (Claude Code pads it with U+00A0, which
|
||||
/// `trim_start` handles), and the colon in `Error:` is required: `● Error
|
||||
/// handling lives in src/foo.rs` is ordinary assistant prose. Neither shape
|
||||
/// can be recognised by colour — the tool error renders palette 211 and the
|
||||
/// API error 220, both theme values, so text shape is the only stable signal.
|
||||
fn text_is_error_row(t: &str) -> bool {
|
||||
let t = t.trim_start();
|
||||
if t.starts_with('⎿') {
|
||||
return false;
|
||||
}
|
||||
let t = t.strip_prefix(['●', '⏺']).unwrap_or(t).trim_start();
|
||||
t.starts_with("Error:") || t.starts_with("API Error") || t.starts_with(['✗', '✘', '✖', '✕'])
|
||||
}
|
||||
|
||||
/// A user prompt echoed into the transcript (`❯ resume`). Above the input
|
||||
/// box's top rule that is the only thing a `❯` row can be — the box's own `❯`
|
||||
/// and a menu's `❯` selection marker both sit *below* the rule, outside the
|
||||
/// window `error_block_top` scans.
|
||||
fn text_is_prompt_row(t: &str) -> bool {
|
||||
t.trim_start().starts_with('❯')
|
||||
}
|
||||
|
||||
/// Topmost row the compact frame extends to in order to show an error Claude
|
||||
/// Code just printed, or None when no error row sits within `ERROR_SCAN` of
|
||||
/// the input box.
|
||||
///
|
||||
/// Takes the *highest* error row in the window rather than the lowest, so a
|
||||
/// turn that reported several shows all of them, and a report wrapped over
|
||||
/// many rows is framed from its first line instead of its tail. Nothing is
|
||||
/// pulled in above that row: a CLI error names itself, unlike a tool error,
|
||||
/// which needed the `● Bash(…)` row above it and is no longer framed at all.
|
||||
///
|
||||
/// The window stops at the newest **prompt row**, which is the real "this is
|
||||
/// over" signal: once the user has typed something else, the error belongs to
|
||||
/// a previous exchange and the pane must go back to being prompt-only.
|
||||
/// `ERROR_SCAN` alone is far too coarse for that — Claude Code's own reply to
|
||||
/// an error is two rows, so the error would sit inside the window for the
|
||||
/// whole of the next turn and hold the pane open through it.
|
||||
fn error_block_top(rows: &[String], from: usize) -> Option<usize> {
|
||||
let mut floor = from.saturating_sub(ERROR_SCAN);
|
||||
if let Some(p) = (floor..=from).rev().find(|&i| text_is_prompt_row(&rows[i])) {
|
||||
// `p + 1 > from` when the prompt is the last row: an empty range, so
|
||||
// nothing is found and the frame collapses, which is the intent.
|
||||
floor = p + 1;
|
||||
}
|
||||
(floor..=from).find(|&i| text_is_error_row(&rows[i]))
|
||||
}
|
||||
|
||||
/// Step one row further up when `i` lands on a blank row, so the frame's top
|
||||
/// context row carries text (the panel is drawn with a blank `marginTop` row
|
||||
/// above it, and showing that blank instead of the spinner row wastes a line).
|
||||
@@ -744,6 +845,10 @@ struct CompactFrame {
|
||||
bottom: usize,
|
||||
/// The region ends on an open `@`/`/` menu rather than the statusLine.
|
||||
menu_open: bool,
|
||||
/// The region was extended upwards to take in an error Claude Code
|
||||
/// printed (`error_block_top`). `EmbeddedTerm::shows_error` reports this
|
||||
/// so the UI can cancel the scheduled ctrl-l wipe.
|
||||
error: bool,
|
||||
}
|
||||
|
||||
/// Same as `compact_frame`, but keeps the fields `compact_view_range` needs.
|
||||
@@ -765,6 +870,14 @@ fn compact_frame_ex(rows: &[String]) -> Option<CompactFrame> {
|
||||
Some(t) => skip_blank_up(rows, t.saturating_sub(1)),
|
||||
None => ctx_top,
|
||||
};
|
||||
// An error Claude Code just printed is the one part of its UI the feed
|
||||
// above cannot stand in for: a tool_result only reaches us with the *next*
|
||||
// request, so a turn that dies on the error never sends one, and a retry
|
||||
// exhaustion or an `API Error` row is Claude Code's own text, never in the
|
||||
// stream at all. So the frame walks further up to take the report in —
|
||||
// above the task panel too, since the error came before it.
|
||||
let err_top = error_block_top(rows, view_top);
|
||||
let view_top = err_top.unwrap_or(view_top);
|
||||
// An open `@`/`/` menu replaces the chrome below the bottom rule with a
|
||||
// list. Deciding that takes *two* rows, because a menu row is not reliably
|
||||
// marked (see `text_is_menu_item`) and the statusLine's text is the user's,
|
||||
@@ -784,7 +897,13 @@ fn compact_frame_ex(rows: &[String]) -> Option<CompactFrame> {
|
||||
let head_ok = last > head && (text_is_menu_item(&rows[head]) || rows[head].trim().is_empty());
|
||||
let menu_open = head_ok && (head + 1..=last).any(|i| text_is_menu_item(&rows[i]));
|
||||
let view_bottom = if menu_open { last } else { (bot_div + 1).min(last) };
|
||||
Some(CompactFrame { top: view_top, ess_top: ctx_top, bottom: view_bottom, menu_open })
|
||||
Some(CompactFrame {
|
||||
top: view_top,
|
||||
ess_top: ctx_top,
|
||||
bottom: view_bottom,
|
||||
menu_open,
|
||||
error: err_top.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Pick the `(start, end)` window `render` shows for `PaneView::Compact`,
|
||||
@@ -1620,6 +1739,125 @@ mod tests {
|
||||
assert_eq!(compact_frame(&screen), Some((2, 6)));
|
||||
}
|
||||
|
||||
/// A turn that died on an API error: Claude Code's own `● API Error:` row,
|
||||
/// then a blank, then its `✻ …` timing row above the input box — so the
|
||||
/// single context row the frame used to show landed on the blank.
|
||||
fn api_error_screen() -> Vec<String> {
|
||||
rows(&[
|
||||
"❯ now fail", // 0
|
||||
"", // 1
|
||||
"● API Error: 400 upstream rejected this", // 2: the error
|
||||
"", // 3
|
||||
"✻ Baked for 0s · done 8.15", // 4: old context row
|
||||
"", // 5
|
||||
&format!("{RULE} minimal ──"), // 6: top rule
|
||||
"❯", // 7
|
||||
RULE, // 8
|
||||
"Session: ▓▓░ Context | Opus", // 9: statusLine
|
||||
"⏵⏵ bypass permissions", // 10: chrome (cropped)
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_an_api_error_above_the_input_box() {
|
||||
// The whole point: an `API Error` row is Claude Code's own text and is
|
||||
// never in the API stream, so the pane is the only place it is ever
|
||||
// shown. It must be inside the frame, not two rows above it.
|
||||
assert_eq!(compact_frame(&api_error_screen()), Some((2, 9)));
|
||||
assert!(compact_frame_ex(&api_error_screen()).unwrap().error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_tool_does_not_grow_the_pane() {
|
||||
// Only the CLI's *own* errors are pane-only. A failed tool reports
|
||||
// through the `⎿` gutter, and its tool_result reaches the feed with
|
||||
// the next request — so the feed already shows it and the pane stays
|
||||
// at its plain one-context-row frame.
|
||||
let screen = rows(&[
|
||||
"● Running that.", // 0
|
||||
"", // 1
|
||||
"● Bash(echo boom >&2; exit 3)", // 2
|
||||
" ⎿ Error: Exit code 3", // 3: a tool talking
|
||||
" boom", // 4: wrapped tail
|
||||
"", // 5
|
||||
"✻ Osmosing… (11s)", // 6
|
||||
"", // 7
|
||||
&format!("{RULE} minimal ──"), // 8: top rule
|
||||
"❯", // 9
|
||||
RULE, // 10
|
||||
"Session: ▓▓░ Context | Opus", // 11
|
||||
]);
|
||||
assert_eq!(compact_frame(&screen), Some((7, 11)));
|
||||
assert!(!compact_frame_ex(&screen).unwrap().error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_frame_reaches_over_an_active_task_panel() {
|
||||
// The error came before the panel, so it sits *above* it — the frame
|
||||
// has to take in both, not stop at the panel's top.
|
||||
let mut screen = task_panel_screen();
|
||||
screen[0] = "● API Error: Connection lost mid-response.".into();
|
||||
assert_eq!(compact_frame(&screen), Some((0, 14)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_prose_and_hints_are_not_errors() {
|
||||
// Only the CLI's own rows count. A `⎿` gutter is a tool talking, so
|
||||
// every gutter shape is out — the idle tip, a plain result, and a
|
||||
// failed tool alike (that last one is the whole point: it is already
|
||||
// in the feed). `Error` without a colon is prose.
|
||||
assert!(!text_is_error_row("● Error handling lives in src/foo.rs"));
|
||||
assert!(!text_is_error_row(" ⎿ Tip: Say \"fan out subagents\""));
|
||||
assert!(!text_is_error_row(" ⎿ Read 20 lines"));
|
||||
assert!(!text_is_error_row(" ⎿ Error: File does not exist."));
|
||||
assert!(!text_is_error_row(" ⎿\u{a0}Error: Exit code 3"));
|
||||
assert!(text_is_error_row("● API Error: 400"));
|
||||
assert!(text_is_error_row("● API Error: Connection lost mid-response."));
|
||||
// Claude Code pads the bullet with a non-breaking space too.
|
||||
assert!(text_is_error_row("●\u{a0}Error: could not reach the API"));
|
||||
// An idle box with no error keeps the plain one-context-row frame.
|
||||
assert!(!compact_frame_ex(&task_panel_screen()).unwrap().error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_prompt_ends_the_errors_relevance() {
|
||||
// Reported: the pane kept holding an `API Error` open through the
|
||||
// whole next turn. Claude Code's reply to an error is only two rows,
|
||||
// so `ERROR_SCAN` alone never expires it — the prompt the user typed
|
||||
// since is the real signal that the error is history.
|
||||
let screen = rows(&[
|
||||
"● API Error: Connection lost mid-response.", // 0
|
||||
"✻ Baked for 3m 55s · done 11.53", // 1
|
||||
"❯ resume", // 2: sent since
|
||||
"· Cascading… (26s)", // 3
|
||||
"", // 4
|
||||
&format!("{RULE} minimal ──"), // 5: top rule
|
||||
"❯", // 6
|
||||
RULE, // 7
|
||||
"Session: ▓▓░ Context | Opus", // 8
|
||||
]);
|
||||
assert!(!compact_frame_ex(&screen).unwrap().error);
|
||||
assert_eq!(compact_frame(&screen), Some((4, 8)));
|
||||
// Without that prompt row the same error is still framed.
|
||||
let mut fresh = screen.clone();
|
||||
fresh[2] = "● Retrying…".into();
|
||||
assert_eq!(compact_frame(&fresh), Some((0, 8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_error_scrolls_out_of_the_scan_window() {
|
||||
// The window is what makes this self-limiting: as the next turn prints
|
||||
// output the error drifts past `ERROR_SCAN` and the pane shrinks back
|
||||
// instead of staying grown for the rest of the session.
|
||||
let mut screen = vec!["● API Error: Connection lost".to_string()];
|
||||
screen.extend((0..ERROR_SCAN + 2).map(|i| format!("● line {i}")));
|
||||
screen.extend([format!("{RULE} minimal ──"), "❯".into(), RULE.into(), "Session".into()]);
|
||||
assert!(!compact_frame_ex(&screen).unwrap().error);
|
||||
// …while two rows closer it is back in reach.
|
||||
screen.drain(1..3);
|
||||
assert_eq!(compact_frame(&screen).unwrap().0, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_input_box_yields_none() {
|
||||
// Startup banner only — no rules, so the caller falls back.
|
||||
|
||||
Reference in New Issue
Block a user