diff --git a/CLAUDE.md b/CLAUDE.md index 27c5b8b..c0011e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,12 @@ src/proxy.rs axum fallback handler: buffers request body (for session metadata response back unbuffered, tees SSE src/sse.rs incremental SSE parser; tolerant of chunk splits mid-event/mid-UTF-8 src/app.rs Arc> shared state; Tap = one in-flight tapped request, - translates SSE events → session Entries (Drop closes it out) + 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 + append-only Vec tagged with `Entry::lane`; per-agent state + (model, tokens, tool count, system/tools signatures, label, + parent, finished) lives on `Lane` 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,6 +63,16 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed minimap: `*` markers show where each user message sits in the whole conversation, with the scroll thumb drawn on top where they coincide. + Subagents never touch this feed: it renders lane + `MAIN_LANE` only, at full width, whatever the agents are doing. + They live in the `A` popup (`popup_rect` = 80% of the *feed* + rect, centred): `draw_agent_list` is the picker, + `draw_feed` the chosen agent's own stream — same function as the + main feed, own FeedCache from the `FeedCaches` pool, own + scroll/follow from `App::lane_cols`, so it follows its own tail + and the border title carries the identity (`⟳ Explore · find the + retry helper · sonnet · out 2.1k · 2/3`). 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 @@ -67,7 +82,8 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed src/markdown.rs wraps tui-markdown: renders GFM tables itself (box-drawing, width-fitted wrapped columns) and strips heading `#` markers — the pinned tui-markdown 0.3.5 does neither -src/sessions.rs on-disk session history: background scanner thread keeps +src/sessions.rs on-disk session history (main chain *and* subagents): + background scanner thread keeps 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 @@ -75,14 +91,21 @@ src/sessions.rs on-disk session history: background scanner thread keeps from a JSONL transcript (lazily, on first view); 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 + writes a new session file from a chosen set of turns. + `scan_agents` reads the `/subagents/agent-*.meta.json` + sidecars (cheap: the transcripts themselves can be MBs) and + `splice_agents` inserts each agent's entries into its own lane + right after the `Agent` tool call that spawned it src/term.rs embedded claude pane: spawns `claude --session-id ` in a portable-pty routed through the proxy; wezterm-term models the screen (and answers terminal queries); renderer paints cells into the ratatui buffer. Each spawn injects a fresh per-pane token via `ANTHROPIC_CUSTOM_HEADERS` (`PANE_TOKEN_HEADER` = `x-claude-cloak-pane`), the correlation handle the proxy uses to - recognise the pane's own traffic (see the embed-identity invariant) + 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) ``` Data flow: proxy task parses SSE chunks → `Tap::handle()` mutates shared state → @@ -106,6 +129,80 @@ UI thread redraws on its own tick (no channel; just the mutex). builds `user_…_session_`; `proxy::session_key` handles both. Concurrent requests (subagents) share a session but each `Tap` tracks its own current entry index — entries/sessions are append-only, so indices stay stable. + `session_key` tolerates whitespace around the JSON colon (a pretty-printed + blob used to fall through to the legacy `session_` split and yield `id": "…`). +- **Subagent identity comes from Claude Code's own header, never a heuristic.** + A subagent's request reports the *parent's* `session_id` and no agent id in + `metadata`, but Claude Code stamps `x-claude-code-agent-id` (and, from spawn + depth 2, `x-claude-code-parent-agent-id`) on every one of them. That id is + unique per agent — **including byte-identical sibling prompts**, which do + occur and which a prompt hash cannot separate — stable across the agent's + inner-loop turns, and equal to the `agentId` of its on-disk + `subagents/agent-.jsonl`. `proxy.rs` reads both headers (`AGENT_ID_HEADER` + / `PARENT_AGENT_ID_HEADER`) and **forwards them untouched** — they are Claude + Code's, not ours; only `x-claude-cloak-pane` is ours to consume. + `Session::lane_for` maps the id to a lane (appended on first sight, so a + `LaneId` stays valid forever). + Labels come from a *separate*, later fact: `Session::label_lane_from_prompt` + matches the subagent's opening prompt against an unclaimed `Agent` tool call's + `prompt` (byte-identical on the wire) to learn subagent_type/description/ + parent, and `close_lane_from_result` scrapes `agentId: ` out of the + `Agent` tool_result to tie the lane to that call and mark it finished. A lane + 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. +- **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 + mutex the tap shares, and lose global wire order. Lane membership is a field; + showing one lane is a filter (`e.lane == args.lane`), which is also why there + is no "show everything interleaved" mode. +- **"The agent finished" is a ``, not the tool_result.** + Claude Code launches *every* `Agent` call asynchronously: the tool_result + comes back immediately and says so (`Async agent launched successfully… \ +agentId: `), and the real completion is injected into the parent's next + user turn as `` … ``. 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 + `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` + 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 + always renders `MAIN_LANE` at full width, so how many agents run changes + nothing about reading the main chain — no split, no rows, no reserved space, + no interleaved entries (`draw_feed` filters `e.lane == args.lane`). `A` + (`App::toggle_agent_popup` → `App::agent_popup`) opens the one place they are + shown: `AgentPopup::List` picks an agent, `AgentPopup::Feed` gives one agent + the whole popup (80% of the feed rect, `ui::popup_rect`). Opening takes the + shortest path — a lone agent goes straight to its stream, several land on the + picker with the first *running* one preselected — and `A` closes whatever is + open. The popup is **modal**: while it is up it takes every key (and the + wheel), which is why it needs no focus/column model at all. Esc unwinds one + layer (feed → picker → closed), `[`/`]` step between agents from inside a + feed. `App::agent_list_of` orders it: running first (`Lane::running`), then + idle/finished, each group in spawn order — but **every** lane is listed, + disk lanes included, because this popup is the only way to read a finished + agent's output. State is session-local: `draw` clears `agent_popup` and + `lane_cols` when the displayed session changes, and `validate_agent_popup` + drops a popup whose lane the displayed session doesn't have (a rebuilt + on-disk view), so the render path never sees a dangling `LaneId`. +- **`Lane::running` is a sort key, never a gate**: streaming (`active > 0`), or + no finish signal and quiet for less than `LANE_IDLE_MAX` (60s); a + `finished_at` (the ``) or no traffic at all (a lane read + from disk) means not running. The long idle net matters because a gap between + an agent's turns (a slow local tool call) looks exactly like "done"; only the + notification distinguishes them. Being wrong therefore costs an ordering and + a `⟳`/`·` mark — never a hidden stream, which is what the old row-collapse + timers could do. +- **Only the main lane drives the pane and the session header.** `embed_grow`, + the ctrl-l wipe scheduled in `Tap::drop`, the prompt minimap, `n`/`N` and + `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. - **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 @@ -153,11 +250,25 @@ UI thread redraws on its own tick (no channel; just the mutex). (`sonnet`, `opus`, … from `App::model_choices`) 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. `[1m]` - (1M-context) picks are the one thing it cannot see — the transcript records - the same base id either way — so `Session::spawn_model` remembers the exact - argument the pane was spawned with in-process and wins **only** while it - still names the model the transcript reports. + 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. - **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 @@ -283,6 +394,14 @@ UI thread redraws on its own tick (no channel; just the mutex). quit). n/N jump the feed scroll to the next/previous user prompt (`App::prompt_jump`, applied in `draw` where entry heights are cached). The feed scrolls only via wheel / PgUp / PgDn / g / G / n / N. + `A` toggles the subagent popup (the footer leads with `A agents (N)` when + the displayed session has any — it is the only route to them). Inside it: + j/k move the picker or scroll the agent feed one line, enter/→ opens the + highlighted agent, `[`/`]` step to the previous/next agent, PgUp/PgDn/g/G + scroll, Esc goes feed → picker → closed, `A`/`q` closes outright. Being modal + it also owns the wheel (`ui::wheel`), so no pointer hit-testing is involved. + Switching the displayed session clears `App::lane_cols` and closes the popup, + so a lane id can't inherit another session's scroll offset. `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 @@ -321,7 +440,8 @@ UI thread redraws on its own tick (no channel; just the mutex). 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 | text`, switchable + (`dev/.fake_scenario` = `ask | plan | todo | taskupdate | agent | 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 obey that skill's safety rule: **never `pkill`/`killall`**, tear down only @@ -331,6 +451,22 @@ UI thread redraws on its own tick (no channel; just the mutex). ## 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 + 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 + long after its launch result landed, and `SendMessage` can revive a finished + one. An agent transcript over `MAX_AGENT_BYTES` (8 MB) is summarised instead + of parsed, because the view is built while the app mutex is held. +- Materialized branch files carry no subagent transcripts: the `Agent` + tool_results in them still hold the reports the parent model saw, and copying + `subagents/` would duplicate `agentId`s across two sessions and contradict + the agent files' own `sessionId`. Deliberate — don't "fix" it by copying. +- A subagent's *first* turn is what labels its lane, so if we attach mid-run + (the parent's `Agent` call never passed through us) it stays listed as + `agent ` until its result lands. - Sessions are never pruned (entry memory grows for the process lifetime); the same goes for viewed disk transcripts (`App::history`). - Request bodies are fully buffered (up to 512 MB) before forwarding — needed diff --git a/dev/fake_upstream.py b/dev/fake_upstream.py index 0a35f1d..5e664ad 100644 --- a/dev/fake_upstream.py +++ b/dev/fake_upstream.py @@ -7,7 +7,8 @@ lets us make Claude Code render its client-side tool UIs (AskUserQuestion, 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 | text). +Scenario is picked per turn from `CT_FAKE_SCENARIO` +(ask | plan | todo | taskupdate | agent | text). Each incoming request is logged to `dev/fake_upstream.log` (declared tool names + the trailing user text) so we can see what CC sends. """ @@ -136,6 +137,19 @@ TASK_INPUTS = [ ] +# Two parallel subagents with the *same* type and byte-identical prompts: the +# worst case for correlation (only Claude Code's `x-claude-code-agent-id` tells +# them apart), and what makes the subagent rows worth having. +AGENT_INPUTS = [ + {"subagent_type": "Explore", "description": "find the retry helper", + "prompt": "Locate the retry helper and report back.", "run_in_background": False}, + {"subagent_type": "Explore", "description": "find the retry helper", + "prompt": "Locate the retry helper and report back.", "run_in_background": False}, + {"subagent_type": "oracle", "description": "review the lane design", + "prompt": "Review the lane design and report back.", "run_in_background": True}, +] + + class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -161,7 +175,12 @@ class Handler(BaseHTTPRequestHandler): has_result = bool(blocks) and all( b.get("type") == "tool_result" for b in blocks if isinstance(b, dict) ) and "toolu_fake" in json.dumps(blocks) - log(f"[{time.strftime('%H:%M:%S')}] {self.path} tools={tools} tail={tail}") + # `model` + `anthropic-beta` show how a `--model opus[1m]` pick reaches + # the wire (the 1M context window is a beta header, not a model id). + log( + f"[{time.strftime('%H:%M:%S')}] {self.path} model={body.get('model')} " + f"betas={self.headers.get('anthropic-beta', '')} tools={tools} tail={tail}" + ) if "count_tokens" in self.path: out = json.dumps({"input_tokens": 100}).encode() @@ -190,6 +209,12 @@ class Handler(BaseHTTPRequestHandler): gen = stream_tools( [("TaskCreate", t) for t in TASK_INPUTS], "Setting up the task list." ) + elif scenario == "agent": + # Spawn subagents. A real child then issues their requests itself, + # each carrying `x-claude-code-agent-id`. + gen = stream_tools( + [("Agent", a) for a in AGENT_INPUTS], "Delegating this." + ) elif scenario == "taskupdate": gen = stream_tool("TaskUpdate", {"taskId": "1", "status": "in_progress"}, "Starting the first task.") diff --git a/src/app.rs b/src/app.rs index f3c70c1..f0bce86 100644 --- a/src/app.rs +++ b/src/app.rs @@ -65,6 +65,14 @@ pub fn model_arg_for_id(id: &str, aliases: &[String]) -> Option { Some(hit.map_or_else(|| id.to_string(), String::clone)) } +/// Claude Code's suffix for the 1M-context variant of a model (`sonnet[1m]`). +pub const LONG_SUFFIX: &str = "[1m]"; + +/// The model part of a `--model` argument: `sonnet[1m]` → `sonnet`. +pub fn base_model(arg: &str) -> &str { + arg.split_once('[').map_or(arg, |(b, _)| b) +} + pub struct App { pub sessions: Vec, pub selected: usize, @@ -104,6 +112,19 @@ pub struct App { /// draw (entry heights live in the render cache, so the key handler can't /// compute the offset itself), then cleared. pub prompt_jump: Option, + /// Feed scroll state of each subagent lane of the displayed session, keyed + /// by lane id: `(scroll, follow)`. Only the agent popup reads it; the main + /// feed keeps using `scroll`/`follow` above. Reset whenever the displayed + /// session changes (`cols_session`) — a lane id must never inherit another + /// session's offset. + pub lane_cols: HashMap, + /// Session key `lane_cols` currently belongs to. + pub cols_session: String, + /// Subagent popup (`A`), the *only* place a subagent's stream is shown. + /// `None` = closed, and then agents cost no layout space at all: the main + /// feed always owns the full width and never interleaves agent entries. + /// Reset when the displayed session changes — a lane id is session-local. + pub agent_popup: Option, /// Session UUID of the embedded `claude` pane (src/term.rs). This is now /// *learned* from the pane's first tagged request (see `embed_token`), /// not assumed from the `--session-id` we spawned with — Claude Code does @@ -198,6 +219,9 @@ impl App { expanded: None, turn_dirty: false, prompt_jump: None, + lane_cols: HashMap::new(), + cols_session: String::new(), + agent_popup: None, embed_session: None, embed_token: None, pane_focused: false, @@ -230,32 +254,62 @@ impl App { } /// The `--model` argument a resume of `key` should spawn with, so the pane - /// continues on the model that session last used instead of the CLI - /// default. Empty string = pass no `--model` flag. + /// continues on the model *and context window* that session last used + /// instead of the CLI default. Empty string = pass no `--model` flag. /// - /// The on-disk transcript is the source of truth: Claude Code records the - /// model of every assistant message, so a mid-session `/model` switch is - /// visible there. The pane's own spawn argument is only consulted to keep a - /// `[1m]` (1M-context) pick alive — the transcript records the base model id - /// for both — and only while it still names the same model. + /// Reads `term::cc_default_model()` (Claude Code's own configured default); + /// `resume_arg` holds the decision itself. pub fn resume_model(&self, key: &str) -> String { + self.resume_arg(key, crate::term::cc_default_model().as_deref()) + } + + /// The resume decision, with Claude Code's configured default passed in + /// (so it is testable without touching settings files). + /// + /// *Which model*: the on-disk transcript wins — Claude Code records the + /// model of every assistant message, so a mid-session `/model` switch is + /// visible there. The pane's own spawn argument covers a session that never + /// answered. + /// + /// *Which window*: `Session::long_context`, observed on the wire, is the + /// only exact answer (nothing on disk distinguishes `opus` from + /// `opus[1m]`); the spawn argument answers for a pane we started ourselves. + /// With neither — a session from before this process — Claude Code's + /// configured default is the one other place a `[1m]` choice is written + /// down, so when it names this same model we pass **no flag at all** and + /// let the child apply that default, window and all. Any explicit answer + /// overrides it, including "this session ran the *short* window". + fn resume_arg(&self, key: &str, cc_default: Option<&str>) -> String { let aliases: Vec = self.model_choices.iter().map(|(_, a)| a.clone()).collect(); - let disk = self + let sess = self.sessions.iter().find(|s| s.key == key); + let spawned = sess + .and_then(|s| s.spawn_model.clone()) + .filter(|m| !m.is_empty()); + let Some(base) = self .disk_sessions .iter() .find(|d| d.uuid == key) - .and_then(|d| model_arg_for_id(&d.model, &aliases)); - let spawned = self - .sessions - .iter() - .find(|s| s.key == key) - .and_then(|s| s.spawn_model.clone()) - .filter(|m| !m.is_empty()); - match (disk, spawned) { - (Some(d), Some(s)) if s.split('[').next() == Some(d.as_str()) => s, - (Some(d), _) => d, - (None, s) => s.unwrap_or_default(), + .and_then(|d| model_arg_for_id(&d.model, &aliases)) + .or_else(|| spawned.as_deref().map(|s| base_model(s).to_string())) + else { + return String::new(); + }; + let long = sess.and_then(|s| s.long_context).or_else(|| { + spawned + .as_deref() + .filter(|s| base_model(s) == base) + .map(|s| s.ends_with(LONG_SUFFIX)) + }); + match long { + Some(true) => { + let long_arg = format!("{base}{LONG_SUFFIX}"); + // Only offer the suffix for a model that really has one. + if aliases.contains(&long_arg) { long_arg } else { base } + } + Some(false) => base, + None if cc_default.is_some_and(|d| base_model(d) == base) => String::new(), + None => base, } } @@ -504,6 +558,9 @@ impl App { uuid: new_uuid.clone(), label: title, model, + // A materialized branch carries no subagent transcripts (the + // `Agent` tool_results in it hold the reports the model saw). + agents: 0, modified: std::time::SystemTime::now(), }, ); @@ -511,6 +568,183 @@ impl App { Ok(new_uuid) } + /// The session the feed currently displays: the on-disk view when a turn + /// is highlighted or a past-session stub is selected (mirroring `draw`), + /// otherwise the live session. + fn displayed_session(&self) -> Option<&Session> { + let key = self.selected_key()?; + let on_disk = self.on_turns() || self.selected >= self.sessions.len(); + if on_disk && let Some(h) = self.history.get(&key) { + return Some(&h.session); + } + self.sessions.iter().find(|s| s.key == key) + } + + /// The session's subagent lanes in popup order: agents that are running + /// right now first, then the idle/finished ones, each group in spawn order + /// so an agent keeps its place while it runs. *Every* lane is listed — lanes + /// read from disk included — because the popup is the only place a + /// subagent's stream is shown, and a finished agent's output must stay + /// readable. + pub fn agent_list_of(s: &Session) -> Vec { + let mut lanes = s.agent_lanes(); + // Stable sort: false (running) sorts before true, spawn order kept + // inside each group. + lanes.sort_by_key(|&l| !s.lanes[l as usize].running()); + lanes + } + + /// `agent_list_of` for the session the feed displays. + pub fn agent_list(&self) -> Vec { + self.displayed_session() + .map(Self::agent_list_of) + .unwrap_or_default() + } + + /// `A`: open or close the subagent popup. Opening takes the shortest path + /// to a stream — a lone agent opens its feed directly, several land on the + /// picker with the first *running* agent preselected (`agent_list` order). + pub fn toggle_agent_popup(&mut self) { + if self.agent_popup.is_some() { + self.agent_popup = None; + self.status = "agent view closed".into(); + return; + } + let lanes = self.agent_list(); + self.agent_popup = match lanes.len() { + 0 => { + self.status = "no subagents in this session".into(); + None + } + 1 => { + self.status = self.lane_status(lanes[0]); + Some(AgentPopup::Feed(lanes[0])) + } + n => { + self.status = picker_status(n); + Some(AgentPopup::List(0)) + } + }; + } + + /// Lane whose feed the popup shows; `None` while it shows the picker or is + /// closed. This is the scroll target of the paging keys and the wheel while + /// the popup is up — the popup is modal, so it takes them all. + pub fn agent_popup_lane(&self) -> Option { + match self.agent_popup { + Some(AgentPopup::Feed(l)) => Some(l), + _ => None, + } + } + + /// Move the picker highlight by `delta`, wrapping. + pub fn agent_popup_move(&mut self, delta: isize) { + let n = self.agent_list().len(); + if let Some(AgentPopup::List(sel)) = self.agent_popup + && n > 0 + { + let next = (sel as isize + delta).rem_euclid(n as isize) as usize; + self.agent_popup = Some(AgentPopup::List(next)); + } + } + + /// Picker → that agent's feed. + pub fn agent_popup_enter(&mut self) { + if let Some(AgentPopup::List(sel)) = self.agent_popup + && let Some(&l) = self.agent_list().get(sel) + { + self.agent_popup = Some(AgentPopup::Feed(l)); + self.status = self.lane_status(l); + } + } + + /// Status line for the agent whose feed the popup opened. + fn lane_status(&self, lane: LaneId) -> String { + self.displayed_session() + .and_then(|s| s.lanes.get(lane as usize)) + .map_or_else(String::new, |l| format!("watching {}", l.title())) + } + + /// Esc / ← inside the popup: a feed steps back to the picker when there is + /// a choice to make, otherwise the popup closes. + pub fn agent_popup_back(&mut self) { + let lanes = self.agent_list(); + self.agent_popup = match self.agent_popup { + Some(AgentPopup::Feed(l)) if lanes.len() > 1 => { + self.status = picker_status(lanes.len()); + Some(AgentPopup::List( + lanes.iter().position(|&x| x == l).unwrap_or(0), + )) + } + _ => None, + }; + } + + /// `[` / `]` on a popup feed: previous/next agent without a detour through + /// the picker. + pub fn agent_popup_cycle(&mut self, forward: bool) { + let lanes = self.agent_list(); + if let Some(AgentPopup::Feed(l)) = self.agent_popup + && !lanes.is_empty() + { + let cur = lanes.iter().position(|&x| x == l).unwrap_or(0) as isize; + let next = (cur + if forward { 1 } else { -1 }).rem_euclid(lanes.len() as isize); + let lane = lanes[next as usize]; + self.agent_popup = Some(AgentPopup::Feed(lane)); + self.status = self.lane_status(lane); + } + } + + /// Keep the popup pointed at something that exists: a lane the displayed + /// session does not have (session switch, rebuilt on-disk view) closes it, + /// a stale picker index is clamped. Called from `draw` before the feed + /// borrow, so the render path never sees an impossible state. + pub fn validate_agent_popup(&mut self) { + let lanes = self.agent_list(); + self.agent_popup = match self.agent_popup { + Some(AgentPopup::Feed(l)) if lanes.contains(&l) => Some(AgentPopup::Feed(l)), + Some(AgentPopup::List(sel)) if !lanes.is_empty() => { + Some(AgentPopup::List(sel.min(lanes.len() - 1))) + } + _ => None, + }; + } + + /// Scroll one feed by `delta` rows (negative scrolls up). `None` is the + /// main feed (`scroll`/`follow`); an agent's popup feed keeps its state in + /// `lane_cols`, so every agent follows its own tail. + pub fn scroll_col(&mut self, lane: Option, delta: isize) { + let (scroll, follow) = match lane { + None => (&mut self.scroll, &mut self.follow), + Some(l) => { + let e = self.lane_cols.entry(l).or_insert((0, true)); + (&mut e.0, &mut e.1) + } + }; + *follow = false; + *scroll = if delta < 0 { + scroll.saturating_sub(delta.unsigned_abs()) + } else { + scroll.saturating_add(delta.unsigned_abs()) + }; + } + + /// `g` / `G`: jump one column to the top (follow off) or the tail (follow + /// on, so it keeps streaming). + pub fn scroll_col_end(&mut self, lane: Option, bottom: bool) { + let (scroll, follow) = match lane { + None => (&mut self.scroll, &mut self.follow), + Some(l) => { + let e = self.lane_cols.entry(l).or_insert((0, true)); + (&mut e.0, &mut e.1) + } + }; + *follow = bottom; + if !bottom { + *scroll = 0; + } + } + /// Swap in a fresh scan result, keeping a stub selection pointed at the /// same session even if the list reordered (scanner thread calls this). pub fn set_disk_sessions(&mut self, list: Vec) { @@ -540,47 +774,434 @@ pub fn filter_index(kind: &Kind) -> usize { } } -pub struct Session { - pub key: String, +/// Index of a *lane* — one agent's stream inside a session. Lane 0 is the +/// main chain; every subagent (`Agent`/`Task` tool call) gets its own lane. +pub type LaneId = u16; +/// The main chain's lane. Claude Code's own turns always land here. +pub const MAIN_LANE: LaneId = 0; + +/// Footer message while the agent picker is up. +fn picker_status(n: usize) -> String { + format!("{n} agents — enter to open, esc to close") +} + +/// State of the subagent view (`A`). It is a *modal popup over the feed*, not a +/// region of the layout: a subagent's entries never appear in the main feed +/// (`draw_feed` filters by lane) and never take space from it, so watching the +/// main chain is unaffected by how many agents run. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AgentPopup { + /// Picking which agent to watch; the index is a position in `agent_list`. + /// Skipped when the session has exactly one agent. + List(usize), + /// One agent's feed fills the popup (its own scroll/follow in `lane_cols`). + Feed(LaneId), +} + +/// Identity Claude Code stamps on a subagent's requests. Both are its own +/// headers (`x-claude-code-agent-id` / `x-claude-code-parent-agent-id`), read +/// but never rewritten or stripped — only our `x-claude-cloak-pane` is ours to +/// consume. Absent on main-chain requests. +/// +/// The id is exact, unique per subagent (byte-identical sibling prompts still +/// differ), stable across the subagent's inner-loop turns, and equal to the +/// `agentId` in `/subagents/agent-.jsonl` — which is why lane +/// identity needs no prompt-hash heuristics. +#[derive(Default, Clone)] +pub struct AgentTag { + pub id: Option, + pub parent: Option, +} + +/// Safety net for a lane we never see finish (we attached mid-run, or the +/// session ended on the `Agent` call): it stops counting as running after this +/// much silence. Long enough to sit through a slow local tool call between the +/// agent's turns — those gaps are indistinguishable from "done" without the +/// finish signal, so a missing signal degrades to a late demotion in the +/// popup's list order, never to a hidden stream (the agent stays listed). +pub const LANE_IDLE_MAX: std::time::Duration = std::time::Duration::from_secs(60); + +/// One agent's stream within a session: the main chain (lane 0) or one +/// subagent. Entries stay in the session's single append-only `Vec` +/// tagged with their lane; a `Lane` holds the per-agent state that used to sit +/// on `Session` and was therefore clobbered whenever a subagent streamed. +pub struct Lane { + /// `x-claude-code-agent-id`; empty for the main lane. + pub agent_id: String, + /// `subagent_type` of the spawning `Agent` call ("Explore", "oracle", …), + /// learned from the parent's tool input. Empty until matched. + pub agent_type: String, + /// The `description` of the spawning `Agent` call — the popup title. + pub label: String, + /// The parent's `Agent` tool_use id, once the launch result identifies it. + pub tool_use_id: Option, + /// Lane that spawned this one (`None` for main, `Some(0)` at depth 1). + pub parent: Option, + /// Entry index of the parent's `Agent` tool call. + pub anchor: Option, + /// 0 = main, 1 = subagent, 2 = subagent of a subagent. + pub depth: u8, + /// Model this lane's requests report (a subagent often runs another one). pub model: String, - pub entries: Vec, + /// Index of this lane's first entry — immutable once set, so a title can + /// key on it without depending on filters. + pub first_entry: Option, + /// In-flight taps for this lane. pub active: usize, + /// Last SSE event seen on this lane. `None` for a lane read from disk (it + /// was never live here), which is what keeps such a lane out of the popup's + /// running group. + pub last_event: Option, pub input_tokens: u64, pub output_tokens: u64, - pub last_activity: Instant, - /// tool_use id → entry index, so results arriving in the *next* request - /// body can be attached to the tool entry they belong to. - pub tool_ids: HashMap, + /// Tool calls this lane made (shown in the popup's agent picker). + pub tool_calls: usize, /// Char length of the system prompt last surfaced as a `Kind::System` - /// entry. The system prompt is re-sent verbatim on every request, so it is - /// emitted once (and again only if the count changes) — never duplicated. + /// entry. Per *lane*: a subagent's system prompt and tool set differ from + /// the main agent's, so session-wide state re-emitted both lines on every + /// alternation between them. pub last_system_len: Option, /// Signature (joined tool names) of the tool set last surfaced as a - /// `Kind::ToolDefs` entry; re-emitted only when the available tools change. + /// `Kind::ToolDefs` entry; re-emitted only when the tool set changes. pub last_tools_sig: Option, + /// When we learned the agent finished (the `✓` mark, and the demotion out + /// of the popup's running group). Two signals feed it (see + /// `Session::finish_lane`), because + /// Claude Code launches every `Agent` call *asynchronously*: its + /// tool_result comes back at launch time and says so, and the completion + /// arrives later as a ``. Never closes the lane itself — + /// a finished agent can still be woken by `SendMessage`. + pub finished_at: Option, +} + +impl Lane { + /// Is this agent running *right now*? Only the popup's list order depends + /// on it (running agents sort first, and the newest running one is + /// preselected), so being wrong costs an ordering, never a stream: + /// + /// - streaming (`active > 0`) → yes; + /// - known finished (``) → no; + /// - no finish signal → yes while the last event is within + /// `LANE_IDLE_MAX`, because a gap between an agent's turns looks exactly + /// like "done"; + /// - no traffic at all → a lane read from disk, so no. + pub fn running(&self) -> bool { + if self.active > 0 { + return true; + } + let Some(last) = self.last_event else { + return false; + }; + self.finished_at.is_none() && last.elapsed() < LANE_IDLE_MAX + } + + fn new(agent_id: String, model: String, parent: Option, depth: u8) -> Self { + Self { + agent_id, + agent_type: String::new(), + label: String::new(), + tool_use_id: None, + parent, + anchor: None, + depth, + model, + first_entry: None, + active: 0, + last_event: None, + input_tokens: 0, + output_tokens: 0, + tool_calls: 0, + last_system_len: None, + last_tools_sig: None, + finished_at: None, + } + } + + /// The agent is known to have finished (drives the `✓` mark). + pub fn finished(&self) -> bool { + self.finished_at.is_some() + } + + /// Display title: `Explore · find the retry helper`, falling back to the + /// agent id while the spawning tool call has not been matched yet. + pub fn title(&self) -> String { + let name = if self.agent_type.is_empty() { + format!( + "agent {}", + self.agent_id.chars().take(8).collect::() + ) + } else { + self.agent_type.clone() + }; + if self.label.is_empty() { + name + } else { + format!("{name} · {}", self.label) + } + } +} + +pub struct Session { + pub key: String, + pub entries: Vec, + /// Lanes of this session; `lanes[0]` is the main chain and always exists. + /// Append-only, so a `LaneId` stays valid for the session's lifetime. + pub lanes: Vec, + /// `x-claude-code-agent-id` → lane. + pub lane_of_agent: HashMap, + /// In-flight taps across all lanes (the session's "running" dot). + pub active: usize, + pub last_activity: Instant, + /// tool_use id → entry index, so results arriving in the *next* request + /// body can be attached to the tool entry they belong to. Session-global: + /// tool_use ids are unique across lanes. + pub tool_ids: HashMap, /// The `--model` argument the embedded pane for this session was spawned /// with (`None` for external sessions, `Some("")` for the CLI default). /// In-process memory only; it travels with the row when the tap renames /// the key. `resume_model` uses it to keep a `[1m]` pick across a resume. pub spawn_model: Option, + /// Whether the main chain runs with the **1M-context window**, observed on + /// the wire (`anthropic-beta: …context-1m…`, what a `[1m]` model + /// sends). `None` until a main-chain turn request passed through us. + /// + /// The window is a beta header, not a model id: the body model and the + /// transcript record read exactly the same for `opus` and `opus[1m]`, so + /// this observation is the only fact that lets a resume keep — or + /// deliberately drop — the long window (`resume_model`). + pub long_context: Option, } impl Session { pub fn new(key: String, model: String) -> Self { Self { key, - model, entries: Vec::new(), + lanes: vec![Lane::new(String::new(), model, None, 0)], + lane_of_agent: HashMap::new(), active: 0, - input_tokens: 0, - output_tokens: 0, last_activity: Instant::now(), tool_ids: HashMap::new(), - last_system_len: None, - last_tools_sig: None, spawn_model: None, + long_context: None, } } + + /// The main chain's lane — the session's model and context size. + pub fn main(&self) -> &Lane { + &self.lanes[MAIN_LANE as usize] + } + + /// Lane for an observed request: `MAIN_LANE` when the request carries no + /// agent id, otherwise the lane of that agent (appended on first sight). + /// + /// The parent edge comes from `x-claude-code-parent-agent-id` when present; + /// a depth-2 agent's parent has necessarily streamed already (it made the + /// `Agent` call), so its lane exists. An unknown parent leaves the edge + /// open for `label_lane_from_prompt` to fill. + pub fn lane_for(&mut self, tag: &AgentTag, model: &str) -> LaneId { + let Some(id) = tag.id.as_deref() else { + return MAIN_LANE; + }; + if let Some(&l) = self.lane_of_agent.get(id) { + return l; + } + let parent = tag + .parent + .as_deref() + .and_then(|p| self.lane_of_agent.get(p).copied()); + let depth = parent.map_or(1, |p| self.lanes[p as usize].depth.saturating_add(1)); + let lane = LaneId::try_from(self.lanes.len()).unwrap_or(LaneId::MAX); + self.lanes + .push(Lane::new(id.to_string(), model.to_string(), parent, depth)); + self.lane_of_agent.insert(id.to_string(), lane); + lane + } + + /// Append an entry to `lane`, recording the lane's first entry. The single + /// push path: entries are append-only (a `Tap` holds a flat index), so a + /// lane is a tag, never a separate vec. + pub fn push(&mut self, lane: LaneId, mut e: Entry) -> usize { + let idx = self.entries.len(); + e.lane = lane; + self.entries.push(e); + let l = &mut self.lanes[lane as usize]; + if l.first_entry.is_none() { + l.first_entry = Some(idx); + } + idx + } + + /// Register a lane for a subagent read from disk (its transcript is a + /// separate file, so identity/label/parent all come from its `.meta.json` + /// rather than from observed traffic). + pub fn add_lane( + &mut self, + agent_id: String, + agent_type: String, + label: String, + tool_use_id: Option, + parent: Option, + depth: u8, + ) -> LaneId { + let lane = LaneId::try_from(self.lanes.len()).unwrap_or(LaneId::MAX); + let mut l = Lane::new(agent_id.clone(), String::from("(resumed)"), parent, depth); + l.agent_type = agent_type; + l.label = label; + l.tool_use_id = tool_use_id; + // A transcript on disk is a finished run (and `last_event: None` means + // `Lane::running` is false, so it sorts below the live agents). + l.finished_at = Some(Instant::now()); + self.lanes.push(l); + if !agent_id.is_empty() { + self.lane_of_agent.insert(agent_id, lane); + } + lane + } + + /// Recompute every lane's `first_entry` from the entry list. Needed after + /// on-disk lanes are spliced in, where entries arrive out of order. + pub fn reindex_lanes(&mut self) { + for l in &mut self.lanes { + l.first_entry = None; + } + for (i, e) in self.entries.iter().enumerate() { + let l = &mut self.lanes[e.lane as usize]; + if l.first_entry.is_none() { + l.first_entry = Some(i); + } + } + } + + /// Non-main lanes that have produced at least one entry, in spawn order. + pub fn agent_lanes(&self) -> Vec { + (1..self.lanes.len()) + .filter(|&i| self.lanes[i].first_entry.is_some()) + .map(|i| i as LaneId) + .collect() + } + + /// Name the lane from the `Agent` tool call that spawned it, by matching + /// the subagent's opening prompt against the tool input's `prompt` (they + /// are byte-identical on the wire). Identity already came from the agent-id + /// header — this only supplies label/type/parent, which is why a collision + /// between byte-identical sibling prompts is harmless: the earliest + /// unclaimed call wins and the labels are the same anyway. + /// + /// Needed because a *synchronous* agent's tool_result — the other source of + /// this link — only arrives when the agent has already finished. + fn label_lane_from_prompt(&mut self, lane: LaneId, text: &str) { + if lane == MAIN_LANE || !self.lanes[lane as usize].label.is_empty() { + return; + } + let claimed: Vec = self.lanes.iter().filter_map(|l| l.anchor).collect(); + let hit = self.entries.iter().enumerate().rev().find(|(i, e)| { + let Kind::Tool { name } = &e.kind else { + return false; + }; + is_agent_tool(name) + && e.lane != lane + && !claimed.contains(i) + && serde_json::from_str::(&e.content) + .ok() + .and_then(|v| { + v.get("prompt") + .and_then(Value::as_str) + .map(|p| p.trim() == text.trim()) + }) + .unwrap_or(false) + }); + let Some((idx, e)) = hit.map(|(i, e)| (i, e.content.clone())) else { + return; + }; + let parent_lane = self.entries[idx].lane; + let parent_depth = self.lanes[parent_lane as usize].depth; + let v: Value = serde_json::from_str(&e).unwrap_or(Value::Null); + let field = |k: &str| { + v.get(k) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + let l = &mut self.lanes[lane as usize]; + l.agent_type = field("subagent_type"); + l.label = field("description"); + l.anchor = Some(idx); + l.parent = Some(parent_lane); + l.depth = parent_depth.saturating_add(1); + } + + /// Mark a lane finished (`✓` in the popup, and out of the running group). + /// Keeps the first timestamp: the signal can be re-read from request + /// history. + fn finish_lane(&mut self, agent_id: &str) { + if let Some(&lane) = self.lane_of_agent.get(agent_id) { + self.lanes[lane as usize] + .finished_at + .get_or_insert_with(Instant::now); + } + } + + /// Claude Code launches every `Agent` call asynchronously and reports the + /// completion by injecting a `` into the parent's next + /// user turn, where `` *is* the agent id. That notification is the + /// only "this agent is done" signal for an async agent — its tool_result + /// arrived back at launch time. + pub(crate) fn finish_lanes_from_notifications(&mut self, text: &str) { + for part in text.split("").skip(1) { + if let Some(id) = part.split("").next() { + // Background *bash* tasks use the same notification shape with + // a short id, which simply matches no lane. + self.finish_lane(id.trim()); + } + } + } + + /// The launch/completion result of an `Agent` call names its agent id, so + /// the lane can be tied to the tool entry (`anchor`). It only means + /// *finished* when it is not an async launch acknowledgement — which, in + /// current Claude Code, it always is (see `finish_lanes_from_notifications`). + fn close_lane_from_result(&mut self, tool_entry: usize, tool_use_id: &str, result: &str) { + let Some(agent_id) = scrape_agent_id(result) else { + return; + }; + let Some(&lane) = self.lane_of_agent.get(agent_id) else { + return; + }; + let parent_lane = self.entries[tool_entry].lane; + let parent_depth = self.lanes[parent_lane as usize].depth; + // "Async agent launched successfully" means it is only *starting*. + let launched = result.contains("Async agent launched"); + let l = &mut self.lanes[lane as usize]; + l.tool_use_id = Some(tool_use_id.to_string()); + if l.anchor.is_none() { + l.anchor = Some(tool_entry); + l.parent = Some(parent_lane); + l.depth = parent_depth.saturating_add(1); + } + if !launched { + l.finished_at.get_or_insert_with(Instant::now); + } + } +} + +/// `Agent` (Claude Code ≥ 2.1) / `Task` (older builds) — the tool that spawns +/// a subagent. +pub fn is_agent_tool(name: &str) -> bool { + name.eq_ignore_ascii_case("agent") || name.eq_ignore_ascii_case("task") +} + +/// Pull the `agentId: ` an `Agent` tool_result carries (both the async +/// "launched successfully" wording and the synchronous report include it). +/// Anchored on the label and nothing more, so either wording works. No length +/// or shape guard is needed: the id is only ever used to look up a lane we +/// already learned from the agent-id header, so a bogus match finds nothing. +fn scrape_agent_id(s: &str) -> Option<&str> { + let rest = s.split("agentId: ").nth(1)?; + let end = rest + .find(|c: char| !c.is_ascii_hexdigit()) + .unwrap_or(rest.len()); + Some(&rest[..end]).filter(|h| !h.is_empty()) } #[derive(PartialEq)] @@ -613,6 +1234,9 @@ pub struct Entry { pub done: bool, /// Tool entries only: the tool_result echoed back in the next request. pub result: Option, + /// Which agent produced this entry (0 = the main chain). Set by + /// `Session::push`, so no call site has to remember it. + pub lane: LaneId, } pub struct ToolResult { @@ -621,8 +1245,36 @@ pub struct ToolResult { } impl Entry { + /// A finished entry in the main lane; `Session::push` re-tags the lane. + pub(crate) fn done(kind: Kind, content: String) -> Self { + Self { + kind, + content, + done: true, + result: None, + lane: MAIN_LANE, + } + } + + pub(crate) fn streaming(kind: Kind, content: String) -> Self { + Self { + kind, + content, + done: false, + result: None, + lane: MAIN_LANE, + } + } + pub(crate) fn meta(content: String) -> Self { - Self { kind: Kind::Meta, content, done: true, result: None } + Self::done(Kind::Meta, content) + } + + /// Tag an entry with its lane up front — for the on-disk parser, which + /// builds a `Vec` before any `Session` exists to push into. + pub(crate) fn in_lane(mut self, lane: LaneId) -> Self { + self.lane = lane; + self } } @@ -655,14 +1307,24 @@ pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) { let Some(idx) = s.tool_ids.remove(id) else { continue; }; + let mut agent_result = None; if let Some(e) = s.entries.get_mut(idx) { if let Kind::Tool { name } = &e.kind { answered |= is_interactive_tool(name); + if is_agent_tool(name) { + agent_result = Some(()); + } + } + let content = flatten_result_content(b.get("content")); + let is_error = b.get("is_error").and_then(Value::as_bool).unwrap_or(false); + let scraped = agent_result.map(|()| content.clone()); + e.result = Some(ToolResult { content, is_error }); + // An `Agent` result names its agent id: tie the lane to this + // tool call and (unless it merely *launched* a background + // agent) mark it finished. + if let Some(r) = scraped { + s.close_lane_from_result(idx, id, &r); } - e.result = Some(ToolResult { - content: flatten_result_content(b.get("content")), - is_error: b.get("is_error").and_then(Value::as_bool).unwrap_or(false), - }); } } } @@ -734,6 +1396,16 @@ pub(crate) fn strip_injected(t: &str) -> String { .to_string() } +/// Record the main chain's context window for `key`, learned from a request's +/// `anthropic-beta` header (see `proxy::LONG_CONTEXT_BETA`). Called per +/// main-chain turn request, so a mid-session window switch is picked up. +pub fn record_long_context(app: &SharedApp, key: &str, long: bool) { + let mut a = lock_app(app); + if let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) { + s.long_context = Some(long); + } +} + /// Record everything new the model received this request as feed entries: /// the trailing user prompt verbatim (incl. slash-command machinery), any /// injected `` blocks (dimmed, before it), and — for a @@ -748,7 +1420,7 @@ pub(crate) fn strip_injected(t: &str) -> String { /// no `tools`; their prompt is still shown, tagged with a `── side request ──` /// divider. Retries/resends are deduped against the last recorded prompt (which /// gates the reminders/divider too, so a resend doesn't double them up). -pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) { +pub fn record_user_prompt(app: &SharedApp, key: &str, lane: LaneId, body: &Value) { // A turn-starting (main) request carries tools; a side request does not. let has_tools = body .get("tools") @@ -807,13 +1479,25 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) { } let text = prompts.join("\n"); let text = text.trim(); - if text.is_empty() { - return; - } let mut a = lock_app(app); let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) else { return; }; + // An async agent's completion rides in on this turn as a + // ``. Read it before any early return below: it is the + // signal that closes that agent's row, and it appears exactly once (only + // the trailing user run is scanned, so history is never re-read). + if text.contains("") { + s.finish_lanes_from_notifications(text); + } + for r in &reminders { + if r.contains("") { + s.finish_lanes_from_notifications(r); + } + } + if text.is_empty() { + return; + } // Dedup only *true resends*: a retry re-fires the same request before any // response lands, so the just-recorded prompt is still the tail entry // (its reminders ride directly in front of it). A later turn that merely @@ -821,9 +1505,14 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) { // previous turn's assistant/tool entries, so it survives. The old check // scanned back to the most recent User entry and dropped every verbatim // repeat regardless of intervening activity. + // + // Scoped to this lane: a subagent streaming between the main agent's + // retries would otherwise sit at the tail and defeat the check (and vice + // versa). if s.entries .iter() .rev() + .filter(|e| e.lane == lane) .find(|e| e.kind != Kind::Reminder) .is_some_and(|e| e.kind == Kind::User && e.content == text) { @@ -832,46 +1521,40 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) { if has_tools { // Surface the system-prompt size and the tool set once, then only when // they change — they ride along on every request but are not new data. + // Both are tracked per *lane*: a subagent runs a different system + // prompt and a restricted tool set, so session-wide state made every + // main↔subagent alternation look like a change. let sys = system_char_len(body); - if sys > 0 && s.last_system_len != Some(sys) { - s.last_system_len = Some(sys); - s.entries.push(Entry { - kind: Kind::System, - content: format!("system prompt: {} chars", fmt_count(sys)), - done: true, - result: None, - }); + if sys > 0 && s.lanes[lane as usize].last_system_len != Some(sys) { + s.lanes[lane as usize].last_system_len = Some(sys); + let e = Entry::done( + Kind::System, + format!("system prompt: {} chars", fmt_count(sys)), + ); + s.push(lane, e); } if let Some((sig, line)) = tools_summary(body) - && s.last_tools_sig.as_deref() != Some(sig.as_str()) + && s.lanes[lane as usize].last_tools_sig.as_deref() != Some(sig.as_str()) { - s.last_tools_sig = Some(sig); - s.entries.push(Entry { - kind: Kind::ToolDefs, - content: line, - done: true, - result: None, - }); + s.lanes[lane as usize].last_tools_sig = Some(sig); + let e = Entry::done(Kind::ToolDefs, line); + s.push(lane, e); } } else { // A background side request (no tools): tag it so its prompt/response // are not mistaken for part of the main conversation. - s.entries.push(Entry::meta("── side request ──".to_string())); + s.push(lane, Entry::meta("── side request ──".to_string())); } for r in reminders { - s.entries.push(Entry { - kind: Kind::Reminder, - content: r, - done: true, - result: None, - }); + let e = Entry::done(Kind::Reminder, r); + s.push(lane, e); } - s.entries.push(Entry { - kind: Kind::User, - content: text.to_string(), - done: true, - result: None, - }); + let e = Entry::done(Kind::User, text.to_string()); + s.push(lane, e); + // A subagent's opening prompt *is* the `Agent` call's `prompt`, so this is + // where the lane learns its type and description — well before the tool + // result (which, for a synchronous agent, lands only when it finishes). + s.label_lane_from_prompt(lane, text); } /// tool_result content is either a plain string or an array of content blocks. @@ -896,17 +1579,30 @@ pub fn flatten_result_content(v: Option<&Value>) -> String { /// One in-flight API request being observed. Created when a streaming /// /v1/messages request passes through the proxy; handles its SSE events. +/// +/// A tap belongs to exactly one *lane*: the main chain, or the subagent whose +/// `x-claude-code-agent-id` the request carried. Concurrent taps (a turn plus +/// its subagents) share the session and append to the same entry vec, each +/// tracking its own current entry index. pub struct Tap { app: SharedApp, sidx: usize, + lane: LaneId, cur: Option, } impl Tap { /// `pane_token` is the `x-claude-cloak-pane` header value when the request - /// came from our embedded pane (`None` for external sessions). - pub fn new(app: SharedApp, key: String, model: String, pane_token: Option) -> Self { - let sidx = { + /// came from our embedded pane (`None` for external sessions); `agent` is + /// Claude Code's own subagent identity (empty for main-chain requests). + pub fn new( + app: SharedApp, + key: String, + model: String, + pane_token: Option, + agent: &AgentTag, + ) -> Self { + let (sidx, lane) = { let mut a = lock_app(&app); // Does this request belong to the embedded pane we spawned? If so, // (re)bind the embed to the id its traffic reports — this is how we @@ -933,11 +1629,25 @@ impl Tap { a.selected = sidx; a.follow = true; } - a.sessions[sidx].active += 1; - a.sessions[sidx].last_activity = Instant::now(); - sidx + let s = &mut a.sessions[sidx]; + let lane = s.lane_for(agent, &model); + s.active += 1; + s.lanes[lane as usize].active += 1; + s.lanes[lane as usize].last_event = Some(Instant::now()); + s.last_activity = Instant::now(); + (sidx, lane) }; - Self { app, sidx, cur: None } + Self { + app, + sidx, + lane, + cur: None, + } + } + + /// The lane this request streams into (`MAIN_LANE` for the main chain). + pub fn lane(&self) -> LaneId { + self.lane } pub fn handle(&mut self, ev: &str, d: &Value) { @@ -946,12 +1656,18 @@ impl Tap { // a big interactive prompt (checked against embed_session below). let mut interactive = false; let mut grow_rows: Option = None; + let lane = self.lane; + let li = lane as usize; let s = &mut a.sessions[self.sidx]; s.last_activity = Instant::now(); + s.lanes[li].last_event = Some(Instant::now()); match ev { "message_start" => { + // Model and context size are per lane: a subagent commonly runs + // another model, and writing them session-wide made a haiku + // subagent rewrite the main chain's title and context figure. if let Some(m) = d.pointer("/message/model").and_then(Value::as_str) { - s.model = m.to_string(); + s.lanes[li].model = m.to_string(); } let u = |k: &str| { d.pointer(&format!("/message/usage/{k}")) @@ -961,15 +1677,17 @@ impl Tap { let ctx = u("input_tokens") + u("cache_read_input_tokens") + u("cache_creation_input_tokens"); - s.input_tokens = ctx; - s.entries - .push(Entry::meta(format!("▶ {} · context {}", s.model, fmt_tokens(ctx)))); + s.lanes[li].input_tokens = ctx; + let model = s.lanes[li].model.clone(); + let e = Entry::meta(format!("▶ {model} · context {}", fmt_tokens(ctx))); + s.push(lane, e); } "content_block_start" => { let bt = d .pointer("/content_block/type") .and_then(Value::as_str) .unwrap_or(""); + let mut is_tool = false; let kind = match bt { "thinking" => Kind::Thinking, "redacted_thinking" => Kind::Thinking, @@ -982,6 +1700,7 @@ impl Tap { { s.tool_ids.insert(id.to_string(), s.entries.len()); } + is_tool = true; Kind::Tool { name: d .pointer("/content_block/name") @@ -995,7 +1714,8 @@ impl Tap { // do NOT set self.cur — we don't want subsequent delta // events appending content to a done Meta entry, and // content_block_stop would wrongly try to pretty-print it. - s.entries.push(Entry::meta(format!("[{other}]"))); + let e = Entry::meta(format!("[{other}]")); + s.push(lane, e); return; } }; @@ -1004,8 +1724,11 @@ impl Tap { } else { String::new() }; - s.entries.push(Entry { kind, content, done: false, result: None }); - self.cur = Some(s.entries.len() - 1); + let idx = s.push(lane, Entry::streaming(kind, content)); + if is_tool { + s.lanes[li].tool_calls += 1; + } + self.cur = Some(idx); } "content_block_delta" => { if let Some(i) = self.cur { @@ -1041,10 +1764,11 @@ impl Tap { } "message_delta" => { if let Some(o) = d.pointer("/usage/output_tokens").and_then(Value::as_u64) { - s.output_tokens += o; + s.lanes[li].output_tokens += o; } if let Some(r) = d.pointer("/delta/stop_reason").and_then(Value::as_str) { - s.entries.push(Entry::meta(format!("■ {r}"))); + let e = Entry::meta(format!("■ {r}")); + s.push(lane, e); } } "error" => { @@ -1056,12 +1780,8 @@ impl Tap { .pointer("/error/type") .and_then(Value::as_str) .unwrap_or("error"); - s.entries.push(Entry { - kind: Kind::Error, - content: format!("✖ {etype}: {msg}"), - done: true, - result: None, - }); + let e = Entry::done(Kind::Error, format!("✖ {etype}: {msg}")); + s.push(lane, e); } _ => {} } @@ -1069,7 +1789,11 @@ impl Tap { // (wire-order guarantees this fires before Claude Code draws it): // ask the UI for more rows, and cancel any pending transcript wipe so // the prompt is not cleared before the user can respond. + // + // Main lane only: a subagent has neither AskUserQuestion nor + // ExitPlanMode, and only the main chain drives the pane. if interactive + && lane == MAIN_LANE && a.embed_session.as_deref() == Some(a.sessions[self.sidx].key.as_str()) { a.embed_grow = true; @@ -1084,6 +1808,10 @@ impl Drop for Tap { let mut a = lock_app(&self.app); let s = &mut a.sessions[self.sidx]; s.active = s.active.saturating_sub(1); + let l = &mut s.lanes[self.lane as usize]; + l.active = l.active.saturating_sub(1); + // The turn ended here; `Lane::running` counts from this stamp. + l.last_event = Some(Instant::now()); if let Some(i) = self.cur.take() { s.entries[i].done = true; } @@ -1092,7 +1820,11 @@ impl Drop for Tap { // Do NOT schedule while embed_grow is active — that means an interactive // prompt (AskUserQuestion / ExitPlanMode) is waiting for user input, and // a ctrl-l wipe would clear the prompt before the user can answer it. - if a.embed_session.as_deref() == Some(a.sessions[self.sidx].key.as_str()) + // + // Main lane only: a subagent finishing mid-turn would otherwise wipe the + // pane while the main agent is still working. + if self.lane == MAIN_LANE + && a.embed_session.as_deref() == Some(a.sessions[self.sidx].key.as_str()) && !a.embed_grow { a.embed_clear_at = Some(Instant::now() + std::time::Duration::from_millis(400)); @@ -1157,6 +1889,7 @@ fn tools_summary(body: &Value) -> Option<(String, String)> { mod tests { use super::*; use serde_json::json; + use std::time::Duration; fn aliases() -> Vec { default_model_choices().into_iter().map(|(_, a)| a).collect() @@ -1168,6 +1901,7 @@ mod tests { uuid: uuid.into(), label: uuid.into(), model: model.into(), + agents: 0, modified: std::time::SystemTime::now(), } } @@ -1192,9 +1926,9 @@ mod tests { fn resume_model_prefers_the_transcript() { let mut a = App::new(); a.disk_sessions = vec![stub("s1", "claude-opus-4-5-20251101")]; - assert_eq!(a.resume_model("s1"), "opus"); + assert_eq!(a.resume_arg("s1", None), "opus"); // Unknown session → no flag. - assert_eq!(a.resume_model("nope"), ""); + assert_eq!(a.resume_arg("nope", None), ""); } #[test] @@ -1205,22 +1939,499 @@ mod tests { // Transcript agrees on the model → the long-context spelling survives // (the transcript records the base id for both). a.disk_sessions = vec![stub("s1", "claude-sonnet-4-5-20250929")]; - assert_eq!(a.resume_model("s1"), "sonnet[1m]"); - // A mid-session `/model` switch shows up on disk and wins. + assert_eq!(a.resume_arg("s1", None), "sonnet[1m]"); + // A mid-session `/model` switch shows up on disk and wins — and takes + // the stale `[1m]` memory with it. a.disk_sessions = vec![stub("s1", "claude-opus-4-5-20251101")]; - assert_eq!(a.resume_model("s1"), "opus"); + assert_eq!(a.resume_arg("s1", None), "opus"); // No transcript model yet (spawned, never answered) → the spawn arg. a.disk_sessions.clear(); - assert_eq!(a.resume_model("s1"), "sonnet[1m]"); + assert_eq!(a.resume_arg("s1", None), "sonnet[1m]"); // The CLI default is remembered as "no flag", not as a model. a.set_spawn_model("s1", ""); - assert_eq!(a.resume_model("s1"), ""); + assert_eq!(a.resume_arg("s1", None), ""); + } + + #[test] + fn resume_model_follows_the_observed_context_window() { + let mut a = App::new(); + a.sessions.push(Session::new("s1".into(), "opus".into())); + a.disk_sessions = vec![stub("s1", "claude-opus-5")]; + // Wire says 1M → resume on the long-context spelling. + a.sessions[0].long_context = Some(true); + assert_eq!(a.resume_arg("s1", None), "opus[1m]"); + // Wire wins over a stale spawn argument, both ways round. + a.set_spawn_model("s1", "opus"); + assert_eq!(a.resume_arg("s1", None), "opus[1m]"); + a.sessions[0].long_context = Some(false); + a.set_spawn_model("s1", "opus[1m]"); + assert_eq!(a.resume_arg("s1", None), "opus"); + // …and an explicit "short window" beats a 1M default: we know better. + assert_eq!(a.resume_arg("s1", Some("opus[1m]")), "opus"); + // A model with no `[1m]` variant never grows one. + a.disk_sessions = vec![stub("s1", "claude-haiku-4-5-20251001")]; + a.sessions[0].long_context = Some(true); + a.sessions[0].spawn_model = None; + assert_eq!(a.resume_arg("s1", None), "haiku"); + } + + #[test] + fn unobserved_session_inherits_claude_codes_own_default() { + // A session from before this process: nothing knows its window, so the + // same-model default is inherited whole (no `--model` flag) rather than + // pinned to the short window. + let mut a = App::new(); + a.disk_sessions = vec![stub("s1", "claude-opus-5")]; + assert_eq!(a.resume_arg("s1", Some("opus[1m]")), ""); + assert_eq!(a.resume_arg("s1", Some("opus")), ""); + // A default for a *different* model can't speak for this session. + assert_eq!(a.resume_arg("s1", Some("sonnet[1m]")), "opus"); + } + + /// Wire fixture: a subagent's request, identified by Claude Code's own + /// `x-claude-code-agent-id`. + fn agent(id: &str) -> AgentTag { + AgentTag { + id: Some(id.to_string()), + parent: None, + } + } + + #[test] + fn lane_running_follows_the_agents_traffic() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + let tap = Tap::new(app.clone(), "s".into(), "m".into(), None, &agent("a1")); + { + let a = app.lock().unwrap(); + assert!(a.sessions[0].lanes[1].running(), "streaming = running"); + } + drop(tap); + { + let a = app.lock().unwrap(); + assert!( + a.sessions[0].lanes[1].running(), + "a gap between turns is not a finish" + ); + } + // The finish signal demotes it immediately — nothing is hidden, the + // agent just stops sorting with the running ones. + { + let mut a = app.lock().unwrap(); + let l = &mut a.sessions[0].lanes[1]; + l.finished_at = Some(std::time::Instant::now()); + assert!(!l.running()); + // Without a finish signal, only a long silence demotes it. + l.finished_at = None; + l.last_event = + std::time::Instant::now().checked_sub(LANE_IDLE_MAX - Duration::from_secs(5)); + assert!(l.running(), "a slow local tool call is still running"); + l.last_event = + std::time::Instant::now().checked_sub(LANE_IDLE_MAX + Duration::from_secs(1)); + assert!(!l.running(), "stale lane eventually demotes"); + } + // Fresh traffic on the same lane promotes it again. + drop(Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &agent("a1"), + )); + let a = app.lock().unwrap(); + assert!(a.sessions[0].lanes[1].running()); + // A lane read from disk was never live here. + let mut disk = Session::new("d".into(), "m".into()); + let l = disk.add_lane( + "x".into(), + "oracle".into(), + "job".into(), + None, + Some(MAIN_LANE), + 1, + ); + assert!(!disk.lanes[l as usize].running()); + } + + /// The popup lists *every* agent (a finished one's output must stay + /// readable) with the running ones first, and `A` takes the shortest path + /// to a stream. + #[test] + fn agent_popup_lists_running_agents_first() { + let mut s = Session::new("d".into(), "m".into()); + let mut lane = |id: &str| { + let l = s.add_lane( + id.into(), + "oracle".into(), + id.into(), + None, + Some(MAIN_LANE), + 1, + ); + s.entries.push(Entry::meta("output".into()).in_lane(l)); + l + }; + let (a1, a2, a3) = (lane("a1"), lane("a2"), lane("a3")); + s.reindex_lanes(); + // add_lane describes a lane read from disk: none of them run yet. + assert_eq!(App::agent_list_of(&s), vec![a1, a2, a3], "spawn order"); + // Make the last two live; they move to the front, spawn order intact. + for l in [a2, a3] { + s.lanes[l as usize].finished_at = None; + s.lanes[l as usize].last_event = Some(Instant::now()); + } + assert_eq!(App::agent_list_of(&s), vec![a2, a3, a1]); + + let mut a = App::new(); + a.sessions.push(s); + a.selected = 0; + // `A` on three agents opens the picker on the first *running* one. + a.toggle_agent_popup(); + assert_eq!(a.agent_popup, Some(AgentPopup::List(0))); + a.agent_popup_move(1); + a.agent_popup_enter(); + assert_eq!(a.agent_popup, Some(AgentPopup::Feed(a3))); + assert_eq!(a.agent_popup_lane(), Some(a3), "paging targets that agent"); + // [ / ] switch agents inside the feed; esc steps back to the picker. + a.agent_popup_cycle(true); + assert_eq!(a.agent_popup, Some(AgentPopup::Feed(a1)), "wraps"); + a.agent_popup_back(); + assert_eq!(a.agent_popup, Some(AgentPopup::List(2))); + a.toggle_agent_popup(); + assert_eq!(a.agent_popup, None, "A closes whatever is open"); + + // One agent = no picker: `A` opens its feed directly. + let mut a = App::new(); + let mut one = Session::new("d1".into(), "m".into()); + let l = one.add_lane("x".into(), "oracle".into(), "job".into(), None, Some(MAIN_LANE), 1); + one.entries.push(Entry::meta("output".into()).in_lane(l)); + one.reindex_lanes(); + a.sessions.push(one); + a.toggle_agent_popup(); + assert_eq!(a.agent_popup, Some(AgentPopup::Feed(l))); + // A session without agents can't open the popup at all. + a.sessions[0].lanes.truncate(1); + a.sessions[0].entries.clear(); + a.agent_popup = None; + assert!(a.agent_list().is_empty()); + a.toggle_agent_popup(); + assert_eq!(a.agent_popup, None); + // A popup pointing at a lane the displayed session lost is dropped. + a.agent_popup = Some(AgentPopup::Feed(7)); + a.validate_agent_popup(); + assert_eq!(a.agent_popup, None); + } + + #[test] + fn subagent_streams_into_its_own_lane() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + let mut main = Tap::new( + app.clone(), + "s".into(), + "claude-opus".into(), + None, + &AgentTag::default(), + ); + let mut sub = Tap::new( + app.clone(), + "s".into(), + "claude-haiku".into(), + None, + &agent("a1"), + ); + assert_eq!(main.lane(), MAIN_LANE); + assert_eq!(sub.lane(), 1); + main.handle( + "content_block_start", + &json!({"content_block": {"type": "text"}}), + ); + main.handle( + "content_block_delta", + &json!({"delta": {"type": "text_delta", "text": "parent"}}), + ); + sub.handle( + "content_block_start", + &json!({"content_block": {"type": "text"}}), + ); + sub.handle( + "content_block_delta", + &json!({"delta": {"type": "text_delta", "text": "child"}}), + ); + // A subagent's model must not overwrite the main chain's. + sub.handle( + "message_start", + &json!({"message": {"model": "claude-haiku", "usage": {"input_tokens": 7}}}), + ); + drop(sub); + drop(main); + let a = app.lock().unwrap(); + let s = &a.sessions[0]; + assert_eq!(s.agent_lanes(), vec![1]); + assert_eq!(s.main().model, "claude-opus"); + assert_eq!(s.lanes[1].model, "claude-haiku"); + assert_eq!(s.lanes[1].input_tokens, 7, "context is tracked per lane"); + assert_eq!(s.main().input_tokens, 0); + let by_lane = |l: LaneId| -> Vec<&str> { + s.entries + .iter() + .filter(|e| e.lane == l && e.kind == Kind::Text) + .map(|e| e.content.as_str()) + .collect() + }; + assert_eq!(by_lane(MAIN_LANE), vec!["parent"]); + assert_eq!(by_lane(1), vec!["child"]); + } + + #[test] + fn sibling_agents_never_merge_and_inner_turns_reuse_their_lane() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + // Two agents of the same type with byte-identical prompts: the header + // still separates them (a prompt hash would not). + drop(Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &agent("a1"), + )); + drop(Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &agent("a2"), + )); + // The first agent's second inner turn reuses its lane. + let again = Tap::new(app.clone(), "s".into(), "m".into(), None, &agent("a1")); + assert_eq!(again.lane(), 1); + drop(again); + // A nested agent hangs off its parent's lane. + let nested = Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &AgentTag { + id: Some("a3".into()), + parent: Some("a2".into()), + }, + ); + assert_eq!(nested.lane(), 3); + drop(nested); + let a = app.lock().unwrap(); + let s = &a.sessions[0]; + assert_eq!(s.lanes.len(), 4, "main + three agents"); + assert_eq!(s.lanes[3].parent, Some(2)); + assert_eq!(s.lanes[3].depth, 2); + assert_eq!(s.lanes[1].depth, 1); + } + + #[test] + fn system_and_tools_lines_are_per_lane() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &AgentTag::default(), + )); + drop(Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &agent("a1"), + )); + let body = |sys: &str, tool: &str, text: &str| { + json!({ + "system": [{"type": "text", "text": sys}], + "tools": [{"name": tool}], + "messages": [{"role": "user", "content": [{"type": "text", "text": text}]}] + }) + }; + // Alternating main/subagent requests, each with its own system prompt + // and tool set: both lines must be emitted once per lane, not on every + // alternation. + for _ in 0..3 { + record_user_prompt(&app, "s", MAIN_LANE, &body("main prompt", "Bash", "one")); + record_user_prompt(&app, "s", 1, &body("agent prompt is longer", "Grep", "two")); + } + let a = app.lock().unwrap(); + let s = &a.sessions[0]; + let count = |l: LaneId, k: &Kind| { + s.entries + .iter() + .filter(|e| e.lane == l && &e.kind == k) + .count() + }; + assert_eq!(count(MAIN_LANE, &Kind::System), 1); + assert_eq!(count(1, &Kind::System), 1); + assert_eq!(count(MAIN_LANE, &Kind::ToolDefs), 1); + assert_eq!(count(1, &Kind::ToolDefs), 1); + // Dedup is per lane: the same text in another lane is its own prompt. + assert_eq!(count(MAIN_LANE, &Kind::User), 1); + assert_eq!(count(1, &Kind::User), 1); + } + + #[test] + fn lane_is_labelled_from_the_agent_tool_call() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + let mut main = Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &AgentTag::default(), + ); + main.handle( + "content_block_start", + &json!({"content_block": {"type": "tool_use", "id": "toolu_A", "name": "Agent"}}), + ); + let input = json!({ + "subagent_type": "Explore", + "description": "find the retry helper", + "prompt": "Locate the retry helper and report back." + }) + .to_string(); + main.handle( + "content_block_delta", + &json!({"delta": {"type": "input_json_delta", "partial_json": input}}), + ); + main.handle("content_block_stop", &json!({})); + drop(main); + + // The subagent's opening prompt is that `prompt`, verbatim. + let sub = Tap::new(app.clone(), "s".into(), "m".into(), None, &agent("a1")); + record_user_prompt( + &app, + "s", + sub.lane(), + &json!({"tools": [{"name": "Grep"}], "messages": [{"role": "user", "content": [ + {"type": "text", "text": "noise"}, + {"type": "text", "text": "Locate the retry helper and report back."} + ]}]}), + ); + drop(sub); + { + let a = app.lock().unwrap(); + let l = &a.sessions[0].lanes[1]; + assert_eq!(l.agent_type, "Explore"); + assert_eq!(l.label, "find the retry helper"); + assert_eq!(l.parent, Some(MAIN_LANE)); + assert_eq!(l.depth, 1); + assert!(!l.finished(), "still running"); + assert_eq!(l.title(), "Explore · find the retry helper"); + } + + // Claude Code answers an `Agent` call at *launch* time, so this result + // ties the lane to the tool call but must NOT mark it finished (the + // wording is verbatim from a real transcript). + attach_tool_results( + &app, + "s", + &json!({"messages": [{"role": "user", "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_A", + "content": [{"type": "text", "text": ASYNC_LAUNCH}] + }]}]}), + ); + { + let a = app.lock().unwrap(); + let l = &a.sessions[0].lanes[1]; + assert_eq!(l.tool_use_id.as_deref(), Some("toolu_A")); + assert!(!l.finished(), "an async launch is not a completion"); + assert!(l.running(), "still running after the launch ack"); + } + + // The completion arrives later as a task notification on the parent's + // next turn — the signal that actually closes the row. + record_user_prompt( + &app, + "s", + MAIN_LANE, + &json!({"tools": [{"name": "Bash"}], "messages": [ + {"role": "user", "content": [{"type": "text", "text": NOTIFICATION}]} + ]}), + ); + let a = app.lock().unwrap(); + let l = &a.sessions[0].lanes[1]; + assert!(l.finished(), "task notification closes the lane"); + assert!( + !l.running(), + "…and that is what demotes it out of the running group" + ); + } + + /// Verbatim from a real transcript in this project: every `Agent` call is + /// launched asynchronously, so its tool_result is only an acknowledgement. + const ASYNC_LAUNCH: &str = "Async agent launched successfully. (This tool result is internal \ +metadata — never quote or paste any part of it, including the agentId below, into a user-facing \ +reply.)\nagentId: a1 (internal ID - do not mention to user. Use SendMessage with to: 'a1', \ +summary: '<5-10 word recap>' to continue this agent.)\nThe agent is working in the background."; + + /// Verbatim shape of the completion Claude Code injects into the next user + /// turn. `` is the agent id. + const NOTIFICATION: &str = "\na1\n\ +completed\nAgent \"Explore\" finished\n"; + + #[test] + fn background_bash_notifications_do_not_touch_lanes() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new( + app.clone(), + "s".into(), + "m".into(), + None, + &agent("a1"), + )); + // A background *command* uses the same notification shape with a short + // id that matches no lane. + record_user_prompt( + &app, + "s", + MAIN_LANE, + &json!({"tools": [{"name": "Bash"}], "messages": [{"role": "user", "content": [ + {"type": "text", "text": "\nb3h8us8uq\ncompleted\n"} + ]}]}), + ); + let a = app.lock().unwrap(); + assert!(!a.sessions[0].lanes[1].finished()); + } + + #[test] + fn only_the_main_lane_drives_the_pane() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + app.lock().unwrap().embed_session = Some("emb".into()); + // A subagent finishing mid-turn must not schedule the ctrl-l wipe. + drop(Tap::new( + app.clone(), + "emb".into(), + "m".into(), + None, + &agent("a1"), + )); + assert!(app.lock().unwrap().embed_clear_at.is_none()); + // The main chain's turn ending still does. + drop(Tap::new( + app.clone(), + "emb".into(), + "m".into(), + None, + &AgentTag::default(), + )); + assert!(app.lock().unwrap().embed_clear_at.is_some()); } #[test] fn tool_result_attaches_to_entry() { let app: SharedApp = Arc::new(Mutex::new(App::new())); - let mut tap = Tap::new(app.clone(), "abc".into(), "claude-x".into(), None); + let mut tap = Tap::new( + app.clone(), + "abc".into(), + "claude-x".into(), + None, + &AgentTag::default(), + ); tap.handle( "content_block_start", &json!({"content_block": {"type": "tool_use", "id": "toolu_01", "name": "Bash"}}), @@ -1256,7 +2467,13 @@ mod tests { fn interactive_tool_toggles_embed_grow() { let app: SharedApp = Arc::new(Mutex::new(App::new())); app.lock().unwrap().embed_session = Some("emb".into()); - let mut tap = Tap::new(app.clone(), "emb".into(), "claude-x".into(), None); + let mut tap = Tap::new( + app.clone(), + "emb".into(), + "claude-x".into(), + None, + &AgentTag::default(), + ); tap.handle( "content_block_start", &json!({"content_block": {"type": "tool_use", "id": "toolu_q", "name": "AskUserQuestion"}}), @@ -1280,15 +2497,21 @@ mod tests { #[test] fn user_prompt_recorded_once_and_injections_skipped() { let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); + drop(Tap::new( + app.clone(), + "abc".into(), + "claude-x".into(), + None, + &AgentTag::default(), + )); let body = json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "user", "content": [ {"type": "text", "text": "noise"}, {"type": "text", "text": "fix the bug"} ]} ]}); - record_user_prompt(&app, "abc", &body); - record_user_prompt(&app, "abc", &body); // resend → deduped + record_user_prompt(&app, "abc", MAIN_LANE, &body); + record_user_prompt(&app, "abc", MAIN_LANE, &body); // resend → deduped { let a = app.lock().unwrap(); let user: Vec<_> = a.sessions[0] @@ -1304,6 +2527,7 @@ mod tests { record_user_prompt( &app, "abc", + MAIN_LANE, &json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "t1", "content": "ok"} @@ -1315,6 +2539,7 @@ mod tests { record_user_prompt( &app, "abc", + MAIN_LANE, &json!({"messages": [{"role": "user", "content": "fresh prompt"}]}), ); let a = app.lock().unwrap(); @@ -1339,23 +2564,26 @@ mod tests { // A verbatim repeat ("continue") in a *later* turn must show — only an // immediate resend (same request, nothing streamed since) is deduped. let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); + drop(Tap::new( + app.clone(), + "abc".into(), + "claude-x".into(), + None, + &AgentTag::default(), + )); let body = json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "user", "content": "continue"} ]}); - record_user_prompt(&app, "abc", &body); - record_user_prompt(&app, "abc", &body); // immediate resend → deduped + record_user_prompt(&app, "abc", MAIN_LANE, &body); + record_user_prompt(&app, "abc", MAIN_LANE, &body); // immediate resend → deduped // Simulate the turn producing a response between the two prompts. { let mut a = app.lock().unwrap(); - a.sessions[0].entries.push(Entry { - kind: Kind::Text, - content: "ok, continuing".into(), - done: true, - result: None, - }); + a.sessions[0] + .entries + .push(Entry::done(Kind::Text, "ok, continuing".into())); } - record_user_prompt(&app, "abc", &body); // new turn, same text → kept + record_user_prompt(&app, "abc", MAIN_LANE, &body); // new turn, same text → kept let a = app.lock().unwrap(); assert_eq!( a.sessions[0].entries.iter().filter(|e| e.kind == Kind::User).count(), @@ -1366,7 +2594,13 @@ mod tests { #[test] fn system_size_and_tools_surfaced_once_then_on_change() { let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); + drop(Tap::new( + app.clone(), + "abc".into(), + "claude-x".into(), + None, + &AgentTag::default(), + )); let turn = |sys: &str, tools: Value, prompt: &str| { json!({"system": sys, "tools": tools, "messages": [ {"role": "user", "content": prompt} @@ -1375,13 +2609,24 @@ mod tests { let tools = json!([{"name": "Bash"}, {"name": "Read"}]); // First turn: system size + tool list + prompt. - record_user_prompt(&app, "abc", &turn("0123456789", tools.clone(), "one")); + record_user_prompt( + &app, + "abc", + MAIN_LANE, + &turn("0123456789", tools.clone(), "one"), + ); // Second turn, same system + tools: only the new prompt. - record_user_prompt(&app, "abc", &turn("0123456789", tools.clone(), "two")); + record_user_prompt( + &app, + "abc", + MAIN_LANE, + &turn("0123456789", tools.clone(), "two"), + ); // Third turn, tools changed: re-emit the tool list (system unchanged). record_user_prompt( &app, "abc", + MAIN_LANE, &turn("0123456789", json!([{"name": "Bash"}]), "three"), ); @@ -1421,10 +2666,17 @@ mod tests { // verbatim; only the turn-tree label projection drops them. let raw = "/commit\n-a\nthe rest"; let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); + drop(Tap::new( + app.clone(), + "abc".into(), + "claude-x".into(), + None, + &AgentTag::default(), + )); record_user_prompt( &app, "abc", + MAIN_LANE, &json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "user", "content": raw} ]}), @@ -1445,10 +2697,17 @@ mod tests { // prepended *inside the same text block* as the real // prompt — the old whole-block filter dropped it entirely. let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); + drop(Tap::new( + app.clone(), + "abc".into(), + "claude-x".into(), + None, + &AgentTag::default(), + )); record_user_prompt( &app, "abc", + MAIN_LANE, &json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "user", "content": [ {"type": "text", "text": @@ -1482,10 +2741,17 @@ mod tests { // dropped the whole turn — the "user messages don't show up reliably" // bug. The prompt (in the second-to-last message) must still land. let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); + drop(Tap::new( + app.clone(), + "abc".into(), + "claude-x".into(), + None, + &AgentTag::default(), + )); record_user_prompt( &app, "abc", + MAIN_LANE, &json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "assistant", "content": "prior reply"}, {"role": "user", "content": [{"type": "text", "text": "what files are here?"}]}, @@ -1517,10 +2783,17 @@ mod tests { // (no text). It must not manufacture a spurious User entry even though // the trailing-run scan now looks past the single last message. let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); + drop(Tap::new( + app.clone(), + "abc".into(), + "claude-x".into(), + None, + &AgentTag::default(), + )); record_user_prompt( &app, "abc", + MAIN_LANE, &json!({"tools": [{"name": "Bash"}], "messages": [ {"role": "user", "content": "run ls"}, {"role": "assistant", "content": [ @@ -1579,7 +2852,13 @@ mod tests { fn non_embed_session_never_grows() { let app: SharedApp = Arc::new(Mutex::new(App::new())); app.lock().unwrap().embed_session = Some("emb".into()); - let mut tap = Tap::new(app.clone(), "other".into(), "claude-x".into(), None); + let mut tap = Tap::new( + app.clone(), + "other".into(), + "claude-x".into(), + None, + &AgentTag::default(), + ); tap.handle( "content_block_start", &json!({"content_block": {"type": "tool_use", "id": "toolu_q", "name": "AskUserQuestion"}}), @@ -1601,7 +2880,13 @@ mod tests { a.embed_session = Some("provisional".into()); a.sessions.push(Session::new("provisional".into(), "(resumed)".into())); } - drop(Tap::new(app.clone(), "real".into(), "m".into(), Some("tok".into()))); + drop(Tap::new( + app.clone(), + "real".into(), + "m".into(), + Some("tok".into()), + &AgentTag::default(), + )); let a = app.lock().unwrap(); assert_eq!(a.embed_session.as_deref(), Some("real")); assert_eq!(a.sessions.len(), 1, "provisional row renamed, not duplicated"); @@ -1621,10 +2906,22 @@ mod tests { a.pane_focused = true; } // The pane's own first request binds + selects it. - drop(Tap::new(app.clone(), "embed".into(), "m".into(), Some("tok".into()))); + drop(Tap::new( + app.clone(), + "embed".into(), + "m".into(), + Some("tok".into()), + &AgentTag::default(), + )); assert_eq!(app.lock().unwrap().selected_key().as_deref(), Some("embed")); // A new external session streams: selection must stay on the embed. - drop(Tap::new(app.clone(), "external".into(), "m".into(), None)); + drop(Tap::new( + app.clone(), + "external".into(), + "m".into(), + None, + &AgentTag::default(), + )); let a = app.lock().unwrap(); assert_eq!(a.selected_key().as_deref(), Some("embed")); assert_eq!(a.sessions.len(), 2, "external session is still tracked"); @@ -1635,10 +2932,25 @@ mod tests { // With no pane focused, a fresh session still auto-jumps so a `/clear` // in an external claude is immediately visible. let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "first".into(), "m".into(), None)); + drop(Tap::new( + app.clone(), + "first".into(), + "m".into(), + None, + &AgentTag::default(), + )); assert_eq!(app.lock().unwrap().selected_key().as_deref(), Some("first")); - drop(Tap::new(app.clone(), "second".into(), "m".into(), None)); - assert_eq!(app.lock().unwrap().selected_key().as_deref(), Some("second")); + drop(Tap::new( + app.clone(), + "second".into(), + "m".into(), + None, + &AgentTag::default(), + )); + assert_eq!( + app.lock().unwrap().selected_key().as_deref(), + Some("second") + ); } fn ds(uuid: &str) -> crate::sessions::DiskSession { @@ -1762,7 +3074,13 @@ mod tests { #[test] fn unknown_tool_id_is_ignored() { let app: SharedApp = Arc::new(Mutex::new(App::new())); - drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None)); + drop(Tap::new( + app.clone(), + "abc".into(), + "claude-x".into(), + None, + &AgentTag::default(), + )); attach_tool_results( &app, "abc", diff --git a/src/proxy.rs b/src/proxy.rs index cbcb465..89b76db 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,15 +1,36 @@ -use crate::app::{attach_tool_results, lock_app, record_user_prompt, SharedApp, Tap}; +use crate::app::{ + AgentTag, SharedApp, Tap, attach_tool_results, lock_app, record_long_context, + record_user_prompt, +}; use crate::sse::SseParser; +use axum::Router; use axum::body::Body; use axum::extract::{Request, State}; use axum::http::Method; use axum::response::Response; -use axum::Router; use futures_util::StreamExt; use serde_json::Value; const UPSTREAM: &str = "https://api.anthropic.com"; +/// Claude Code stamps a subagent's requests with the agent's own id (and its +/// parent's, from spawn depth 2). These are *its* headers, so they are read and +/// forwarded untouched — only `x-claude-cloak-pane` is ours to consume. This is +/// the exact correlation that separates a subagent's stream from the main +/// chain: unique per agent even for byte-identical sibling prompts, stable +/// across the agent's inner turns, and equal to the `agentId` in its on-disk +/// `subagents/agent-.jsonl`. +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"; + /// Upstream base URL; `CT_UPSTREAM` overrides for offline testing against a /// fake server (the relay itself is identical either way). fn upstream() -> String { @@ -20,8 +41,17 @@ fn upstream() -> String { /// sends a JSON-ish blob containing `"session_id":""`; older builds /// used `user__account__session_`. fn session_key(u: &str) -> Option<&str> { - if let Some(rest) = u.split(r#"session_id":""#).nth(1) { - return rest.split('"').next().filter(|s| !s.is_empty()); + // Tolerate whitespace around the colon: Claude Code sends compact JSON, + // but a pretty-printed blob would otherwise fall through to the legacy + // `session_` split and yield a garbage key (`id": "…`). + if let Some(rest) = u.split(r#"session_id""#).nth(1) { + let rest = rest.trim_start(); + if let Some(rest) = rest.strip_prefix(':') { + let rest = rest.trim_start(); + if let Some(rest) = rest.strip_prefix('"') { + return rest.split('"').next().filter(|s| !s.is_empty()); + } + } } // `.filter`: a trailing "session_" would otherwise yield Some("") — an // empty key that every malformed user_id would then collide on. @@ -98,12 +128,36 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result { .get(crate::term::PANE_TOKEN_HEADER) .and_then(|v| v.to_str().ok()) .map(str::to_string); + let header = |name: &str| { + parts + .headers + .get(name) + .and_then(|v| v.to_str().ok()) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + let agent = 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. attach_tool_results(&ctx.app, &key, &v); - tap = Some(Tap::new(ctx.app.clone(), key.clone(), model, pane_token)); - // After Tap::new: the session must exist for the entry to land. - record_user_prompt(&ctx.app, &key, &v); + 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); + } + tap = Some(t); } let mut rb = ctx.client.request(parts.method.clone(), &url); @@ -196,5 +250,10 @@ mod tests { // Degenerate inputs must not produce an empty (colliding) key. assert_eq!(session_key("user_x_session_"), None); assert_eq!(session_key(r#"{"session_id":""}"#), None); + // Pretty-printed metadata must not fall through to the legacy split. + assert_eq!( + session_key(r#"{"session_id": "29bd3436-aaaa-bbbb-cccc-111122223333"}"#), + Some("29bd3436-aaaa-bbbb-cccc-111122223333") + ); } } diff --git a/src/sessions.rs b/src/sessions.rs index 491b995..8b6d0f3 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -10,8 +10,8 @@ //! spaces … all become `-`) — see `encode_cwd`. use crate::app::{ - flatten_result_content, lock_app, strip_injected, Entry, Kind, Session, SharedApp, - ToolResult, + Entry, Kind, LaneId, MAIN_LANE, Session, SharedApp, ToolResult, flatten_result_content, + lock_app, strip_injected, }; use serde_json::Value; use std::collections::HashMap; @@ -22,6 +22,11 @@ use std::time::SystemTime; #[derive(Clone, PartialEq)] pub struct DiskSession { pub uuid: String, + /// Subagent transcripts recorded alongside this session, shown as `⑂N` in + /// the list. Counted in the same cached pass as the label (a subagent run + /// always writes the parent's `Agent` records too, so the parent's mtime + /// moves whenever this can change). + pub agents: usize, /// Best human-readable label: ai-title > last-prompt text > uuid prefix. pub label: String, /// API model id of the session's last main-chain assistant message @@ -38,9 +43,9 @@ pub struct DiskSession { /// only taken when the list actually changed. pub fn spawn_scanner(app: SharedApp) { std::thread::spawn(move || { - // uuid → (mtime when read, label, model): skip re-parsing unchanged - // files (one pass yields both — see `read_meta`). - let mut meta: HashMap = HashMap::new(); + // uuid → (mtime when read, label, model, subagent count): skip + // re-parsing unchanged files (one pass yields all — see `read_meta`). + let mut meta: HashMap = HashMap::new(); let mut last: Vec = Vec::new(); loop { let list = scan(&mut meta).unwrap_or_default(); @@ -55,7 +60,7 @@ pub fn spawn_scanner(app: SharedApp) { /// One scan of the project directory, newest first. fn scan( - meta: &mut HashMap, + meta: &mut HashMap, ) -> Result, String> { let dir = project_dir()?; let rd = std::fs::read_dir(&dir) @@ -69,15 +74,25 @@ fn scan( } let uuid = path.file_stem()?.to_str()?.to_string(); let modified = e.metadata().ok()?.modified().ok()?; - let (label, model) = match meta.get(&uuid) { - Some((m, l, md)) if *m == modified => (l.clone(), md.clone()), + let (label, model, agents) = match meta.get(&uuid) { + Some((m, l, md, n)) if *m == modified => (l.clone(), md.clone(), *n), _ => { - let read = read_meta(&path, &uuid); - meta.insert(uuid.clone(), (modified, read.0.clone(), read.1.clone())); - read + let (label, model) = read_meta(&path, &uuid); + let agents = scan_agents(&uuid).len(); + meta.insert( + uuid.clone(), + (modified, label.clone(), model.clone(), agents), + ); + (label, model, agents) } }; - Some(DiskSession { uuid, label, model, modified }) + Some(DiskSession { + uuid, + label, + model, + agents, + modified, + }) }) .collect(); sessions.sort_by(|a, b| b.modified.cmp(&a.modified)); @@ -320,6 +335,82 @@ fn materialize_in( Ok(new_uuid) } +/// One subagent transcript on disk. Claude Code ≥2.1.2x writes each subagent +/// to `//subagents/agent-.jsonl` with a small +/// `agent-.meta.json` sidecar; the sidecar alone reconstructs the whole +/// lane tree, so a multi-megabyte transcript is only read when its lane is +/// actually rendered. +pub struct DiskAgent { + pub agent_id: String, + pub agent_type: String, + pub description: String, + /// The parent's `Agent` tool_use id — where this lane is spliced in. + pub tool_use_id: String, + /// Set from spawn depth 2 (a subagent of a subagent). + pub parent_agent_id: Option, + pub spawn_depth: u8, + pub path: PathBuf, +} + +/// An agent transcript larger than this is summarised instead of parsed: the +/// view is built synchronously while the app mutex is held, and a few MB of +/// JSONL would stall the UI (and the proxy tap that shares the mutex). +const MAX_AGENT_BYTES: u64 = 8 * 1024 * 1024; + +/// Subagent transcripts recorded for `uuid`, in spawn order. Reads only the +/// `.meta.json` sidecars. +pub fn scan_agents(uuid: &str) -> Vec { + let Ok(dir) = project_dir() else { + return Vec::new(); + }; + scan_agents_in(&dir.join(uuid).join("subagents")) +} + +/// `scan_agents` against an explicit directory (the I/O seam tests use). +pub(crate) fn scan_agents_in(subdir: &std::path::Path) -> Vec { + let Ok(rd) = std::fs::read_dir(subdir) else { + return Vec::new(); + }; + let mut out: Vec = rd + .flatten() + .filter_map(|e| { + let meta_path = e.path(); + let name = meta_path.file_name()?.to_str()?; + let agent_id = name.strip_prefix("agent-")?.strip_suffix(".meta.json")?; + let v: Value = serde_json::from_str(&std::fs::read_to_string(&meta_path).ok()?).ok()?; + let field = |k: &str| { + v.get(k) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + Some(DiskAgent { + agent_id: agent_id.to_string(), + agent_type: field("agentType"), + description: field("description"), + tool_use_id: field("toolUseId"), + parent_agent_id: v + .get("parentAgentId") + .and_then(Value::as_str) + .map(str::to_string), + spawn_depth: v + .get("spawnDepth") + .and_then(Value::as_u64) + .unwrap_or(1) + .try_into() + .unwrap_or(1), + path: subdir.join(format!("agent-{agent_id}.jsonl")), + }) + }) + .collect(); + out.sort_by(|a, b| { + a.spawn_depth + .cmp(&b.spawn_depth) + .then_with(|| a.agent_id.cmp(&b.agent_id)) + }); + out +} + /// A rendered transcript: the feed `Session` plus which tree path it shows. pub struct HistoryView { pub session: Session, @@ -341,10 +432,13 @@ pub fn load_history(uuid: &str) -> Option { /// preamble + the root→leaf chain is rendered (dead branches excluded) and /// per-turn entry offsets are recorded; with None, the whole file. pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option { + // Subagent transcripts live in their own files next to the session's, so + // they are spliced in after the main chain is parsed (see `splice_agents`). + let agents = scan_agents(uuid); match path { None => { let fpath = project_dir().ok()?.join(format!("{uuid}.jsonl")); - load_file_view(&fpath, uuid) + load_file_view(&fpath, uuid, &agents) } Some((tree, leaf)) => { let mut p = EntryParser::new(); @@ -358,20 +452,195 @@ pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option Option { +pub(crate) fn load_file_view( + path: &std::path::Path, + uuid: &str, + agents: &[DiskAgent], +) -> Option { let f = std::fs::File::open(path).ok()?; let mut p = EntryParser::new(); for line in std::io::BufReader::new(f).lines().map_while(Result::ok) { p.line(&line); } - p.into_view(uuid, None, Vec::new()) + let anchors = std::mem::take(&mut p.agent_tools); + let mut view = p.into_view(uuid, None, Vec::new())?; + splice_agents(&mut view, anchors, agents); + 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, HashMap) { + let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + if size > MAX_AGENT_BYTES { + let mb = size / (1024 * 1024); + let note = + Entry::meta(format!("(subagent transcript too large to show: {mb} MB)")).in_lane(lane); + return (vec![note], HashMap::new()); + } + let Ok(f) = std::fs::File::open(path) else { + return (Vec::new(), HashMap::new()); + }; + let mut p = EntryParser::new_lane(lane); + for line in std::io::BufReader::new(f).lines().map_while(Result::ok) { + p.line(&line); + } + (p.entries, p.agent_tools) +} + +/// Tool calls per lane, for the agent picker's summary line. +fn count_tools(entries: &[Entry]) -> HashMap { + let mut out: HashMap = HashMap::new(); + for e in entries { + if matches!(e.kind, Kind::Tool { .. }) { + *out.entry(e.lane).or_default() += 1; + } + } + out +} + +/// Insert `add` at `at`, keeping the anchor map pointing at the same entries. +/// Anchors *at* the insertion point shift; the spawning tool call itself sits +/// at `at - 1` and stays put. +fn insert_entries( + entries: &mut Vec, + anchors: &mut HashMap, + at: usize, + add: Vec, +) { + let n = add.len(); + let at = at.min(entries.len()); + entries.splice(at..at, add); + for v in anchors.values_mut() { + if *v >= at { + *v += n; + } + } +} + +/// Splice each subagent transcript into the view right after the `Agent` tool +/// call that spawned it, as its own lane. +/// +/// Deepest agents go first, so a depth-2 transcript is nested into its parent's +/// entry list *before* that list is spliced into the main chain; within a level +/// 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. +fn splice_agents( + view: &mut HistoryView, + mut anchors: HashMap, + agents: &[DiskAgent], +) { + if agents.is_empty() { + return; + } + struct Loaded { + id: String, + lane: LaneId, + entries: Vec, + anchors: HashMap, + tool_use_id: String, + parent: Option, + depth: u8, + spliced: bool, + } + let mut loaded: Vec = agents + .iter() + .map(|a| { + let lane = view.session.add_lane( + a.agent_id.clone(), + a.agent_type.clone(), + a.description.clone(), + Some(a.tool_use_id.clone()), + None, + a.spawn_depth, + ); + let (entries, anchors) = parse_agent_file(&a.path, lane); + Loaded { + id: a.agent_id.clone(), + lane, + entries, + anchors, + tool_use_id: a.tool_use_id.clone(), + parent: a.parent_agent_id.clone(), + depth: a.spawn_depth, + spliced: false, + } + }) + .collect(); + + // Nest children into their parents, deepest first. + let mut order: Vec = (0..loaded.len()).collect(); + order.sort_by_key(|&i| std::cmp::Reverse(loaded[i].depth)); + for ci in order { + let Some(pid) = loaded[ci].parent.clone() else { + continue; + }; + let Some(pi) = loaded.iter().position(|l| l.id == pid) else { + continue; + }; + if pi == ci || loaded[ci].entries.is_empty() { + continue; + } + let child = std::mem::take(&mut loaded[ci].entries); + let tuid = loaded[ci].tool_use_id.clone(); + let parent_lane = loaded[pi].lane; + { + let p = &mut loaded[pi]; + match p.anchors.get(&tuid).copied() { + Some(at) => insert_entries(&mut p.entries, &mut p.anchors, at + 1, child), + // The parent's transcript doesn't contain the spawn point: + // keep the entries rather than lose them. + None => p.entries.extend(child), + } + } + loaded[ci].spliced = true; + let l = &mut view.session.lanes[loaded[ci].lane as usize]; + l.parent = Some(parent_lane); + } + + // Splice what's left into the main chain, highest anchor first. + let mut top: Vec = (0..loaded.len()).filter(|&i| !loaded[i].spliced).collect(); + top.sort_by_key(|&i| { + std::cmp::Reverse(anchors.get(&loaded[i].tool_use_id).copied().unwrap_or(0)) + }); + for i in top { + let entries = std::mem::take(&mut loaded[i].entries); + if entries.is_empty() { + continue; + } + let Some(at) = anchors.get(&loaded[i].tool_use_id).copied() else { + continue; // spawn point outside this view + }; + let n = entries.len(); + insert_entries(&mut view.session.entries, &mut anchors, at + 1, entries); + // Turn offsets after the insertion point move with it. + for (_, e) in view.turn_entries.iter_mut() { + if *e > at { + *e += n; + } + } + let l = &mut view.session.lanes[loaded[i].lane as usize]; + l.anchor = Some(at); + 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). + for (lane, n) in count_tools(&view.session.entries) { + view.session.lanes[lane as usize].tool_calls = n; + } } /// Incremental JSONL-record → feed-`Entry` translation (shared by the whole /// file and path views). @@ -380,6 +649,16 @@ struct EntryParser { model: String, /// tool_use id → entry index, to attach results from later user lines. tool_idx: HashMap, + /// `Agent`/`Task` tool_use id → entry index. Kept separately from + /// `tool_idx`, which is *drained* as results attach — the anchor is still + /// needed afterwards to splice the subagent's transcript in. + agent_tools: HashMap, + /// Lane every parsed entry is tagged with (0 = the main chain). + lane: LaneId, + /// Keep `isSidechain` records instead of skipping them. A subagent file + /// (`subagents/agent-.jsonl`) consists *entirely* of such records, so + /// the main-chain skip would yield an empty lane. + keep_sidechain: bool, } impl EntryParser { @@ -388,6 +667,19 @@ impl EntryParser { entries: Vec::new(), model: String::from("(resumed)"), tool_idx: HashMap::new(), + agent_tools: HashMap::new(), + lane: MAIN_LANE, + keep_sidechain: false, + } + } + + /// Parser for one subagent transcript: entries land in `lane` and the + /// sidechain records that make up the file are kept. + fn new_lane(lane: LaneId) -> Self { + Self { + lane, + keep_sidechain: true, + ..Self::new() } } @@ -401,33 +693,31 @@ impl EntryParser { if self.entries.is_empty() { return None; } + // One construction site for a `Session`, so a new field only needs a + // default in `Session::new`. + let mut session = Session::new(uuid.to_string(), self.model); + session.entries = self.entries; Some(HistoryView { - session: Session { - key: uuid.to_string(), - model: self.model, - entries: self.entries, - active: 0, - input_tokens: 0, - output_tokens: 0, - last_activity: std::time::Instant::now(), - tool_ids: HashMap::new(), - last_system_len: None, - last_tools_sig: None, - spawn_model: None, - }, + session, leaf, turn_entries, }) } fn line(&mut self, line: &str) { + let lane = self.lane; let entries = &mut self.entries; let tool_idx = &mut self.tool_idx; + let agent_tools = &mut self.agent_tools; let Ok(v) = serde_json::from_str::(line) else { return; }; - // Skip subagent transcripts and synthetic/meta user lines. - if v.get("isSidechain").and_then(Value::as_bool) == Some(true) + // Skip synthetic/meta user lines, and — for the main chain — subagent + // records. `new_lane` parsers keep the latter: an agent file is made of + // nothing else. (Claude Code ≥2.1.2x writes subagents to their own + // files, so the main-chain guard is also belt-and-braces for older + // transcripts that inlined them.) + if (!self.keep_sidechain && v.get("isSidechain").and_then(Value::as_bool) == Some(true)) || v.get("isMeta").and_then(Value::as_bool) == Some(true) { return; @@ -443,57 +733,44 @@ impl EntryParser { }; for b in blocks { let entry = match b.get("type").and_then(Value::as_str) { - Some("thinking") => Some(Entry { - kind: Kind::Thinking, - content: text_of(b, "thinking"), - done: true, - result: None, - }), - Some("redacted_thinking") => Some(Entry { - kind: Kind::Thinking, - content: "[redacted]".into(), - done: true, - result: None, - }), - Some("text") => Some(Entry { - kind: Kind::Text, - content: text_of(b, "text"), - done: true, - result: None, - }), + Some("thinking") => { + Some(Entry::done(Kind::Thinking, text_of(b, "thinking"))) + } + Some("redacted_thinking") => { + Some(Entry::done(Kind::Thinking, "[redacted]".into())) + } + Some("text") => Some(Entry::done(Kind::Text, text_of(b, "text"))), Some("tool_use" | "server_tool_use" | "mcp_tool_use") => { + let tool_name = b.get("name").and_then(Value::as_str).unwrap_or(""); if let Some(id) = b.get("id").and_then(Value::as_str) { tool_idx.insert(id.to_string(), entries.len()); + if crate::app::is_agent_tool(tool_name) { + agent_tools.insert(id.to_string(), entries.len()); + } } let input = b.get("input").map_or(String::new(), |i| { serde_json::to_string_pretty(i).unwrap_or_default() }); - Some(Entry { - kind: Kind::Tool { - name: b - .get("name") - .and_then(Value::as_str) - .unwrap_or("tool") - .to_string(), - }, - content: input, - done: true, - result: None, - }) + let name = b + .get("name") + .and_then(Value::as_str) + .unwrap_or("tool") + .to_string(); + Some(Entry::done(Kind::Tool { name }, input)) } _ => None, }; if let Some(e) = entry { - entries.push(e); + entries.push(e.in_lane(lane)); } } } Some("user") => match v.pointer("/message/content") { - Some(Value::String(s)) => push_user_text(entries, s), + Some(Value::String(s)) => push_user_text(entries, 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")), + Some("text") => push_user_text(entries, &text_of(b, "text"), lane), Some("tool_result") => { let Some(idx) = b .get("tool_use_id") @@ -529,13 +806,13 @@ fn text_of(b: &Value, key: &str) -> 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, text: &str) { +fn push_user_text(entries: &mut Vec, text: &str, lane: LaneId) { let (reminders, prompt) = crate::app::extract_user_text(text); for r in reminders { - entries.push(Entry { kind: Kind::Reminder, content: r, done: true, result: None }); + entries.push(Entry::done(Kind::Reminder, r).in_lane(lane)); } if !prompt.is_empty() { - entries.push(Entry { kind: Kind::User, content: prompt, done: true, result: None }); + entries.push(Entry::done(Kind::User, prompt).in_lane(lane)); } } @@ -719,6 +996,102 @@ mod tests { } } + /// A subagent transcript is a separate file linked by `toolUseId`; the view + /// must splice it in right after the `Agent` call, in its own lane, with a + /// nested (depth-2) agent inside its parent's lane — and the main chain's + /// turn offsets must survive the insertion. + #[test] + fn subagent_files_splice_into_their_agent_call() { + let dir = std::env::temp_dir().join(format!("ct-agents-{}", std::process::id())); + let subs = dir.join("subagents"); + std::fs::create_dir_all(&subs).unwrap(); + let main = dir.join("s1.jsonl"); + std::fs::write( + &main, + [ + r#"{"type":"user","uuid":"u1","parentUuid":null,"message":{"role":"user","content":"go"}}"#, + r#"{"type":"assistant","uuid":"a1","parentUuid":"u1","message":{"model":"claude-x","content":[{"type":"tool_use","id":"toolu_A","name":"Agent","input":{"subagent_type":"Explore","description":"sweep","prompt":"p"}}]}}"#, + r#"{"type":"user","uuid":"u2","parentUuid":"a1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_A","content":"done"}]}}"#, + r#"{"type":"assistant","uuid":"a2","parentUuid":"u2","message":{"model":"claude-x","content":[{"type":"text","text":"after"}]}}"#, + ] + .join("\n"), + ) + .unwrap(); + // Depth-1 agent: makes its own nested Agent call. + 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":"user","isSidechain":true,"uuid":"s1","parentUuid":null,"message":{"role":"user","content":"p"}}"#, + r#"{"type":"assistant","isSidechain":true,"uuid":"s2","parentUuid":"s1","message":{"model":"claude-y","content":[{"type":"text","text":"child text"}]}}"#, + r#"{"type":"assistant","isSidechain":true,"uuid":"s3","parentUuid":"s2","message":{"model":"claude-y","content":[{"type":"tool_use","id":"toolu_B","name":"Agent","input":{"subagent_type":"oracle","description":"deep","prompt":"q"}}]}}"#, + ] + .join("\n"), + ) + .unwrap(); + // Depth-2 agent, spawned by the first one. + std::fs::write( + subs.join("agent-bbb2.meta.json"), + r#"{"agentType":"oracle","description":"deep","toolUseId":"toolu_B","parentAgentId":"aaa1","spawnDepth":2}"#, + ) + .unwrap(); + std::fs::write( + subs.join("agent-bbb2.jsonl"), + r#"{"type":"assistant","isSidechain":true,"uuid":"n1","parentUuid":null,"message":{"model":"claude-z","content":[{"type":"text","text":"nested text"}]}}"#, + ) + .unwrap(); + + let agents = scan_agents_in(&subs); + assert_eq!(agents.len(), 2); + assert_eq!(agents[0].agent_id, "aaa1"); + assert_eq!(agents[1].parent_agent_id.as_deref(), Some("aaa1")); + + let view = load_file_view(&main, "s1", &agents).expect("view"); + let s = &view.session; + // Lanes: main + the two agents, labelled from their sidecars. + assert_eq!(s.lanes.len(), 3); + assert_eq!(s.lanes[1].title(), "Explore · sweep"); + assert_eq!( + s.lanes[2].parent, + Some(1), + "nested agent hangs off its parent lane" + ); + assert!( + s.lanes[1].finished(), + "a transcript on disk is a finished run" + ); + assert_eq!(s.agent_lanes(), vec![1, 2]); + // Order: the child's entries sit between the Agent call and what + // followed it, and the nested lane sits inside its parent's stretch. + let order: Vec<(LaneId, &str)> = s + .entries + .iter() + .map(|e| (e.lane, e.content.as_str())) + .collect(); + let pos = |needle: &str| { + order + .iter() + .position(|(_, c)| c.contains(needle)) + .unwrap_or_else(|| panic!("missing {needle} in {order:?}")) + }; + // A tool entry's content is its pretty-printed input. + assert!(pos("\"subagent_type\": \"Explore\"") < pos("child text")); + assert!(pos("child text") < pos("nested text")); + assert!(pos("nested text") < pos("after")); + assert_eq!(order[pos("child text")].0, 1); + assert_eq!(order[pos("nested text")].0, 2); + assert_eq!(order[pos("after")].0, MAIN_LANE); + // first_entry is recomputed after splicing, so a lane can anchor. + let first_of = |l: LaneId| s.entries.iter().position(|e| e.lane == l); + assert_eq!(s.lanes[1].first_entry, first_of(1)); + assert_eq!(s.lanes[2].first_entry, first_of(2)); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn materialize_chain_and_stitch() { let tree = build_tree(branched()); @@ -874,12 +1247,12 @@ mod tests { // Parse via the explicit-path seam: no HOME mutation (an unsafe // set_var would race the env reads of concurrently running tests). let uuid = "11111111-2222-3333-4444-555555555555"; - let s = load_file_view(&p, uuid).map(|h| h.session); + let s = load_file_view(&p, uuid, &[]).map(|h| h.session); std::fs::remove_file(&p).ok(); let s = s.expect("history loaded"); assert_eq!(s.key, uuid); - assert_eq!(s.model, "claude-x"); + assert_eq!(s.main().model, "claude-x"); // user prompt, thinking, tool, text — sidechain line skipped. assert_eq!(s.entries.len(), 4); assert!(matches!(s.entries[0].kind, Kind::User)); diff --git a/src/term.rs b/src/term.rs index 66618db..60227b7 100644 --- a/src/term.rs +++ b/src/term.rs @@ -720,6 +720,43 @@ fn conv_color(c: ColorAttribute) -> Option { } } +/// 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. +/// +/// Resolved the way Claude Code layers it: `ANTHROPIC_MODEL`, then +/// project-local, project, and user settings. `None` when nothing sets one +/// (Claude Code then picks for itself). Read fresh on every call — a `/model` +/// during the session must not be answered from a stale cache. +pub fn cc_default_model() -> Option { + if let Ok(m) = std::env::var("ANTHROPIC_MODEL") + && !m.is_empty() + { + return Some(m); + } + let user = std::env::var_os("HOME") + .map(|h| std::path::PathBuf::from(h).join(".claude/settings.json")); + [ + Some(std::path::PathBuf::from(".claude/settings.local.json")), + Some(std::path::PathBuf::from(".claude/settings.json")), + user, + ] + .into_iter() + .flatten() + .find_map(|p| settings_model(&p)) +} + +/// `model` field of one settings file (absent/unreadable/invalid → None). +fn settings_model(path: &std::path::Path) -> Option { + let body = std::fs::read_to_string(path).ok()?; + let v: serde_json::Value = serde_json::from_str(&body).ok()?; + v.get("model")? + .as_str() + .filter(|m| !m.is_empty()) + .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. diff --git a/src/ui.rs b/src/ui.rs index 8a1ee85..8eff68e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,7 +1,9 @@ use crate::app::{ - filter_index, fmt_tokens, Entry, Kind, Session, SharedApp, ToolResult, FILTER_LABELS, + AgentPopup, App, Entry, FILTER_LABELS, Kind, Lane, LaneId, MAIN_LANE, Session, SharedApp, + ToolResult, filter_index, fmt_tokens, }; use crate::term::{EmbeddedTerm, PaneView}; +use ratatui::Frame; use ratatui::crossterm::cursor::SetCursorStyle; use ratatui::crossterm::event::{ self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, @@ -11,10 +13,9 @@ use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::text::{Line, Span, Text}; -use serde_json::Value; use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap}; -use ratatui::Frame; -use std::collections::HashSet; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; /// Embedded `claude` pane state (UI-thread only; the shared App carries just @@ -132,6 +133,25 @@ fn smooth_compact( /// length (the proxy tap shares that mutex — a slow render must not starve /// it), and the feed scrolls correctly past u16::MAX wrapped lines because /// only the visible window of lines is handed to ratatui. +/// One `FeedCache` per lane ever drawn (the main feed and the popup's agent +/// feed have different widths and scrolls, so they must not share cached +/// wrapped heights). Dropped wholesale when the displayed session changes. +#[derive(Default)] +struct FeedCaches { + session: String, + by_lane: HashMap, +} + +impl FeedCaches { + fn get(&mut self, session: &str, lane: LaneId) -> &mut FeedCache { + if self.session != session { + self.session = session.to_string(); + self.by_lane.clear(); + } + self.by_lane.entry(lane).or_default() + } +} + #[derive(Default)] struct FeedCache { session_key: String, @@ -141,6 +161,8 @@ struct FeedCache { /// Live in-memory session vs on-disk transcript (same uuid, different /// entry lists — must not share cache slots). live: bool, + /// Which lane this cache renders (the popup is narrower than the feed). + lane: LaneId, entries: Vec, } @@ -151,6 +173,19 @@ struct CachedEntry { height: usize, } +impl CachedEntry { + /// Placeholder for an entry belonging to another lane: cache slots stay + /// aligned with the session's entry indices (an entry never changes lane, + /// so a placeholder is never re-examined). + fn blank() -> Self { + Self { + fingerprint: (usize::MAX, false, usize::MAX, false, false), + lines: Vec::new(), + height: 0, + } + } +} + /// Cheap change-detector for a cached entry: content only ever grows (or is /// swapped for the pretty-printed form along with `done`), results attach /// once — length + flags capture every mutation the app performs. The trailing @@ -621,12 +656,12 @@ fn event_loop( eui: &mut EmbedUi, ) -> anyhow::Result<()> { let mut sel: Option = None; - let mut cache = FeedCache::default(); + let mut caches = FeedCaches::default(); // Last cursor shape pushed to the outer terminal; re-emit only on change so // a blinking cursor isn't reset to its non-blinking phase every frame. let mut applied_cursor: Option = None; loop { - terminal.draw(|f| draw(f, &app, eui, &mut sel, &mut cache))?; + terminal.draw(|f| draw(f, &app, eui, &mut sel, &mut caches))?; if eui.cursor_shape != applied_cursor { applied_cursor = eui.cursor_shape; let style = eui @@ -668,21 +703,13 @@ fn event_loop( } // Wheel scroll always drives the feed, regardless of which pane has // keyboard focus (the embedded pane gets no mouse forwarding anyway). + // The agent popup is modal, so while it is up the wheel is *its*: + // it moves the picker highlight or scrolls the agent's feed. // Left drag = text selection; the copy happens on release in draw(). if let Event::Mouse(m) = &ev { match m.kind { - MouseEventKind::ScrollUp => { - let mut a = app.lock().unwrap(); - a.follow = false; - a.scroll = a.scroll.saturating_sub(3); - } - MouseEventKind::ScrollDown => { - // follow re-engages automatically when draw() clamps - // the scroll to the bottom. - let mut a = app.lock().unwrap(); - a.follow = false; - a.scroll += 3; - } + MouseEventKind::ScrollUp => wheel(&app, -1), + MouseEventKind::ScrollDown => wheel(&app, 1), MouseEventKind::Down(MouseButton::Left) => { sel = Some(Selection { start: (m.column, m.row), @@ -780,9 +807,7 @@ fn event_loop( let n = FILTER_LABELS.len(); match k.code { KeyCode::Char(' ') => a.filters[sel] = !a.filters[sel], - KeyCode::Up | KeyCode::Char('k') => { - a.filter_popup = Some((sel + n - 1) % n) - } + KeyCode::Up | KeyCode::Char('k') => a.filter_popup = Some((sel + n - 1) % n), KeyCode::Down | KeyCode::Char('j') => a.filter_popup = Some((sel + 1) % n), KeyCode::Char('f') | KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { a.filter_popup = None @@ -811,6 +836,53 @@ fn event_loop( } continue; } + // Agent popup (`A`): modal, so it takes every key while open — + // the picker navigates, an agent feed scrolls. Esc steps back one + // layer (feed → picker → closed), `A`/`q` closes outright. + if a.agent_popup.is_some() { + match k.code { + KeyCode::Up | KeyCode::Char('k') => match a.agent_popup { + Some(AgentPopup::List(_)) => a.agent_popup_move(-1), + _ => { + let t = a.agent_popup_lane(); + a.scroll_col(t, -1); + } + }, + KeyCode::Down | KeyCode::Char('j') => match a.agent_popup { + Some(AgentPopup::List(_)) => a.agent_popup_move(1), + _ => { + let t = a.agent_popup_lane(); + a.scroll_col(t, 1); + } + }, + KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Right | KeyCode::Char('l') => { + a.agent_popup_enter() + } + KeyCode::Esc | KeyCode::Left | KeyCode::Char('h') => a.agent_popup_back(), + KeyCode::Char('A') | KeyCode::Char('q') => a.agent_popup = None, + // Switch agents without a detour through the picker. + KeyCode::Char('[') => a.agent_popup_cycle(false), + KeyCode::Char(']') => a.agent_popup_cycle(true), + KeyCode::PageUp => { + let t = a.agent_popup_lane(); + a.scroll_col(t, -20); + } + KeyCode::PageDown => { + let t = a.agent_popup_lane(); + a.scroll_col(t, 20); + } + KeyCode::Home | KeyCode::Char('g') => { + let t = a.agent_popup_lane(); + a.scroll_col_end(t, false); + } + KeyCode::End | KeyCode::Char('G') => { + let t = a.agent_popup_lane(); + a.scroll_col_end(t, true); + } + _ => {} + } + continue; + } match k.code { KeyCode::Char('q') => return Ok(()), // Esc unwinds one layer: visual mode → turn tree → quit. @@ -884,19 +956,15 @@ fn event_loop( // the feed scrolls with the wheel (or PgUp/PgDn/g/G). KeyCode::Up | KeyCode::Char('k') => a.nav(false), KeyCode::Down | KeyCode::Char('j') => a.nav(true), - KeyCode::PageUp => { - a.follow = false; - a.scroll = a.scroll.saturating_sub(20); - } - KeyCode::PageDown => { - a.follow = false; - a.scroll += 20; - } - KeyCode::Home | KeyCode::Char('g') => { - a.follow = false; - a.scroll = 0; - } - KeyCode::End | KeyCode::Char('G') => a.follow = true, + // A → the subagent popup: one agent opens straight into its + // feed, several show the picker first. Subagents are shown + // nowhere else, so this is also how a finished agent's output + // is read back. + KeyCode::Char('A') => a.toggle_agent_popup(), + KeyCode::PageUp => a.scroll_col(None, -20), + KeyCode::PageDown => a.scroll_col(None, 20), + KeyCode::Home | KeyCode::Char('g') => a.scroll_col_end(None, false), + KeyCode::End | KeyCode::Char('G') => a.scroll_col_end(None, true), _ => {} } } @@ -908,7 +976,7 @@ fn draw( app: &SharedApp, eui: &mut EmbedUi, selection: &mut Option, - cache: &mut FeedCache, + caches: &mut FeedCaches, ) { let mut a = app.lock().unwrap(); // Embedded pane height: nothing when hidden. The pane is meant to be @@ -1008,6 +1076,14 @@ fn draw( let sel = a.selected.min((live_n + stubs.len()).saturating_sub(1)); a.selected = sel; let sel_key = a.selected_key(); + // Agent scroll state belongs to the displayed session: a switch drops it + // (and closes the popup), so a lane id can never inherit another session's + // offset or, worse, point into another session's lane vec. + if a.cols_session != sel_key.clone().unwrap_or_default() { + a.cols_session = sel_key.clone().unwrap_or_default(); + a.lane_cols.clear(); + a.agent_popup = None; + } // Session list: live sessions first, then this directory's past sessions // as dimmed stubs (kept fresh by the scanner thread — no I/O here). The @@ -1048,16 +1124,32 @@ fn draw( }; let id: String = s.key.chars().take(8).collect(); let meta = if is_embed { - format!("{id} · {} · running", short_model(&s.model)) + format!("{id} · {} · running", short_model(&s.main().model)) } else { - format!("{id} · {}", short_model(&s.model)) + format!("{id} · {}", short_model(&s.main().model)) + }; + let title_style = if is_embed { + Style::new().fg(ACCENT).bold() + } else { + white }; - let title_style = if is_embed { Style::new().fg(ACCENT).bold() } else { white }; (s.key.clone(), lead, live_title(s), meta, title_style) } else { let d = &a.disk_sessions[stubs[m - live_n]]; let id: String = d.uuid.chars().take(8).collect(); - (d.uuid.clone(), "· ".dark_gray(), d.label.clone(), id, white) + // ⑂N = subagent transcripts recorded next to this session. + let meta = if d.agents > 0 { + format!("{id} · ⑂{}", d.agents) + } else { + id + }; + ( + d.uuid.clone(), + "· ".dark_gray(), + d.label.clone(), + meta, + white, + ) }; let mut rows: Vec = Vec::new(); for (i, w) in wrap_words(&sanitize(&title), inner_w.saturating_sub(2)) @@ -1153,11 +1245,7 @@ fn draw( } // Feed - let follow = a.follow; - let scroll0 = a.scroll; let filters = a.filters; - let mut new_scroll = scroll0; - let mut new_follow = follow; // Scroll the feed to the highlighted turn's first entry, once per // highlight move (the offset needs the freshly cached entry heights). let scroll_target: Option = match (a.turn_dirty, &hist_view, turn_view) { @@ -1172,6 +1260,10 @@ fn draw( // `n`/`N`: jump to the next/previous user prompt. Taken once, here, before // the feed borrow so we can clear it (offset computed below from the cache). let prompt_jump = a.prompt_jump.take(); + // Keep the agent popup pointed at a lane that exists (a session switch or + // a rebuilt on-disk view can invalidate it). Done before the feed borrow, + // which freezes `a`. + a.validate_agent_popup(); let (feed_session, feed_leaf): (Option<&Session>, Option) = match &hist_view { Some((u, _)) => { let h = a.history.get(u); @@ -1180,193 +1272,85 @@ fn draw( None => (a.sessions.get(sel), None), }; let feed_live = hist_view.is_none(); + // Scroll state written back after the feed borrow ends: the main feed keeps + // using App::scroll/follow, the popup's agent owns an entry in + // App::lane_cols so it can follow its own tail independently. + let mut main_col = (a.scroll, a.follow); + let mut lane_writeback: Option<(LaneId, usize, bool)> = None; + let agent_popup = a.agent_popup; if let Some(s) = feed_session { - let feed_width = right.width.saturating_sub(2); - // (In)validate the render cache: a width change, session switch, or - // a different transcript view of the same session (live vs on-disk, - // another tree path) invalidates everything, otherwise only entries - // whose fingerprint changed (the in-flight one, or a tool entry - // whose result attached) are re-rendered. - if cache.session_key != s.key - || cache.width != feed_width - || cache.leaf != feed_leaf - || cache.live != feed_live - { - cache.session_key = s.key.clone(); - cache.width = feed_width; - cache.leaf = feed_leaf; - cache.live = feed_live; - cache.entries.clear(); - } - for (i, e) in s.entries.iter().enumerate() { - let fp = fingerprint(e, feed_focused); - if cache.entries.get(i).is_none_or(|c| c.fingerprint != fp) { - let lines = entry_lines(e, feed_width, feed_focused); - let height = wrapped_height(&lines, feed_width); - let ce = CachedEntry { fingerprint: fp, lines, height }; - if i < cache.entries.len() { - cache.entries[i] = ce; - } else { - cache.entries.push(ce); - } - } - } - - // Visible (filter-passing) entries and their total wrapped height. - let visible: Vec = s - .entries - .iter() - .enumerate() - .filter(|(_, e)| filters[filter_index(&e.kind)]) - .map(|(i, _)| i) - .collect(); - let total: usize = visible.iter().map(|&i| cache.entries[i].height).sum(); - let height = right.height.saturating_sub(2) as usize; - let max_scroll = total.saturating_sub(height); - new_scroll = if follow { max_scroll } else { scroll0.min(max_scroll) }; - if let Some(target) = scroll_target { - // Pin the highlighted turn's first entry to the viewport top. - new_scroll = visible - .iter() - .take_while(|&&i| i < target) - .map(|&i| cache.entries[i].height) - .sum::() - .min(max_scroll); - new_follow = false; - } - if let Some(down) = prompt_jump { - // Wrapped-row offset of each visible user prompt's first row. - let mut offsets: Vec = Vec::new(); - let mut acc = 0usize; - for &i in &visible { - if matches!(s.entries[i].kind, Kind::User) { - offsets.push(acc); - } - acc += cache.entries[i].height; - } - let pick = if down { - offsets.iter().copied().find(|&o| o > scroll0) - } else { - offsets.iter().rev().copied().find(|&o| o < scroll0) - }; - if let Some(o) = pick { - new_scroll = o.min(max_scroll); - new_follow = false; - } - } - // Reaching the bottom re-engages follow automatically. - if new_scroll >= max_scroll { - new_follow = true; - } - - // Window: hand ratatui only the entries intersecting the viewport, - // with the residual offset into the first one. Scroll state stays - // usize end-to-end, so feeds longer than u16::MAX rows keep working. - let mut lines: Vec> = Vec::new(); - let mut acc = 0usize; // wrapped rows before the current entry - let mut skipped = 0usize; // wrapped rows before the first included entry - let mut included = false; - for &i in &visible { - let h = cache.entries[i].height; - if acc + h <= new_scroll { - acc += h; - continue; // entirely above the viewport - } - if acc >= new_scroll + height { - break; // below the viewport - } - if !included { - skipped = acc; - included = true; - } - lines.extend(cache.entries[i].lines.iter().cloned()); - acc += h; - } - let residual = new_scroll.saturating_sub(skipped); - - let title = if a.show_sessions { - format!( - " {} · in {} · out {} ", - s.model, - fmt_tokens(s.input_tokens), - fmt_tokens(s.output_tokens) - ) - } else { - let id: String = s.key.chars().take(8).collect(); - format!( - " {} · {} · in {} · out {} ", - id, - s.model, - fmt_tokens(s.input_tokens), - fmt_tokens(s.output_tokens) - ) - }; - let p = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); - f.render_widget( - p.block(Block::bordered().title(title).border_style(border_style(feed_focused))) - // residual < first visible entry's height; an entry would - // need >65k wrapped rows of its own to hit the clamp. - .scroll((residual.min(u16::MAX as usize) as u16, 0)), - right, + // The main feed always gets the whole area: a subagent never takes + // space from it (nor interleaves into it — draw_feed filters by lane). + // Agents live in the popup drawn on top, below. + let out = draw_feed( + f, + caches.get(&s.key, MAIN_LANE), + &FeedArgs { + s, + lane: MAIN_LANE, + area: right, + focused: feed_focused && agent_popup.is_none(), + filters, + live: feed_live, + leaf: feed_leaf, + scroll: main_col.0, + follow: main_col.1, + scroll_target, + prompt_jump, + title: main_title(s, a.show_sessions), + minimap: true, + }, ); - - // Right-border overlay: first `*` markers showing where the user's own - // messages sit in the whole conversation (a minimap of prompts), then - // the scroll thumb painted on top of them where they coincide. - if height > 0 && right.width >= 2 { - let col = right.x + right.width - 1; - // Map a wrapped-row offset within the transcript to a border row. - // When everything fits, offsets are 1:1 with screen rows; once - // scrollable, compress the whole transcript onto the track. - let track_row = |offset: usize| -> u16 { - let r = if total <= height { - offset - } else { - offset * height / total - }; - r.min(height - 1) as u16 - }; - // Markers track focus like the prompt blocks: orange when focused, - // dim grey when not. - let marker_style = if feed_focused { - Style::new().fg(ACCENT).bold() - } else { - Style::new().fg(Color::DarkGray) - }; - let mut acc = 0usize; - { - let buf = f.buffer_mut(); - for &i in &visible { - if matches!(s.entries[i].kind, Kind::User) { - let y = right.y + 1 + track_row(acc); - buf[(col, y)].set_symbol("*").set_style(marker_style); - } - acc += cache.entries[i].height; - } + main_col = (out.scroll, out.follow); + // The subagent popup: 80% of the feed area, centred, drawn over the + // main feed. Either the agent picker, or one agent's whole stream. + match agent_popup { + None => {} + Some(AgentPopup::List(sel)) => { + draw_agent_list(f, popup_rect(right), s, &App::agent_list_of(s), sel); } - - // Scroll thumb: a solid block marking the visible window's position - // within the whole transcript, drawn last so it sits *on top* of a - // marker at the same row. Shown only when scrollable. - if total > height { - let thumb = ((height * height) / total).max(1).min(height); - let max_scroll = total - height; - let thumb_top = if max_scroll == 0 { - 0 - } else { - (new_scroll * (height - thumb)) / max_scroll - }; - let style = if feed_focused { - Style::new().fg(ACCENT) - } else { - Style::new().fg(Color::Gray) - }; - let buf = f.buffer_mut(); - for k in 0..thumb { - let y = right.y + 1 + (thumb_top + k) as u16; - buf[(col, y)].set_symbol("█").set_style(style); - } + // `validate_agent_popup` already dropped a lane this session does + // not have; the bound check makes any disagreement between it and + // the session actually rendered here a blank frame instead of a + // panic (the UI thread holds the mutex the proxy tap needs). + Some(AgentPopup::Feed(lane)) if (lane as usize) < s.lanes.len() => { + let area = popup_rect(right); + // `2/3` in the title: which agent of the session this is, so + // `[`/`]` has somewhere to walk from. + let list = App::agent_list_of(s); + let pos = list + .iter() + .position(|&x| x == lane) + .map(|i| (i + 1, list.len())); + let (scroll, follow) = a.lane_cols.get(&lane).copied().unwrap_or((0, true)); + f.render_widget(Clear, area); + let out = draw_feed( + f, + caches.get(&s.key, lane), + &FeedArgs { + s, + lane, + area, + // The popup is modal: it is where the keys go, so it + // always reads as focused. + focused: true, + filters, + live: feed_live, + leaf: feed_leaf, + scroll, + follow, + // A subagent's stream has no user prompts and no turn + // tree, so neither the minimap nor the turn/prompt + // jumps apply to it. + scroll_target: None, + prompt_jump: None, + title: lane_title(&s.lanes[lane as usize], pos), + minimap: false, + }, + ); + lane_writeback = Some((lane, out.scroll, out.follow)); } + Some(AgentPopup::Feed(_)) => {} } } else { f.render_widget( @@ -1379,8 +1363,11 @@ fn draw( right, ); } - a.scroll = new_scroll; - a.follow = new_follow; + a.scroll = main_col.0; + a.follow = main_col.1; + if let Some((lane, scroll, follow)) = lane_writeback { + a.lane_cols.insert(lane, (scroll, follow)); + } // Embedded claude pane let embed_focused = eui.focused(); @@ -1445,6 +1432,10 @@ fn draw( "space toggle · j/k move · f/esc close" } else if a.model_popup.is_some() { "enter new session · j/k move · esc cancel" + } else if matches!(a.agent_popup, Some(AgentPopup::List(_))) { + "enter watch agent · j/k move · esc/A close" + } else if a.agent_popup.is_some() { + "j/k · PgUp/PgDn · g/G scroll · [/] agent · esc back · A close" } else if embed_focused { "ctrl-↑ feed · ctrl-f fullscreen · ctrl-q quit · F2 hide claude" } else if visual_on { @@ -1456,6 +1447,16 @@ fn draw( } else { "q quit · n new · j/k move · space/→ tree · f filter · c continue · ctrl-↓ attach" }; + // `A` only matters when the displayed session has agents at all — and it is + // the *only* way to see one, so the hint leads the footer instead of + // trailing it, where the long key list gets cut off on narrow terminals. + // Not while the pane has focus: there every key belongs to the child. + let n_agents = a.agent_list().len(); + let keys = if a.agent_popup.is_some() || embed_focused || n_agents == 0 { + keys.to_string() + } else { + format!("A agents ({n_agents}) · {keys}") + }; f.render_widget( Paragraph::new(Line::from(format!(" {} | {keys}", a.status)).dark_gray()), footer, @@ -1611,6 +1612,385 @@ fn draw( } } +/// Share of the feed area the subagent popup covers, per axis. +const POPUP_PCT: u32 = 80; + +/// The subagent popup's rect: `POPUP_PCT` of the *feed* area on both axes, +/// centred over it. Deliberately not the whole main area — the sessions panel +/// keeps its half, and the ring of main feed left visible around the popup +/// says "this is an overlay, not the conversation you were reading". Clamped +/// to `area`, so a tiny terminal simply gets all of it. +fn popup_rect(area: Rect) -> Rect { + let pct = |v: u16, floor: u16| -> u16 { + let scaled = (u32::from(v) * POPUP_PCT / 100) as u16; + scaled.max(floor.min(v)) + }; + let (w, h) = (pct(area.width, 24), pct(area.height, 6)); + Rect { + x: area.x + (area.width.saturating_sub(w)) / 2, + y: area.y + (area.height.saturating_sub(h)) / 2, + width: w, + height: h, + } +} + +/// Title of the main feed: id (unless the sessions panel already shows it), +/// the main chain's model and its token counters. +fn main_title(s: &Session, show_sessions: bool) -> String { + let m = s.main(); + let tokens = format!( + "{} · in {} · out {} ", + m.model, + fmt_tokens(m.input_tokens), + fmt_tokens(m.output_tokens) + ); + if show_sessions { + format!(" {tokens}") + } else { + let id: String = s.key.chars().take(8).collect(); + format!(" {id} · {tokens}") + } +} + +/// Title of an agent's popup feed: activity mark, agent type · description, +/// model, output tokens — plus `2/3` when there are siblings for `[`/`]` to +/// walk to. +fn lane_title(l: &Lane, pos: Option<(usize, usize)>) -> String { + let mut t = format!( + " {} {} · {} · out {}", + lane_mark(l), + l.title(), + short_model(&l.model), + fmt_tokens(l.output_tokens) + ); + if let Some((i, n)) = pos.filter(|&(_, n)| n > 1) { + t.push_str(&format!(" · {i}/{n}")); + } + t.push(' '); + t +} + +/// Activity mark, the same three-way answer the picker sorts on +/// (`Lane::running`): running now, known finished, or neither (idle without a +/// finish signal, or a lane read from disk). +fn lane_mark(l: &Lane) -> &'static str { + if l.running() { + "⟳" + } else if l.finished() { + // A `` confirmed the run is over. + "✓" + } else { + "·" + } +} + +/// The popup's agent picker (shown when the session has more than one agent): +/// two lines per agent — mark + `type · description` over a dimmed +/// model/tools/tokens row — in `App::agent_list_of` order, so the running ones +/// come first and read bright while finished ones stay reachable below. +fn draw_agent_list(f: &mut Frame, area: Rect, s: &Session, lanes: &[LaneId], sel: usize) { + f.render_widget(Clear, area); + let inner_w = area.width.saturating_sub(3) as usize; + let items: Vec = lanes + .iter() + .map(|&i| { + let l = &s.lanes[i as usize]; + let style = if l.running() { + Style::new().fg(ACCENT).bold() + } else { + Style::new().fg(Color::White) + }; + let head = format!(" {} {}", lane_mark(l), l.title()); + let meta = format!( + " {} · {} tools · out {}", + short_model(&l.model), + l.tool_calls, + fmt_tokens(l.output_tokens) + ); + ListItem::new(vec![ + Line::from(truncate_str(&head, inner_w)).style(style), + Line::from(truncate_str(&meta, inner_w)).dark_gray(), + ]) + }) + .collect(); + let running = lanes + .iter() + .filter(|&&i| s.lanes[i as usize].running()) + .count(); + let mut ls = ListState::default(); + ls.select(Some(sel.min(lanes.len().saturating_sub(1)))); + f.render_stateful_widget( + List::new(items) + .block( + Block::bordered() + .title(format!(" agents · {running} of {} running ", lanes.len())) + .border_style(Style::new().fg(ACCENT).bold()), + ) + .highlight_style(Style::new().reversed()), + area, + &mut ls, + ); +} + +/// Everything one feed needs. Bundled because a feed is drawn from two places +/// (the main chain, and one agent inside the popup) with only these differing. +struct FeedArgs<'a> { + s: &'a Session, + lane: LaneId, + area: Rect, + focused: bool, + filters: [bool; FILTER_LABELS.len()], + live: bool, + leaf: Option, + scroll: usize, + follow: bool, + /// Pin a turn's first entry to the top (main feed only). + scroll_target: Option, + /// `n`/`N` prompt jump (main feed only). + prompt_jump: Option, + title: String, + /// Draw the user-prompt minimap on the right border (main feed only). + minimap: bool, +} + +struct FeedOut { + scroll: usize, + follow: bool, +} + +/// Render one lane of a session as a scrollable feed, returning its clamped +/// scroll state. Only the entries of `args.lane` are considered — that filter +/// is what keeps subagents out of the main feed entirely, and what lets the +/// popup show one agent's stream as its own conversation, following its own +/// tail. +fn draw_feed(f: &mut Frame, cache: &mut FeedCache, args: &FeedArgs) -> FeedOut { + let mut new_scroll = args.scroll; + let mut new_follow = args.follow; + let s = args.s; + if args.area.height < 3 || args.area.width < 4 { + return FeedOut { + scroll: new_scroll, + follow: new_follow, + }; + } + let feed_width = args.area.width.saturating_sub(2); + // (In)validate the render cache: a width change, session switch, or + // a different transcript view of the same session (live vs on-disk, + // another tree path) invalidates everything, otherwise only entries + // whose fingerprint changed (the in-flight one, or a tool entry + // whose result attached) are re-rendered. + if cache.session_key != s.key + || cache.width != feed_width + || cache.leaf != args.leaf + || cache.live != args.live + || cache.lane != args.lane + { + cache.session_key = s.key.clone(); + cache.width = feed_width; + cache.leaf = args.leaf; + cache.live = args.live; + cache.lane = args.lane; + cache.entries.clear(); + } + for (i, e) in s.entries.iter().enumerate() { + if e.lane != args.lane { + if cache.entries.len() <= i { + cache.entries.push(CachedEntry::blank()); + } + continue; + } + let fp = fingerprint(e, args.focused); + if cache.entries.get(i).is_none_or(|c| c.fingerprint != fp) { + let lines = entry_lines(e, feed_width, args.focused); + let height = wrapped_height(&lines, feed_width); + let ce = CachedEntry { + fingerprint: fp, + lines, + height, + }; + if i < cache.entries.len() { + cache.entries[i] = ce; + } else { + cache.entries.push(ce); + } + } + } + + // Visible (filter-passing) entries of this lane and their total height. + let visible: Vec = s + .entries + .iter() + .enumerate() + .filter(|(_, e)| e.lane == args.lane && args.filters[filter_index(&e.kind)]) + .map(|(i, _)| i) + .collect(); + let total: usize = visible.iter().map(|&i| cache.entries[i].height).sum(); + let height = args.area.height.saturating_sub(2) as usize; + let max_scroll = total.saturating_sub(height); + new_scroll = if args.follow { + max_scroll + } else { + args.scroll.min(max_scroll) + }; + if let Some(target) = args.scroll_target { + // Pin the highlighted turn's first entry to the viewport top. + new_scroll = visible + .iter() + .take_while(|&&i| i < target) + .map(|&i| cache.entries[i].height) + .sum::() + .min(max_scroll); + new_follow = false; + } + if let Some(down) = args.prompt_jump { + // Wrapped-row offset of each visible user prompt's first row. + let mut offsets: Vec = Vec::new(); + let mut acc = 0usize; + for &i in &visible { + if matches!(s.entries[i].kind, Kind::User) { + offsets.push(acc); + } + acc += cache.entries[i].height; + } + let pick = if down { + offsets.iter().copied().find(|&o| o > args.scroll) + } else { + offsets.iter().rev().copied().find(|&o| o < args.scroll) + }; + if let Some(o) = pick { + new_scroll = o.min(max_scroll); + new_follow = false; + } + } + // Reaching the bottom re-engages follow automatically. + if new_scroll >= max_scroll { + new_follow = true; + } + + // Window: hand ratatui only the entries intersecting the viewport, + // with the residual offset into the first one. Scroll state stays + // usize end-to-end, so feeds longer than u16::MAX rows keep working. + let mut lines: Vec> = Vec::new(); + let mut acc = 0usize; // wrapped rows before the current entry + let mut skipped = 0usize; // wrapped rows before the first included entry + let mut included = false; + for &i in &visible { + let h = cache.entries[i].height; + if acc + h <= new_scroll { + acc += h; + continue; // entirely above the viewport + } + if acc >= new_scroll + height { + break; // below the viewport + } + if !included { + skipped = acc; + included = true; + } + lines.extend(cache.entries[i].lines.iter().cloned()); + acc += h; + } + let residual = new_scroll.saturating_sub(skipped); + + let border = if args.focused { + Style::new().fg(ACCENT).bold() + } else { + Style::new().dark_gray() + }; + let p = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); + f.render_widget( + p.block( + Block::bordered() + .title(args.title.clone()) + .border_style(border), + ) + // residual < first visible entry's height; an entry would + // need >65k wrapped rows of its own to hit the clamp. + .scroll((residual.min(u16::MAX as usize) as u16, 0)), + args.area, + ); + + // Right-border overlay: first `*` markers showing where the user's own + // messages sit in the whole conversation (a minimap of prompts), then + // the scroll thumb painted on top of them where they coincide. + if height > 0 && args.area.width >= 2 { + let col = args.area.x + args.area.width - 1; + // Map a wrapped-row offset within the transcript to a border row. + // When everything fits, offsets are 1:1 with screen rows; once + // scrollable, compress the whole transcript onto the track. + let track_row = |offset: usize| -> u16 { + let r = if total <= height { + offset + } else { + offset * height / total + }; + r.min(height - 1) as u16 + }; + // Markers track focus like the prompt blocks: orange when focused, + // dim grey when not. + let marker_style = if args.focused { + Style::new().fg(ACCENT).bold() + } else { + Style::new().fg(Color::DarkGray) + }; + if args.minimap { + let mut acc = 0usize; + let buf = f.buffer_mut(); + for &i in &visible { + if matches!(s.entries[i].kind, Kind::User) { + let y = args.area.y + 1 + track_row(acc); + buf[(col, y)].set_symbol("*").set_style(marker_style); + } + acc += cache.entries[i].height; + } + } + + // Scroll thumb: a solid block marking the visible window's position + // within the whole transcript, drawn last so it sits *on top* of a + // marker at the same row. Shown only when scrollable. + if total > height { + let thumb = ((height * height) / total).max(1).min(height); + let max_scroll = total - height; + let thumb_top = if max_scroll == 0 { + 0 + } else { + (new_scroll * (height - thumb)) / max_scroll + }; + let style = if args.focused { + Style::new().fg(ACCENT) + } else { + Style::new().fg(Color::Gray) + }; + let buf = f.buffer_mut(); + for k in 0..thumb { + let y = args.area.y + 1 + (thumb_top + k) as u16; + buf[(col, y)].set_symbol("█").set_style(style); + } + } + } + FeedOut { + scroll: new_scroll, + follow: new_follow, + } +} + +/// One wheel notch, `dir` = -1 up / +1 down. The agent popup is modal, so +/// while it is open the notch is its: the picker takes it as a highlight move, +/// an agent feed as a scroll. No rect hit-testing — a modal owns the wheel. +/// Otherwise the main feed scrolls, wherever the pointer sits. +fn wheel(app: &SharedApp, dir: isize) { + let mut a = app.lock().unwrap(); + match a.agent_popup { + Some(AgentPopup::List(_)) => a.agent_popup_move(dir), + // follow re-engages automatically when draw() clamps the scroll to + // the bottom. + _ => { + let target = a.agent_popup_lane(); + a.scroll_col(target, dir * 3); + } + } +} + /// Copy text to the system clipboard via the OSC 52 escape sequence (works /// over SSH; the outer terminal must support it, as it must for Claude Code). fn osc52_copy(text: &str) { @@ -1652,7 +2032,7 @@ fn live_title(s: &Session) -> String { if s.entries.is_empty() { "(new session)".into() } else { - short_model(&s.model) + short_model(&s.main().model) } } @@ -1768,6 +2148,25 @@ fn render_tool<'a>( } push_result(out, result, None); } + // Subagent spawn. The child's own stream is never in this feed (it is + // the `A` popup's), so the main chain shows only what was delegated + // (type, description, opening line of the prompt) and the report that + // came back — which is all the parent model ever saw of it. + "agent" | "task" => { + let kind = sf("subagent_type").unwrap_or("agent"); + let mut head = vec![ + format!("⚙ Agent({kind}) ").yellow().bold(), + sf("description").unwrap_or_default().to_string().bold(), + ]; + if input.get("run_in_background").and_then(Value::as_bool) == Some(true) { + head.push(" (background)".dark_gray()); + } + out.push(Line::from(head)); + if let Some(prompt) = sf("prompt") { + out.push(Line::from(format!(" → {}", one_line(prompt))).dark_gray()); + } + push_result(out, result, Some(20)); + } "glob" | "grep" => { out.push(Line::from(vec![ format!("⚙ {name} ").yellow().bold(), @@ -2007,13 +2406,38 @@ fn sanitize_md(s: &str) -> String { #[cfg(test)] mod tests { use super::{ - base64, color_on, entry_lines, sanitize_md, smooth_compact, truncate_str, wrap_words, - SHRINK_DELAY, + SHRINK_DELAY, base64, color_on, entry_lines, popup_rect, sanitize_md, smooth_compact, + truncate_str, wrap_words, }; use crate::app::{Entry, Kind}; + use ratatui::layout::Rect; use ratatui::style::Color; use std::time::Instant; + /// The subagent popup covers 80% of the *feed* area, centred — never the + /// sessions panel, and never so small that a tiny terminal loses the view. + #[test] + fn agent_popup_covers_four_fifths_of_the_feed() { + // Feed area offset inside the screen (sessions panel to its left). + let feed = Rect::new(40, 1, 60, 30); + let r = popup_rect(feed); + assert_eq!((r.width, r.height), (48, 24), "80% on both axes"); + // Centred over the feed, so the main feed shows around every edge. + assert_eq!(r.x, 40 + 6); + assert_eq!(r.y, 1 + 3); + assert!(r.x >= feed.x && r.right() <= feed.right()); + assert!(r.y >= feed.y && r.bottom() <= feed.bottom()); + + // Tiny feed: the popup takes all of it rather than collapsing to a + // couple of unusable rows. + let tiny = Rect::new(0, 0, 20, 5); + let r = popup_rect(tiny); + assert_eq!((r.width, r.height), (20, 5)); + // A wide terminal must not overflow the percentage arithmetic. + let wide = popup_rect(Rect::new(0, 0, u16::MAX, 100)); + assert_eq!(wide.width, 52428); + } + /// The compact pane grows on the frame the prompt gets taller, but a /// smaller reading is held back until it has been stable for SHRINK_DELAY — /// so the per-frame wobble during subagent turns / menu filtering doesn't @@ -2059,12 +2483,7 @@ mod tests { /// reaches the terminal verbatim and shifts the cursor, scattering the row. #[test] fn feed_text_strips_control_chars() { - let e = Entry { - kind: Kind::Text, - content: "```\n\tif self.queued:\n\t\treturn\n```".into(), - done: true, - result: None, - }; + let e = Entry::done(Kind::Text, "```\n\tif self.queued:\n\t\treturn\n```".into()); let lines = entry_lines(&e, 60, false); for line in &lines { for span in &line.spans {