Compare commits
6 Commits
c7eddf3bde
...
b9d7d2c969
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9d7d2c969 | ||
|
|
73871cb1dc | ||
|
|
a5a6092579 | ||
|
|
1f483ad3d1 | ||
|
|
8907766b5b | ||
|
|
ba6b18e7d9 |
461
CLAUDE.md
461
CLAUDE.md
@@ -9,19 +9,31 @@ extra usage is the core constraint of this project.
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/main.rs entry; tokio runtime for proxy task, TUI on main thread; --headless mode
|
||||
src/main.rs entry; tokio runtime for proxy task, TUI on main thread; --headless mode.
|
||||
Also the *incoming* half of a hot reload: `reload::take_handoff`
|
||||
decides whether this process was started by a user or exec'd by
|
||||
its own previous image, and `open_listener` adopts the inherited
|
||||
accept socket instead of binding a new one
|
||||
src/proxy.rs axum fallback handler: buffers request body (for session metadata,
|
||||
tool results, and user prompts — `app::record_user_prompt` lifts
|
||||
the trailing user message into a Kind::User feed entry verbatim
|
||||
(incl. slash-command machinery — the goal is to show everything
|
||||
the model received, never filter it). On a turn-starting request
|
||||
(tools present) it also emits the system-prompt *size* as a
|
||||
the model received, never filter it; the one exception is a
|
||||
`<task-notification>`, which is *relocated* — see its invariant).
|
||||
`app::classify_request` sorts every request three ways
|
||||
(`ReqKind::{Turn, ServerTool, Side}`) — "has tools" alone is not
|
||||
a turn. On a `Turn` it also emits the system-prompt *size* as a
|
||||
Kind::System line (the prompt itself is too long to show) and the
|
||||
available tool set as Kind::ToolDefs — each once, re-emitted only
|
||||
on change (system/tools/history are re-sent every request but are
|
||||
not new data). A *side* request (no tools — topic/title haiku
|
||||
calls) is still shown, tagged with a `── side request ──` Meta
|
||||
divider. `app::extract_user_text` splits a user text block into
|
||||
divider. A `ServerTool` request (WebSearch's nested hosted-tool
|
||||
call) emits none of those lines and streams into its own lane.
|
||||
A response that is not a 2xx SSE stream is no longer silent: a
|
||||
non-2xx pushes a Kind::Error naming the status and the upstream
|
||||
message, built from the same best-effort tee (never
|
||||
`resp.bytes().await`). `app::extract_user_text` splits a user text block into
|
||||
its injected `<system-reminder>` spans (kept as dimmed
|
||||
Kind::Reminder entries, never discarded — the prompt survives even
|
||||
when it shares its block with a reminder, the
|
||||
@@ -38,10 +50,23 @@ src/sse.rs incremental SSE parser; tolerant of chunk splits mid-event/mid-UT
|
||||
src/app.rs Arc<Mutex<App>> shared state; Tap = one in-flight tapped request,
|
||||
translates SSE events → session Entries (Drop closes it out).
|
||||
Each Tap belongs to a *lane* (`Lane`/`LaneId`): lane 0 is the
|
||||
main chain, every subagent gets its own. Entries stay in one
|
||||
main chain, every subagent — and every nested server-tool call —
|
||||
gets its own. Entries stay in one
|
||||
append-only Vec tagged with `Entry::lane`; per-agent state
|
||||
(model, tokens, tool count, system/tools signatures, label,
|
||||
parent, finished) lives on `Lane`
|
||||
parent, finished, `<usage>` totals) lives on `Lane`.
|
||||
Also home to the `<task-notification>` parser
|
||||
(`TaskNotification` / `split_task_notifications` /
|
||||
`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/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`/
|
||||
`sanitize_md` are thin wrappers over
|
||||
`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
|
||||
(FeedCache: per-entry rendered lines + wrapped heights, only
|
||||
changed entries re-render; the viewport window of lines is
|
||||
@@ -58,7 +83,15 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed
|
||||
filled blocks stay legible under any terminal theme. Injected
|
||||
`<system-reminder>`s and the system-prompt-size `Kind::System`
|
||||
line show dim under the "system" filter; the `Kind::ToolDefs`
|
||||
tool-list line shares the "tools" filter with tool calls. The
|
||||
tool-list line shares the "tools" filter with tool calls, and
|
||||
`Kind::TaskNote` shares the "meta" filter with `Kind::Meta`
|
||||
(`FILTER_LABELS` stays 7 wide). `push_result`, `Kind::Reminder`
|
||||
and `Kind::User` route their text through `ansi`; `render_tool`
|
||||
covers the file, shell, task/monitor, prompt (AskUserQuestion /
|
||||
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
|
||||
@@ -71,8 +104,10 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed
|
||||
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`). See the
|
||||
subagent-popup invariant.
|
||||
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 (live = first user
|
||||
prompt via `live_title`, stub = disk label, word-wrapped by
|
||||
@@ -87,8 +122,15 @@ src/sessions.rs on-disk session history (main chain *and* subagents):
|
||||
App::disk_sessions fresh (~1/s poll, `read_meta` re-read only on
|
||||
mtime change — one pass yields the label *and* the session's
|
||||
last main-chain model, which `App::resume_model` turns into the
|
||||
`--model` a resume spawns with); load_view/load_history rebuild a feed Session
|
||||
from a JSONL transcript (lazily, on first view); build_tree
|
||||
`--model` a resume spawns with — always at `[1m]`);
|
||||
load_view/load_history rebuild a feed Session
|
||||
from a JSONL transcript (lazily, on first view) and give it the
|
||||
same `<task-notification>` treatment as the live path — notes
|
||||
lifted into Kind::TaskNote, the `<result>` moved onto the `Agent`
|
||||
tool entry it answers (matched by `<tool-use-id>` against
|
||||
`EntryParser::agent_tools`, inline in the note when that call is
|
||||
outside the view), `<usage>` totals applied to the matching lane
|
||||
in `splice_agents`; build_tree
|
||||
parses uuid/parentUuid chains into a TurnTree (one node per
|
||||
real user prompt; rewinds leave fork points); materialize
|
||||
writes a new session file from a chosen set of turns.
|
||||
@@ -104,8 +146,20 @@ src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` in a
|
||||
`x-claude-cloak-pane`), the correlation handle the proxy uses to
|
||||
recognise the pane's own traffic (see the embed-identity invariant).
|
||||
`cc_default_model` reads Claude Code's *own* configured default
|
||||
model out of its settings — the only persisted record of a
|
||||
`[1m]` pick (see the 1M-context invariant)
|
||||
model out of its settings, and `spawn_model_discovery` scans the
|
||||
`claude` binary for `App::models` (the aliases, and which of them
|
||||
ship a `[1m]` variant — see the 1M invariant). `EmbeddedTerm::adopt`
|
||||
rebuilds a pane around an inherited pty fd + pid after a hot
|
||||
reload (`AdoptedMaster` / `PidKiller` stand in for the
|
||||
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
|
||||
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
|
||||
hot-reload invariant
|
||||
```
|
||||
|
||||
Data flow: proxy task parses SSE chunks → `Tap::handle()` mutates shared state →
|
||||
@@ -113,6 +167,65 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
|
||||
## Key invariants
|
||||
|
||||
- **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
|
||||
`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
|
||||
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,
|
||||
which is the whole reason all three things survive: the **port** (the accept
|
||||
socket is inherited by fd number — `App::listener_fd` is a *dup*, so axum's
|
||||
graceful shutdown can drop its own listener without ever closing the socket),
|
||||
the **pane** (still our child, still on the same pty —
|
||||
`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
|
||||
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.
|
||||
2. **Drain before exec.** The exec destroys the tokio tasks relaying
|
||||
in-flight responses, so `proxy::run` serves `with_graceful_shutdown` and
|
||||
the exec waits for it (`App::drain_tx` → `App::drained`, capped by
|
||||
`DRAIN_MAX`). Counting `Session::active` is *not* the gate — it misses
|
||||
untapped traffic (`count_tokens`, non-streaming posts) and races
|
||||
`Tap::drop`, which runs on the tee task after the relay is done.
|
||||
Meanwhile the socket stays open, so requests Claude Code makes *during*
|
||||
the swap queue in the kernel backlog and are served by the new image:
|
||||
verified end-to-end — nothing refused, nothing truncated.
|
||||
3. **The snapshot is advisory, and split.** It is written by the old binary
|
||||
and read by the new one, whose types usually just changed — that is the
|
||||
normal case, not the edge case. So `Handoff` keeps the fd numbers in plain
|
||||
fields and leaves the feed as an undecoded `serde_json::Value`, decoded
|
||||
per session, each one passed through `sanitize_session`. Losing the feed
|
||||
must never cost the port or the pane. `Entry::lane`, `Lane::anchor`,
|
||||
`Lane::first_entry` and `Session::tool_ids` are raw indices that
|
||||
`#[serde(default)]` does *not* protect, so they are validated once there
|
||||
rather than defensively at every use site; `active` counters are zeroed
|
||||
(nothing streams into a process that no longer exists) and
|
||||
`app::seed_server_tool_seq` pushes the process-global `srvtool-` counter
|
||||
past whatever the restored lanes already hold.
|
||||
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
|
||||
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
|
||||
the pre-raw termios in a process global, so `disable_raw_mode` runs *before*
|
||||
the exec or the new image records raw as the original and hands the shell
|
||||
back in raw mode; and ratatui diffs its first frame against an empty buffer,
|
||||
so the incoming image clears the screen once. The alternate screen is
|
||||
deliberately **not** left — re-entering it is a no-op and avoids a flash.
|
||||
`Instant` has no epoch, so the three snapshotted ones travel as "ms ago"
|
||||
(`reload::ms_ago`) — otherwise every restored lane would read as freshly
|
||||
active. `App::embed_clear_at` points *forward* and is simply not carried.
|
||||
|
||||
- **Latency-neutral pass-through**: response bytes are forwarded as-is, never
|
||||
buffered or rewritten. Auth headers pass through untouched. If the tap code
|
||||
panics or misparses, the proxy must still relay bytes (tee is best-effort):
|
||||
@@ -151,6 +264,35 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
must never wait for either: a synchronous agent's result only lands when it
|
||||
has already finished, and the child's first request can beat the parent's next
|
||||
one, so lanes are born anonymous and adopted later.
|
||||
- **Turn detection is a three-way classification, not "has tools".**
|
||||
`app::classify_request` returns `ReqKind::{Turn, ServerTool, Side}` from the
|
||||
`tools` array alone and is the single predicate shared by `proxy.rs` and
|
||||
`record_user_prompt`. A hosted tool is **`type` present + `input_schema`
|
||||
absent** — never a version allowlist, so a future `web_search_20260101` still
|
||||
classifies, and the API's explicit `{"type":"custom"}` spelling is excluded.
|
||||
An empty/absent array stays the side/title request; a mixed array is a `Turn`.
|
||||
- **Claude Code's `WebSearch` is not purely client-side.** It issues a *nested*
|
||||
`/v1/messages` declaring Anthropic's server-side `web_search` under the
|
||||
parent's `session_id` with **no agent-id header** — verified on the wire. It
|
||||
gets its own lane via a synthetic `srvtool-<seq>` id, which cannot collide
|
||||
with a real agent id because Claude Code's are bare lowercase hex and
|
||||
`s`/`r`/`v`/`t`/`o`/`l`/`-` are not hex digits (so `finish_lane`,
|
||||
`close_lane_from_result` and the `<task-id>` scan can never land on one). It
|
||||
is labelled `web_search · "<query>"`, finished in `Tap::drop` (one request =
|
||||
one turn), emits no system/tools/side-request lines, and is readable only in
|
||||
the `A` popup — never in the main feed. Treating it as a turn start used to
|
||||
push a fake user prompt and clobber the main lane's system/tools signatures.
|
||||
- **The trailing user run skips `role:"system"` messages.** Claude Code ≥2.1.247
|
||||
sends beta `mid-conversation-system-2026-04-07` and appends the agent-type
|
||||
listing as a `role:"system"` message *after* the prompt, so turn 1 of every
|
||||
session reads `["user","system"]` — verified in both `claude -p` and the
|
||||
interactive CLI. A `take_while` on "user" saw that system message first,
|
||||
collected nothing and returned early, which dropped the **whole first turn**:
|
||||
no prompt block, no system/tools lines, no lane labelling, no notification
|
||||
scan, nothing for `ui::live_title`. Only an assistant message ends the run; a
|
||||
tool-loop continuation still contributes nothing. The mid-conversation
|
||||
message's size is surfaced as a second `Kind::System` line, tracked per lane
|
||||
in `Lane::last_mid_system_len` with the same once-then-on-change policy.
|
||||
- **One entry vec, tagged with lanes.** Per-lane vecs would double every index
|
||||
site (`Tap::cur`), break `FeedCache`'s positional alignment with
|
||||
`Session::entries`, turn the viewport window into a k-way merge inside the
|
||||
@@ -164,13 +306,57 @@ agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
user turn as `<task-notification>` … `<task-id><agent id></task-id>`. So
|
||||
`close_lane_from_result` only ties the lane to its tool call (and finishes it
|
||||
in the non-async wording, kept for older builds), while
|
||||
`Session::finish_lanes_from_notifications` — called from
|
||||
`Session::apply_task_notifications` — called from
|
||||
`record_user_prompt` on the trailing user run, before its early returns — is
|
||||
what stamps `Lane::finished_at`. Background *bash* tasks share the
|
||||
notification shape with a short id that matches no lane. Reading `finished`
|
||||
what stamps `Lane::finished_at`. It does four things per notification: stamps
|
||||
`finished_at` (**skipped for a monitor event** — a `<status>`-less progress
|
||||
ping is not a stop), records the `<usage>` totals on the lane, moves the
|
||||
`<result>` report onto the `Agent` tool entry via
|
||||
`task-id → lane_of_agent → Lane::anchor` (falling back to
|
||||
`<tool-use-id> → Lane::tool_use_id`), and pushes one `Kind::TaskNote` status
|
||||
line. The **disk path resolves the report differently** — by `<tool-use-id>`
|
||||
against `EntryParser::agent_tools`, which is never drained — and its lanes are
|
||||
finished by `add_lane`, not by the notification. Background *bash* tasks share
|
||||
the notification shape with a short id that matches no lane; they get a note
|
||||
and nothing else. Reading `finished`
|
||||
off the tool_result alone is why a finished agent used to keep reading as
|
||||
running.
|
||||
- **Subagents live in a popup; they never share the feed.** The main feed
|
||||
- **A task notification is relocated, never dropped.** This is the one
|
||||
refinement to "show everything the model received": `record_user_prompt`
|
||||
splits a `<task-notification>` out of the prompt (and out of a
|
||||
`<system-reminder>` wrapping one) instead of rendering the raw XML as a
|
||||
full-width orange prompt rectangle. The `<result>` report *moves* onto the
|
||||
`Agent` tool call it answers — replacing the `Async agent launched…`
|
||||
acknowledgement, `is_error` set on a failed status — and stays inline in the
|
||||
note only when there is no lane or no anchor to move it to. The status becomes
|
||||
one line whose **leading glyph is the status channel**:
|
||||
`app::TaskNotification::glyph` writes it and `ui::entry_lines` colours the
|
||||
entry from it, so `app.rs` stays the only place a note's text is built. Only
|
||||
`<note>` is elided — byte-identical boilerplate on all 208 real occurrences.
|
||||
`<status>` is **not reliably a word** (4 of 208 carry a raw upstream error
|
||||
body), so a non-word status is clipped to one line and reads as a failure.
|
||||
- **A server tool's result exists only in the stream.** A
|
||||
`web_search_tool_result` / `mcp_tool_result` arrives as a *complete*
|
||||
`content_block_start` (no deltas; `content_block_stop` follows immediately)
|
||||
and is **never** echoed back as a `tool_result` block in a later request body,
|
||||
so `attach_tool_results` can never fill it in — drop it and it is gone.
|
||||
`Tap::handle` attaches it to the entry `Session::tool_ids` maps `tool_use_id`
|
||||
to, and **consumes the id** exactly as `attach_tool_results` does, so the map
|
||||
stays bounded (a hosted search would otherwise leak one entry per call for the
|
||||
process lifetime). Hits are emitted in the *same* `Links: [{title,url}…]` wire
|
||||
shape Claude Code's client-side `WebSearch` string result uses, and
|
||||
`ui::render_tool`'s arm is `"websearch" | "web_search"`, so hosted and client
|
||||
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`
|
||||
@@ -202,7 +388,11 @@ agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
`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
|
||||
main↔subagent alternation), and the prompt dedup is scoped to the lane.
|
||||
main↔subagent alternation), and the prompt dedup is scoped to the lane. That
|
||||
dedup walk-back skips `Kind::TaskNote` alongside `Kind::Reminder` — both are
|
||||
turn preamble pushed just above the prompt, and leaving either in the way
|
||||
stops resends being deduped at all. Notes have their own resend guard against
|
||||
the lane's tail run.
|
||||
- **Embed identity is learned from traffic, never assumed from `--session-id`.**
|
||||
Claude Code's interactive `--session-id` is *not* guaranteed to equal the id
|
||||
it reports in request metadata (and a `--resume` can mint a fresh one), so the
|
||||
@@ -246,29 +436,34 @@ agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
last main-chain assistant message (`DiskSession::model`, filled by the
|
||||
scanner's `read_meta` — subagent `isSidechain` records run their own model
|
||||
and `<synthetic>` error records carry no model, so both are skipped) and
|
||||
`app::model_arg_for_id` maps that id to a `--model` argument: a known alias
|
||||
(`sonnet`, `opus`, … from `App::model_choices`) wins over the dated snapshot
|
||||
`Models::base_for_id` maps that id to a base model name: a known alias
|
||||
(`sonnet`, `opus`, … from `Models::aliases`) wins over the dated snapshot
|
||||
id, so a retired snapshot can't pin the pane; an id with no alias inside is
|
||||
passed through verbatim (`--model` takes full names too). The transcript is
|
||||
authoritative, so a mid-session `/model` switch is honoured.
|
||||
- **The 1M context window is a header, and a resume must keep it.**
|
||||
`--model opus[1m]` differs from `opus` only by `anthropic-beta:
|
||||
…,context-1m-…` — same body `model`, same transcript record — so no amount of
|
||||
transcript reading can tell them apart. The proxy is the only place that
|
||||
sees it: `proxy.rs` reads `BETA_HEADER` on **main-chain turn requests only**
|
||||
(a side/title call runs haiku without the flag, a subagent runs its own
|
||||
model) and `app::record_long_context` stores it as `Session::long_context`.
|
||||
`App::resume_arg` then picks the window: the wire observation wins, else the
|
||||
`[1m]` in `Session::spawn_model` (our own spawn, while it still names the
|
||||
same model), and with **neither** — a session that predates this process —
|
||||
it falls back to `term::cc_default_model()`, Claude Code's configured default
|
||||
(`ANTHROPIC_MODEL`, then local/project/user `settings.json`), which is the
|
||||
one place a `[1m]` pick is persisted (`/model` writes it there). When that
|
||||
default names the same base model the resume passes **no `--model` at all**
|
||||
and inherits it whole, window included; any explicit knowledge overrides it,
|
||||
including "this session ran the *short* window", which is why an observed
|
||||
non-1m session is resumed with an explicit `--model opus`. The suffix is only
|
||||
ever added for an alias that `model_choices` says has a `[1m]` variant.
|
||||
authoritative, so a mid-session `/model` switch is honoured. Only the
|
||||
*model* is decided there — the window is not a question (see below).
|
||||
- **Every model runs the 1M context window, always.** `Models::arg` is the one
|
||||
place that decides and it answers `<base>[1m]` whenever the installed
|
||||
`claude` ships that variant (`Models::long`, read out of the binary — the
|
||||
suffix is never assumed, so `haiku` stays `haiku`). Every spawn goes through
|
||||
it: `App::spawn_arg` for a fresh pane and the `a` picker, `App::resume_arg`
|
||||
for a resume. So a resume is `--model <base>[1m]` and nothing infers a
|
||||
window any more.
|
||||
The window is a *header*, not a model: `--model opus[1m]` differs from
|
||||
`opus` only by `anthropic-beta: …,context-1m-…`, and the body `model` and
|
||||
the transcript record read identically either way. That is why the old
|
||||
"observe it on the wire, replay it on resume" design existed — and why it
|
||||
kept losing the window: the observation had to survive a rename, a reload,
|
||||
a `WebSearch` sub-request and a process restart, and any gap fell back to
|
||||
the short window. Forcing it removes the failure mode instead of patching
|
||||
it. `Session::long_context`, `record_long_context` and the proxy's
|
||||
`anthropic-beta` read are gone with it.
|
||||
A model still has to be *named* for the suffix to attach, so a session we
|
||||
know nothing about (no transcript model, no spawn argument) borrows the
|
||||
model — never the window — from `term::cc_default_model()`
|
||||
(`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):
|
||||
`space` (or `→`/`l`) expands the selected session's turn tree — one row per
|
||||
real user prompt, abandoned rewind branches indented `⑂` under their fork
|
||||
@@ -288,11 +483,61 @@ agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
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).
|
||||
the feed shows the context) — **except in fullscreen**, where that wipe is
|
||||
*cancelled*, not deferred (see the pane-scroll invariant).
|
||||
- **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
|
||||
flag clear and `alternate_on=0`), so in a plain terminal the wheel scrolls
|
||||
*that terminal's* scrollback and the child never hears about it. The
|
||||
fullscreen pane is that terminal, so it does the same job:
|
||||
`EmbeddedTerm::scroll` moves a view into wezterm-term's scrollback (3500
|
||||
rows, the crate default) and `render` reads the window from there instead of
|
||||
the live screen. Four rules keep it honest:
|
||||
1. The view is a **`StableRowIndex`, not an offset** — the child keeps
|
||||
writing while you read, and a terminal pins the rows you scrolled to
|
||||
rather than sliding them up under you. Reaching the live top re-engages
|
||||
follow mode instead of pinning to it, and no cursor is reported while
|
||||
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.
|
||||
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
|
||||
keeps those for its own scrollback too, so they page the pane and are not
|
||||
forwarded.
|
||||
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
|
||||
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.
|
||||
- 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
|
||||
markdown self-heals; completed entries render from cache.
|
||||
markdown self-heals; completed entries render from cache. A
|
||||
**`citations_delta` is not text** — appending it would corrupt the entry, so
|
||||
citations are collected on the `Tap` per text block, deduplicated by url, and
|
||||
flushed by `flush_citations` at `content_block_stop` *and* in `Drop` (a cut
|
||||
stream keeps its sources) as one dim `Kind::Meta` entry.
|
||||
- **ANSI colour stops at a filled block.** Anything the feed renders as a filled
|
||||
rectangle owns its own fg/bg: `Kind::User` blocks pick their foreground with
|
||||
`color_on(bg)` so text stays legible under any terminal theme, so ANSI colour
|
||||
is dropped inside them (`ansi::plain_with_mods`) and only
|
||||
bold/dim/italic/underline survive; `ansi::spans` keeps the colour everywhere
|
||||
else. Attributes are re-applied *after* wrapping by walking a source cursor
|
||||
forward per character — safe because `wrap_words` only ever drops whitespace,
|
||||
never reorders.
|
||||
- **The ANSI parser is total.** It sits on the render path of output we do not
|
||||
control (`cargo`, rustfmt, slash-command stdout), so every failure mode
|
||||
degrades rather than throws: an unknown SGR parameter is skipped individually,
|
||||
a CSI with no valid final byte consumes only its parameter bytes (so a
|
||||
following multi-byte char survives), an unterminated OSC gives up at the
|
||||
newline. `push_search_result` follows the same rule — an unparseable `Links:`
|
||||
array returns false and the caller falls back to the raw result rendering.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -311,13 +556,22 @@ agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
and shows one context row above the top rule (the spinner / "✻ Worked…" row)
|
||||
down to the statusLine just under the bottom rule, cropping the persistent
|
||||
hint/token/effort chrome below it. When an `@`/`/` menu is open it has
|
||||
replaced that chrome with a list (`text_is_menu_item`, CC-2.1.x glyphs —
|
||||
retune there if an update changes them), so the frame extends to the last
|
||||
non-blank row instead. The menu is detected by scanning the *whole* region
|
||||
below the bottom rule for a menu row, not just the row directly under it: the
|
||||
list can start after a blank/header row and only the highlighted item carries
|
||||
a glyph (unselected file rows are plain names), so checking one row collapsed
|
||||
the pane whenever that row wasn't the selected item. Above the top rule the
|
||||
replaced that chrome with a list (`text_is_menu_item`), so the frame extends
|
||||
to the last non-blank row instead. **A menu row is not reliably marked**, so
|
||||
that list is recognised by *shape* and confirmed by a second row: a `+ `
|
||||
fuzzy hit, a `/command` / `@agent` row and the highlighted `❯` all carry a
|
||||
marker, but a path that leaves the project (`@../de`) switches CC to a plain
|
||||
**directory listing** whose rows are bare padded paths — the selected one
|
||||
differs only in colour, which `row_text` drops. So a whitespace-free token
|
||||
holding a `/` counts too, and because the user's own statusLine can look
|
||||
exactly like that, `compact_frame_ex` votes over two rows: the row directly
|
||||
under the rule must be a menu row *or blank* (it is shown either way — it is
|
||||
the statusLine's slot — so a one-row menu needs no detection), and at least
|
||||
one *further* row must match as well (which is what keeps a two-line
|
||||
statusLine from dragging the whole chrome into the pane). Scanning the whole
|
||||
tail also matters because only the highlighted row of a fuzzy list is marked,
|
||||
so checking one row collapsed the pane whenever that row wasn't the selected
|
||||
item. Above the top rule the
|
||||
frame also swallows an **active task panel** (`text_is_task_row` /
|
||||
`task_block_top`): Claude Code parks the `N tasks (…)` header + `✔ ◼ ◻` rows
|
||||
(and its `… +N pending` overflow line) directly above the input box, so
|
||||
@@ -370,25 +624,31 @@ agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
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), ctrl-q quit (global; needed while the pane is focused, where
|
||||
plain `q` is forwarded to the child), c attach most-recent past session.
|
||||
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 —
|
||||
`show_embed_new`; saves resume-then-/clear to get a fresh chat). The picker
|
||||
list comes from `App::model_choices`: seeded with `default_model_choices`,
|
||||
then replaced by `term::spawn_model_discovery` — a background scan that
|
||||
reads the live model-alias array (`["sonnet","opus","haiku","fable",…]`)
|
||||
straight out of the installed `claude` ELF (single self-contained binary
|
||||
with the JS bundle embedded). No API call, never runs claude — just resolves
|
||||
`claude` on PATH and greps its bytes for the longest lowercase-token array
|
||||
anchored by `opus`+`sonnet`. The list then gets the **1M-context variants**
|
||||
appended (`sonnet[1m]` etc., Claude Code's `--model` spelling for the long
|
||||
context window): `term::long_context_tokens` collects every quoted
|
||||
`"<token>[1m]"` literal in the same byte scan, and only aliases that really
|
||||
have one are offered (today `opus`/`sonnet`/`fable` — *not* `haiku` or
|
||||
`mythos`), so the suffix is never assumed. `[1m]` needs no shell quoting:
|
||||
the pane spawns via `CommandBuilder` argv, not a shell.
|
||||
list is `Models::choices()` — one row per model, at the window
|
||||
`Models::arg` gives it (`sonnet (1M context)` → `sonnet[1m]`, `haiku` →
|
||||
`haiku`), plus a `default` row that `App::spawn_arg` resolves to Claude
|
||||
Code's own configured model. There is deliberately **no short-window row**:
|
||||
every model runs 1M (see the invariant), so offering one would be a lie.
|
||||
`App::models` is seeded by `Models::seed` and replaced by
|
||||
`term::spawn_model_discovery` — a background scan that reads the live
|
||||
model-alias array (`["sonnet","opus","haiku","fable",…]`) straight out of
|
||||
the installed `claude` ELF (single self-contained binary with the JS bundle
|
||||
embedded). No API call, never runs claude — just resolves `claude` on PATH
|
||||
and greps its bytes for the longest lowercase-token array anchored by
|
||||
`opus`+`sonnet`. The same pass collects every quoted `"<token>[1m]"` literal
|
||||
(`term::long_context_tokens`) and keeps the base names as `Models::long`, so
|
||||
only models that really have the variant get the suffix — today
|
||||
`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
|
||||
@@ -402,10 +662,20 @@ agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
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.
|
||||
`CT_DEBUG_KEYS=1` shows raw key events in the status bar.
|
||||
- Mouse is captured: wheel always scrolls the feed (regardless of focus), and
|
||||
left-drag selects screen text, copied on release via OSC 52 (like Claude
|
||||
Code). Native terminal selection therefore needs shift held.
|
||||
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.
|
||||
`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
|
||||
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,
|
||||
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`):
|
||||
a multiline paste arrives as one `Event::Paste` and, when the claude pane
|
||||
has focus, is handed to the child via `EmbeddedTerm::paste`
|
||||
@@ -440,20 +710,47 @@ agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
fully offline end-to-end tests with zero API usage. That fake server is
|
||||
`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 | text`,
|
||||
switchable
|
||||
(`dev/.fake_scenario` =
|
||||
`ask | plan | todo | taskupdate | agent | websearch | ansi | text`, switchable
|
||||
mid-run) — this is how the pane's frame detector is developed against what
|
||||
Ink actually draws. Drive it through tmux (`.claude/skills/tui-verify`) and
|
||||
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
|
||||
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
|
||||
*every* tool-bearing request with `Agent` calls, including a subagent's own,
|
||||
so agents spawn recursively; switch to `text` once they are running.
|
||||
Drive it through tmux (`.claude/skills/tui-verify`) and
|
||||
obey that skill's safety rule: **never `pkill`/`killall`**, tear down only
|
||||
your own named tmux session. The child writes real task files under
|
||||
`~/.claude/tasks/<its-session-id>/`; delete that directory afterwards.
|
||||
|
||||
## Not yet handled (known MVP limits)
|
||||
|
||||
- Non-streaming requests pass through untapped (e.g. `count_tokens`).
|
||||
- Subagent popup: no per-lane prompt minimap (a subagent has no user prompts),
|
||||
and lanes loaded from disk have no token counts (a transcript records no
|
||||
usage) — the picker shows tool counts too. Only one agent is readable at a
|
||||
- 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.
|
||||
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
|
||||
caches and the turn-tree expansion are not snapshotted — all are lazily
|
||||
rebuilt. The adopted
|
||||
pane loses Ink's `<Static>` transcript, because the repaint is a SIGWINCH and
|
||||
Ink only redraws the live frame; for a prompt-only pane that is the intended
|
||||
end state anyway.
|
||||
|
||||
- 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).
|
||||
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
|
||||
replaced). A lane is never closed, only
|
||||
marked `finished`: a background agent (`x-app: cli-bg`) can wake up again
|
||||
@@ -473,10 +770,18 @@ agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
to read session metadata; adds first-byte latency on huge bodies.
|
||||
- Tool results only appear once the *next* request fires; if the session ends
|
||||
right after a tool call, that result is never seen. Output is what Claude
|
||||
Code sends the model (i.e. post-truncation).
|
||||
- Embedded pane: no mouse forwarding yet; no scrollback view (live screen
|
||||
only); shift+enter needs kitty keyboard protocol pushed on the outer
|
||||
terminal (not done); permission prompts aren't detected for pane growth
|
||||
Code sends the model (i.e. post-truncation). A *server* tool is the exception
|
||||
— its result rides in the same stream, so it lands immediately.
|
||||
- Binary tool_result blocks are named, not shown: `flatten_result_content`
|
||||
renders an image as `[image <media_type> · <size>]` (size derived from the
|
||||
base64 *length*, never a decode — a screenshot is hundreds of KB and this runs
|
||||
under the app mutex; `source.type == "url"` shows the url) and a
|
||||
`tool_reference` as `[tool <name>]`. The bare `[<type>]` placeholder remains
|
||||
the fallback for everything else. No terminal graphics protocol.
|
||||
- Embedded pane: no mouse forwarding to the child (it asks for none — see the
|
||||
pane-scroll invariant); the scrollback view is fullscreen-only, and the
|
||||
cropped views stay live-screen-only by design; shift+enter needs kitty
|
||||
keyboard protocol pushed on the outer terminal (not done); permission prompts aren't detected for pane growth
|
||||
(not visible in the API stream — would need a Notification hook hitting a
|
||||
local control endpoint).
|
||||
- Materialized branch files satisfy our own parser (round-trip tested) but
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -353,6 +353,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"futures-util",
|
||||
"libc",
|
||||
"portable-pty",
|
||||
"ratatui",
|
||||
"reqwest",
|
||||
|
||||
@@ -21,3 +21,7 @@ portable-pty = "0.9"
|
||||
wezterm-term = { git = "https://github.com/wezterm/wezterm", rev = "891bed31b75f7a71b78e8f42ad07ae89bf99a7de" }
|
||||
wezterm-surface = { git = "https://github.com/wezterm/wezterm", rev = "891bed31b75f7a71b78e8f42ad07ae89bf99a7de" }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
# Hot reload (src/reload.rs): fd inheritance across execve, PTY ioctls and
|
||||
# child signalling once the portable-pty handles are gone.
|
||||
libc = "0.2"
|
||||
|
||||
33
README.md
33
README.md
@@ -60,3 +60,36 @@ Sessions are keyed by the Claude Code session ID found in request metadata;
|
||||
concurrent requests (subagents) tap independently.
|
||||
|
||||
`--headless` runs the proxy without the TUI. `CT_PORT` overrides the port (default 8484).
|
||||
|
||||
## Hot reload
|
||||
|
||||
Swap a running instance onto a newly built binary with **ctrl-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
|
||||
```
|
||||
|
||||
claude-cloak never builds anything itself and watches no files. You rebuild the
|
||||
way you always would; ctrl-r says "run that one now".
|
||||
|
||||
ctrl-r 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.
|
||||
|
||||
### How it survives
|
||||
|
||||
The app `execve`s the new binary into its **own process**, so the pid, the open
|
||||
file descriptors and the child processes all stay. Before the exec the proxy
|
||||
drains: it stops accepting and lets in-flight responses finish, while the
|
||||
listening socket stays open so requests made during the swap wait in the kernel
|
||||
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.
|
||||
|
||||
A state snapshot the new types no longer fit costs the feed only — never the
|
||||
port and never the pane.
|
||||
|
||||
@@ -8,7 +8,12 @@ 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 | text).
|
||||
(ask | plan | todo | taskupdate | agent | websearch | ansi | 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
|
||||
with `server_tool_use` + `web_search_tool_result` + `citations_delta` — the block
|
||||
types only a hosted tool produces.
|
||||
Each incoming request is logged to `dev/fake_upstream.log` (declared tool names
|
||||
+ the trailing user text) so we can see what CC sends.
|
||||
"""
|
||||
@@ -61,6 +66,9 @@ def stream_text(text):
|
||||
yield sse("message_stop", {"type": "message_stop"})
|
||||
|
||||
|
||||
_TOOL_SEQ = 0
|
||||
|
||||
|
||||
def stream_tool(name, tool_input, lead="Working on it."):
|
||||
"""A turn that calls one client-side tool."""
|
||||
yield from stream_tools([(name, tool_input)], lead)
|
||||
@@ -78,8 +86,13 @@ def stream_tools(calls, lead="Working on it."):
|
||||
"delta": {"type": "text_delta", "text": lead}})
|
||||
yield sse("content_block_stop", {"type": "content_block_stop", "index": 0})
|
||||
for n, (name, tool_input) in enumerate(calls, start=1):
|
||||
# Ids must be unique across the whole session, exactly as the real API
|
||||
# guarantees: Claude Code resends the full history every request, so a
|
||||
# reused id makes an *old* tool_result re-attach to the newest call.
|
||||
global _TOOL_SEQ
|
||||
_TOOL_SEQ += 1
|
||||
yield sse("content_block_start", {"type": "content_block_start", "index": n,
|
||||
"content_block": {"type": "tool_use", "id": f"toolu_fake{n}",
|
||||
"content_block": {"type": "tool_use", "id": f"toolu_fake{_TOOL_SEQ}",
|
||||
"name": name, "input": {}}})
|
||||
blob = json.dumps(tool_input)
|
||||
for i in range(0, len(blob), 40):
|
||||
@@ -92,6 +105,63 @@ def stream_tools(calls, lead="Working on it."):
|
||||
yield sse("message_stop", {"type": "message_stop"})
|
||||
|
||||
|
||||
def stream_server_websearch():
|
||||
"""What a *hosted* web_search turn looks like: `server_tool_use`, then a
|
||||
complete `web_search_tool_result` block (no deltas — the whole payload
|
||||
rides in `content_block_start`), then cited text. Claude Code issues this
|
||||
nested request itself when it runs the client-side `WebSearch` tool."""
|
||||
yield sse("message_start", {"type": "message_start", "message": {
|
||||
"id": "msg_ws", "type": "message", "role": "assistant", "model": MODEL,
|
||||
"content": [], "stop_reason": None, "stop_sequence": None,
|
||||
"usage": {"input_tokens": 50, "output_tokens": 1}}})
|
||||
yield sse("content_block_start", {"type": "content_block_start", "index": 0,
|
||||
"content_block": {"type": "server_tool_use", "id": "srvtoolu_fake1",
|
||||
"name": "web_search", "input": {}}})
|
||||
blob = json.dumps({"query": "ratatui scrollbar thumb"})
|
||||
yield sse("content_block_delta", {"type": "content_block_delta", "index": 0,
|
||||
"delta": {"type": "input_json_delta", "partial_json": blob}})
|
||||
yield sse("content_block_stop", {"type": "content_block_stop", "index": 0})
|
||||
yield sse("content_block_start", {"type": "content_block_start", "index": 1,
|
||||
"content_block": {
|
||||
"type": "web_search_tool_result", "tool_use_id": "srvtoolu_fake1",
|
||||
"content": [
|
||||
{"type": "web_search_result", "title": "Ratatui Scrollbar docs",
|
||||
"url": "https://ratatui.rs/widgets/scrollbar", "page_age": "2 days"},
|
||||
{"type": "web_search_result", "title": "Scrollbar example",
|
||||
"url": "https://ratatui.rs/examples/scrollbar", "page_age": None},
|
||||
]}})
|
||||
yield sse("content_block_stop", {"type": "content_block_stop", "index": 1})
|
||||
yield sse("content_block_start", {"type": "content_block_start", "index": 2,
|
||||
"content_block": {"type": "text", "text": ""}})
|
||||
for chunk in "Ratatui renders the thumb through its Scrollbar widget. ".split(" "):
|
||||
yield sse("content_block_delta", {"type": "content_block_delta", "index": 2,
|
||||
"delta": {"type": "text_delta", "text": chunk + " "}})
|
||||
yield sse("content_block_delta", {"type": "content_block_delta", "index": 2,
|
||||
"delta": {"type": "citations_delta", "citation": {
|
||||
"type": "web_search_result_location",
|
||||
"url": "https://ratatui.rs/widgets/scrollbar",
|
||||
"title": "Ratatui Scrollbar docs",
|
||||
"cited_text": "Scrollbar renders a thumb over the track."}}})
|
||||
yield sse("content_block_stop", {"type": "content_block_stop", "index": 2})
|
||||
yield sse("message_delta", {"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
||||
"usage": {"output_tokens": 30, "server_tool_use": {"web_search_requests": 1}}})
|
||||
yield sse("message_stop", {"type": "message_stop"})
|
||||
|
||||
|
||||
# A command whose *output* carries real SGR codes, so the feed's ANSI handling
|
||||
# is exercised by a genuine tool_result rather than a hand-written fixture.
|
||||
ANSI_INPUT = {
|
||||
"command": (
|
||||
"printf '\\033[1mbold heading\\033[22m\\n'; "
|
||||
"printf '\\033[31m- removed line\\033[0m\\n'; "
|
||||
"printf '\\033[32m+ added line\\033[0m\\n'; "
|
||||
"printf '\\033[38;5;208m256-colour orange\\033[0m\\n'"
|
||||
),
|
||||
"description": "Print coloured output",
|
||||
}
|
||||
|
||||
|
||||
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 "
|
||||
@@ -163,7 +233,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
body = json.loads(raw)
|
||||
except Exception:
|
||||
body = {}
|
||||
tools = [t.get("name") for t in body.get("tools", []) or []]
|
||||
raw_tools = body.get("tools", []) or []
|
||||
tools = [t.get("name") for t in raw_tools]
|
||||
# A hosted tool carries a `type` and no `input_schema`; that is the
|
||||
# nested WebSearch call, not a turn start.
|
||||
hosted = bool(raw_tools) and all(
|
||||
t.get("input_schema") is None and t.get("type") not in (None, "custom")
|
||||
for t in raw_tools
|
||||
)
|
||||
msgs = body.get("messages", []) or []
|
||||
tail = json.dumps(msgs[-1])[:300] if msgs else ""
|
||||
# Only the immediate reply to *our* canned tool call ends the turn with
|
||||
@@ -192,8 +269,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
scenario = read_scenario()
|
||||
# The nested hosted-tool request answers itself, whatever the scenario.
|
||||
if hosted:
|
||||
gen = stream_server_websearch()
|
||||
# A request with no tools is CC's side/title call — answer with text.
|
||||
if not tools or has_result:
|
||||
elif not tools or has_result:
|
||||
gen = stream_text("Done. Ask me anything else.")
|
||||
elif scenario == "ask":
|
||||
gen = stream_tool("AskUserQuestion", ASK_INPUT, "Let me check how you want this framed.")
|
||||
@@ -215,6 +295,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
gen = stream_tools(
|
||||
[("Agent", a) for a in AGENT_INPUTS], "Delegating this."
|
||||
)
|
||||
elif scenario == "websearch":
|
||||
gen = stream_tool("WebSearch", {"query": "ratatui scrollbar thumb"},
|
||||
"Let me search for that.")
|
||||
elif scenario == "ansi":
|
||||
gen = stream_tool("Bash", ANSI_INPUT, "Printing coloured output.")
|
||||
elif scenario == "taskupdate":
|
||||
gen = stream_tool("TaskUpdate", {"taskId": "1", "status": "in_progress"},
|
||||
"Starting the first task.")
|
||||
@@ -241,5 +326,5 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 9911
|
||||
print(f"fake upstream on 127.0.0.1:{port} scenario={os.environ.get('CT_FAKE_SCENARIO', 'ask')}")
|
||||
print(f"fake upstream on 127.0.0.1:{port} scenario={read_scenario()}")
|
||||
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
||||
|
||||
513
src/ansi.rs
Normal file
513
src/ansi.rs
Normal file
@@ -0,0 +1,513 @@
|
||||
//! ANSI escape-sequence handling for feed text.
|
||||
//!
|
||||
//! Plenty of what reaches the feed is real terminal output rather than plain
|
||||
//! prose: colourised `cargo`/rustfmt results (`\x1b[31m- app.lo…`) and
|
||||
//! Claude Code's own slash-command stdout, which arrives inside the user turn
|
||||
//! verbatim (`/model` prints `Set model to \x1b[1mSonnet 5\x1b[22m …`).
|
||||
//! The feed used to drop the ESC byte as "just another control char" and leave
|
||||
//! `[1m` behind as literal text. Here the sequences are parsed instead: SGR
|
||||
//! becomes ratatui styling, every other escape is stripped.
|
||||
//!
|
||||
//! Self-contained on purpose — no new dependency — and deliberately total: an
|
||||
//! unknown parameter, a truncated colour spec or a sequence whose final byte
|
||||
//! never arrives degrades to "drop what we understood, keep the rest as text".
|
||||
//! Never a panic, and never a swallowed line: this runs on the render path of
|
||||
//! output we do not control.
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
|
||||
const ESC: u8 = 0x1b;
|
||||
const BEL: u8 = 0x07;
|
||||
|
||||
/// SGR state in force at a point in the text. Colours are optional so that
|
||||
/// "default foreground" (SGR 39) means *the caller's* base style rather than a
|
||||
/// hardcoded white — the feed's dim/red/accent bases must keep governing
|
||||
/// everything the output does not colour itself.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
struct Sgr {
|
||||
fg: Option<Color>,
|
||||
bg: Option<Color>,
|
||||
mods: Modifier,
|
||||
}
|
||||
|
||||
impl Sgr {
|
||||
/// This state layered on top of `base`.
|
||||
fn style(self, base: Style) -> Style {
|
||||
let mut s = base;
|
||||
if let Some(c) = self.fg {
|
||||
s = s.fg(c);
|
||||
}
|
||||
if let Some(c) = self.bg {
|
||||
s = s.bg(c);
|
||||
}
|
||||
s.add_modifier(self.mods)
|
||||
}
|
||||
}
|
||||
|
||||
/// SGR 30-37 / 40-47.
|
||||
const BASIC: [Color; 8] = [
|
||||
Color::Black,
|
||||
Color::Red,
|
||||
Color::Green,
|
||||
Color::Yellow,
|
||||
Color::Blue,
|
||||
Color::Magenta,
|
||||
Color::Cyan,
|
||||
Color::Gray,
|
||||
];
|
||||
|
||||
/// SGR 90-97 / 100-107.
|
||||
const BRIGHT: [Color; 8] = [
|
||||
Color::DarkGray,
|
||||
Color::LightRed,
|
||||
Color::LightGreen,
|
||||
Color::LightYellow,
|
||||
Color::LightBlue,
|
||||
Color::LightMagenta,
|
||||
Color::LightCyan,
|
||||
Color::White,
|
||||
];
|
||||
|
||||
/// Escape-free plain text for one visual row: sequences stripped, tabs expanded
|
||||
/// to four spaces, every remaining control char (newlines included) dropped.
|
||||
pub fn strip(text: &str) -> String {
|
||||
join(runs(text, false))
|
||||
}
|
||||
|
||||
/// Like [`strip`] but keeps `\n`, for block content whose newlines carry
|
||||
/// structure (markdown) and is split into rows further downstream.
|
||||
pub fn strip_multiline(text: &str) -> String {
|
||||
join(runs(text, true))
|
||||
}
|
||||
|
||||
/// One visual row rendered as styled spans: `base`, with each run's SGR applied
|
||||
/// on top. Runs are already coalesced, so a line with no escapes yields exactly
|
||||
/// one span (and an empty line yields none).
|
||||
pub fn spans(text: &str, base: Style) -> Vec<Span<'static>> {
|
||||
runs(text, false)
|
||||
.into_iter()
|
||||
.map(|(s, sgr)| Span::styled(s, sgr.style(base)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Escape-free text plus the attribute set in force at each *character* of it.
|
||||
///
|
||||
/// For the one caller that has to lay text out itself before it can style it:
|
||||
/// the filled user-prompt blocks wrap and pad to an exact width, so they need
|
||||
/// the attributes back *after* wrapping. Colours are deliberately not returned
|
||||
/// — a filled block owns its own fg/bg (`ui::color_on` picks a foreground that
|
||||
/// stays legible on the block's background under any terminal theme), and an
|
||||
/// arbitrary ANSI colour from slash-command stdout would wreck that contrast.
|
||||
pub fn plain_with_mods(text: &str) -> (String, Vec<Modifier>) {
|
||||
let mut plain = String::new();
|
||||
let mut mods = Vec::new();
|
||||
for (run, sgr) in runs(text, false) {
|
||||
mods.extend(std::iter::repeat_n(sgr.mods, run.chars().count()));
|
||||
plain.push_str(&run);
|
||||
}
|
||||
(plain, mods)
|
||||
}
|
||||
|
||||
/// Concatenate runs back into plain text, reusing the first run's buffer. Text
|
||||
/// with no escapes in it — the overwhelming majority, and this sits on the feed
|
||||
/// render path — is exactly one run, so it costs no copy at all.
|
||||
fn join(runs: Vec<(String, Sgr)>) -> String {
|
||||
let mut it = runs.into_iter().map(|(s, _)| s);
|
||||
let Some(mut first) = it.next() else {
|
||||
return String::new();
|
||||
};
|
||||
for s in it {
|
||||
first.push_str(&s);
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
/// Split `text` into escape-free runs, each paired with the SGR state that
|
||||
/// applies across it. Text is sanitized as it is collected (tabs expanded,
|
||||
/// other control chars dropped; `\n` survives only when `keep_newlines`) —
|
||||
/// a literal tab reaching ratatui becomes a `\t` cell symbol that the terminal
|
||||
/// renders by jumping to the next tab stop, scattering the row.
|
||||
fn runs(text: &str, keep_newlines: bool) -> Vec<(String, Sgr)> {
|
||||
let b = text.as_bytes();
|
||||
let mut out: Vec<(String, Sgr)> = Vec::new();
|
||||
let mut buf = String::new();
|
||||
let mut state = Sgr::default();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == ESC {
|
||||
let (next, sgr) = escape_at(text, i);
|
||||
if let Some(params) = sgr {
|
||||
let mut new = state;
|
||||
apply_sgr(&mut new, params);
|
||||
if new != state {
|
||||
if !buf.is_empty() {
|
||||
out.push((std::mem::take(&mut buf), state));
|
||||
}
|
||||
state = new;
|
||||
}
|
||||
}
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
// ESC is ASCII, so it can never sit inside a multi-byte sequence:
|
||||
// `i` is always on a char boundary here.
|
||||
let Some(c) = text[i..].chars().next() else { break };
|
||||
i += c.len_utf8();
|
||||
match c {
|
||||
'\t' => buf.push_str(" "),
|
||||
'\n' if keep_newlines => buf.push('\n'),
|
||||
c if c.is_control() => {}
|
||||
c => buf.push(c),
|
||||
}
|
||||
}
|
||||
if !buf.is_empty() {
|
||||
out.push((buf, state));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Consume the escape sequence starting at `i` (where `text` holds an ESC).
|
||||
/// Returns the byte index just past it and, for a CSI ending in `m`, that
|
||||
/// sequence's parameter string.
|
||||
///
|
||||
/// Everything else is stripped with no styling: other CSI finals (cursor moves,
|
||||
/// erases), OSC/DCS/SOS/PM/APC strings, nF escapes (charset designation) and
|
||||
/// two-char escapes. Damage from
|
||||
/// malformed input is bounded — a CSI whose final byte never arrives consumes
|
||||
/// only the parameter bytes it saw, and a string sequence missing its
|
||||
/// terminator stops at a newline, so at most one line is lost rather than the
|
||||
/// whole remaining text.
|
||||
fn escape_at(text: &str, i: usize) -> (usize, Option<&str>) {
|
||||
let b = text.as_bytes();
|
||||
match b.get(i + 1) {
|
||||
// Lone ESC at the very end of the text.
|
||||
None => (i + 1, None),
|
||||
Some(b'[') => {
|
||||
// CSI: parameter bytes 0x30-0x3f, intermediates 0x20-0x2f, final
|
||||
// byte 0x40-0x7e.
|
||||
let mut j = i + 2;
|
||||
while j < b.len() && (0x30..=0x3f).contains(&b[j]) {
|
||||
j += 1;
|
||||
}
|
||||
let params = &text[i + 2..j];
|
||||
while j < b.len() && (0x20..=0x2f).contains(&b[j]) {
|
||||
j += 1;
|
||||
}
|
||||
match b.get(j) {
|
||||
Some(&f) if (0x40..=0x7e).contains(&f) => (j + 1, (f == b'm').then_some(params)),
|
||||
// No valid final byte (end of text, or a UTF-8 lead byte):
|
||||
// the sequence never terminated. Stop here so the rest of the
|
||||
// line still reaches the reader.
|
||||
_ => (j, None),
|
||||
}
|
||||
}
|
||||
// OSC / DCS / SOS / PM / APC: a string run terminated by BEL or ST.
|
||||
Some(&b']' | b'P' | b'X' | b'^' | b'_') => {
|
||||
let mut j = i + 2;
|
||||
while j < b.len() {
|
||||
match b[j] {
|
||||
BEL => return (j + 1, None),
|
||||
ESC if b.get(j + 1) == Some(&b'\\') => return (j + 2, None),
|
||||
// No terminator on this line: give up rather than eat the
|
||||
// rest of the text (these bytes are all ASCII, so `j` is
|
||||
// on a char boundary).
|
||||
b'\n' => return (j, None),
|
||||
_ => j += 1,
|
||||
}
|
||||
}
|
||||
(b.len(), None)
|
||||
}
|
||||
// nF escape (`ESC I… F`): intermediate bytes 0x20-0x2f, then a final
|
||||
// 0x30-0x7e. Charset designation lives here — `ESC ( B` (G0 = ASCII),
|
||||
// which rustfmt and `git diff` emit after *every* colour reset
|
||||
// (`\x1b(B\x1b[m`). Consumed as a two-char escape it left a literal `B`
|
||||
// at the head of each coloured run.
|
||||
Some(&c) if (0x20..=0x2f).contains(&c) => {
|
||||
let mut j = i + 2;
|
||||
while j < b.len() && (0x20..=0x2f).contains(&b[j]) {
|
||||
j += 1;
|
||||
}
|
||||
match b.get(j) {
|
||||
Some(&f) if (0x30..=0x7e).contains(&f) => (j + 1, None),
|
||||
// Never terminated (end of text, or a UTF-8 lead byte): stop
|
||||
// here, exactly as the CSI arm does.
|
||||
_ => (j, None),
|
||||
}
|
||||
}
|
||||
// Two-char escape (`ESC c`); in malformed input `c` may be multi-byte.
|
||||
Some(_) => {
|
||||
let n = text[i + 1..].chars().next().map_or(1, char::len_utf8);
|
||||
(i + 1 + n, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold one SGR sequence's parameters into `state`. Unknown parameters are
|
||||
/// skipped individually so the ones around them still take effect.
|
||||
fn apply_sgr(state: &mut Sgr, params: &str) {
|
||||
// An omitted parameter means 0 (ECMA-48), so a bare `ESC [ m` is a reset.
|
||||
// An unparseable one stays `None` and is skipped without stopping the run.
|
||||
let vals: Vec<Option<u16>> = params
|
||||
.split(';')
|
||||
.map(|p| if p.is_empty() { Some(0) } else { p.parse().ok() })
|
||||
.collect();
|
||||
let mut i = 0;
|
||||
while i < vals.len() {
|
||||
let Some(v) = vals[i] else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let mut step = 1;
|
||||
match v {
|
||||
0 => *state = Sgr::default(),
|
||||
1 => state.mods.insert(Modifier::BOLD),
|
||||
2 => state.mods.insert(Modifier::DIM),
|
||||
3 => state.mods.insert(Modifier::ITALIC),
|
||||
4 => state.mods.insert(Modifier::UNDERLINED),
|
||||
7 => state.mods.insert(Modifier::REVERSED),
|
||||
21 => state.mods.remove(Modifier::BOLD),
|
||||
// 22 turns off bold *and* dim (they share an "intensity" axis).
|
||||
22 => state.mods.remove(Modifier::BOLD | Modifier::DIM),
|
||||
23 => state.mods.remove(Modifier::ITALIC),
|
||||
24 => state.mods.remove(Modifier::UNDERLINED),
|
||||
27 => state.mods.remove(Modifier::REVERSED),
|
||||
30..=37 => state.fg = Some(BASIC[usize::from(v - 30)]),
|
||||
38 => {
|
||||
let (c, n) = extended(&vals, i + 1);
|
||||
if c.is_some() {
|
||||
state.fg = c;
|
||||
}
|
||||
step = n + 1;
|
||||
}
|
||||
39 => state.fg = None,
|
||||
40..=47 => state.bg = Some(BASIC[usize::from(v - 40)]),
|
||||
48 => {
|
||||
let (c, n) = extended(&vals, i + 1);
|
||||
if c.is_some() {
|
||||
state.bg = c;
|
||||
}
|
||||
step = n + 1;
|
||||
}
|
||||
49 => state.bg = None,
|
||||
90..=97 => state.fg = Some(BRIGHT[usize::from(v - 90)]),
|
||||
100..=107 => state.bg = Some(BRIGHT[usize::from(v - 100)]),
|
||||
_ => {}
|
||||
}
|
||||
i += step;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the sub-parameters of a `38`/`48` extended colour, starting at the
|
||||
/// `5` (indexed) or `2` (truecolor) selector. Returns the colour and how many
|
||||
/// parameters the whole spec occupies — a truncated or out-of-range spec yields
|
||||
/// no colour but still reports its width, so the parameters after it survive.
|
||||
fn extended(vals: &[Option<u16>], i: usize) -> (Option<Color>, usize) {
|
||||
let at = |k: usize| {
|
||||
vals.get(i + k)
|
||||
.copied()
|
||||
.flatten()
|
||||
.and_then(|n| u8::try_from(n).ok())
|
||||
};
|
||||
match vals.get(i).copied().flatten() {
|
||||
Some(5) => (at(1).map(Color::Indexed), 2),
|
||||
Some(2) => match (at(1), at(2), at(3)) {
|
||||
(Some(r), Some(g), Some(b)) => (Some(Color::Rgb(r, g, b)), 4),
|
||||
_ => (None, 4),
|
||||
},
|
||||
_ => (None, 1),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{plain_with_mods, spans, strip, strip_multiline};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
/// Flatten spans to `(text, fg, modifiers)` for terse assertions.
|
||||
fn parts(text: &str) -> Vec<(String, Option<Color>, Modifier)> {
|
||||
spans(text, Style::default())
|
||||
.into_iter()
|
||||
.map(|s| (s.content.into_owned(), s.style.fg, s.style.add_modifier))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The motivating case: a `/model` slash-command result. The markers must
|
||||
/// be gone from the text and `Sonnet 5` must come out actually bold.
|
||||
#[test]
|
||||
fn model_command_output_renders_bold_with_markers_gone() {
|
||||
let raw = "Set model to \u{1b}[1mSonnet 5\u{1b}[22m and saved as your default\u{1b}[2m\u{1b}[22m";
|
||||
assert_eq!(
|
||||
strip(raw),
|
||||
"Set model to Sonnet 5 and saved as your default"
|
||||
);
|
||||
|
||||
let p = parts(raw);
|
||||
assert_eq!(p.len(), 3, "plain / bold / plain: {p:?}");
|
||||
assert_eq!(p[0].0, "Set model to ");
|
||||
assert!(!p[0].2.contains(Modifier::BOLD));
|
||||
assert_eq!(p[1].0, "Sonnet 5");
|
||||
assert!(p[1].2.contains(Modifier::BOLD), "bold between 1m and 22m");
|
||||
assert_eq!(p[2].0, " and saved as your default");
|
||||
// `2m` then `22m` cancel out: the tail is unstyled, not left dim.
|
||||
assert!(!p[2].2.intersects(Modifier::BOLD | Modifier::DIM));
|
||||
// No stray `[1m` / `[22m` anywhere in the rendered text.
|
||||
for (t, _, _) in &p {
|
||||
assert!(!t.contains('['), "escape leaked as literal text: {t:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Colourised diff output (`cargo`, rustfmt) — the other everyday source.
|
||||
#[test]
|
||||
fn basic_and_bright_colours_map_to_ratatui() {
|
||||
let p = parts("\u{1b}[31m- removed");
|
||||
assert_eq!(p.len(), 1);
|
||||
assert_eq!(p[0].0, "- removed");
|
||||
assert_eq!(p[0].1, Some(Color::Red));
|
||||
|
||||
// Bright foreground, background, and the 39/49 defaults.
|
||||
assert_eq!(parts("\u{1b}[92mok")[0].1, Some(Color::LightGreen));
|
||||
assert_eq!(
|
||||
spans("\u{1b}[41mhot", Style::default())[0].style.bg,
|
||||
Some(Color::Red)
|
||||
);
|
||||
// 39 returns to "whatever the caller's base says", i.e. unset.
|
||||
assert_eq!(parts("\u{1b}[31ma\u{1b}[39mb")[1].1, None);
|
||||
}
|
||||
|
||||
/// The base style shows through wherever the output sets nothing itself,
|
||||
/// and only the properties SGR names are overridden.
|
||||
#[test]
|
||||
fn base_style_survives_underneath() {
|
||||
let base = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC);
|
||||
let out = spans("plain \u{1b}[31mred", base);
|
||||
assert_eq!(out[0].style.fg, Some(Color::DarkGray));
|
||||
assert_eq!(out[1].style.fg, Some(Color::Red));
|
||||
// The base's italic rides along on both runs.
|
||||
assert!(out[0].style.add_modifier.contains(Modifier::ITALIC));
|
||||
assert!(out[1].style.add_modifier.contains(Modifier::ITALIC));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_colours_indexed_and_truecolor() {
|
||||
let p = parts("\u{1b}[38;5;208mX");
|
||||
assert_eq!(p[0].0, "X");
|
||||
assert_eq!(p[0].1, Some(Color::Indexed(208)));
|
||||
|
||||
let p = parts("\u{1b}[38;2;10;20;30mX");
|
||||
assert_eq!(p[0].1, Some(Color::Rgb(10, 20, 30)));
|
||||
|
||||
// Background forms of both.
|
||||
let bg = |t: &str| spans(t, Style::default())[0].style.bg;
|
||||
assert_eq!(bg("\u{1b}[48;5;17mX"), Some(Color::Indexed(17)));
|
||||
assert_eq!(bg("\u{1b}[48;2;1;2;3mX"), Some(Color::Rgb(1, 2, 3)));
|
||||
|
||||
// A 256-colour spec inside a longer run: the parameters after the
|
||||
// extended colour still apply.
|
||||
let p = parts("\u{1b}[1;38;5;208;4mX");
|
||||
assert_eq!(p[0].1, Some(Color::Indexed(208)));
|
||||
assert!(p[0].2.contains(Modifier::BOLD | Modifier::UNDERLINED));
|
||||
}
|
||||
|
||||
/// OSC (window title, OSC 8 hyperlinks, OSC 52 clipboard) carries no
|
||||
/// styling: strip the whole sequence, keep the text around it.
|
||||
#[test]
|
||||
fn osc_sequences_are_stripped_entirely() {
|
||||
// BEL-terminated.
|
||||
assert_eq!(strip("a\u{1b}]0;my title\u{7}b"), "ab");
|
||||
// ST-terminated (`ESC \`).
|
||||
assert_eq!(strip("a\u{1b}]52;c;Zm9v\u{1b}\\b"), "ab");
|
||||
// OSC 8 hyperlink wrapper around visible text.
|
||||
assert_eq!(
|
||||
strip("\u{1b}]8;;https://x/\u{7}link\u{1b}]8;;\u{7}"),
|
||||
"link"
|
||||
);
|
||||
// Non-SGR CSI (cursor move, erase) and a two-char escape.
|
||||
assert_eq!(strip("a\u{1b}[2Kb\u{1b}[10;5Hc\u{1b}=d"), "abcd");
|
||||
}
|
||||
|
||||
/// Real `cargo fmt` output: each coloured run is closed with `ESC ( B`
|
||||
/// (G0 = ASCII) *before* the SGR reset. Consumed as a two-char escape that
|
||||
/// left the `B` behind, so every diff line in the feed read `B+ added line`.
|
||||
#[test]
|
||||
fn charset_designation_leaves_no_stray_letter() {
|
||||
let line = "\u{1b}[32m+ break;\u{1b}(B\u{1b}[m";
|
||||
assert_eq!(strip(line), "+ break;");
|
||||
let p = parts(line);
|
||||
assert_eq!(p.len(), 1, "one green run, no stray letter: {p:?}");
|
||||
assert_eq!(p[0].1, Some(Color::Green));
|
||||
// Other nF escapes: line-drawing G1, `ESC # 8` (DECALN), `ESC % G`.
|
||||
assert_eq!(strip("a\u{1b})0b\u{1b}#8c\u{1b}%Gd"), "abcd");
|
||||
// Never terminated: the text after it still reaches the reader.
|
||||
assert_eq!(strip("keep \u{1b}("), "keep ");
|
||||
assert_eq!(strip("keep \u{1b}(\u{e6}"), "keep \u{e6}");
|
||||
}
|
||||
|
||||
/// Malformed input must not panic and must not eat the visible text.
|
||||
#[test]
|
||||
fn malformed_escapes_keep_the_rest_of_the_line() {
|
||||
// Unterminated CSI at end of text.
|
||||
assert_eq!(strip("keep \u{1b}[1"), "keep ");
|
||||
assert_eq!(strip("keep \u{1b}["), "keep ");
|
||||
assert_eq!(strip("keep \u{1b}"), "keep ");
|
||||
// Parameters with no final byte, followed by real (multi-byte) text.
|
||||
assert_eq!(strip("a\u{1b}[1;2é"), "aé");
|
||||
// Truncated extended colours: no colour, but the text survives.
|
||||
assert_eq!(parts("\u{1b}[38;5mZ")[0].0, "Z");
|
||||
assert_eq!(parts("\u{1b}[38;5mZ")[0].1, None);
|
||||
assert_eq!(parts("\u{1b}[38;2;10;20mZ")[0].1, None);
|
||||
assert_eq!(parts("\u{1b}[38;9;7mZ")[0].0, "Z");
|
||||
// Out-of-range and unknown parameters are skipped one at a time.
|
||||
assert_eq!(parts("\u{1b}[999;1mZ")[0].0, "Z");
|
||||
assert!(parts("\u{1b}[999;1mZ")[0].2.contains(Modifier::BOLD));
|
||||
assert_eq!(parts("\u{1b}[38;5;300mZ")[0].1, None);
|
||||
// An unterminated OSC gives up at the newline instead of swallowing on.
|
||||
assert_eq!(strip_multiline("\u{1b}]0;no end\nnext line"), "\nnext line");
|
||||
}
|
||||
|
||||
/// The sanitizing half of the old `sanitize`/`sanitize_md` pair is intact.
|
||||
#[test]
|
||||
fn tabs_expand_and_newlines_follow_the_mode() {
|
||||
assert_eq!(strip("a\tb"), "a b");
|
||||
assert_eq!(strip_multiline("a\tb\nc"), "a b\nc");
|
||||
// Newlines are a control char for the single-row form, kept for blocks.
|
||||
assert_eq!(strip("x\r\ny"), "xy");
|
||||
assert_eq!(strip_multiline("x\r\ny"), "x\ny");
|
||||
// Styling survives across a kept newline.
|
||||
assert_eq!(strip_multiline("\u{1b}[1ma\nb"), "a\nb");
|
||||
}
|
||||
|
||||
/// One modifier per character of the plain text, colours dropped — what
|
||||
/// the filled user-prompt blocks need to restyle after wrapping.
|
||||
#[test]
|
||||
fn plain_with_mods_is_char_aligned_and_colourless() {
|
||||
let (plain, mods) = plain_with_mods("ab\u{1b}[1;31mCD\u{1b}[22mef");
|
||||
assert_eq!(plain, "abCDef");
|
||||
assert_eq!(mods.len(), plain.chars().count());
|
||||
assert!(!mods[1].contains(Modifier::BOLD));
|
||||
assert!(mods[2].contains(Modifier::BOLD));
|
||||
assert!(mods[3].contains(Modifier::BOLD));
|
||||
assert!(!mods[4].contains(Modifier::BOLD));
|
||||
|
||||
// Multi-byte text stays aligned by *character*, not by byte.
|
||||
let (plain, mods) = plain_with_mods("é\u{1b}[3mü");
|
||||
assert_eq!(plain, "éü");
|
||||
assert_eq!(mods.len(), 2);
|
||||
assert!(mods[1].contains(Modifier::ITALIC));
|
||||
|
||||
// A tab expands to four characters, all carrying its modifier.
|
||||
let (plain, mods) = plain_with_mods("\u{1b}[4m\tx");
|
||||
assert_eq!(plain, " x");
|
||||
assert_eq!(mods.len(), 5);
|
||||
assert!(mods.iter().all(|m| m.contains(Modifier::UNDERLINED)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_escape_only_input_is_harmless() {
|
||||
assert_eq!(strip(""), "");
|
||||
assert_eq!(strip("\u{1b}[0m"), "");
|
||||
assert!(spans("", Style::default()).is_empty());
|
||||
assert!(spans("\u{1b}[1m\u{1b}[0m", Style::default()).is_empty());
|
||||
assert_eq!(plain_with_mods("").0, "");
|
||||
}
|
||||
}
|
||||
2427
src/app.rs
2427
src/app.rs
File diff suppressed because it is too large
Load Diff
77
src/main.rs
77
src/main.rs
@@ -1,18 +1,41 @@
|
||||
mod ansi;
|
||||
mod app;
|
||||
mod markdown;
|
||||
mod proxy;
|
||||
mod reload;
|
||||
mod sessions;
|
||||
mod sse;
|
||||
mod term;
|
||||
mod ui;
|
||||
|
||||
use app::App;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let headless = std::env::args().any(|a| a == "--headless");
|
||||
|
||||
let app = Arc::new(Mutex::new(App::new()));
|
||||
// A hot reload execs the new binary into this same process (see
|
||||
// reload.rs), so a handoff here means "we are the new code and everything
|
||||
// the old code owned is still open": the listener socket, the pty of the
|
||||
// embedded pane, and a snapshot of the feed.
|
||||
let handoff = reload::take_handoff();
|
||||
|
||||
let mut state = App::new();
|
||||
let mut pane = None;
|
||||
let mut pane_ui = reload::PaneState::default();
|
||||
let reloaded = handoff.is_some();
|
||||
if let Some(h) = handoff {
|
||||
state.reload_gen = h.generation + 1;
|
||||
state.listener_fd = h.listener_fd;
|
||||
// Best-effort: a snapshot the new types no longer fit costs the feed,
|
||||
// never the port and never the pane (they are plain numbers above).
|
||||
reload::restore(&mut state, h.app);
|
||||
pane = h.pane;
|
||||
pane_ui = h.pane_ui;
|
||||
}
|
||||
|
||||
let app = Arc::new(Mutex::new(state));
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
@@ -20,22 +43,34 @@ fn main() -> anyhow::Result<()> {
|
||||
// Bind up front so every instance gets its own port: CT_PORT pins it
|
||||
// (hard error if taken), otherwise prefer 8484 and fall back to an
|
||||
// OS-assigned free port so multiple instances can coexist.
|
||||
let listener = rt.block_on(async {
|
||||
match std::env::var("CT_PORT").ok().and_then(|p| p.parse::<u16>().ok()) {
|
||||
Some(p) => tokio::net::TcpListener::bind(("127.0.0.1", p)).await,
|
||||
None => match tokio::net::TcpListener::bind(("127.0.0.1", 8484u16)).await {
|
||||
Ok(l) => Ok(l),
|
||||
Err(_) => tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await,
|
||||
},
|
||||
}
|
||||
})?;
|
||||
//
|
||||
// A reload skips all of that and adopts the socket the previous image was
|
||||
// already accepting on, so the port never closes and Claude Code never
|
||||
// sees a refused connection.
|
||||
let inherited = app::lock_app(&app).listener_fd;
|
||||
let listener = rt.block_on(open_listener(inherited))?;
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
// Keep a dup for the *next* reload: the graceful shutdown drops axum's own
|
||||
// listener, and this fd is what holds the socket open across the exec.
|
||||
let keep_fd = reload::dup_listener(&listener)?;
|
||||
let (drain_tx, drain_rx) = tokio::sync::oneshot::channel();
|
||||
let drained = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let mut a = app::lock_app(&app);
|
||||
a.listener_fd = keep_fd;
|
||||
a.drain_tx = Some(drain_tx);
|
||||
a.drained = drained.clone();
|
||||
}
|
||||
|
||||
let papp = app.clone();
|
||||
let proxy_handle = rt.spawn(async move {
|
||||
if let Err(e) = proxy::run(papp.clone(), listener).await {
|
||||
if let Err(e) = proxy::run(papp.clone(), listener, drain_rx).await {
|
||||
app::lock_app(&papp).status = format!("proxy failed: {e}");
|
||||
}
|
||||
// `serve` has returned, so every connection task is finished. This is
|
||||
// the reload's go-ahead.
|
||||
drained.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
if headless {
|
||||
@@ -43,8 +78,26 @@ fn main() -> anyhow::Result<()> {
|
||||
rt.block_on(proxy_handle)?;
|
||||
Ok(())
|
||||
} else {
|
||||
let r = ui::run(app, port);
|
||||
let r = ui::run(app, port, pane, pane_ui, reloaded);
|
||||
rt.shutdown_background();
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// The proxy's accept socket: adopted from the previous image after a hot
|
||||
/// reload, freshly bound otherwise.
|
||||
async fn open_listener(inherited: std::os::fd::RawFd) -> anyhow::Result<tokio::net::TcpListener> {
|
||||
if let Ok(std) = reload::adopt_listener(inherited)
|
||||
&& let Ok(l) = tokio::net::TcpListener::from_std(std)
|
||||
{
|
||||
return Ok(l);
|
||||
}
|
||||
let l = match std::env::var("CT_PORT").ok().and_then(|p| p.parse::<u16>().ok()) {
|
||||
Some(p) => tokio::net::TcpListener::bind(("127.0.0.1", p)).await?,
|
||||
None => match tokio::net::TcpListener::bind(("127.0.0.1", 8484u16)).await {
|
||||
Ok(l) => l,
|
||||
Err(_) => tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await?,
|
||||
},
|
||||
};
|
||||
Ok(l)
|
||||
}
|
||||
|
||||
123
src/proxy.rs
123
src/proxy.rs
@@ -1,6 +1,6 @@
|
||||
use crate::app::{
|
||||
AgentTag, SharedApp, Tap, attach_tool_results, lock_app, record_long_context,
|
||||
record_user_prompt,
|
||||
AgentTag, ReqKind, SharedApp, Tap, attach_tool_results, classify_request,
|
||||
label_server_tool_lane, lock_app, next_server_tool_id, record_user_prompt,
|
||||
};
|
||||
use crate::sse::SseParser;
|
||||
use axum::Router;
|
||||
@@ -23,13 +23,11 @@ const UPSTREAM: &str = "https://api.anthropic.com";
|
||||
pub const AGENT_ID_HEADER: &str = "x-claude-code-agent-id";
|
||||
pub const PARENT_AGENT_ID_HEADER: &str = "x-claude-code-parent-agent-id";
|
||||
|
||||
/// Claude Code asks for the **1M-context window** with a beta flag, not a
|
||||
/// different model: `--model opus[1m]` sends `anthropic-beta: …,context-1m-…`
|
||||
/// while plain `opus` does not, and both report the same `model` in the body
|
||||
/// (and in the transcript). So this header is the only place the window is
|
||||
/// observable — `App::resume_model` needs it to resume a session the way it ran.
|
||||
pub const BETA_HEADER: &str = "anthropic-beta";
|
||||
pub const LONG_CONTEXT_BETA: &str = "context-1m";
|
||||
/// Most of a failed response we keep in order to name the error. An Anthropic
|
||||
/// error body is a few hundred bytes; the cap exists so a pathological upstream
|
||||
/// can't make the tee task grow without bound (the relay itself is unaffected
|
||||
/// either way — it never waits on this).
|
||||
const ERR_BODY_MAX: usize = 4096;
|
||||
|
||||
/// Upstream base URL; `CT_UPSTREAM` overrides for offline testing against a
|
||||
/// fake server (the relay itself is identical either way).
|
||||
@@ -64,13 +62,45 @@ struct Ctx {
|
||||
app: SharedApp,
|
||||
}
|
||||
|
||||
pub async fn run(app: SharedApp, listener: tokio::net::TcpListener) -> anyhow::Result<()> {
|
||||
/// Serve until `drain` fires, then finish every in-flight response and return.
|
||||
///
|
||||
/// The drain signal is the hot-reload handshake (`reload.rs`): axum stops
|
||||
/// accepting, closes idle keep-alive connections and lets streaming responses
|
||||
/// run to completion, so the exec that follows can never truncate one. The
|
||||
/// socket itself stays open the whole time — `App::listener_fd` holds a dup of
|
||||
/// it — so connections Claude Code opens during the swap queue in the kernel
|
||||
/// backlog and are served by the new image.
|
||||
pub async fn run(
|
||||
app: SharedApp,
|
||||
listener: tokio::net::TcpListener,
|
||||
drain: tokio::sync::oneshot::Receiver<()>,
|
||||
) -> anyhow::Result<()> {
|
||||
let client = reqwest::Client::builder().build()?;
|
||||
let ctx = Ctx { client, app: app.clone() };
|
||||
let router = Router::new().fallback(forward).with_state(ctx);
|
||||
let port = listener.local_addr()?.port();
|
||||
lock_app(&app).status = format!("proxy http://127.0.0.1:{port} → api.anthropic.com");
|
||||
axum::serve(listener, router).await?;
|
||||
{
|
||||
// Written here rather than in `main` because this line is the last one
|
||||
// to land at startup — a reload note set earlier would be overwritten
|
||||
// by it. The port is the same one across a reload; the counter is the
|
||||
// only visible sign that the code under it changed.
|
||||
let mut a = lock_app(&app);
|
||||
let note = match (a.reload_gen, a.reload_dropped) {
|
||||
(0, _) => String::new(),
|
||||
(n, 0) => format!(" · reload #{n}"),
|
||||
(n, d) => format!(" · reload #{n} ({d} session(s) not restored)"),
|
||||
};
|
||||
a.status = format!("proxy http://127.0.0.1:{port} → api.anthropic.com{note}");
|
||||
}
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async move {
|
||||
// A dropped sender (no reload was ever started) simply never
|
||||
// resolves into a shutdown — `Err` means "hold the door open".
|
||||
if drain.await.is_err() {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -136,9 +166,26 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let agent = AgentTag {
|
||||
// Turn start / nested server-tool call / side request. A non-empty
|
||||
// `tools` array alone used to mean "turn start", which misfiled
|
||||
// `WebSearch`'s nested hosted-tool request as a main-chain turn.
|
||||
let kind = classify_request(&v);
|
||||
let agent = match kind {
|
||||
// A nested server-tool call carries no `x-claude-code-agent-id`, so
|
||||
// it gets a synthetic key of ours (never hex, so it can never
|
||||
// collide with a real agent id) and therefore its own lane: it is
|
||||
// readable in the `A` popup like a subagent and stays out of the
|
||||
// main feed. If Claude Code ever *does* stamp the caller's agent id
|
||||
// on one of these, that agent is this lane's parent — the nested
|
||||
// call is not the agent itself.
|
||||
ReqKind::ServerTool => AgentTag {
|
||||
id: Some(next_server_tool_id()),
|
||||
parent: header(AGENT_ID_HEADER),
|
||||
},
|
||||
_ => AgentTag {
|
||||
id: header(AGENT_ID_HEADER),
|
||||
parent: header(PARENT_AGENT_ID_HEADER),
|
||||
},
|
||||
};
|
||||
// Tool results ride along in the request body; surface them
|
||||
// on the tool entries from the previous turn.
|
||||
@@ -146,17 +193,10 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
|
||||
let t = Tap::new(ctx.app.clone(), key.clone(), model, pane_token, &agent);
|
||||
// After Tap::new: the session (and the lane) must exist for the entry
|
||||
// to land.
|
||||
record_user_prompt(&ctx.app, &key, t.lane(), &v);
|
||||
// Context window of the *main chain*, from this request's betas. Only a
|
||||
// turn-starting main-chain request counts: a side/title call runs haiku
|
||||
// without the flag and a subagent runs its own model, so either would
|
||||
// report a window that is not the session's.
|
||||
if t.lane() == crate::app::MAIN_LANE
|
||||
&& v.get("tools").and_then(Value::as_array).is_some_and(|t| !t.is_empty())
|
||||
{
|
||||
let long = header(BETA_HEADER).is_some_and(|b| b.contains(LONG_CONTEXT_BETA));
|
||||
record_long_context(&ctx.app, &key, long);
|
||||
if kind == ReqKind::ServerTool {
|
||||
label_server_tool_lane(&ctx.app, &key, t.lane(), &v);
|
||||
}
|
||||
record_user_prompt(&ctx.app, &key, t.lane(), &v);
|
||||
tap = Some(t);
|
||||
}
|
||||
|
||||
@@ -177,7 +217,8 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
|
||||
}
|
||||
let resp = rb.body(body_bytes).send().await?;
|
||||
|
||||
let mut builder = Response::builder().status(resp.status().as_u16());
|
||||
let status = resp.status();
|
||||
let mut builder = Response::builder().status(status.as_u16());
|
||||
let is_sse = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
@@ -202,8 +243,8 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
|
||||
// (drop-on-full, best-effort per the tee invariant); the tap task
|
||||
// drains that channel independently, so the forwarded byte stream is
|
||||
// never held back by the app mutex.
|
||||
let body = match (is_sse, tap) {
|
||||
(true, Some(mut tap)) => {
|
||||
let body = match tap {
|
||||
Some(mut tap) if is_sse && status.is_success() => {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
|
||||
tokio::spawn(async move {
|
||||
let mut parser = SseParser::default();
|
||||
@@ -225,6 +266,36 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
|
||||
});
|
||||
Body::from_stream(stream)
|
||||
}
|
||||
// Upstream refused (429 / 500 / 529 …). The body is JSON, not SSE, so
|
||||
// the tap used to close with nothing in the feed: the turn just stopped,
|
||||
// with no status and no message. Surfaced through the *same* best-effort
|
||||
// tee as SSE — never `resp.bytes().await`, which would buffer the
|
||||
// response and break latency-neutral pass-through. Chunks are cloned
|
||||
// with `try_send` into a bounded channel (dropped on overflow), and a
|
||||
// separate task keeps at most `ERR_BODY_MAX` of them.
|
||||
Some(mut tap) if !status.is_success() => {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(8);
|
||||
let code = status.as_u16();
|
||||
tokio::spawn(async move {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
while let Some(b) = rx.recv().await {
|
||||
let room = ERR_BODY_MAX.saturating_sub(buf.len());
|
||||
if room > 0 {
|
||||
buf.extend_from_slice(&b[..b.len().min(room)]);
|
||||
}
|
||||
}
|
||||
tap.record_http_error(code, &buf);
|
||||
});
|
||||
let stream = resp.bytes_stream().map(move |chunk| {
|
||||
if let Ok(b) = &chunk {
|
||||
let _ = tx.try_send(b.to_vec());
|
||||
}
|
||||
chunk
|
||||
});
|
||||
Body::from_stream(stream)
|
||||
}
|
||||
// A 2xx non-SSE response (`count_tokens`, …) stays untapped and
|
||||
// silent — a documented MVP limit, not an error.
|
||||
_ => Body::from_stream(resp.bytes_stream()),
|
||||
};
|
||||
Ok(builder.body(body)?)
|
||||
|
||||
626
src/reload.rs
Normal file
626
src/reload.rs
Normal file
@@ -0,0 +1,626 @@
|
||||
//! Hot reload: `execve` the binary on disk *into this process* instead of
|
||||
//! restarting.
|
||||
//!
|
||||
//! This module builds nothing and watches nothing. You rebuild however you
|
||||
//! normally would — `cargo build`, `cargo build --release`, a script — and then
|
||||
//! press **ctrl-r** in each running instance to swap it onto the new binary.
|
||||
//! Separating the two is the point: a build is your business, and a running
|
||||
//! instance should not decide on its own when to become different code.
|
||||
//!
|
||||
//! `execve` replaces the program image but keeps the pid, the open file
|
||||
//! descriptors and the child processes. That is the whole trick, and it is what
|
||||
//! lets all three things the proxy cares about survive:
|
||||
//!
|
||||
//! - **the listener** — the accept socket is inherited by fd number, so the
|
||||
//! port is never closed and never rebound. Claude Code keeps talking to the
|
||||
//! same `127.0.0.1:<port>` across the swap;
|
||||
//! - **the embedded `claude` pane** — still our child, still on the same pty
|
||||
//! (`term::PtyHandoff` / `EmbeddedTerm::adopt`). It is never told anything; it
|
||||
//! just gets a repaint;
|
||||
//! - **the live feed** — sessions/entries/lanes travel in a JSON snapshot.
|
||||
//!
|
||||
//! Two rules keep it honest:
|
||||
//!
|
||||
//! 1. **Drain before the exec.** It destroys the tokio tasks relaying in-flight
|
||||
//! responses, so the proxy is asked to stop accepting and finish what it has
|
||||
//! first. The socket stays open throughout, so requests made during the swap
|
||||
//! queue in the kernel backlog and are served by the new image.
|
||||
//! 2. **The snapshot is advisory.** It is written by the *old* binary and read
|
||||
//! by the *new* one, whose types may have just changed — the normal case
|
||||
//! when the reason you rebuilt was editing `app.rs`. Every restore step is
|
||||
//! best-effort: a snapshot that no longer fits costs the feed, never the
|
||||
//! port and never the pane.
|
||||
//!
|
||||
//! Nothing here talks to the network, and nothing here runs a subprocess.
|
||||
|
||||
use anyhow::Context;
|
||||
use crate::app::App;
|
||||
use std::os::fd::{AsRawFd, FromRawFd, RawFd};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Env var pointing the new image at its snapshot file. Its presence is what
|
||||
/// distinguishes "started by a reload" from "started by the user".
|
||||
const HANDOFF_ENV: &str = "CT_RELOAD_HANDOFF";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relative-time serde for the `Instant` fields on Session/Lane
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `Instant` has no absolute epoch, so it is snapshotted as "this many ms ago"
|
||||
/// and rebuilt against the new image's clock. Idle/liveness logic
|
||||
/// (`Lane::running`, `LANE_IDLE_MAX`) therefore reads the same before and after
|
||||
/// a reload instead of every lane looking freshly active.
|
||||
pub mod ms_ago {
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub fn serialize<S: Serializer>(v: &Instant, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_u64(v.elapsed().as_millis() as u64)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Instant, D::Error> {
|
||||
let ms = u64::deserialize(d)?;
|
||||
Ok(back(ms))
|
||||
}
|
||||
|
||||
/// `checked_sub` because a monotonic clock that has not been running long
|
||||
/// enough cannot represent the age — then "now" is the closest truth.
|
||||
pub(crate) fn back(ms: u64) -> Instant {
|
||||
let now = Instant::now();
|
||||
now.checked_sub(Duration::from_millis(ms)).unwrap_or(now)
|
||||
}
|
||||
}
|
||||
|
||||
/// `ms_ago` for an `Option<Instant>`.
|
||||
pub mod ms_ago_opt {
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
use std::time::Instant;
|
||||
|
||||
pub fn serialize<S: Serializer>(v: &Option<Instant>, s: S) -> Result<S::Ok, S::Error> {
|
||||
match v {
|
||||
Some(i) => s.serialize_some(&(i.elapsed().as_millis() as u64)),
|
||||
None => s.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Instant>, D::Error> {
|
||||
Ok(Option::<u64>::deserialize(d)?.map(super::ms_ago::back))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reload status (owned by App, rendered in the status bar)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default, Clone, PartialEq)]
|
||||
pub enum Status {
|
||||
/// Nothing in progress. ctrl-r starts a reload from here.
|
||||
#[default]
|
||||
Idle,
|
||||
/// The proxy is finishing its in-flight responses; the exec follows.
|
||||
Draining(std::time::Instant),
|
||||
/// The exec failed, so the old code is still running and still serving.
|
||||
/// Purely informational — usually "you pressed ctrl-r mid-build".
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// One-line status-bar rendering, or `None` when there is nothing to say.
|
||||
pub fn note(&self, in_flight: usize) -> Option<String> {
|
||||
match self {
|
||||
Status::Idle => None,
|
||||
Status::Draining(_) if in_flight > 0 => {
|
||||
Some(format!("⟳ reloading · draining {in_flight} turn(s)…"))
|
||||
}
|
||||
Status::Draining(_) => Some("⟳ reloading…".into()),
|
||||
Status::Failed(e) => Some(format!("⚠ reload failed: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap on the graceful drain. A stuck upstream response must not pin the
|
||||
/// reload forever; past this the exec happens anyway and that one response is
|
||||
/// cut — the same outcome as no drain at all, just far less likely.
|
||||
pub const DRAIN_MAX: Duration = Duration::from_secs(30);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The snapshot handed across the exec
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Written by the outgoing image, read by the incoming one. The fd numbers in
|
||||
/// here are only meaningful because `keep_open` cleared their FD_CLOEXEC.
|
||||
///
|
||||
/// Read side only — the write side is `HandoffRef`, which borrows the live
|
||||
/// state instead of moving it, so a failed exec leaves the running app whole.
|
||||
#[derive(Default, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Handoff {
|
||||
/// Inherited accept socket. `-1` means "bind a new one" (should not happen).
|
||||
pub listener_fd: RawFd,
|
||||
/// The embedded pane, when there was a live one.
|
||||
pub pane: Option<crate::term::PtyHandoff>,
|
||||
/// The feed, **left unparsed on purpose**. Schema churn is the normal case
|
||||
/// for a dev tool — the edit that triggered the reload is usually the one
|
||||
/// that changed these types — and parsing it inline would let one renamed
|
||||
/// field take the port and the pane down with the feed. It is decoded
|
||||
/// separately, session by session, in `restore`.
|
||||
pub app: serde_json::Value,
|
||||
pub pane_ui: PaneState,
|
||||
/// Reloads so far, for the status line.
|
||||
pub generation: u32,
|
||||
}
|
||||
|
||||
/// The part of `App` worth carrying over. Deliberately a separate struct rather
|
||||
/// than `#[derive(Serialize)] on App`: popups, caches and lazily loaded disk
|
||||
/// views are cheap to rebuild and would only add schema churn.
|
||||
#[derive(Default, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AppState {
|
||||
/// One `Value` per session, decoded individually: a session that no longer
|
||||
/// fits is skipped instead of discarding the whole feed.
|
||||
pub sessions: Vec<serde_json::Value>,
|
||||
/// Session the highlight was on. Preferred over `selected`: disk stubs are
|
||||
/// rescanned asynchronously, so their indices are not stable across the
|
||||
/// exec, but their uuids are.
|
||||
pub selected_key: Option<String>,
|
||||
pub selected: usize,
|
||||
pub scroll: usize,
|
||||
pub follow: bool,
|
||||
pub filters: Vec<bool>,
|
||||
pub show_sessions: bool,
|
||||
pub embed_session: Option<String>,
|
||||
pub embed_token: Option<String>,
|
||||
}
|
||||
|
||||
/// The pane-related UI state that lives on `EmbedUi`, not on `App`.
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PaneState {
|
||||
pub visible: bool,
|
||||
pub focused: bool,
|
||||
pub fullscreen: bool,
|
||||
pub past_embeds: Vec<String>,
|
||||
pub compact_inner: u16,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup side: did a reload just hand us the process?
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read (and consume) the handoff this process was exec'd with. Returns `None`
|
||||
/// for a normal start, and also for a snapshot that no longer parses — an
|
||||
/// expected outcome when the edit that triggered the reload changed the state
|
||||
/// types, and the reason this is `Option` rather than `Result`.
|
||||
pub fn take_handoff() -> Option<Handoff> {
|
||||
let path = std::env::var_os(HANDOFF_ENV)?;
|
||||
// Consume it either way: a stale file must never be picked up twice.
|
||||
// Sound here and nowhere else — `main` has not spawned a thread yet.
|
||||
unsafe { std::env::remove_var(HANDOFF_ENV) };
|
||||
let raw = std::fs::read(&path).ok();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let parsed = raw.as_ref().and_then(|b| serde_json::from_slice::<Handoff>(b).ok());
|
||||
if parsed.is_none() {
|
||||
// Without the fd numbers the inherited socket and pty are unusable
|
||||
// (still open, but anonymous), so the kernel closes them when we exit.
|
||||
// A fresh bind is the safe outcome.
|
||||
eprintln!("claude-cloak: reload handoff unreadable, starting fresh");
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
/// Rebuild a `std::net::TcpListener` from the inherited fd. The caller converts
|
||||
/// it to a tokio listener inside the runtime.
|
||||
///
|
||||
/// The fd is verified to be a listening socket first: the number comes out of a
|
||||
/// file on disk, and a stale handoff could name an fd this process has since
|
||||
/// reused for something else entirely.
|
||||
pub fn adopt_listener(fd: RawFd) -> anyhow::Result<std::net::TcpListener> {
|
||||
anyhow::ensure!(fd >= 0, "no inherited listener");
|
||||
anyhow::ensure!(is_listening(fd), "inherited fd {fd} is not a listening socket");
|
||||
// Put CLOEXEC back: from here on it is a normal socket again, and the next
|
||||
// reload clears the flag itself.
|
||||
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
|
||||
let l = unsafe { std::net::TcpListener::from_raw_fd(fd) };
|
||||
l.set_nonblocking(true)?;
|
||||
Ok(l)
|
||||
}
|
||||
|
||||
fn is_listening(fd: RawFd) -> bool {
|
||||
let mut on: libc::c_int = 0;
|
||||
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
|
||||
let rc = unsafe {
|
||||
libc::getsockopt(
|
||||
fd,
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_ACCEPTCONN,
|
||||
(&raw mut on).cast(),
|
||||
&mut len,
|
||||
)
|
||||
};
|
||||
rc == 0 && on != 0
|
||||
}
|
||||
|
||||
/// Duplicate the accept socket so the reload can hold the port open while
|
||||
/// axum's own listener is dropped by the graceful shutdown.
|
||||
pub fn dup_listener(l: &tokio::net::TcpListener) -> anyhow::Result<RawFd> {
|
||||
let fd = unsafe { libc::fcntl(l.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if fd < 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("dup listener");
|
||||
}
|
||||
Ok(fd)
|
||||
}
|
||||
|
||||
/// Apply a restored snapshot to a fresh `App`. Every step is best-effort by
|
||||
/// design — see the module docs.
|
||||
pub fn restore(app: &mut App, raw: serde_json::Value) {
|
||||
let s: AppState = serde_json::from_value(raw).unwrap_or_default();
|
||||
let total = s.sessions.len();
|
||||
app.sessions = s
|
||||
.sessions
|
||||
.into_iter()
|
||||
.filter_map(|v| serde_json::from_value::<crate::app::Session>(v).ok())
|
||||
.filter_map(sanitize_session)
|
||||
.collect();
|
||||
let kept = app.sessions.len();
|
||||
|
||||
app.selected = s.selected;
|
||||
app.scroll = s.scroll;
|
||||
app.follow = s.follow;
|
||||
app.show_sessions = s.show_sessions;
|
||||
app.embed_session = s.embed_session;
|
||||
app.embed_token = s.embed_token;
|
||||
// Length-tolerant: the filter set grows as entry kinds are added, and a
|
||||
// snapshot from before that must not shift the toggles.
|
||||
for (i, v) in s.filters.iter().take(app.filters.len()).enumerate() {
|
||||
app.filters[i] = *v;
|
||||
}
|
||||
// The synthetic-lane counter is process-global; restart it past whatever
|
||||
// the restored sessions already use.
|
||||
crate::app::seed_server_tool_seq(max_server_tool_seq(&app.sessions) + 1);
|
||||
app.after_restore(s.selected_key.as_deref());
|
||||
app.reload_dropped = total - kept;
|
||||
}
|
||||
|
||||
/// Make one restored session safe to render.
|
||||
///
|
||||
/// `Entry::lane`, `Lane::first_entry`, `Lane::anchor` and the values of
|
||||
/// `Session::tool_ids` are all raw indices into vectors that a schema change
|
||||
/// can shorten. `#[serde(default)]` covers a *missing* field and does nothing
|
||||
/// for an *inconsistent* one, and the first draw indexes them directly — so
|
||||
/// they are checked here, once, instead of defensively at every use site.
|
||||
fn sanitize_session(mut s: crate::app::Session) -> Option<crate::app::Session> {
|
||||
// `#[serde(default)]` is deliberately forgiving, which means an object that
|
||||
// is not a session at all still decodes — into an empty one. A live session
|
||||
// is always keyed (`proxy::forward_inner` falls back to `"unknown"`), so an
|
||||
// empty key is the tell.
|
||||
if s.key.is_empty() || s.lanes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let lanes = s.lanes.len();
|
||||
let entries = s.entries.len();
|
||||
s.entries.retain(|e| (e.lane as usize) < lanes);
|
||||
// Retaining shifts positions, so any index into `entries` is only sound
|
||||
// when nothing was dropped.
|
||||
let shifted = s.entries.len() != entries;
|
||||
let entries = s.entries.len();
|
||||
|
||||
// Nothing is streaming into a process that no longer exists. Leaving these
|
||||
// set would show a permanent running dot and keep `Lane::running` true
|
||||
// forever — and `Tap::drop` runs on the tee task, so the snapshot can
|
||||
// legitimately have caught a count that was about to be decremented.
|
||||
s.active = 0;
|
||||
for l in &mut s.lanes {
|
||||
l.active = 0;
|
||||
if shifted || l.first_entry.is_some_and(|i| i >= entries) {
|
||||
l.first_entry = None;
|
||||
}
|
||||
if shifted || l.anchor.is_some_and(|i| i >= entries) {
|
||||
l.anchor = None;
|
||||
}
|
||||
}
|
||||
// A tool call whose result can no longer be attached is better than one
|
||||
// attached to the wrong entry.
|
||||
s.tool_ids.retain(|_, i| !shifted && *i < entries);
|
||||
// A half-streamed entry never gets its remaining deltas: close it out so it
|
||||
// renders as finished markdown instead of a permanently pending block.
|
||||
if let Some(e) = s.entries.last_mut() {
|
||||
e.done = true;
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Highest `srvtool-<n>` index across the restored sessions.
|
||||
fn max_server_tool_seq(sessions: &[crate::app::Session]) -> u64 {
|
||||
sessions
|
||||
.iter()
|
||||
.flat_map(|s| s.lane_of_agent.keys())
|
||||
.filter_map(|k| k.strip_prefix(crate::app::SERVER_TOOL_PREFIX))
|
||||
.filter_map(|n| n.parse::<u64>().ok())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Write side of `Handoff`. It *borrows* the live app: the feed can be tens of
|
||||
/// megabytes, and — more importantly — an exec that fails must leave the
|
||||
/// running process exactly as it was, which a moved-out `Vec<Session>` would
|
||||
/// not. The field names mirror `Handoff`/`AppState` exactly; that is the whole
|
||||
/// contract between the two.
|
||||
#[derive(serde::Serialize)]
|
||||
struct HandoffRef<'a> {
|
||||
listener_fd: RawFd,
|
||||
pane: &'a Option<crate::term::PtyHandoff>,
|
||||
app: AppStateRef<'a>,
|
||||
pane_ui: &'a PaneState,
|
||||
generation: u32,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct AppStateRef<'a> {
|
||||
sessions: &'a [crate::app::Session],
|
||||
selected_key: Option<String>,
|
||||
selected: usize,
|
||||
scroll: usize,
|
||||
follow: bool,
|
||||
filters: &'a [bool],
|
||||
show_sessions: bool,
|
||||
embed_session: &'a Option<String>,
|
||||
embed_token: &'a Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> AppStateRef<'a> {
|
||||
fn of(app: &'a App) -> Self {
|
||||
Self {
|
||||
sessions: &app.sessions,
|
||||
selected_key: app.selected_key(),
|
||||
selected: app.selected,
|
||||
scroll: app.scroll,
|
||||
follow: app.follow,
|
||||
filters: &app.filters,
|
||||
show_sessions: app.show_sessions,
|
||||
embed_session: &app.embed_session,
|
||||
embed_token: &app.embed_token,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commit side: exec ourselves
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Clear FD_CLOEXEC so `fd` survives the coming `execve`. Everything else we
|
||||
/// hold keeps the flag and is closed by the kernel, which is what we want:
|
||||
/// only the listener and the pty master are meant to cross over.
|
||||
fn keep_open(fd: RawFd) -> bool {
|
||||
unsafe { libc::fcntl(fd, libc::F_SETFD, 0) == 0 }
|
||||
}
|
||||
|
||||
/// Undo `keep_open`. Only reached when the exec failed: the fds must not stay
|
||||
/// inheritable, or the next `claude` we spawn would get a copy of the proxy
|
||||
/// socket and the pane's pty.
|
||||
fn close_on_exec(fd: RawFd) {
|
||||
if fd >= 0 {
|
||||
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace this process with `exe`, carrying the live state across.
|
||||
///
|
||||
/// On success this never returns: the new image continues from `main` with the
|
||||
/// same pid, the same listener socket and the same `claude` child. It only
|
||||
/// returns on failure — and then nothing has been consumed, so the caller just
|
||||
/// keeps running the old code.
|
||||
pub fn exec_into(
|
||||
exe: &Path,
|
||||
app: &App,
|
||||
pane: Option<crate::term::PtyHandoff>,
|
||||
pane_ui: &PaneState,
|
||||
) -> anyhow::Error {
|
||||
// Only these two fds are meant to cross. Everything else we hold keeps its
|
||||
// FD_CLOEXEC and is closed by the kernel during the exec — including the
|
||||
// established connections, which is why the reload waits for a quiet wire.
|
||||
let listener_fd = if keep_open(app.listener_fd) { app.listener_fd } else { -1 };
|
||||
let pane = match pane {
|
||||
Some(p) if keep_open(p.master_fd) => Some(p),
|
||||
// A pane whose fd cannot be kept open is dropped rather than handed
|
||||
// over as a dangling number: the new image then simply has no pane.
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let path = std::env::temp_dir().join(format!("claude-cloak-reload-{}.json", std::process::id()));
|
||||
let snapshot = HandoffRef {
|
||||
listener_fd,
|
||||
pane: &pane,
|
||||
app: AppStateRef::of(app),
|
||||
pane_ui,
|
||||
generation: app.reload_gen,
|
||||
};
|
||||
let written = serde_json::to_vec(&snapshot).map_err(anyhow::Error::from).and_then(|b| {
|
||||
// Written and flushed before the exec: a crash in between would
|
||||
// otherwise leave a truncated file the new image reads as garbage.
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::File::create(&path)?;
|
||||
f.write_all(&b)?;
|
||||
f.sync_all()?;
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let err = match written {
|
||||
Err(e) => e.context("write reload snapshot"),
|
||||
Ok(()) => {
|
||||
// Same arguments we were started with, argv[0] aside.
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
use std::os::unix::process::CommandExt;
|
||||
let e = std::process::Command::new(exe).args(&args).env(HANDOFF_ENV, &path).exec();
|
||||
// `exec` returns only on failure.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
anyhow::Error::from(e).context(format!("exec {}", exe.display()))
|
||||
}
|
||||
};
|
||||
// The reload did not happen, so put the fds back the way we found them and
|
||||
// let the old code carry on.
|
||||
close_on_exec(listener_fd);
|
||||
if let Some(p) = &pane {
|
||||
close_on_exec(p.master_fd);
|
||||
}
|
||||
err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Which binary to exec
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve our own executable **path** (not inode) at startup.
|
||||
///
|
||||
/// Read once, in `main`, and then carried across every reload in the handoff —
|
||||
/// because by the time you press ctrl-r the file has usually been replaced.
|
||||
/// A linker writes the new binary and renames it over the old one, which
|
||||
/// unlinks the inode we are running from; Linux then reports `/proc/self/exe`
|
||||
/// as `…/claude-cloak (deleted)`. Resolving late would exec that literal name
|
||||
/// and fail, so the suffix is also stripped defensively.
|
||||
pub fn exe_path() -> PathBuf {
|
||||
let raw = std::env::current_exe()
|
||||
.ok()
|
||||
.or_else(|| std::env::args_os().next().map(PathBuf::from))
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_PKG_NAME")));
|
||||
strip_deleted(raw)
|
||||
}
|
||||
|
||||
fn strip_deleted(p: PathBuf) -> PathBuf {
|
||||
match p.to_str().and_then(|s| s.strip_suffix(" (deleted)")) {
|
||||
Some(s) => PathBuf::from(s),
|
||||
None => p,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{Entry, Kind, Session};
|
||||
use std::time::Instant;
|
||||
|
||||
/// A session as the *old* image would have snapshotted it.
|
||||
fn live_session() -> Session {
|
||||
let mut s = Session::new("sess".into(), "opus".into());
|
||||
s.push(0, Entry { kind: Kind::Text, content: "hi".into(), ..Default::default() });
|
||||
s.active = 2;
|
||||
s.lanes[0].active = 2;
|
||||
s.tool_ids.insert("toolu_1".into(), 0);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_round_trips_through_json() {
|
||||
let s = live_session();
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
let back: Session = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.key, "sess");
|
||||
assert_eq!(back.entries.len(), 1);
|
||||
assert_eq!(back.entries[0].content, "hi");
|
||||
assert_eq!(back.lanes.len(), 1);
|
||||
}
|
||||
|
||||
/// `Instant` is snapshotted as an age, so a lane restored from disk must
|
||||
/// still look as old as it was — that is what `Lane::running` reads.
|
||||
#[test]
|
||||
fn instants_survive_as_ages_not_as_now() {
|
||||
let mut s = live_session();
|
||||
let old = Instant::now() - Duration::from_secs(120);
|
||||
s.last_activity = old;
|
||||
s.lanes[0].last_event = Some(old);
|
||||
let back: Session = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert!(back.last_activity.elapsed() >= Duration::from_secs(119));
|
||||
assert!(back.lanes[0].last_event.unwrap().elapsed() >= Duration::from_secs(119));
|
||||
// …and therefore reads as idle, not as freshly running, once the
|
||||
// in-flight counters are cleared.
|
||||
assert!(!sanitize_session(back).unwrap().lanes[0].running());
|
||||
}
|
||||
|
||||
/// Nothing streams into a process that no longer exists.
|
||||
#[test]
|
||||
fn sanitize_clears_in_flight_counters() {
|
||||
let s = sanitize_session(live_session()).unwrap();
|
||||
assert_eq!(s.active, 0);
|
||||
assert_eq!(s.lanes[0].active, 0);
|
||||
assert!(s.entries.last().unwrap().done, "a cut entry is closed out");
|
||||
}
|
||||
|
||||
/// The dangerous case: a snapshot whose entries point at lanes the new
|
||||
/// binary's session no longer has. Indexing those panics on the first draw.
|
||||
#[test]
|
||||
fn sanitize_drops_entries_with_dangling_lanes() {
|
||||
let mut s = live_session();
|
||||
s.entries.push(Entry { kind: Kind::Text, content: "ghost".into(), lane: 7, ..Default::default() });
|
||||
let s = sanitize_session(s).unwrap();
|
||||
assert_eq!(s.entries.len(), 1);
|
||||
assert!(s.entries.iter().all(|e| (e.lane as usize) < s.lanes.len()));
|
||||
// Positions shifted, so index-valued maps are dropped rather than
|
||||
// left pointing at the wrong entry.
|
||||
assert!(s.tool_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_a_session_without_lanes() {
|
||||
let mut s = live_session();
|
||||
s.lanes.clear();
|
||||
assert!(sanitize_session(s).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_clears_out_of_range_lane_indices() {
|
||||
let mut s = live_session();
|
||||
s.lanes[0].anchor = Some(99);
|
||||
s.lanes[0].first_entry = Some(99);
|
||||
let s = sanitize_session(s).unwrap();
|
||||
assert_eq!(s.lanes[0].anchor, None);
|
||||
assert_eq!(s.lanes[0].first_entry, None);
|
||||
}
|
||||
|
||||
/// The synthetic-lane counter is process-global, so a reload must not
|
||||
/// restart it at 0 and re-enter a lane the restored session already holds.
|
||||
#[test]
|
||||
fn server_tool_seq_is_seeded_past_restored_lanes() {
|
||||
let mut s = live_session();
|
||||
s.lane_of_agent.insert(format!("{}4", crate::app::SERVER_TOOL_PREFIX), 0);
|
||||
s.lane_of_agent.insert("deadbeef".into(), 0);
|
||||
assert_eq!(max_server_tool_seq(&[s]), 4);
|
||||
}
|
||||
|
||||
/// The binary is expected to have been replaced while we run: a rename
|
||||
/// over the running image makes Linux report `/proc/self/exe` with a
|
||||
/// ` (deleted)` suffix, and exec'ing that literal name would fail.
|
||||
#[test]
|
||||
fn exe_path_drops_the_deleted_suffix() {
|
||||
let p = strip_deleted(PathBuf::from("/x/target/debug/claude-cloak (deleted)"));
|
||||
assert_eq!(p, PathBuf::from("/x/target/debug/claude-cloak"));
|
||||
// An ordinary path is untouched, including one that merely contains
|
||||
// the word.
|
||||
let p = PathBuf::from("/x/deleted/claude-cloak");
|
||||
assert_eq!(strip_deleted(p.clone()), p);
|
||||
}
|
||||
|
||||
/// A handoff whose feed no longer parses must still surrender the fd
|
||||
/// numbers: losing the code's state is survivable, losing the port and the
|
||||
/// pane is not.
|
||||
#[test]
|
||||
fn unparseable_feed_keeps_the_port_and_the_pane() {
|
||||
let raw = serde_json::json!({
|
||||
"listener_fd": 9,
|
||||
"pane": {
|
||||
"master_fd": 11, "child_pid": 4242, "session_id": "s",
|
||||
"pane_token": "t", "pty_rows": 20, "cols": 80
|
||||
},
|
||||
"app": {"sessions": [{"this": "is not a session"}, {"key": "keeper"}], "selected": 3},
|
||||
"pane_ui": {"visible": true},
|
||||
"generation": 2,
|
||||
});
|
||||
let h: Handoff = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(h.listener_fd, 9);
|
||||
assert_eq!(h.pane.as_ref().unwrap().child_pid, 4242);
|
||||
assert_eq!(h.generation, 2);
|
||||
|
||||
let mut app = App::new();
|
||||
restore(&mut app, h.app);
|
||||
let keys: Vec<_> = app.sessions.iter().map(|s| s.key.as_str()).collect();
|
||||
assert_eq!(keys, ["keeper"], "the junk entry is skipped, the real one kept");
|
||||
}
|
||||
}
|
||||
433
src/sessions.rs
433
src/sessions.rs
@@ -10,8 +10,8 @@
|
||||
//! spaces … all become `-`) — see `encode_cwd`.
|
||||
|
||||
use crate::app::{
|
||||
Entry, Kind, LaneId, MAIN_LANE, Session, SharedApp, ToolResult, flatten_result_content,
|
||||
lock_app, strip_injected,
|
||||
Entry, Kind, LaneId, MAIN_LANE, Session, SharedApp, TaskNotification, ToolResult,
|
||||
flatten_result_content, lock_app, split_task_notifications, strip_injected, task_note_line,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
@@ -453,8 +453,9 @@ pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option<History
|
||||
}
|
||||
}
|
||||
let anchors = std::mem::take(&mut p.agent_tools);
|
||||
let usage = std::mem::take(&mut p.task_usage);
|
||||
let mut view = p.into_view(uuid, Some(leaf), turn_entries)?;
|
||||
splice_agents(&mut view, anchors, &agents);
|
||||
splice_agents(&mut view, anchors, &agents, usage);
|
||||
Some(view)
|
||||
}
|
||||
}
|
||||
@@ -473,15 +474,67 @@ pub(crate) fn load_file_view(
|
||||
p.line(&line);
|
||||
}
|
||||
let anchors = std::mem::take(&mut p.agent_tools);
|
||||
let usage = std::mem::take(&mut p.task_usage);
|
||||
let mut view = p.into_view(uuid, None, Vec::new())?;
|
||||
splice_agents(&mut view, anchors, agents);
|
||||
splice_agents(&mut view, anchors, agents, usage);
|
||||
Some(view)
|
||||
}
|
||||
|
||||
/// Parse one subagent transcript into its own lane. Oversized files are
|
||||
/// summarised rather than parsed: the view is built while the app mutex is
|
||||
/// held (the proxy tap shares it), so a few MB of JSONL must not stall it.
|
||||
fn parse_agent_file(path: &std::path::Path, lane: LaneId) -> (Vec<Entry>, HashMap<String, usize>) {
|
||||
/// Claude Code's own accounting for one whole agent run, scraped from the
|
||||
/// `<usage>` block of a `<task-notification>`. A transcript records no *API*
|
||||
/// usage, but it does record the notifications, so this is the only token
|
||||
/// figure an on-disk lane can ever have (`Lane::subagent_tokens` and friends).
|
||||
#[derive(Default, Clone, Copy, Debug)]
|
||||
struct LaneUsage {
|
||||
tokens: Option<u64>,
|
||||
tool_uses: Option<u64>,
|
||||
duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Agent id → run totals. The key is a notification's `<task-id>`, which is
|
||||
/// also the agent id its lane is registered under and the stem of its
|
||||
/// `subagents/agent-<id>.jsonl` — so the usage a *parent's* file reports finds
|
||||
/// the child's lane without any extra lookup table.
|
||||
type UsageByAgent = HashMap<String, LaneUsage>;
|
||||
|
||||
impl LaneUsage {
|
||||
/// Field-by-field, later totals winning — same policy as the live
|
||||
/// `Session::record_task_usage` (an agent woken again by `SendMessage`
|
||||
/// reports afresh).
|
||||
fn merge(&mut self, o: LaneUsage) {
|
||||
self.tokens = o.tokens.or(self.tokens);
|
||||
self.tool_uses = o.tool_uses.or(self.tool_uses);
|
||||
self.duration_ms = o.duration_ms.or(self.duration_ms);
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.tokens.is_none() && self.tool_uses.is_none() && self.duration_ms.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember a notification's `<usage>` under its agent id, for `splice_agents`
|
||||
/// to hand to that agent's lane once the lanes exist.
|
||||
fn record_task_usage(usage: &mut UsageByAgent, n: &TaskNotification) {
|
||||
let u = LaneUsage {
|
||||
tokens: n.subagent_tokens,
|
||||
tool_uses: n.tool_uses,
|
||||
duration_ms: n.duration_ms,
|
||||
};
|
||||
if u.is_empty() {
|
||||
return;
|
||||
}
|
||||
usage.entry(n.task_id.clone()).or_default().merge(u);
|
||||
}
|
||||
|
||||
/// Parse one subagent transcript into its own lane, merging any `<usage>` it
|
||||
/// reports for *its* children into `usage`. Oversized files are summarised
|
||||
/// rather than parsed: the view is built while the app mutex is held (the
|
||||
/// proxy tap shares it), so a few MB of JSONL must not stall it.
|
||||
fn parse_agent_file(
|
||||
path: &std::path::Path,
|
||||
lane: LaneId,
|
||||
usage: &mut UsageByAgent,
|
||||
) -> (Vec<Entry>, HashMap<String, usize>) {
|
||||
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
if size > MAX_AGENT_BYTES {
|
||||
let mb = size / (1024 * 1024);
|
||||
@@ -496,6 +549,9 @@ fn parse_agent_file(path: &std::path::Path, lane: LaneId) -> (Vec<Entry>, HashMa
|
||||
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
|
||||
p.line(&line);
|
||||
}
|
||||
for (id, u) in p.task_usage {
|
||||
usage.entry(id).or_default().merge(u);
|
||||
}
|
||||
(p.entries, p.agent_tools)
|
||||
}
|
||||
|
||||
@@ -537,10 +593,15 @@ fn insert_entries(
|
||||
/// the highest anchor goes first. A lane whose spawn point is not in this view
|
||||
/// (a path view can exclude that turn) keeps no entries, so it never shows up
|
||||
/// in the agent popup.
|
||||
///
|
||||
/// `usage` carries the `<task-notification>` totals the main chain reported
|
||||
/// (see `LaneUsage`); each agent file's own notifications are merged in as it
|
||||
/// is parsed, and the lot is handed to the lanes at the end.
|
||||
fn splice_agents(
|
||||
view: &mut HistoryView,
|
||||
mut anchors: HashMap<String, usize>,
|
||||
agents: &[DiskAgent],
|
||||
mut usage: UsageByAgent,
|
||||
) {
|
||||
if agents.is_empty() {
|
||||
return;
|
||||
@@ -566,7 +627,7 @@ fn splice_agents(
|
||||
None,
|
||||
a.spawn_depth,
|
||||
);
|
||||
let (entries, anchors) = parse_agent_file(&a.path, lane);
|
||||
let (entries, anchors) = parse_agent_file(&a.path, lane, &mut usage);
|
||||
Loaded {
|
||||
id: a.agent_id.clone(),
|
||||
lane,
|
||||
@@ -636,11 +697,25 @@ fn splice_agents(
|
||||
l.parent = Some(MAIN_LANE);
|
||||
}
|
||||
view.session.reindex_lanes();
|
||||
// Tool counts for the agent picker (a transcript carries no usage, so
|
||||
// token totals stay zero for on-disk lanes).
|
||||
// Tool counts for the agent picker, straight off the entries.
|
||||
for (lane, n) in count_tools(&view.session.entries) {
|
||||
view.session.lanes[lane as usize].tool_calls = n;
|
||||
}
|
||||
// Run totals for the picker and the agent-feed title. A transcript records
|
||||
// no API usage, but the `<task-notification>`s in it carry Claude Code's
|
||||
// own accounting, keyed by the agent id the lane is registered under. An
|
||||
// id that matches no lane (a background *bash* task, or an agent whose
|
||||
// transcript this session never kept) is simply skipped — the same
|
||||
// silence as the live `Session::record_task_usage`.
|
||||
for (id, u) in &usage {
|
||||
let Some(&lane) = view.session.lane_of_agent.get(id) else {
|
||||
continue;
|
||||
};
|
||||
let l = &mut view.session.lanes[lane as usize];
|
||||
l.subagent_tokens = u.tokens.or(l.subagent_tokens);
|
||||
l.tool_uses = u.tool_uses.or(l.tool_uses);
|
||||
l.duration_ms = u.duration_ms.or(l.duration_ms);
|
||||
}
|
||||
}
|
||||
/// Incremental JSONL-record → feed-`Entry` translation (shared by the whole
|
||||
/// file and path views).
|
||||
@@ -653,6 +728,10 @@ struct EntryParser {
|
||||
/// `tool_idx`, which is *drained* as results attach — the anchor is still
|
||||
/// needed afterwards to splice the subagent's transcript in.
|
||||
agent_tools: HashMap<String, usize>,
|
||||
/// `<usage>` totals scraped from this file's `<task-notification>`s, keyed
|
||||
/// by the agent id they report on. Collected in the parse pass and applied
|
||||
/// to the lanes by `splice_agents`, which is where the lanes exist.
|
||||
task_usage: UsageByAgent,
|
||||
/// Lane every parsed entry is tagged with (0 = the main chain).
|
||||
lane: LaneId,
|
||||
/// Keep `isSidechain` records instead of skipping them. A subagent file
|
||||
@@ -668,6 +747,7 @@ impl EntryParser {
|
||||
model: String::from("(resumed)"),
|
||||
tool_idx: HashMap::new(),
|
||||
agent_tools: HashMap::new(),
|
||||
task_usage: UsageByAgent::new(),
|
||||
lane: MAIN_LANE,
|
||||
keep_sidechain: false,
|
||||
}
|
||||
@@ -709,6 +789,7 @@ impl EntryParser {
|
||||
let entries = &mut self.entries;
|
||||
let tool_idx = &mut self.tool_idx;
|
||||
let agent_tools = &mut self.agent_tools;
|
||||
let task_usage = &mut self.task_usage;
|
||||
let Ok(v) = serde_json::from_str::<Value>(line) else {
|
||||
return;
|
||||
};
|
||||
@@ -766,11 +847,19 @@ impl EntryParser {
|
||||
}
|
||||
}
|
||||
Some("user") => match v.pointer("/message/content") {
|
||||
Some(Value::String(s)) => push_user_text(entries, s, lane),
|
||||
Some(Value::String(s)) => {
|
||||
push_user_text(entries, agent_tools, task_usage, s, lane)
|
||||
}
|
||||
Some(Value::Array(blocks)) => {
|
||||
for b in blocks {
|
||||
match b.get("type").and_then(Value::as_str) {
|
||||
Some("text") => push_user_text(entries, &text_of(b, "text"), lane),
|
||||
Some("text") => push_user_text(
|
||||
entries,
|
||||
agent_tools,
|
||||
task_usage,
|
||||
&text_of(b, "text"),
|
||||
lane,
|
||||
),
|
||||
Some("tool_result") => {
|
||||
let Some(idx) = b
|
||||
.get("tool_use_id")
|
||||
@@ -804,10 +893,83 @@ fn text_of(b: &Value, key: &str) -> String {
|
||||
b.get(key).and_then(Value::as_str).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// Translate a user text block into feed entries: each injected reminder as a
|
||||
/// dimmed `Kind::Reminder`, then the real prompt as `Kind::User`.
|
||||
fn push_user_text(entries: &mut Vec<Entry>, text: &str, lane: LaneId) {
|
||||
/// Move an agent's final report onto the `Agent` tool entry that spawned it,
|
||||
/// replacing the `Async agent launched successfully… agentId: <hex>`
|
||||
/// acknowledgement the parent model got at launch time. The disk mirror of
|
||||
/// `Session::attach_task_report`, and a shorter one: the notification names
|
||||
/// the `<tool-use-id>` of that very call, and `EntryParser::agent_tools` still
|
||||
/// holds it (unlike `tool_idx`, which is drained when the launch result
|
||||
/// attaches) — so no lane/anchor round-trip is needed. Both records live in
|
||||
/// the same file, nested agents included: a depth-2 `Agent` call and the
|
||||
/// notification answering it both sit in the parent *agent's* transcript.
|
||||
///
|
||||
/// False when the spawn point is not in this parse (a path view can exclude
|
||||
/// that turn, and 6 of the notifications in the real transcripts name a call
|
||||
/// recorded nowhere we read); the caller then keeps the report inline in the
|
||||
/// note rather than losing it.
|
||||
fn attach_task_report(
|
||||
entries: &mut [Entry],
|
||||
agent_tools: &HashMap<String, usize>,
|
||||
n: &TaskNotification,
|
||||
report: &str,
|
||||
) -> bool {
|
||||
let Some(&idx) = n.tool_use_id.as_deref().and_then(|id| agent_tools.get(id)) else {
|
||||
return false;
|
||||
};
|
||||
let Some(e) = entries.get_mut(idx) else {
|
||||
return false;
|
||||
};
|
||||
e.result = Some(ToolResult {
|
||||
content: report.to_string(),
|
||||
is_error: n.failed(),
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Translate a user text block into feed entries, exactly as the live path
|
||||
/// (`app::record_user_prompt`) does, so a session reads the same whether it is
|
||||
/// streaming or loaded from disk:
|
||||
///
|
||||
/// 1. every `<task-notification>` is lifted out — of the prompt *and* of each
|
||||
/// injected reminder, since Claude Code sometimes wraps one in a
|
||||
/// `<system-reminder>` — and becomes one `Kind::TaskNote` line, with the
|
||||
/// agent's `<result>` report moved onto its `Agent` tool entry (or kept
|
||||
/// inline when that call is not in this view). `<usage>` is remembered for
|
||||
/// the lane;
|
||||
/// 2. each remaining reminder as a dimmed `Kind::Reminder`;
|
||||
/// 3. what is left as the real prompt, `Kind::User`.
|
||||
///
|
||||
/// Notes come first, as they do live. Nothing is dropped: a block that fails
|
||||
/// to parse is handed back inside the text by `split_task_notifications` and
|
||||
/// so still shows in the prompt.
|
||||
///
|
||||
/// No resend dedup here (the live path's) — a transcript records each turn
|
||||
/// once, so an identical note twice means the agent really was woken twice.
|
||||
fn push_user_text(
|
||||
entries: &mut Vec<Entry>,
|
||||
agent_tools: &HashMap<String, usize>,
|
||||
usage: &mut UsageByAgent,
|
||||
text: &str,
|
||||
lane: LaneId,
|
||||
) {
|
||||
let (reminders, prompt) = crate::app::extract_user_text(text);
|
||||
let (mut notes, prompt) = split_task_notifications(&prompt);
|
||||
let reminders: Vec<String> = reminders
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let (n, rest) = split_task_notifications(&r);
|
||||
notes.extend(n);
|
||||
// A reminder that was *only* a notification leaves nothing to show.
|
||||
(!rest.is_empty()).then_some(rest)
|
||||
})
|
||||
.collect();
|
||||
for n in ¬es {
|
||||
record_task_usage(usage, n);
|
||||
let report = n.result.as_deref().map(str::trim).filter(|r| !r.is_empty());
|
||||
let attached = report.is_some_and(|r| attach_task_report(entries, agent_tools, n, r));
|
||||
let line = task_note_line(n, if attached { None } else { report });
|
||||
entries.push(Entry::done(Kind::TaskNote, line).in_lane(lane));
|
||||
}
|
||||
for r in reminders {
|
||||
entries.push(Entry::done(Kind::Reminder, r).in_lane(lane));
|
||||
}
|
||||
@@ -1092,6 +1254,244 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// An `Agent` tool call, and the launch acknowledgement its tool_result
|
||||
/// carries (Claude Code launches every agent asynchronously).
|
||||
fn agent_call(uuid: &str, parent: &str, tool_id: &str) -> String {
|
||||
serde_json::json!({
|
||||
"type": "assistant", "uuid": uuid, "parentUuid": parent,
|
||||
"message": {"model": "claude-x", "content": [{
|
||||
"type": "tool_use", "id": tool_id, "name": "Agent",
|
||||
"input": {"subagent_type": "Explore", "description": "sweep", "prompt": "p"}
|
||||
}]}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn launch_result(uuid: &str, parent: &str, tool_id: &str, agent_id: &str) -> String {
|
||||
serde_json::json!({
|
||||
"type": "user", "uuid": uuid, "parentUuid": parent,
|
||||
"message": {"role": "user", "content": [{
|
||||
"type": "tool_result", "tool_use_id": tool_id,
|
||||
"content": format!("Async agent launched successfully, agentId: {agent_id}")
|
||||
}]}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A completion `<task-notification>` in Claude Code's real wire shape.
|
||||
fn completion_note(task_id: &str, tool_id: &str, result: &str) -> String {
|
||||
format!(
|
||||
"<task-notification>\n\
|
||||
<task-id>{task_id}</task-id>\n\
|
||||
<tool-use-id>{tool_id}</tool-use-id>\n\
|
||||
<output-file>/tmp/claude/tasks/{task_id}.output</output-file>\n\
|
||||
<status>completed</status>\n\
|
||||
<summary>Agent \"sweep\" finished</summary>\n\
|
||||
<note>A task-notification fires each time this agent stops.</note>\n\
|
||||
<result>{result}</result>\n\
|
||||
<usage><subagent_tokens>128633</subagent_tokens><tool_uses>63</tool_uses>\
|
||||
<duration_ms>1115197</duration_ms></usage>\n\
|
||||
</task-notification>"
|
||||
)
|
||||
}
|
||||
|
||||
/// The four records of an agent run: prompt, `Agent` call, launch
|
||||
/// acknowledgement, and the turn its completion notification rode in on.
|
||||
fn agent_run(note: &str) -> Vec<String> {
|
||||
vec![
|
||||
prompt("u1", None, "go"),
|
||||
agent_call("a1", "u1", "toolu_A"),
|
||||
launch_result("u2", "a1", "toolu_A", "aaa1"),
|
||||
prompt("u3", Some("a1"), &format!("{note}\nwhat did it find?")),
|
||||
]
|
||||
}
|
||||
|
||||
fn view_of(lines: &[String]) -> Session {
|
||||
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
|
||||
let p = write_jsonl(&refs);
|
||||
let s = load_file_view(&p, "sess-notif", &[]).map(|h| h.session);
|
||||
std::fs::remove_file(&p).ok();
|
||||
s.expect("view")
|
||||
}
|
||||
|
||||
fn only_note(s: &Session) -> &Entry {
|
||||
let mut it = s.entries.iter().filter(|e| e.kind == Kind::TaskNote);
|
||||
let n = it.next().expect("a Kind::TaskNote entry");
|
||||
assert!(it.next().is_none(), "exactly one note expected");
|
||||
n
|
||||
}
|
||||
|
||||
/// A `<task-notification>` in a transcript is lifted out of the prompt: a
|
||||
/// one-line `Kind::TaskNote` in front of it, the XML (and the `<note>`
|
||||
/// boilerplate) gone from the user entry — the live path's rendering.
|
||||
#[test]
|
||||
fn disk_task_notification_becomes_a_note_beside_the_prompt() {
|
||||
let s = view_of(&agent_run(&completion_note("aaa1", "toolu_A", "THE REPORT")));
|
||||
let note = only_note(&s);
|
||||
assert!(note.content.starts_with('✔'), "{}", note.content);
|
||||
assert!(note.content.contains("sweep finished"), "{}", note.content);
|
||||
assert!(note.content.contains("128.6k tok · 63 tools · 18m35s"), "{}", note.content);
|
||||
// The report went to the tool call, so it is not repeated inline, and
|
||||
// the spool path is only shown when there is no report at all.
|
||||
assert!(!note.content.contains("THE REPORT"), "{}", note.content);
|
||||
assert!(!note.content.contains(".output"), "{}", note.content);
|
||||
for e in &s.entries {
|
||||
assert!(!e.content.contains("<task-notification>"), "raw XML left in {:?}", e.content);
|
||||
assert!(!e.content.contains("task-notification fires"), "boilerplate kept");
|
||||
}
|
||||
// What the user actually typed survives, as its own entry after the note.
|
||||
let users: Vec<&str> = s
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.kind == Kind::User)
|
||||
.map(|e| e.content.as_str())
|
||||
.collect();
|
||||
assert_eq!(users, ["go", "what did it find?"]);
|
||||
let pos = |k: &Kind| s.entries.iter().position(|e| &e.kind == k).unwrap();
|
||||
assert!(
|
||||
pos(&Kind::TaskNote) < s.entries.iter().rposition(|e| e.kind == Kind::User).unwrap(),
|
||||
"note precedes the prompt it rode in with"
|
||||
);
|
||||
}
|
||||
|
||||
/// The report replaces the `Async agent launched…` placeholder on the
|
||||
/// `Agent` entry when `<tool-use-id>` resolves to a call in this view.
|
||||
#[test]
|
||||
fn disk_task_report_replaces_the_launch_placeholder() {
|
||||
let s = view_of(&agent_run(&completion_note("aaa1", "toolu_A", "THE REPORT")));
|
||||
let tool = s
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent"))
|
||||
.expect("Agent entry");
|
||||
let r = tool.result.as_ref().expect("a result");
|
||||
assert_eq!(r.content, "THE REPORT");
|
||||
assert!(!r.is_error);
|
||||
}
|
||||
|
||||
/// … and when it resolves to nothing (the spawning turn is outside this
|
||||
/// view), the report stays inline in the note rather than being lost.
|
||||
#[test]
|
||||
fn disk_task_report_stays_inline_when_unresolved() {
|
||||
let s = view_of(&agent_run(&completion_note("aaa1", "toolu_GONE", "THE REPORT")));
|
||||
let note = only_note(&s);
|
||||
assert!(note.content.contains("\n THE REPORT"), "{}", note.content);
|
||||
let tool = s
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent"))
|
||||
.expect("Agent entry");
|
||||
assert!(
|
||||
tool.result.as_ref().unwrap().content.contains("Async agent launched"),
|
||||
"the launch acknowledgement is untouched when the report can't be placed"
|
||||
);
|
||||
}
|
||||
|
||||
/// A failed run marks the attached report as an error, and a `<status>`
|
||||
/// holding a raw error body (4 of the real ones) is kept on the note.
|
||||
#[test]
|
||||
fn disk_failed_task_marks_the_report_as_an_error() {
|
||||
let note = "<task-notification>\n\
|
||||
<task-id>aaa1</task-id>\n\
|
||||
<tool-use-id>toolu_A</tool-use-id>\n\
|
||||
<status>Error: 403: {\"message\":\"Access to model denied.\"}</status>\n\
|
||||
<summary>Agent \"sweep\" failed</summary>\n\
|
||||
<result>partial work</result>\n\
|
||||
</task-notification>";
|
||||
let s = view_of(&agent_run(note));
|
||||
let n = only_note(&s);
|
||||
assert!(n.content.starts_with('✖'), "{}", n.content);
|
||||
assert!(n.content.contains("Error: 403"), "{}", n.content);
|
||||
let tool = s
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent"))
|
||||
.expect("Agent entry");
|
||||
let r = tool.result.as_ref().unwrap();
|
||||
assert_eq!(r.content, "partial work");
|
||||
assert!(r.is_error, "a failed run's report is an error result");
|
||||
}
|
||||
|
||||
/// A monitor event is a progress ping: a note, no lane touched, no usage.
|
||||
/// (An on-disk lane is finished by construction — `Session::add_lane` —
|
||||
/// so what matters here is that the short id claims no lane at all.)
|
||||
#[test]
|
||||
fn disk_monitor_event_is_a_note_and_claims_no_lane() {
|
||||
let note = "<task-notification>\n\
|
||||
<task-id>b8s2gso3a</task-id>\n\
|
||||
<summary>Monitor event: \"world skin bench\"</summary>\n\
|
||||
<event>BENCH progress phase=traverse ms=141974</event>\n\
|
||||
</task-notification>";
|
||||
let s = view_of(&agent_run(note));
|
||||
let n = only_note(&s);
|
||||
assert!(n.content.starts_with('▸'), "{}", n.content);
|
||||
assert!(n.content.contains("Monitor event: world skin bench"), "{}", n.content);
|
||||
assert!(n.content.contains("\n BENCH progress phase=traverse"), "{}", n.content);
|
||||
assert!(!s.lane_of_agent.contains_key("b8s2gso3a"), "a monitor id is not a lane");
|
||||
assert_eq!(s.lanes.len(), 1, "no agent transcripts here, so main only");
|
||||
// Its `Agent` call keeps the launch acknowledgement: a monitor event
|
||||
// reports on nothing that has a report.
|
||||
let tool = s
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent"))
|
||||
.expect("Agent entry");
|
||||
assert!(tool.result.as_ref().unwrap().content.contains("Async agent launched"));
|
||||
}
|
||||
|
||||
/// A notification wrapped in a `<system-reminder>` (Claude Code does this)
|
||||
/// is still lifted, and a reminder that held nothing else vanishes with it.
|
||||
#[test]
|
||||
fn disk_notification_inside_a_reminder_is_lifted() {
|
||||
let note = completion_note("aaa1", "toolu_A", "THE REPORT");
|
||||
let wrapped = format!("<system-reminder>\n{note}\n</system-reminder>");
|
||||
let s = view_of(&agent_run(&wrapped));
|
||||
let n = only_note(&s);
|
||||
assert!(n.content.starts_with('✔'), "{}", n.content);
|
||||
assert!(
|
||||
!s.entries.iter().any(|e| e.kind == Kind::Reminder),
|
||||
"a reminder that was only a notification leaves nothing behind"
|
||||
);
|
||||
}
|
||||
|
||||
/// `<usage>` totals reach the agent's own lane — the only token figures an
|
||||
/// on-disk lane can have. Matched by agent id: the notification's
|
||||
/// `<task-id>` is the stem of `subagents/agent-<id>.jsonl`.
|
||||
#[test]
|
||||
fn disk_task_usage_reaches_the_lane() {
|
||||
let dir = std::env::temp_dir().join(format!("ct-usage-{}", std::process::id()));
|
||||
let subs = dir.join("subagents");
|
||||
std::fs::create_dir_all(&subs).unwrap();
|
||||
let main = dir.join("s-usage.jsonl");
|
||||
std::fs::write(
|
||||
&main,
|
||||
agent_run(&completion_note("aaa1", "toolu_A", "THE REPORT")).join("\n"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
subs.join("agent-aaa1.meta.json"),
|
||||
r#"{"agentType":"Explore","description":"sweep","toolUseId":"toolu_A","spawnDepth":1}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
subs.join("agent-aaa1.jsonl"),
|
||||
r#"{"type":"assistant","isSidechain":true,"uuid":"s1","parentUuid":null,"message":{"model":"claude-y","content":[{"type":"text","text":"child"}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let agents = scan_agents_in(&subs);
|
||||
let s = load_file_view(&main, "s-usage", &agents).expect("view").session;
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
|
||||
let lane = &s.lanes[1];
|
||||
assert_eq!(lane.agent_id, "aaa1");
|
||||
assert_eq!(lane.subagent_tokens, Some(128633));
|
||||
assert_eq!(lane.tool_uses, Some(63));
|
||||
assert_eq!(lane.duration_ms, Some(1115197));
|
||||
// The main lane never takes an agent's totals.
|
||||
assert_eq!(s.lanes[MAIN_LANE as usize].subagent_tokens, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_chain_and_stitch() {
|
||||
let tree = build_tree(branched());
|
||||
@@ -1265,3 +1665,4 @@ mod tests {
|
||||
assert!(s.entries.iter().all(|e| e.done));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
592
src/term.rs
592
src/term.rs
@@ -16,14 +16,16 @@ use ratatui::buffer::Buffer;
|
||||
use ratatui::crossterm::cursor::SetCursorStyle;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier};
|
||||
use std::io::Read;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
pub use wezterm_surface::CursorShape;
|
||||
use wezterm_surface::CursorVisibility;
|
||||
use wezterm_term::color::{ColorAttribute, ColorPalette};
|
||||
use wezterm_term::{
|
||||
Intensity, KeyCode, KeyModifiers, Terminal, TerminalConfiguration, TerminalSize, Underline,
|
||||
Intensity, KeyCode, KeyModifiers, StableRowIndex, Terminal, TerminalConfiguration, TerminalSize,
|
||||
Underline,
|
||||
};
|
||||
|
||||
/// Extra PTY rows beyond the visible pane window, so the child has room to
|
||||
@@ -84,6 +86,21 @@ pub struct EmbeddedTerm {
|
||||
/// Actual PTY rows (visible rows + pad when cropping is active).
|
||||
pty_rows: u16,
|
||||
cols: u16,
|
||||
/// Top row of the *fullscreen* pane's view, as a stable row index —
|
||||
/// `None` (the normal state) means "follow the live screen".
|
||||
///
|
||||
/// Claude Code grabs no mouse and stays off the alternate screen, so in a
|
||||
/// plain terminal the wheel scrolls that terminal's scrollback and the
|
||||
/// child never hears about it. The fullscreen pane is that terminal, so it
|
||||
/// does the same job (see `scroll`). A *stable* index rather than an offset
|
||||
/// because the child keeps writing while you read: a terminal pins the rows
|
||||
/// you scrolled to instead of sliding them up under you.
|
||||
scroll_top: Mutex<Option<StableRowIndex>>,
|
||||
/// The child's pid. Kept as a plain number because a hot reload
|
||||
/// (`reload.rs`) execs us: the portable-pty `Child` handle dies with the
|
||||
/// old image, but we stay the same process, so the *pid* is still ours to
|
||||
/// wait on and signal after the exec.
|
||||
child_pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// HTTP header carrying the pane token; the proxy reads it to bind this pane's
|
||||
@@ -156,6 +173,7 @@ impl EmbeddedTerm {
|
||||
.context("openpty")?;
|
||||
let child = pty.slave.spawn_command(cmd).context("spawn child")?;
|
||||
let killer = child.clone_killer();
|
||||
let child_pid = child.process_id();
|
||||
drop(pty.slave);
|
||||
|
||||
// The terminal model writes query responses (DSR/DA/XTGETTCAP…) and
|
||||
@@ -194,13 +212,114 @@ impl EmbeddedTerm {
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self { term, master: pty.master, killer, exited, session_id, pane_token, pty_rows: rows + PTY_PAD, cols })
|
||||
Ok(Self {
|
||||
term,
|
||||
master: pty.master,
|
||||
killer,
|
||||
exited,
|
||||
session_id,
|
||||
pane_token,
|
||||
pty_rows: rows + PTY_PAD,
|
||||
cols,
|
||||
scroll_top: Mutex::new(None),
|
||||
child_pid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Take a *running* child back over after a hot reload. The PTY master fd
|
||||
/// came through the `execve` (see `reload::keep_open`), and the child never
|
||||
/// noticed: same pid on our side, same pty, same session.
|
||||
///
|
||||
/// What does not survive is the wezterm screen model: it is rebuilt empty,
|
||||
/// so the cells have to come from the child again. The trick is to adopt
|
||||
/// the pty **one row short** of its real height and let the first
|
||||
/// `ui::draw` frame restore it — `resize` then sees a changed geometry and
|
||||
/// issues a real `TIOCSWINSZ`, which Linux only turns into a SIGWINCH when
|
||||
/// the size actually differs, and Ink answers with a full repaint. Poking
|
||||
/// the ioctl twice in a row here instead would coalesce into one signal
|
||||
/// carrying the *unchanged* final size, and redraw nothing.
|
||||
pub fn adopt(h: PtyHandoff) -> anyhow::Result<Self> {
|
||||
let rows = h.pty_rows.saturating_sub(1).max(1);
|
||||
let master = AdoptedMaster { fd: unsafe { OwnedFd::from_raw_fd(h.master_fd) } };
|
||||
// The fd arrived non-CLOEXEC (that is how it survived the exec). Put
|
||||
// the flag back so it isn't inherited by anything we spawn from here.
|
||||
unsafe { libc::fcntl(h.master_fd, libc::F_SETFD, libc::FD_CLOEXEC) };
|
||||
let writer = master.take_writer().context("pty writer")?;
|
||||
let _ = master.resize(PtySize { rows, cols: h.cols, pixel_width: 0, pixel_height: 0 });
|
||||
let term = Arc::new(Mutex::new(Terminal::new(
|
||||
TerminalSize {
|
||||
rows: rows as usize,
|
||||
cols: h.cols as usize,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
dpi: 0,
|
||||
},
|
||||
Arc::new(Config),
|
||||
"claude-cloak",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
writer,
|
||||
)));
|
||||
|
||||
let exited = Arc::new(AtomicBool::new(false));
|
||||
let mut reader = master.try_clone_reader().context("pty reader")?;
|
||||
let pid = h.child_pid;
|
||||
{
|
||||
let term = term.clone();
|
||||
let exited = exited.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => term.lock().unwrap().advance_bytes(&buf[..n]),
|
||||
}
|
||||
}
|
||||
// Still the child's parent across the exec, so it is still
|
||||
// ours to reap — the portable-pty `Child` that used to do it
|
||||
// died with the old image. Retry on EINTR; anything else
|
||||
// (notably ECHILD) means there is nothing left to wait for.
|
||||
let mut status = 0;
|
||||
while unsafe { libc::waitpid(pid as i32, &mut status, 0) } < 0
|
||||
&& std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR)
|
||||
{}
|
||||
exited.store(true, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
term,
|
||||
master: Box::new(master),
|
||||
killer: Box::new(PidKiller(pid)),
|
||||
exited,
|
||||
session_id: h.session_id,
|
||||
pane_token: h.pane_token,
|
||||
// Deliberately the short height: the next frame's `resize` restores
|
||||
// the real one and that is what triggers the repaint.
|
||||
pty_rows: rows,
|
||||
cols: h.cols,
|
||||
scroll_top: Mutex::new(None),
|
||||
child_pid: Some(pid),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn exited(&self) -> bool {
|
||||
self.exited.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Describe this pane well enough for the post-exec image to re-adopt it
|
||||
/// (`adopt`). `None` when the child's pid is unknown or the master has no
|
||||
/// fd — either way the pane can't survive a reload and is killed instead.
|
||||
pub fn handoff(&self) -> Option<PtyHandoff> {
|
||||
Some(PtyHandoff {
|
||||
master_fd: self.master.as_raw_fd()?,
|
||||
child_pid: self.child_pid?,
|
||||
session_id: self.session_id.clone(),
|
||||
pane_token: self.pane_token.clone(),
|
||||
pty_rows: self.pty_rows,
|
||||
cols: self.cols,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resize PTY + terminal model for a pane of `rows` visible rows.
|
||||
/// With `crop` (the compact pane), the PTY gets `PTY_PAD` extra rows:
|
||||
/// render() crops Claude Code's persistent status/hint rows, so the
|
||||
@@ -244,6 +363,51 @@ impl EmbeddedTerm {
|
||||
let _ = self.term.lock().unwrap().send_paste(text);
|
||||
}
|
||||
|
||||
/// Scroll the fullscreen pane's view by `delta` rows — negative up, into
|
||||
/// the child's scrollback. Clamps to what the scrollback still holds;
|
||||
/// arriving back at the live screen re-engages follow mode rather than
|
||||
/// pinning to it.
|
||||
///
|
||||
/// Deliberately *our* scroll and not a forwarded mouse report: Claude Code
|
||||
/// enables no mouse tracking and never leaves the normal screen, so a
|
||||
/// plain terminal scrolls its own scrollback here too and the child sees
|
||||
/// nothing. The fullscreen pane is that terminal.
|
||||
pub fn scroll(&self, delta: isize) {
|
||||
let term = self.term.lock().unwrap();
|
||||
let screen = term.screen();
|
||||
// Top row of the live screen, and the oldest row still held.
|
||||
let live = screen.visible_row_to_stable_row(0);
|
||||
let oldest = screen.phys_to_stable_row_index(0);
|
||||
let mut top = self.scroll_top.lock().unwrap();
|
||||
let next = (top.unwrap_or(live) + delta).clamp(oldest, live);
|
||||
*top = (next < live).then_some(next);
|
||||
}
|
||||
|
||||
/// One page of the pane, in rows: the child's screen height less a row of
|
||||
/// overlap, so a page scroll keeps a line of context.
|
||||
pub fn page_rows(&self) -> isize {
|
||||
(self.term.lock().unwrap().screen().physical_rows as isize - 1).max(1)
|
||||
}
|
||||
|
||||
/// Snap the view back to the live screen. A terminal does this on a
|
||||
/// keypress (xterm's scroll-on-key); without it a scrolled-back pane looks
|
||||
/// frozen the moment you start typing again.
|
||||
pub fn follow_live(&self) {
|
||||
*self.scroll_top.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Rows the view sits above the live screen (0 = following). The pane
|
||||
/// border shows this, so a scrolled-back view is never mistaken for a
|
||||
/// stalled child.
|
||||
pub fn scrolled_rows(&self) -> usize {
|
||||
// `term` before `scroll_top`: the one lock order used here.
|
||||
let term = self.term.lock().unwrap();
|
||||
let Some(top) = *self.scroll_top.lock().unwrap() else {
|
||||
return 0;
|
||||
};
|
||||
(term.screen().visible_row_to_stable_row(0) - top).max(0) as usize
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -340,15 +504,33 @@ impl EmbeddedTerm {
|
||||
/// - `Interactive`: the AskUserQuestion / ExitPlanMode prompt the tap grew
|
||||
/// the pane for — framed from its own top border (see
|
||||
/// `interactive_view_range`) so the question text is never cropped.
|
||||
/// - `Full` (fullscreen): the screen verbatim from row 0, nothing cut off.
|
||||
/// - `Full` (fullscreen): the screen verbatim from row 0, nothing cut off —
|
||||
/// or, once the wheel has scrolled the pane back (`scroll`), a window of
|
||||
/// the child's scrollback instead. Only fullscreen scrolls: the cropped
|
||||
/// views frame the input box, which is always at the live bottom.
|
||||
pub fn render(&self, area: Rect, buf: &mut Buffer, view: PaneView) -> Option<(u16, u16)> {
|
||||
let term = self.term.lock().unwrap();
|
||||
let screen = term.screen();
|
||||
let first = screen.phys_row(0);
|
||||
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows);
|
||||
let h = area.height as usize;
|
||||
// Scrolled back: take the window out of the scrollback instead. A
|
||||
// stable index maps to `None` once its row has been trimmed away, and
|
||||
// the live top is the floor — a view *at* it is just following.
|
||||
let live_first = screen.phys_row(0);
|
||||
let back = (view == PaneView::Full)
|
||||
.then(|| *self.scroll_top.lock().unwrap())
|
||||
.flatten()
|
||||
.and_then(|top| screen.stable_row_to_phys(top))
|
||||
.filter(|&p| p < live_first);
|
||||
let (first, count) = match back {
|
||||
Some(p) => (p, h.min(live_first - p + screen.physical_rows)),
|
||||
None => (live_first, screen.physical_rows),
|
||||
};
|
||||
let lines = screen.lines_in_phys_range(first..first + count);
|
||||
if lines.is_empty() {
|
||||
return None; // zero-height pane: nothing to paint or index into
|
||||
}
|
||||
let rows: Vec<String> = lines.iter().map(row_text).collect();
|
||||
let last = rows.iter().rposition(|t| !t.trim().is_empty()).unwrap_or(0);
|
||||
let h = area.height as usize;
|
||||
let (start, end) = match view {
|
||||
PaneView::Full => {
|
||||
// The PTY is sized to the pane in fullscreen, but a resize may
|
||||
@@ -400,7 +582,11 @@ impl EmbeddedTerm {
|
||||
}
|
||||
let cursor = term.cursor_pos();
|
||||
let cy = cursor.y as usize;
|
||||
(cursor.visibility == CursorVisibility::Visible
|
||||
// A scrolled-back window is not the live screen, so the cursor row the
|
||||
// child reports does not index into it — show none, exactly like a
|
||||
// terminal scrolled away from its prompt.
|
||||
(back.is_none()
|
||||
&& cursor.visibility == CursorVisibility::Visible
|
||||
&& (cursor.x as u16) < area.width
|
||||
&& cy >= start
|
||||
&& cy <= end
|
||||
@@ -424,11 +610,33 @@ fn text_is_rule(t: &str) -> bool {
|
||||
|
||||
/// A `@`-file / `/`-command menu row. When such a menu is open it replaces the
|
||||
/// statusLine + hint/token/effort chrome with a list directly under the input
|
||||
/// box's bottom rule. These markers are the Claude Code 2.1.x list glyphs;
|
||||
/// retune here if a CC update changes them.
|
||||
/// box's bottom rule.
|
||||
///
|
||||
/// Claude Code 2.1.x draws four shapes here, and **a glyph is not one of the
|
||||
/// things they share** (captured from a real child):
|
||||
/// - fuzzy match inside the project (`@bug`, `@src/`): `+ src/debug/`;
|
||||
/// - a **directory listing** — the typed token holds a path that leaves the
|
||||
/// project (`@../de`): plain padded paths, `../destinations/`, no marker at
|
||||
/// all. The selected row differs only in colour, which `row_text` drops, so
|
||||
/// text alone can never find a glyph here;
|
||||
/// - a session/agent mention (`@CLA`): `@claude-cloak-a4 message session · …`;
|
||||
/// - a `/` command, whose description wraps onto plain continuation rows.
|
||||
///
|
||||
/// So the last resort is the *shape* of a path: one whitespace-free token
|
||||
/// holding a `/`. That is deliberately loose — `compact_frame_ex` votes over
|
||||
/// two rows, and keeps the statusLine out of the vote, rather than trusting any
|
||||
/// single row (see the menu detection there).
|
||||
fn text_is_menu_item(t: &str) -> bool {
|
||||
let t = t.trim_start();
|
||||
["+ ", "* ", "❯ ", "› "].iter().any(|m| t.starts_with(m)) || t.starts_with('/')
|
||||
let t = t.trim();
|
||||
if ["+ ", "* ", "❯ ", "› "].iter().any(|m| t.starts_with(m)) {
|
||||
return true;
|
||||
}
|
||||
// `/command …` and `@agent …` rows carry their description inline.
|
||||
if t.starts_with('/') || t.starts_with('@') {
|
||||
return true;
|
||||
}
|
||||
// Bare listing row: `../destinations/`, `src/game/foo.gd`.
|
||||
t.contains('/') && !t.contains(char::is_whitespace)
|
||||
}
|
||||
|
||||
/// Status glyphs Claude Code prints in front of a task/todo row: pending
|
||||
@@ -558,14 +766,23 @@ fn compact_frame_ex(rows: &[String]) -> Option<CompactFrame> {
|
||||
None => ctx_top,
|
||||
};
|
||||
// An open `@`/`/` menu replaces the chrome below the bottom rule with a
|
||||
// list. Scan the *whole* region under the rule for a menu row, not just the
|
||||
// one immediately below it: the list can start after a blank separator or a
|
||||
// header row, and only the highlighted item carries a recognisable glyph
|
||||
// (unselected file rows are plain indented names), so checking a single row
|
||||
// missed the menu whenever that row happened not to be the selected one.
|
||||
// The persistent chrome rows (statusLine / hint / tokens / effort) never
|
||||
// match `text_is_menu_item`, so scanning stays free of false positives.
|
||||
let menu_open = last > bot_div && (bot_div + 1..=last).any(|i| text_is_menu_item(&rows[i]));
|
||||
// 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,
|
||||
// so it can look like anything — a bare `~/projects/foo` included:
|
||||
// - the row right under the rule (`head`) must be a menu row or blank. It
|
||||
// is shown either way (it is the statusLine's slot when no menu is up),
|
||||
// so a one-row menu needs no detection at all; blank counts because the
|
||||
// chrome never leaves that row empty, while a menu may separate itself
|
||||
// from the box.
|
||||
// - at least one *further* row must look like a menu row too. This is what
|
||||
// keeps a two-line statusLine (line 2 a bare path) from reading as a
|
||||
// menu and dragging the whole hint/token/effort chrome into the pane.
|
||||
// Scanning the whole tail (not just `bot_div + 1`) is also required: only
|
||||
// the highlighted row of a fuzzy list carries a glyph, so checking one row
|
||||
// collapsed the pane whenever that row was not the selected one.
|
||||
let head = bot_div + 1;
|
||||
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 })
|
||||
}
|
||||
@@ -680,6 +897,118 @@ fn interactive_view_range(rows: &[String], last: usize, h: usize) -> (usize, usi
|
||||
(end + 1 - h, end)
|
||||
}
|
||||
|
||||
/// Everything the post-exec image needs to take a running `claude` child back
|
||||
/// over (`EmbeddedTerm::adopt`). An `execve` keeps our pid, our open fds and
|
||||
/// our children, so the child never notices the reload — but every Rust-side
|
||||
/// handle is gone, which is why the pane is rebuilt from a bare fd + pid.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct PtyHandoff {
|
||||
/// PTY master. `reload::keep_open` clears its FD_CLOEXEC before the exec,
|
||||
/// so this number is still valid — and still the same pty — afterwards.
|
||||
pub master_fd: RawFd,
|
||||
pub child_pid: u32,
|
||||
pub session_id: String,
|
||||
pub pane_token: String,
|
||||
pub pty_rows: u16,
|
||||
pub cols: u16,
|
||||
}
|
||||
|
||||
/// A PTY master rebuilt from an inherited fd. Implements just enough of
|
||||
/// `MasterPty` to stand in for portable-pty's own master, so `EmbeddedTerm`
|
||||
/// keeps one type for both the freshly spawned and the adopted pane.
|
||||
///
|
||||
/// Note its writer is a plain `File`: portable-pty's writer sends EOT to the
|
||||
/// child when dropped, which would end the adopted session on every teardown.
|
||||
#[derive(Debug)]
|
||||
struct AdoptedMaster {
|
||||
fd: OwnedFd,
|
||||
}
|
||||
|
||||
impl AdoptedMaster {
|
||||
/// Duplicate the master fd for an independent reader/writer handle.
|
||||
/// `F_DUPFD_CLOEXEC` keeps the clone out of the *next* reload's exec —
|
||||
/// only the one fd named in the handoff is meant to survive.
|
||||
fn dup(&self) -> anyhow::Result<std::fs::File> {
|
||||
let fd = unsafe { libc::fcntl(self.fd.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if fd < 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("dup pty master");
|
||||
}
|
||||
Ok(unsafe { std::fs::File::from_raw_fd(fd) })
|
||||
}
|
||||
}
|
||||
|
||||
impl MasterPty for AdoptedMaster {
|
||||
fn resize(&self, size: PtySize) -> Result<(), anyhow::Error> {
|
||||
let ws = libc::winsize {
|
||||
ws_row: size.rows,
|
||||
ws_col: size.cols,
|
||||
ws_xpixel: size.pixel_width,
|
||||
ws_ypixel: size.pixel_height,
|
||||
};
|
||||
let rc = unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCSWINSZ as _, &ws) };
|
||||
if rc != 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("ioctl(TIOCSWINSZ)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_size(&self) -> Result<PtySize, anyhow::Error> {
|
||||
let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
|
||||
let rc = unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCGWINSZ as _, &mut ws) };
|
||||
if rc != 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("ioctl(TIOCGWINSZ)");
|
||||
}
|
||||
Ok(PtySize {
|
||||
rows: ws.ws_row,
|
||||
cols: ws.ws_col,
|
||||
pixel_width: ws.ws_xpixel,
|
||||
pixel_height: ws.ws_ypixel,
|
||||
})
|
||||
}
|
||||
|
||||
fn try_clone_reader(&self) -> Result<Box<dyn Read + Send>, anyhow::Error> {
|
||||
Ok(Box::new(self.dup()?))
|
||||
}
|
||||
|
||||
fn take_writer(&self) -> Result<Box<dyn Write + Send>, anyhow::Error> {
|
||||
Ok(Box::new(self.dup()?))
|
||||
}
|
||||
|
||||
fn process_group_leader(&self) -> Option<libc::pid_t> {
|
||||
match unsafe { libc::tcgetpgrp(self.fd.as_raw_fd()) } {
|
||||
pid if pid > 0 => Some(pid),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_raw_fd(&self) -> Option<RawFd> {
|
||||
Some(self.fd.as_raw_fd())
|
||||
}
|
||||
|
||||
fn tty_name(&self) -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Signals a child by pid. Stands in for portable-pty's killer, whose handle
|
||||
/// doesn't survive the exec. SIGHUP matches what portable-pty sends, so an
|
||||
/// adopted pane dies exactly like a spawned one.
|
||||
#[derive(Debug)]
|
||||
struct PidKiller(u32);
|
||||
|
||||
impl ChildKiller for PidKiller {
|
||||
fn kill(&mut self) -> std::io::Result<()> {
|
||||
if unsafe { libc::kill(self.0 as i32, libc::SIGHUP) } != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
|
||||
Box::new(PidKiller(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EmbeddedTerm {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.killer.kill();
|
||||
@@ -720,10 +1049,10 @@ fn conv_color(c: ColorAttribute) -> Option<Color> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude Code's own default model, as a `--model` argument (`opus`,
|
||||
/// `opus[1m]`, …). Its settings files are the one place the **1M-context**
|
||||
/// choice is written down — `/model` saves the pick there, suffix and all,
|
||||
/// while a transcript records the same base model id either way.
|
||||
/// Claude Code's own default model, as it writes it (`opus`, `opus[1m]`, …).
|
||||
/// Only the *model* is used: `App::spawn_arg` / `App::resume_arg` strip any
|
||||
/// suffix and re-apply the 1M window themselves, so a `default` pick or a
|
||||
/// session we know nothing about still names a model we can attach `[1m]` to.
|
||||
///
|
||||
/// Resolved the way Claude Code layers it: `ANTHROPIC_MODEL`, then
|
||||
/// project-local, project, and user settings. `None` when nothing sets one
|
||||
@@ -757,21 +1086,21 @@ fn settings_model(path: &std::path::Path) -> Option<String> {
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Background scan that replaces `App::model_choices` with the live alias set
|
||||
/// read from the installed `claude` binary (see `discover_model_aliases`).
|
||||
/// Runs off the UI thread; on failure the seeded fallback list stays in place.
|
||||
/// Background scan that replaces `App::models` with the live sets read from the
|
||||
/// installed `claude` binary (see `discover_models`). Runs off the UI thread;
|
||||
/// on failure the seeded fallback catalog stays in place.
|
||||
pub fn spawn_model_discovery(app: crate::app::SharedApp) {
|
||||
std::thread::spawn(move || {
|
||||
if let Some(choices) = discover_model_choices() {
|
||||
crate::app::lock_app(&app).model_choices = choices;
|
||||
if let Some(models) = discover_models() {
|
||||
crate::app::lock_app(&app).models = models;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Best-effort discovery of the models the installed `claude` accepts (aliases
|
||||
/// like `opus`, `sonnet`, `haiku`, `fable`, plus their `<alias>[1m]`
|
||||
/// long-context variants), so the `a` picker tracks new models without us
|
||||
/// hardcoding a list that drifts.
|
||||
/// like `opus`, `sonnet`, `haiku`, `fable`, and which of them ship a
|
||||
/// `<alias>[1m]` long-context variant), so the `a` picker and the always-1M
|
||||
/// policy track new models without us hardcoding a list that drifts.
|
||||
///
|
||||
/// Claude Code ships as one self-contained executable with its (minified) JS
|
||||
/// bundle embedded; the alias set appears verbatim as a JSON array literal like
|
||||
@@ -779,28 +1108,25 @@ pub fn spawn_model_discovery(app: crate::app::SharedApp) {
|
||||
/// quoted `"sonnet[1m]"` literal. We resolve the `claude` binary on PATH and
|
||||
/// read it once. This issues **no API request** (the project's core constraint)
|
||||
/// and never executes claude. Returns None if the binary can't be found/read or
|
||||
/// nothing matches — the caller keeps its built-in fallback list.
|
||||
fn discover_model_choices() -> Option<Vec<(String, String)>> {
|
||||
/// nothing matches — the caller keeps its built-in fallback catalog.
|
||||
fn discover_models() -> Option<crate::app::Models> {
|
||||
let bytes = std::fs::read(claude_binary_path()?).ok()?;
|
||||
let aliases = longest_alias_array(&bytes)?;
|
||||
Some(model_choices_from(&bytes, &aliases))
|
||||
Some(models_from(&bytes, aliases))
|
||||
}
|
||||
|
||||
/// Assemble picker entries `(label, --model arg)`: `default` (no `--model`
|
||||
/// flag) first, then every alias, then the `<alias>[1m]` long-context variants
|
||||
/// the binary actually ships (see `long_context_tokens`).
|
||||
fn model_choices_from(bytes: &[u8], aliases: &[String]) -> Vec<(String, String)> {
|
||||
let long = long_context_tokens(bytes);
|
||||
let mut choices: Vec<(String, String)> = vec![("default".into(), String::new())];
|
||||
choices.extend(aliases.iter().map(|a| (a.clone(), a.clone())));
|
||||
choices.extend(
|
||||
aliases
|
||||
/// Build the catalog: every alias, plus every model the binary really ships a
|
||||
/// `[1m]` variant for — never assumed. `long` keeps the *base* name of each
|
||||
/// `"<name>[1m]"` literal (see `long_context_tokens`), so it covers full model
|
||||
/// ids as well as aliases: a session whose transcript names a model with no
|
||||
/// alias still resumes at the long window.
|
||||
fn models_from(bytes: &[u8], aliases: Vec<String>) -> crate::app::Models {
|
||||
let mut long: Vec<String> = long_context_tokens(bytes)
|
||||
.iter()
|
||||
.map(|a| format!("{a}[1m]"))
|
||||
.filter(|v| long.contains(v))
|
||||
.map(|v| (format!("{v} (1M context)"), v)),
|
||||
);
|
||||
choices
|
||||
.map(|t| crate::app::base_model(t).to_string())
|
||||
.collect();
|
||||
long.sort();
|
||||
crate::app::Models { aliases, long }
|
||||
}
|
||||
|
||||
/// Resolve `claude` on `PATH` to a readable file path (symlinks followed).
|
||||
@@ -932,14 +1258,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_1m_choices_for_aliases_that_have_them() {
|
||||
let bytes = br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]""#;
|
||||
fn catalog_marks_only_aliases_with_a_real_1m_variant() {
|
||||
let bytes =
|
||||
br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]" "claude-mythos-1[1m]""#;
|
||||
let aliases = longest_alias_array(bytes).unwrap();
|
||||
let got = model_choices_from(bytes, &aliases);
|
||||
let args: Vec<&str> = got.iter().map(|c| c.1.as_str()).collect();
|
||||
// default (no flag), the plain aliases, then only the real 1M variants.
|
||||
assert_eq!(args, ["", "sonnet", "opus", "haiku", "sonnet[1m]", "opus[1m]"]);
|
||||
assert_eq!(got[4].0, "sonnet[1m] (1M context)");
|
||||
let models = models_from(bytes, aliases);
|
||||
assert_eq!(models.aliases, ["sonnet", "opus", "haiku"]);
|
||||
// Full ids count too: a transcript can name a model no alias covers.
|
||||
assert_eq!(models.long, ["claude-mythos-1", "opus", "sonnet"]);
|
||||
assert_eq!(models.arg("claude-mythos-1"), "claude-mythos-1[1m]");
|
||||
assert_eq!(models.arg("haiku"), "haiku");
|
||||
// Picker rows: every model at the window `Models::arg` gives it —
|
||||
// 1M where the binary ships one, plain where it does not.
|
||||
let args: Vec<String> = models.choices().into_iter().map(|c| c.1).collect();
|
||||
assert_eq!(args, ["", "sonnet[1m]", "opus[1m]", "haiku"]);
|
||||
}
|
||||
|
||||
/// Build a `rows` fixture (visible text per screen row) from string slices.
|
||||
@@ -1020,6 +1352,93 @@ mod tests {
|
||||
assert_eq!(compact_frame(&screen), Some((1, 8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_unmarked_path_listing_menu() {
|
||||
// Real capture, `@../de` typed in ~/projects/destinations: a path that
|
||||
// leaves the project switches CC to a *listing*, whose rows carry no
|
||||
// `+`/`❯` marker at all (the selected one differs only in colour).
|
||||
// Regression: the pane showed the first hit and cropped the rest.
|
||||
let screen = rows(&[
|
||||
"", "",
|
||||
&format!("{RULE} minimal ──"), // 2: top rule
|
||||
"❯ @../de", // 3: input
|
||||
RULE, // 4: bottom rule
|
||||
" ../destinations/", // 5: selected (colour only)
|
||||
" ../destinations-player-host/", // 6
|
||||
" ../destinations-terrain-unify/", // 7: last non-blank
|
||||
"", "",
|
||||
]);
|
||||
assert_eq!(compact_frame(&screen), Some((1, 7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_agent_mention_menu() {
|
||||
// `@CLA` lists sessions/agents: `@name` + an inline description.
|
||||
let screen = rows(&[
|
||||
"", "",
|
||||
&format!("{RULE} minimal ──"), // 2: top rule
|
||||
"❯ @CLA", // 3: input
|
||||
RULE, // 4: bottom rule
|
||||
" @claude-cloak-a4 message session · active 30s ago", // 5
|
||||
" @claude-cloak-10 message session · active 2m ago", // 6
|
||||
"", "",
|
||||
]);
|
||||
assert_eq!(compact_frame(&screen), Some((1, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_item_menu_needs_no_detection() {
|
||||
// One match, and it sits in the statusLine's own slot — always shown.
|
||||
let screen = rows(&[
|
||||
"", "",
|
||||
&format!("{RULE} minimal ──"), // 2: top rule
|
||||
"❯ @../destinations-t", // 3: input
|
||||
RULE, // 4: bottom rule
|
||||
" ../destinations-terrain-unify/", // 5: the only hit
|
||||
"", "",
|
||||
]);
|
||||
assert_eq!(compact_frame(&screen), Some((1, 5)));
|
||||
// A blank separator before a lone item still shows the item.
|
||||
let screen = rows(&[
|
||||
"", "",
|
||||
&format!("{RULE} minimal ──"),
|
||||
"❯ @../destinations-t",
|
||||
RULE,
|
||||
"", // 5: separator
|
||||
" ../destinations-terrain-unify/", // 6
|
||||
"", "",
|
||||
]);
|
||||
assert_eq!(compact_frame(&screen), Some((1, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_shaped_statusline_is_not_a_menu() {
|
||||
// The statusLine is the user's own text, so it can be a bare path —
|
||||
// which is exactly the shape an unmarked menu row has. Detection votes
|
||||
// over a second row for this reason: a path-ish statusLine (even a
|
||||
// two-line one) must not drag the hint/token chrome into the pane.
|
||||
let screen = rows(&[
|
||||
"", "",
|
||||
&format!("{RULE} minimal ──"), // 2: top rule
|
||||
"❯", // 3: input
|
||||
RULE, // 4: bottom rule
|
||||
"~/projects/destinations", // 5: statusLine (kept)
|
||||
"⏵⏵ bypass permissions (shift+tab)", // 6: chrome (cropped)
|
||||
" 0 tokens", // 7: chrome (cropped)
|
||||
]);
|
||||
assert_eq!(compact_frame(&screen), Some((1, 5)));
|
||||
let screen = rows(&[
|
||||
"", "",
|
||||
&format!("{RULE} minimal ──"),
|
||||
"❯",
|
||||
RULE,
|
||||
"Opus 5 · xhigh", // 5: statusLine line 1 (kept)
|
||||
"~/projects/destinations", // 6: statusLine line 2 (cropped)
|
||||
"? for shortcuts", // 7
|
||||
]);
|
||||
assert_eq!(compact_frame(&screen), Some((1, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_menu_keeps_input_box_visible() {
|
||||
// Regression: when a long `@`/`/` match list doesn't fit in the pane's
|
||||
@@ -1215,6 +1634,67 @@ mod tests {
|
||||
assert!(longest_alias_array(br#"["Opus","sonnet"]"#).is_none());
|
||||
}
|
||||
|
||||
/// First rendered row of `view`, trimmed.
|
||||
fn top_row(et: &EmbeddedTerm, area: Rect, view: PaneView) -> String {
|
||||
let mut buf = Buffer::empty(area);
|
||||
et.render(area, &mut buf, view);
|
||||
(0..area.width).map(|x| buf[(x, 0)].symbol()).collect::<String>().trim().to_string()
|
||||
}
|
||||
|
||||
/// The fullscreen pane scrolls its own scrollback (Claude Code grabs no
|
||||
/// mouse, so there is nothing to forward to it): a real child writes more
|
||||
/// rows than the screen holds, and `scroll` has to reach the ones that left
|
||||
/// it — then hand the view back to the live screen at the bottom.
|
||||
#[test]
|
||||
fn fullscreen_pane_scrolls_into_the_scrollback() {
|
||||
let mut cmd = CommandBuilder::new("sh");
|
||||
cmd.args(["-c", "seq 1 60; sleep 5"]);
|
||||
let area = Rect::new(0, 0, 40, 10);
|
||||
let et = EmbeddedTerm::spawn_cmd(cmd, "test-session".into(), "test-token".into(), 6, 40).unwrap();
|
||||
// Wait for the tail of the child's output to reach the live screen.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let mut buf = Buffer::empty(area);
|
||||
et.render(area, &mut buf, PaneView::Full);
|
||||
let text: String = (0..area.height)
|
||||
.flat_map(|y| (0..area.width).map(move |x| (x, y)))
|
||||
.map(|(x, y)| buf[(x, y)].symbol())
|
||||
.collect();
|
||||
if text.contains("60") {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "child output never arrived: {text:?}");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let live = top_row(&et, area, PaneView::Full);
|
||||
assert_eq!(et.scrolled_rows(), 0, "starts out following the live screen");
|
||||
|
||||
// Up 40 rows: a window of rows that have left the screen.
|
||||
et.scroll(-40);
|
||||
assert_eq!(et.scrolled_rows(), 40);
|
||||
let scrolled = top_row(&et, area, PaneView::Full);
|
||||
assert_ne!(scrolled, live, "the view did not move");
|
||||
let n: usize = scrolled.parse().expect("a `seq` line number");
|
||||
assert!(n < live.parse::<usize>().unwrap(), "scrolled the wrong way: {n}");
|
||||
// No cursor while scrolled away — its row does not index this window.
|
||||
let mut buf = Buffer::empty(area);
|
||||
assert_eq!(et.render(area, &mut buf, PaneView::Full), None);
|
||||
// A cropped view never scrolls: it frames the input box at the bottom.
|
||||
assert_eq!(top_row(&et, area, PaneView::Compact), live);
|
||||
|
||||
// Past the bottom re-engages follow mode rather than pinning to it.
|
||||
et.scroll(1000);
|
||||
assert_eq!(et.scrolled_rows(), 0);
|
||||
assert_eq!(top_row(&et, area, PaneView::Full), live);
|
||||
|
||||
// …as does `follow_live`, from anywhere.
|
||||
et.scroll(-10);
|
||||
assert_eq!(et.scrolled_rows(), 10);
|
||||
et.follow_live();
|
||||
assert_eq!(et.scrolled_rows(), 0);
|
||||
assert_eq!(top_row(&et, area, PaneView::Full), live);
|
||||
}
|
||||
|
||||
/// Full pipeline: PTY spawn → reader thread → wezterm-term model →
|
||||
/// ratatui buffer. Headless-safe: the *child* gets the tty, not us.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user