SLOPSHOPPER

agentpane

A side pane for Claude Code that lists the subagents a session runs, what each is doing and what it costs in tokens, with each one's conversation a click away.

newpanebandguardcommandtoast
★ 3v1.1.4MITupdated 2026-10-04xuanji86/claude-agentpane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agentpane
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ ✻ Agents │ ┃ No agents yet. They show here while they r ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ╷ › /agentpane │ ┃ │ ⎿ agentpane: Agents pane opened. │ ┃ ▸ │ ┃ │ │ ┃ ╵ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩
Pane · Agents
✻ Agents No agents yet. They show here while they run. ╷ │ ▸ │ ╵
README

agentpane

See your subagents at work, beside the conversation.<br> Every subagent a Claude Code session runs, what it is doing right now, what it costs in tokens, and its whole conversation one click away.

Version Claude Code mod License: MIT validates

English · 中文

<img src="assets/pane.svg" alt="The agents pane beside the transcript" width="760">

Why

When Claude fans work out to subagents, the terminal shows one line per agent and a footer list. agentpane is a mod that keeps them in a side pane, drawn the way Claude Code draws its own rows, and opens each one's conversation in place.

Features

Live list⏺ Type(description), the tool call it runs now, +N more tool uses, and ✶ Running… (1m 12s · Opus 5.5 · 412k in · 1.9k out); listed the moment it spawns
Nested agentsAn agent's own subagents sit indented under it
Tokens, model and effortInput (cached share shown), output, context size and requests per agent, from the API's own usage figures; the model and thinking effort each agent's requests actually use; max_tokens in red when a response was cut off
Other loopsModel loops no listed agent claims (a workflow's agents, compaction and memory forks), counted under the list
TimelineThe latest batch of agents on one time axis, a bar each: what ran side by side, and what took longest
Batch receiptOnce a batch ends: ✓ 4 agents in 31s · 1m 18s of agent time (2.5× in parallel) · 21 tool uses · 190k tokens
Status line✻ 2 of 4 agents running under the prompt while the pane is folded or closed
Follows the main viewThe agent you open from Claude Code's footer list is marked ◂ main view
ConversationsClick an agent: its brief, replies in Markdown, tool calls with their results, runs of calls folded into one line
Stop■ Stop, then Confirm stop, ends a running agent through Claude Code's TaskStop
NoticesA toast when an agent finishes, one for several at once
Opens and folds itselfOpens when an agent starts, folds 10 s after the last one ends, to a tab above the prompt
Theme coloursClaude Code's own theme keys: follows dark, light and colourblind themes
Light on redrawsThe spinner, shimmer, clocks and timeline run on the terminal's own frame clock; the pane redraws only when something changes
Main screen tooWithout fullscreen, a summary of up to eight rows above the prompt instead of a side pane

Install

Needs Claude Code 2.1.287 or later, the release that brought mods (function hooks); tested on 2.1.288. The side pane needs the fullscreen layout (/tui fullscreen); on the main screen agentpane is a summary above the prompt. Mods are early access: their API may change between releases, and a release that breaks the pane gets a fix here.

/plugin marketplace add xuanji86/claude-agentpane
/plugin install agentpane@claude-agentpane
claude plugin marketplace add xuanji86/claude-agentpane
claude plugin install agentpane@claude-agentpane

Use

<table> <tr> <td width="60%" valign="top">

A conversation — click an agent

<img src="assets/conversation.svg" alt="One agent's conversation in the widened pane" width="100%">

The pane widens to read it. ▲ ▼ step through it (k j), ← goes back (b); scrolled back, the view holds still while the agent writes on. Click a folded run of tool calls to open it.

</td> <td width="40%" valign="top">

Folded — the ▸ handle on the pane's left edge

<img src="assets/tab.svg" alt="The tab above the prompt" width="100%">

The pane hides and a tab above the prompt keeps the counts. Click it to bring the pane back; a new agent brings it back by itself.

</td> </tr> </table>

  • /agentpane opens or closes the pane.
  • 3 done in the header hides the finished agents (their running children stay in sight).
  • The pane opens by itself where Claude Code seats a pane nobody asked for: at least 144 columns (110 once you have opened it yourself). Below that, /agentpane opens it.
  • The keys b k j work while the pane holds the keyboard: it takes it when you open a conversation (over an empty prompt), Esc gives it back.

Configure

In /config, or under pluginConfigs["agentpane"].options in settings.json:

OptionDefaultMeaning
autoOpentrueopen the pane when an agent starts
foldAfter10seconds after the last agent finishes before the pane folds; 0 keeps it open
motiontrueanimate the spinner and shimmer; off, they stand still and the clocks still run
toaststruea toast when agents finish
keepFinished8finished agents the list keeps (1–30)
statusLinetruethe running agents under the prompt while the pane is not on screen

What it can reach

Its footprint, as the awesome-claude-code-mods scan reads it from claude plugin validate:

reach

agentpane watches, and acts only when you press Stop. Every hook passes its event on unchanged.

It seesThrough
the session's agents and their status$.agent.list(), agent.spawn
each agent's tool calls (name and a short argument)tool.call
each agent's token usage and stop reasonturn.step
an agent's transcript, while you have it open$.session.messages({ agentId })
which agent the main view showsthe pane's view

What it draws outside its pane: the tab above the prompt, toasts, and one status line ($.ui.status).

It makes no network requests, runs no processes, reads and writes no files, stores nothing across sessions and calls no model. Its one action is Stop: Claude Code's own TaskStop, on your second press, naming the agent in a cleaned, shortened line. Text from transcripts is stripped of control, bidi and zero-width characters and cut to length before it is drawn. claude plugin validate . prints exactly what it hooks and calls.

What is inferred, not measured

  • Start times are when the pane first saw the agent: at its spawn, or, for one already running when agentpane loaded, the next poll.
  • Tokens count from when agentpane loaded; an agent that finished before then shows none.
  • Other loops cannot tell a workflow's agent from a compaction fork: both are model loops no listed agent claims.
  • Tool counts are the calls the pane saw, and show only for agents it watched.
  • A batch is the agents that ran with no quiet minute between them; the timeline and the receipt cover the latest. Agent time is the sum of their run times, so 2.5× in parallel is that sum over the batch's wall time.

Limits

  • The main view stays yours. A mod cannot switch Claude Code's main view to an agent; the footer's agent list (↓ then Enter) does that. agentpane shows the conversation in the pane, and marks the one the main view shows.
  • Workflow agents have no names to list: the engine does not hand them to mods. They count under other loops.

Troubleshooting

The pane doesn't appear. Check claude --version is 2.1.287 or later, run /reload-plugins, then /agentpane. Below 144 columns Claude Code won't seat a pane nobody asked for; /agentpane opens it at any width. Look in the transcript for a dim line starting agentpane:: it names a hook that failed or why the pane was refused. Please open an issue with it.

b k j do nothing. The pane needs the keyboard: click in it, or open a conversation over an empty prompt.

Too much motion. Set motion to false in /config.

Develop

git clone https://github.com/xuanji86/claude-agentpane
cd claude-agentpane
claude plugin validate .
claude plugin test .
claude --plugin-dir .        # or add the folder to CLAUDE_CODE_PLUGIN_DIRS

A session that loaded the mod from its folder reloads it each time you save a file. The tests draw the pane on every surface (terminal, desktop, VS Code, mobile) at 40–120 columns. python3 assets/make_previews.py redraws the README pictures. Changes are listed in CHANGELOG.md. Issues and pull requests are welcome.

License

MIT © Anji Xu

Source 5 files
hooks/register.tsx 1100 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, RenderChildren, RenderElement, SessionMessage, TurnUsage } from 'claude-code'
3
4import type { AgentpaneAgent as Agent, AgentpaneLoop as Loop, AgentpaneTokens as Tokens } from '../types'
5import { fmtDuration, laneRows } from './time'
6import type { Lane } from './time'
7
8const PANE = 'agents'
9const TITLE = 'Agents'
10// lazy: polls the engine's agent list every second, as no agent-status event reaches a mod; move to one if it lands.
11const SYNC_MS = 1_000
12const CONFIRM_MS = 5_000 // a pressed Stop waits this long for the press that confirms it
13const SPAWN_GRACE_MS = 10_000 // a spawn the engine's list does not show yet stays this long
14const LOOP_SHOWN_MS = 60_000 // a model loop no agent claims shows while it made a request this recently
15const LOOP_KEPT_MS = 300_000 // and is forgotten after this long
16const INLINE_ROWS = 8 // the pane above the prompt, where it cannot dock: a summary
17const INLINE_READ_ROWS = 24 // and while it shows one conversation
18const PROMPT_LINES = 4 // a prompt is the agent's brief: its head is enough
19const PREVIEW_LINES = 3 // of a tool's result
20const MAX_BLOCKS = 40 // of a conversation, drawn at most
21// The engine refuses a tree with a text over 10,000 characters, or over 100,000 in all: stay well under.
22const MAX_ARG = 300 // characters of a tool call's argument kept
23const MAX_LINE = 500 // characters of one result line kept
24const MAX_REPLY = 6_000 // characters of one reply drawn as Markdown
25const TEXT_BUDGET = 60_000 // characters of a conversation drawn at once
26const GROUP_BUDGET = 30_000 // characters of one opened run of tool calls: its newest calls
27const GROUP_CALLS = 40 // and at most this many of them
28const PREVIEW_SCAN = 8_192 // characters of a tool's result read for its preview
29const MIN_ROWS = 3
30const BATCH_GAP_MS = 60_000 // an agent started this long after the last one ended begins a new batch
31const MAX_LANES = 8 // rows of the time axis
32const DUR_COLS = 8 // a lane's clock, right-aligned
33
34const agents = atom({ plugin: 'agentpane', key: 'agents' } as const, [])
35const activity = atom({ plugin: 'agentpane', key: 'activity' } as const, {})
36const viewing = atom({ plugin: 'agentpane', key: 'viewing' } as const, null)
37const scrollTop = atom({ plugin: 'agentpane', key: 'scrollTop' } as const, null)
38const rev = atom({ plugin: 'agentpane', key: 'rev' } as const, 0)
39const expanded = atom({ plugin: 'agentpane', key: 'expanded' } as const, [])
40const folded = atom({ plugin: 'agentpane', key: 'folded' } as const, false)
41const tab = atom({ plugin: 'agentpane', key: 'tab' } as const, false)
42const tokens = atom({ plugin: 'agentpane', key: 'tokens' } as const, {})
43const loops = atom({ plugin: 'agentpane', key: 'loops' } as const, {})
44const confirmStop = atom({ plugin: 'agentpane', key: 'confirmStop' } as const, null)
45const hideDone = atom({ plugin: 'agentpane', key: 'hideDone' } as const, false)
46
47// Colors are theme keys, so the pane follows the person's theme (dark, light, colorblind) as Claude Code's
48// own rows do. The spinner and shimmer live in live.tsx, on the surface's frame clock.
49const WIDE_SHARE = 0.62 // of the terminal, for the pane while it shows one agent's conversation
50const MIN_WIDE = 60
51const HANDLE_COLS = 2 // the hide handle's column, against the pane's left edge: its glyph and one space
52const RIGHT_MARGIN = 1 // matches that one space on the right, so the content sits centred
53// The handle: a tab several rows tall, each row of it a press target, so a click anywhere on it lands.
54const HANDLE = ['╷ ', '│ ', '▸ ', '│ ', '╵ ']
55const CLOSE_MARK_COLS = 3 // the engine draws its close mark over the end of the pane's first row
56const BAND_RIGHT_PAD = 5 // clear of the engine's [-] band toggle at the band's top right
57
58// The person's settings (`/config`, or pluginConfigs.agentpane.options in settings.json).
59export type Config = { autoOpen: boolean; foldAfterMs: number; motion: boolean; toasts: boolean; keepFinished: number; statusLine: boolean }
60export const parseConfig = (options: unknown): Config => {
61  const o = (options && typeof options === 'object' ? options : {}) as Record<string, unknown>
62  const num = (v: unknown, d: number, lo: number, hi: number) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, Math.round(v))) : d)
63  const bool = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d)
64  return {
65    autoOpen: bool(o.autoOpen, true),
66    foldAfterMs: num(o.foldAfter, 10, 0, 3600) * 1000,
67    motion: bool(o.motion, true),
68    toasts: bool(o.toasts, true),
69    keepFinished: num(o.keepFinished, 8, 1, 30),
70    statusLine: bool(o.statusLine, true),
71  }
72}
73let cfg = parseConfig(undefined) // set by register from the options it is given
74
75// "claude-sonnet-5-5" -> "Sonnet 5.5", "claude-haiku-4-5-20251001" -> "Haiku 4.5"; anything else as given.
76export const prettyModel = (id: string | undefined) => {
77  if (!id) return ''
78  const m = /^claude-([a-z]+)-(\d{1,2})(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/.exec(id)
79  if (!m) return id
80  const [, family = '', major, minor, wide] = m
81  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}${minor ? `.${minor}` : ''}${wide ? ' (1M)' : ''}`
82}
83
84// How hard it is asked to think, as its requests name it: 'high', or a number where the model takes a budget.
85export const prettyEffort = (effort: string | number | undefined) =>
86  effort === undefined || effort === '' ? '' : typeof effort === 'number' ? `effort ${effort}` : effort
87
88// What an agent runs on, for a detail line: 'Opus 5.5 (1M) · high'.
89export const runsOn = (a: { model?: string; effort?: string | number }) =>
90  [prettyModel(a.model), prettyEffort(a.effort)].filter(Boolean).join(' · ')
91
92// How a status reads and shows, wherever an agent's status is drawn.
93export const look = (status: string): { word: string; mark: string; color?: string; dim?: boolean } =>
94  status === 'running' ? { word: 'Running', mark: '✻', color: 'claude' }
95  : status === 'completed' ? { word: 'Done', mark: '✓', color: 'success' }
96  : status === 'failed' ? { word: 'Failed', mark: '✗', color: 'error' }
97  : status === 'killed' ? { word: 'Stopped', mark: '⊘', dim: true }
98  : { word: status, mark: '·', dim: true }
99
100// Terminal columns a string takes: East Asian wide and fullwidth characters and emoji take two.
101// lazy: range table, not full Unicode East Asian Width; upgrade to a generated table if a script draws wrong.
102export const cols = (s: string) => {
103  let n = 0
104  for (const ch of s) {
105    const c = ch.codePointAt(0) ?? 0
106    const wide =
107      (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
108      (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) ||
109      (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1faff) || (c >= 0x20000 && c <= 0x3fffd)
110    n += wide ? 2 : 1
111  }
112  return n
113}
114
115// Cut text to `max` columns with an ellipsis.
116export const fit = (s: string, max: number) => {
117  if (cols(s) <= max) return s
118  let out = ''
119  let n = 1
120  for (const ch of s) {
121    if (n + cols(ch) > max) break
122    n += cols(ch)
123    out += ch
124  }
125  return `${out}…`
126}
127
128// Text from the agents' transcripts: no control, escape or bidi characters; tabs as spaces; reminders the
129// engine injected left out.
130const UNSAFE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g
131export const clean = (s: string) =>
132  s.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').replace(/\t/g, '  ').replace(UNSAFE, '').trim()
133const oneLine = (s: string) => clean(s).replace(/\s+/g, ' ')
134
135// Word-wrap to `width` columns; a word longer than a line is cut where the line ends.
136export const wrap = (text: string, width: number): string[] => {
137  const w = Math.max(8, width)
138  const out: string[] = []
139  for (const para of text.split('\n')) {
140    let line = ''
141    let n = 0
142    for (const ch of para) {
143      if (n + cols(ch) > w) {
144        const cut = line.lastIndexOf(' ')
145        if (cut > 0 && cut >= line.length / 2) {
146          out.push(line.slice(0, cut))
147          line = line.slice(cut + 1)
148        } else {
149          out.push(line)
150          line = ''
151        }
152        n = cols(line)
153      }
154      line += ch
155      n += cols(ch)
156    }
157    out.push(line.trimEnd())
158  }
159  return out
160}
161
162// A tool call in Claude Code's words: "Bash(npm test)", "Read(register.tsx)", "github:list_issues".
163export const toolParts = (tool: string, input: Record<string, unknown>) => {
164  const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
165  const base = (p: string) => p.split(/[\\/]/).pop() ?? p
166  const arg =
167    tool === 'Bash' ? s('command')
168    : ['Read', 'Write', 'Edit', 'NotebookEdit'].includes(tool) ? base(s('file_path') || s('notebook_path'))
169    : tool === 'Grep' || tool === 'Glob' ? s('pattern')
170    : tool === 'WebFetch' ? s('url')
171    : tool === 'WebSearch' ? s('query')
172    : tool === 'Agent' ? s('description')
173    : tool === 'Skill' ? s('skill')
174    : ''
175  return { name: tool.startsWith('mcp__') ? tool.split('__').slice(1).join(':') : tool, arg: fit(oneLine(arg), MAX_ARG) }
176}
177export const describeTool = (tool: string, input: Record<string, unknown>) => {
178  const { name, arg } = toolParts(tool, input)
179  return arg ? `${name}(${arg})` : name
180}
181
182// The engine's list over what the pane knew: when each was first seen, and when it was first seen done.
183export const mergeAgents = (before: readonly Agent[], list: readonly AgentInfo[], now: number): Agent[] => {
184  const prev = new Map(before.map(a => [a.id, a]))
185  const listed = new Set(list.map(a => a.id))
186  // A spawn the hook recorded that the engine's list does not show yet: kept a moment, not dropped.
187  const pending = before.filter(a => !listed.has(a.id) && a.status === 'running' && now - a.firstSeen < SPAWN_GRACE_MS)
188  const merged = list.map(info => {
189    const old = prev.get(info.id)
190    const running = info.status === 'running'
191    const seenRunning = (old?.seenRunning ?? false) || running
192    const endedAt = running ? undefined : (old?.endedAt ?? (seenRunning ? now : undefined))
193    const resumed = running && old?.endedAt !== undefined // ended, then sent a message: a new run from now
194    return {
195      id: info.id, description: info.description, type: info.type, status: info.status,
196      ...(info.name && { name: info.name }), ...(info.parentId && { parentId: info.parentId }),
197      firstSeen: resumed ? now : (old?.firstSeen ?? now), seenRunning, ...(endedAt !== undefined && { endedAt }),
198      ...(old?.model && { model: old.model }), ...(old?.effort !== undefined && { effort: old.effort }),
199    }
200  })
201  return [...merged, ...pending]
202}
203
204// A spawn as the pane records it the moment it starts, before the engine's list shows it.
205export const spawned = (list: readonly Agent[], a: Agent): Agent[] =>
206  list.some(x => x.id === a.id) ? list.map(x => (x.id === a.id ? { ...x, model: a.model ?? x.model } : x)) : [...list, a]
207
208// A model loop no agent claims, one more request.
209export const stepLoop = (all: Readonly<Record<string, Loop>>, id: string, now: number): Record<string, Loop> => {
210  const old = all[id]
211  return { ...all, [id]: { firstSeen: old?.firstSeen ?? now, lastSeen: now, requests: (old?.requests ?? 0) + 1 } }
212}
213
214// What the list shows: every running agent and the most recently finished ones, in the engine's order.
215export const visibleAgents = (all: readonly Agent[], keep = 8): Agent[] => {
216  const ended = (a: Agent, i: number) => [a.endedAt ?? a.firstSeen, i] as const
217  const recent = all
218    .map((a, i) => ({ a, key: ended(a, i) }))
219    .filter(({ a }) => a.status !== 'running')
220    .sort((x, y) => y.key[0] - x.key[0] || y.key[1] - x.key[1])
221    .slice(0, keep)
222    .map(({ a }) => a)
223  return all.filter(a => a.status === 'running' || recent.includes(a))
224}
225
226// Pure: the list as groups, each agent whose parent is not listed heading one with its descendants under
227// it; running groups first. With `onlyRunning`, finished agents are left out and their running children
228// head groups of their own.
229export const arrange = (shown: readonly Agent[], onlyRunning = false) => {
230  const list = onlyRunning ? shown.filter(a => a.status === 'running') : shown
231  const ids = new Set(list.map(a => a.id))
232  const under = (id: string) => {
233    const out: { agent: Agent; depth: number }[] = []
234    const walk = (parent: string, depth: number) => {
235      if (depth > 3) return // a loop in the parents, or a tree deeper than the pane can indent
236      for (const a of list) if (a.parentId === parent) out.push({ agent: a, depth }), walk(a.id, depth + 1)
237    }
238    walk(id, 1)
239    return out
240  }
241  const tops = list.filter(a => !a.parentId || !ids.has(a.parentId)).map(a => ({ agent: a, kids: under(a.id) }))
242  return { running: tops.filter(t => t.agent.status === 'running'), finished: tops.filter(t => t.agent.status !== 'running') }
243}
244
245const elapsed = (a: Agent, now: number) => (a.seenRunning ? fmtDuration((a.endedAt ?? now) - a.firstSeen) : '')
246
247// One response's usage added to an agent's tally; `context` is what its latest request carried.
248export const addUsage = (
249  t: Tokens | undefined,
250  u: Pick<TurnUsage, 'input_tokens' | 'output_tokens' | 'cache_read_input_tokens' | 'cache_creation_input_tokens'>,
251  stopReason: string | null = null,
252): Tokens => {
253  const input = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
254  const truncated = (t?.truncated ?? 0) + (stopReason === 'max_tokens' ? 1 : 0)
255  return {
256    ...(truncated && { truncated }),
257    input: (t?.input ?? 0) + input,
258    cached: (t?.cached ?? 0) + u.cache_read_input_tokens,
259    output: (t?.output ?? 0) + u.output_tokens,
260    context: input + u.output_tokens,
261    requests: (t?.requests ?? 0) + 1,
262  }
263}
264
265// "950", "12.3k", "312k", "1.2M".
266export const fmtTokens = (n: number) =>
267  n < 1000 ? String(n) : n < 99_950 ? `${+(n / 1000).toFixed(1)}k` : n < 999_500 ? `${Math.round(n / 1000)}k` : `${+(n / 1_000_000).toFixed(1)}M`
268
269export type CallState = 'running' | 'ok' | 'error' | 'stopped'
270export type Call = { id: string; name: string; arg: string; state: CallState; result: string[]; more: number }
271export type Block =
272  | { kind: 'prompt'; text: string }
273  | { kind: 'reply'; text: string }
274  | { kind: 'tools'; key: string; calls: Call[] }
275const STATE_COLOR: Record<CallState, string> = { running: 'claude', ok: 'success', error: 'error', stopped: 'inactive' }
276
277// A tool's result as its first lines, each cut to length, and how many more it had.
278// Only the head is cleaned and split; the rest, often megabytes, is counted by its newlines.
279export const preview = (text: string) => {
280  const lines = clean(text.slice(0, PREVIEW_SCAN)).split('\n').map(l => l.trimEnd()).filter(l => l.trim())
281  let rest = 0 // lazy: counts blank lines past the head too; exact would need the whole text split
282  for (let i = text.indexOf('\n', PREVIEW_SCAN); i !== -1; i = text.indexOf('\n', i + 1)) rest++
283  return { result: lines.slice(0, PREVIEW_LINES).map(l => fit(l, MAX_LINE)), more: Math.max(0, lines.length - PREVIEW_LINES) + rest }
284}
285
286// Pure: an agent's conversation as blocks: its brief (and any later message to it), each reply, and each
287// run of tool calls between them as one group. A call left unanswered by an agent that `ended` was cut off.
288export const transcriptBlocks = (msgs: readonly SessionMessage[], ended = false): Block[] => {
289  const out: Block[] = []
290  for (const m of msgs) {
291    const text = clean(m.text)
292    if (m.role === 'user') {
293      if (text) out.push({ kind: 'prompt', text })
294      continue
295    }
296    if (text) out.push({ kind: 'reply', text })
297    for (const use of m.toolUses) {
298      const { name, arg } = toolParts(use.tool, use.input)
299      const state: CallState = use.text === undefined ? (ended ? 'stopped' : 'running') : use.isError ? 'error' : 'ok'
300      const call: Call = { id: use.tool_use_id, name, arg, state, ...(use.text === undefined ? { result: [], more: 0 } : preview(use.text)) }
301      const last = out[out.length - 1]
302      if (last?.kind === 'tools') last.calls.push(call)
303      else out.push({ kind: 'tools', key: use.tool_use_id, calls: [call] })
304    }
305  }
306  return out
307}
308
309// Pure: the blocks to draw, within MAX_BLOCKS and the text budget: from `top` down when scrolled back, the
310// newest otherwise. Always at least one.
311export const pickBlocks = (blocks: readonly Block[], top: number | null) => {
312  const size = (b: Block) =>
313    b.kind === 'tools' ? b.calls.reduce((n, c) => n + c.arg.length + c.result.join('').length + 40, 0) : Math.min(b.text.length, MAX_REPLY) + 40
314  const out: Block[] = []
315  let used = 0
316  const take = (b: Block | undefined, add: (b: Block) => void) => {
317    if (!b || out.length >= MAX_BLOCKS) return false
318    used += size(b)
319    if (used > TEXT_BUDGET && out.length) return false
320    add(b)
321    return true
322  }
323  if (top === null) for (let i = blocks.length - 1; take(blocks[i], b => out.unshift(b)); i--);
324  else for (let i = Math.max(0, top); take(blocks[i], b => out.push(b)); i++);
325  return out
326}
327
328// Pure: the newest calls of an opened run that fit GROUP_BUDGET and GROUP_CALLS (always one), and how many
329// earlier ones are left out: an agent that only calls tools makes one run of hundreds.
330export const lastCalls = (calls: readonly Call[]) => {
331  let used = 0
332  let from = calls.length
333  while (from > 0 && calls.length - from < GROUP_CALLS) {
334    const c = calls[from - 1]!
335    used += c.arg.length + c.result.join('').length + 40
336    if (used > GROUP_BUDGET && from < calls.length) break
337    from--
338  }
339  return { shown: calls.slice(from), hidden: from }
340}
341
342// A run of tool calls in Claude Code's words: "Read 3 files, ran 2 commands".
343const VERBS: Record<string, [string, string, string]> = {
344  Read: ['read', 'file', 'files'], Bash: ['ran', 'command', 'commands'], Edit: ['edited', 'file', 'files'],
345  Write: ['wrote', 'file', 'files'], NotebookEdit: ['edited', 'notebook', 'notebooks'], Grep: ['searched for', 'pattern', 'patterns'],
346  Glob: ['searched for', 'pattern', 'patterns'], WebFetch: ['fetched', 'page', 'pages'], WebSearch: ['searched the web', 'time', 'times'],
347  Agent: ['ran', 'agent', 'agents'],
348}
349export const groupSummary = (calls: readonly Call[]) => {
350  const counts = new Map<string, number>()
351  for (const c of calls) counts.set(c.name, (counts.get(c.name) ?? 0) + 1)
352  const parts = [...counts].map(([name, n]) => {
353    const verb = VERBS[name]
354    return verb ? `${verb[0]} ${n} ${n === 1 ? verb[1] : verb[2]}` : `called ${name}${n > 1 ? ` ${n} times` : ''}`
355  })
356  const text = parts.join(', ')
357  return text.charAt(0).toUpperCase() + text.slice(1)
358}
359const groupState = (calls: readonly Call[]): CallState =>
360  calls.some(c => c.state === 'running') ? 'running'
361  : calls.some(c => c.state === 'error') ? 'error'
362  : calls.some(c => c.state === 'stopped') ? 'stopped'
363  : 'ok'
364
365// An agent's name as the pane, its toasts and its Stop say it: type and description, cleaned and cut.
366const nameOf = (a: Agent, max = 60) => `${fit(oneLine(a.type), 30)}(${fit(oneLine(a.description || a.type), max)})`
367
368// Pure: the toast for agents that finished since the last look, or null when none did.
369export const finishNotice = (before: readonly Agent[], after: readonly Agent[], now: number) => {
370  const ended = after.filter(a => a.status !== 'running' && before.some(b => b.id === a.id && b.status === 'running'))
371  if (!ended.length) return null
372  if (ended.length === 1) {
373    const a = ended[0]!
374    const l = look(a.status)
375    const t = elapsed(a, now)
376    return `${nameOf(a, 40)} ${l.mark} ${l.word.toLowerCase()}${t ? ` · ${t}` : ''}`
377  }
378  const failed = ended.filter(a => a.status === 'failed').length
379  return `✓ ${ended.length} agents finished${failed ? ` · ✗ ${failed} failed` : ''}`
380}
381
382const uses = (n: number) => `${n} tool use${n === 1 ? '' : 's'}`
383
384// Pure: the latest batch, oldest first: the agents that ran, back from the newest to a start more than
385// BATCH_GAP_MS after everything before it had ended.
386export const latestBatch = (all: readonly Agent[]): Agent[] => {
387  const ran = all.filter(a => a.seenRunning).sort((x, y) => x.firstSeen - y.firstSeen)
388  let start = 0
389  let reach = -Infinity // when the agents so far had all ended; never, while one runs
390  ran.forEach((a, i) => {
391    if (a.firstSeen > reach + BATCH_GAP_MS) start = i
392    reach = Math.max(reach, a.status === 'running' ? Infinity : (a.endedAt ?? a.firstSeen))
393  })
394  return ran.slice(start)
395}
396
397// Pure: what a batch of two or more came to, once none of it runs: how long it took, how much agent time
398// that was and so how much ran side by side, and what it used.
399export const batchReceipt = (
400  batch: readonly Agent[],
401  act: Readonly<Record<string, { tools: number }>>,
402  tok: Readonly<Record<string, Tokens>>,
403) => {
404  if (batch.length < 2 || batch.some(a => a.status === 'running')) return null
405  const end = (a: Agent) => a.endedAt ?? a.firstSeen
406  const wall = Math.max(...batch.map(end)) - Math.min(...batch.map(a => a.firstSeen))
407  // An agent's subagents run inside its own time: only the agents at the top of the batch add to it.
408  const work = batch.filter(a => !batch.some(p => p.id === a.parentId)).reduce((n, a) => n + end(a) - a.firstSeen, 0)
409  const count = (s: string) => batch.filter(a => a.status === s).length
410  const tools = batch.reduce((n, a) => n + (act[a.id]?.tools ?? 0), 0)
411  const used = batch.reduce((n, a) => n + (tok[a.id] ? tok[a.id]!.input + tok[a.id]!.output : 0), 0)
412  const together = wall > 0 ? work / wall : 1
413  const who = count('completed') === batch.length
414    ? `${batch.length} agents`
415    : [...new Set(['completed', 'failed', 'killed', ...batch.map(a => a.status)])]
416        .map(s => (count(s) ? `${count(s)} ${look(s).word.toLowerCase()}` : '')).filter(Boolean).join(' · ')
417  return {
418    failed: count('failed') > 0,
419    text: [
420      `${who} in ${fmtDuration(wall)}`,
421      `${fmtDuration(work)} of agent time${together >= 1.2 ? ` (${together.toFixed(1)}× in parallel)` : ''}`,
422      tools ? uses(tools) : '',
423      used ? `${fmtTokens(used)} tokens` : '',
424    ].filter(Boolean).join(' · '),
425  }
426}
427
428// Pure: the status line under the prompt while a batch runs, or undefined to clear it.
429export const statusOf = (batch: readonly Agent[]) => {
430  const running = batch.filter(a => a.status === 'running').length
431  if (!running) return undefined
432  const failed = batch.filter(a => a.status === 'failed').length
433  const of = running === batch.length ? `${running} agent${running === 1 ? '' : 's'} running` : `${running} of ${batch.length} agents running`
434  return `${look('running').mark} ${of}${failed ? ` · ${look('failed').mark} ${failed} failed` : ''}`
435}
436
437// Module state that nothing draws from (a reload resets it to "nothing opened yet").
438let autoOpened = false // the mod opened the pane unasked, rather than the person
439let closedByPerson = false // closed by hand while agents ran: stays shut until a new one starts
440let foldingAway = false // the close under way is a fold: the tab above the prompt brings the pane back
441let foldWhenIdle = false // agents ran while the pane was open: fold it once they are all done
442let isFullscreen: boolean | undefined // from the last drawing: only the fullscreen layout docks a pane
443let lastPlacement: string | null = null
444let lastColumns = 0 // the terminal's width, from the last drawing
445let lastRunningAt = 0
446let armedAt = 0 // when Stop was pressed once
447// The status line as last set, so it is set only when it changes; null: not yet by this module, so a line a
448// reload left behind is cleared on the first sync.
449let lastStatus: string | undefined | null = null
450let viewingId: string | null = null // the conversation on screen, for the events that redraw it
451let transcript = { key: '', blocks: [] as Block[] } // that conversation's blocks, read once per change
452// lazy: one sync at a time by a module flag; a slow agent.list only skips ticks.
453let syncing = false
454// Agents the spawn hook listed since the last sync: new to the pane, though the list already holds them.
455const spawnedSinceSync = new Set<string>()
456
457// Never rejects. Keeps the list current, opens the pane when an agent starts, folds it once they are done.
458async function sync($: EngineInterface) {
459  if (syncing) return
460  syncing = true
461  try {
462    const now = await $.clock.now()
463    // The desktop app has its own agents view: with no terminal drawing, nothing opens or shows unasked there.
464    const surfaces = await $.session.surfaces().catch(() => [])
465    const quiet = surfaces.includes('desktop') && !surfaces.includes('terminal')
466    const info = await $.agent.list()
467    const before = await read($, agents)
468    let list = mergeAgents(before, info, now)
469    // Written over the list as it stands then, so an agent the spawn hook records meanwhile keeps its model.
470    if (JSON.stringify(list) !== JSON.stringify(before)) list = await update($, agents, cur => mergeAgents(cur, info, now))
471    const notice = finishNotice(before, list, now)
472    if (notice && cfg.toasts && !quiet) $.ui.toast(notice)
473    const ids = new Set(list.map(a => a.id))
474    if (Object.keys(await read($, activity)).some(id => !ids.has(id)))
475      await update($, activity, m => Object.fromEntries(Object.entries(m).filter(([id]) => ids.has(id))))
476
477    const running = list.filter(a => a.status === 'running')
478    const pane = (await $.ui.panes()).find(p => p.id === PANE)
479    // A new agent unfolds the pane, whoever folded or closed it, and shows the list.
480    const fresh = running.some(a => spawnedSinceSync.has(a.id) || !before.some(b => b.id === a.id))
481    for (const a of list) spawnedSinceSync.delete(a.id) // a spawn recorded after this list was read waits for the next
482    if (fresh) {
483      closedByPerson = false
484      // With autoOpen off the pane stays shut, and a fold the person made keeps its tab.
485      if (!pane && cfg.autoOpen) await showList($, { clearFold: true })
486    }
487    // The conversation on screen belongs to an agent no longer listed (/clear): back to the list, so it can fold.
488    const open = await read($, viewing)
489    if (open && !ids.has(open)) await showList($)
490    if (running.length) lastRunningAt = now
491    if (pane && running.length) foldWhenIdle = true
492    if (!pane && running.length && cfg.autoOpen && !quiet && !closedByPerson && !(await read($, folded))) {
493      autoOpened = true
494      await openPane($, 'list') // docked beside a fullscreen transcript, else the summary above the prompt
495    } else if (
496      pane && foldWhenIdle && !running.length && cfg.foldAfterMs > 0 && now - lastRunningAt >= cfg.foldAfterMs &&
497      !pane.isFocused && !(await read($, viewing))
498    ) {
499      await foldAway($) // all done; not while the person reads a conversation
500    }
501    // Folded, the tab above the prompt stands in for the pane while the session has agents to show.
502    const showTab = (await read($, folded)) && list.length > 0
503    if (showTab !== (await read($, tab))) await update($, tab, () => showTab)
504    if ((await read($, confirmStop)) && now - armedAt > CONFIRM_MS) await update($, confirmStop, () => null)
505
506    // Loops that turned out to be agents, or went quiet long ago, are forgotten; tokens of neither go too.
507    const loopsNow = await read($, loops)
508    const keptLoops = Object.fromEntries(Object.entries(loopsNow).filter(([id, l]) => !ids.has(id) && now - l.lastSeen < LOOP_KEPT_MS))
509    if (Object.keys(keptLoops).length !== Object.keys(loopsNow).length) await update($, loops, () => keptLoops)
510    const kept = (id: string) => ids.has(id) || id in keptLoops
511    if (Object.keys(await read($, tokens)).some(id => !kept(id)))
512      await update($, tokens, m => Object.fromEntries(Object.entries(m).filter(([id]) => kept(id))))
513
514    // Under the prompt while a batch runs and the pane is not on screen (folded, closed, or waiting for room).
515    const placed = cfg.statusLine && (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced && p.isShown)
516    const status = cfg.statusLine && !placed && !quiet ? statusOf(latestBatch(list)) : undefined
517    if (status !== lastStatus) {
518      lastStatus = status
519      $.ui.status(status)
520    }
521  } catch {
522    // the next tick tries again
523  } finally {
524    syncing = false
525  }
526}
527
528// Back to the list, live, nothing armed; `clearFold` also clears the fold.
529async function showList($: EngineInterface, { clearFold = false } = {}) {
530  await update($, viewing, () => null)
531  await update($, scrollTop, () => null)
532  await update($, expanded, () => [])
533  await update($, confirmStop, () => null)
534  if (clearFold) await update($, folded, () => false)
535}
536
537// The pane sized for what it shows: docked, at its usual width or widened to read a conversation; above the
538// prompt, a summary's rows or a conversation's. `focus` asks for the keyboard (granted over an empty
539// composer), so the conversation's b/k/j keys work.
540async function openPane($: EngineInterface, view: 'list' | 'conversation', focus = false) {
541  const docked = lastPlacement ? lastPlacement === 'dock' : isFullscreen !== false
542  const columns = docked && view === 'conversation' && lastColumns ? Math.max(MIN_WIDE, Math.round(lastColumns * WIDE_SHARE)) : undefined
543  const rows = docked ? undefined : view === 'conversation' ? INLINE_READ_ROWS : INLINE_ROWS
544  await $.ui.open({ id: PANE, title: TITLE, ...(columns && { columns }), ...(rows && { rows }), ...(focus && { focus: true as const }) })
545}
546
547// Fold: the pane closes, so the transcript has the width back, and a tab above the prompt reopens it.
548async function foldAway($: EngineInterface) {
549  await update($, folded, () => true)
550  await update($, tab, () => true)
551  foldingAway = true
552  try {
553    await $.ui.close({ id: PANE })
554  } finally {
555    foldingAway = false
556  }
557}
558
559// The person unfolds it from the tab: theirs now, so it folds again only after agents run in it.
560async function unfold($: EngineInterface) {
561  await update($, folded, () => false)
562  await update($, tab, () => false)
563  closedByPerson = false
564  autoOpened = false
565  foldWhenIdle = (await read($, agents)).some(a => a.status === 'running')
566  await openPane($, (await read($, viewing)) ? 'conversation' : 'list')
567}
568
569// Stop a running agent with Claude Code's own TaskStop, on the person's confirmed press of Stop.
570async function stopAgent($: EngineInterface, a: Agent) {
571  const name = nameOf(a)
572  const done = (await $.tool
573    // The consent reads to the engine as the person's words: the type and id, never the description a model wrote.
574    .call({ tool: 'TaskStop', task_id: a.id, consent: `The user pressed "Stop" on the ${JSON.stringify(fit(oneLine(a.type), 30))} agent ${fit(a.id, 40)} in the agents pane` } as never)
575    .catch((err: unknown) => ({ deny: String(err) }))) as { deny?: string; isError?: boolean; text?: string }
576  const why = done.deny || (done.isError ? oneLine(String(done.text ?? '')) || 'the tool reported an error' : '')
577  if (why) $.ui.toast(`Could not stop ${name}: ${fit(why, 120)}`)
578}
579
580// The conversation on screen changed: draw it again.
581const redraw = ($: EngineInterface, agentId: string | undefined) => {
582  if (agentId && agentId === viewingId) void update($, rev, n => n + 1).catch(() => undefined)
583}
584
585export const register: Register = (on, options) => {
586  cfg = parseConfig(options)
587
588  on('session.start', async ($, e, next) => {
589    await $.command.register({ name: 'agentpane', description: 'Show or hide the pane of running agents' })
590    $.clock.every(SYNC_MS, () => void sync($))
591    return next(e)
592  })
593
594  on('command.run', { command: 'agentpane' }, async $ => {
595    const pane = (await $.ui.panes()).find(p => p.id === PANE)
596    if (pane?.isPlaced) {
597      await $.ui.close({ id: PANE })
598      return { text: 'Agents pane closed.' }
599    }
600    autoOpened = false // opened by hand: the mod leaves it open
601    closedByPerson = false
602    foldWhenIdle = false
603    await update($, folded, () => false)
604    await update($, tab, () => false)
605    await openPane($, 'list', true) // asked for, so placed even where an unasked pane waits
606    return { text: 'Agents pane opened.' }
607  })
608
609  on('ui.close', async ($, e, next) => {
610    const result = await next(e)
611    if (e.id === PANE && !('deny' in result && result.deny)) {
612      foldWhenIdle = false
613      autoOpened = false
614      await update($, confirmStop, () => null)
615      if (!foldingAway) {
616        // closed by hand (its close mark, Esc, ctrl+x x, /agentpane) while agents run: stays shut until a new one starts
617        closedByPerson = (await read($, agents)).some(a => a.status === 'running')
618        await update($, viewing, () => null)
619      }
620    }
621    return result
622  })
623
624  // What each agent is doing: its latest tool call; and its conversation, if on screen, grows.
625  on('tool.call', async ($, e, next) => {
626    const id = e.agentId
627    if (id) {
628      const text = describeTool(e.tool, e as unknown as Record<string, unknown>)
629      void update($, activity, m => ({ ...m, [id]: { text, tools: (m[id]?.tools ?? 0) + 1 } })).catch(() => undefined)
630      redraw($, id)
631    }
632    const result = await next(e)
633    redraw($, id)
634    return result
635  })
636
637  // An agent is listed the moment it starts, with the model it runs on, ahead of the next poll.
638  on('agent.spawn', async ($, e, next) => {
639    const started = await next(e)
640    const id = started.agentId
641    if (id && !started.deny) {
642      const a: Agent = {
643        id, description: e.description, type: e.subagentType, status: 'running',
644        ...(e.parentAgentId && { parentId: e.parentAgentId }), ...(e.name && { name: e.name }),
645        firstSeen: await $.clock.now(), seenRunning: true, ...(started.model && { model: started.model }),
646      }
647      spawnedSinceSync.add(id)
648      void update($, agents, list => spawned(list, a)).catch(() => undefined)
649    }
650    return started
651  })
652
653  // What each agent's responses cost, as the API reported each one; a response grows its conversation. A
654  // loop no listed agent claims (a workflow's agent, a compaction or memory fork) is counted on its own.
655  on('turn.step', async function* ($, e, next) {
656    const result = yield* next(e)
657    const id = e.agentId
658    if (id) {
659      const usage = result?.usage
660      if (usage) void update($, tokens, m => ({ ...m, [id]: addUsage(m[id], usage, result.stopReason) })).catch(() => undefined)
661      const listed = (await read($, agents)).find(a => a.id === id)
662      // The model and effort its requests name, as the engine resolved them: an agent whose spawn named no model (a
663      // forked skill, an inherited model) still shows what it runs on, and an alias the spawn gave ('sonnet') reads as
664      // the real id. Effort is the step's alone (a spawn reports none); absent for a model without it.
665      if (listed && ((e.model && listed.model !== e.model) || listed.effort !== e.effort)) {
666        const ran = { ...(e.model && { model: e.model }), effort: e.effort }
667        void update($, agents, l => l.map(a => (a.id === id ? { ...a, ...ran } : a))).catch(() => undefined)
668      }
669      if (!listed) {
670        const now = await $.clock.now()
671        void update($, loops, l => stepLoop(l, id, now)).catch(() => undefined)
672      }
673    }
674    redraw($, id)
675    return result
676  })
677
678  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
679    const els = $.ui.resolve(e)
680    const { Box, Button, Markdown, Text } = els
681    // Live lines run on the surface's own frame clock where it has one (terminal, desktop); elsewhere they are
682    // static. Every surface's table names Client, but on the others it draws nothing: ask by surface.
683    const Client = (e.surface === 'terminal' || e.surface === 'desktop') && 'Client' in els ? els.Client : null
684    lastPlacement = e.props.placement
685    lastColumns = e.viewport?.columns ?? lastColumns
686    isFullscreen = e.viewport?.isFullscreen ?? isFullscreen
687    const inline = e.props.placement === 'inline' // above the prompt: a summary, no handle
688    const W = Math.max(22, e.props.bodyColumns - (inline ? 0 : HANDLE_COLS) - RIGHT_MARGIN) // the content, right of the handle
689    const viewed = e.props.view?.agentId ?? null // the agent the person has open in the main view
690    const rows = Math.max(MIN_ROWS + 6, e.props.scroll.bodyRows)
691    const now = await $.clock.now()
692    const all = await read($, agents)
693    const act = await read($, activity)
694    const tok = await read($, tokens)
695    const id = await read($, viewing)
696    const agent = id ? all.find(a => a.id === id) : undefined
697    viewingId = agent ? agent.id : null
698    const runningCount = all.filter(a => a.status === 'running').length
699    const doneCount = all.length - runningCount
700    const hint = (text: string) => <Text dimColor wrap="truncate-end">{text}</Text>
701    const alertOf = (a: Agent) => (tok[a.id]?.truncated ? `max_tokens ×${tok[a.id]!.truncated}` : '')
702    // A running agent's spinner, shimmering "Running…" and clock, redrawn by the surface, not by this hook.
703    const liveLine = (a: Agent, pad: string, detail: string) =>
704      Client && a.seenRunning ? (
705        <Client key={`live-${a.id}`} module="./live.tsx" props={{ since: a.firstSeen, now, pad, detail, alert: alertOf(a), motion: cfg.motion, clockOnly: false }} />
706      ) : (
707        <Text wrap="truncate-end">
708          <Text color="claude">{`${pad}  ✻ Running…`}</Text>
709          <Text dimColor>{` (${[elapsed(a, now), detail].filter(Boolean).join(' · ')}`}</Text>
710          {alertOf(a) ? <Text color="error">{` · ${alertOf(a)}`}</Text> : null}
711          <Text dimColor>)</Text>
712        </Text>
713      )
714    const clock = (a: Agent) =>
715      Client && a.status === 'running' && a.seenRunning ? (
716        <Client key={`clock-${a.id}`} module="./live.tsx" props={{ since: a.firstSeen, now, pad: '', detail: '', alert: '', motion: false, clockOnly: true }} />
717      ) : (
718        <Text dimColor>{elapsed(a, now)}</Text>
719      )
720    // The first row, clear of the engine's close mark at its end.
721    const header = (left: RenderChildren, right: RenderChildren) => (
722      <Box width={W - CLOSE_MARK_COLS} justifyContent="space-between">
723        {left}
724        {typeof right === 'string' ? <Text dimColor>{right}</Text> : right}
725      </Box>
726    )
727    // A handle on the pane's left edge, halfway down: ▸ hides the pane. Above the prompt there is no edge to hold.
728    const withHandle = (body: RenderChildren) =>
729      inline ? (
730        <Box flexDirection="column" width={W}>
731          {body}
732        </Box>
733      ) : (
734      <Box flexDirection="row">
735        <Box key="handle" flexDirection="column" width={HANDLE_COLS} flexShrink={0}>
736          {Array.from({ length: Math.max(0, Math.floor((rows - HANDLE.length) / 2)) }, () => <Text> </Text>)}
737          {HANDLE.map((glyph, i) => (
738            <Button
739              key={glyph.startsWith('▸') ? 'collapse' : `collapse-${i}`}
740              label={glyph}
741              plain
742              dimColor
743              hover={{ color: 'claude', bold: true }}
744              onPress={() => foldAway($)}
745            />
746          ))}
747        </Box>
748        <Box flexDirection="column" width={W} height={rows}>
749          {body}
750        </Box>
751      </Box>
752      )
753
754    // One agent's conversation, as Claude Code draws its own: > the brief, ⏺ replies and tool calls, ⎿ results.
755    if (agent) {
756      const key = `${agent.id}:${await read($, rev)}:${agent.status}`
757      if (transcript.key !== key) {
758        const msgs = await $.session.messages({ agentId: agent.id })
759        transcript = { key, blocks: Array.isArray(msgs) ? transcriptBlocks(msgs, agent.status !== 'running') : [] }
760      }
761      const blocks = transcript.blocks
762      const open = new Set(await read($, expanded))
763      const armed = await read($, confirmStop)
764      const savedTop = await read($, scrollTop)
765      const top = savedTop === null ? null : Math.min(savedTop, Math.max(0, blocks.length - 1))
766      const shown = pickBlocks(blocks, top)
767      const newer = top === null ? 0 : blocks.length - top - 1 // blocks after the one at the top
768      const used = tok[agent.id]
769      const l = look(agent.status)
770      const leave = async () => {
771        await showList($)
772        await openPane($, 'list') // back to the usual size
773      }
774      const toggle = (k: string) => update($, expanded, list => (list.includes(k) ? list.filter(x => x !== k) : [...list, k]))
775      const result = (c: Call) =>
776        c.state === 'running' ? [<Text color="claude">{'  ⎿  Running…'}</Text>]
777        : c.state === 'stopped' ? [<Text dimColor>{'  ⎿  Interrupted'}</Text>]
778        : [
779            ...(c.result.length ? c.result : ['(No output)']).map((r, i) => (
780              <Text color={c.state === 'error' ? 'error' : undefined} dimColor={c.state !== 'error'} wrap="truncate-end">
781                {`${i ? '     ' : '  ⎿  '}${r}`}
782              </Text>
783            )),
784            ...(c.more ? [<Text dimColor>{`     … +${c.more} line${c.more === 1 ? '' : 's'}`}</Text>] : []),
785          ]
786      const call = (c: Call) => (
787        <Box flexDirection="column" flexShrink={0}>
788          <Text wrap="truncate-end">
789            <Text color={STATE_COLOR[c.state]}>⏺ </Text>
790            <Text bold>{c.name}</Text>
791            <Text>{c.arg ? `(${c.arg})` : ''}</Text>
792          </Text>
793          {result(c)}
794        </Box>
795      )
796      const block = (b: Block): RenderElement => {
797        if (b.kind === 'prompt') {
798          const lines = wrap(b.text.slice(0, PROMPT_LINES * W * 2), W - 2)
799          return (
800            <Box flexDirection="column" flexShrink={0} marginTop={1}>
801              {lines.slice(0, PROMPT_LINES).map((line, i) => (
802                <Text dimColor wrap="truncate-end">{`${i ? '  ' : '> '}${line}`}</Text>
803              ))}
804              {lines.length > PROMPT_LINES ? <Text dimColor>{'  …'}</Text> : null}
805            </Box>
806          )
807        }
808        if (b.kind === 'reply') {
809          const text = b.text.length > MAX_REPLY ? `${b.text.slice(0, MAX_REPLY)}\n\n… (${b.text.length - MAX_REPLY} more characters)` : b.text
810          return (
811            <Box flexDirection="row" flexShrink={0} marginTop={1}>
812              <Text>⏺ </Text>
813              <Box flexDirection="column" width={W - 2}>
814                <Markdown text={text} />
815              </Box>
816            </Box>
817          )
818        }
819        if (b.calls.length === 1) return <Box flexShrink={0} marginTop={1}>{call(b.calls[0]!)}</Box>
820        const isOpen = open.has(b.key)
821        const opened = lastCalls(b.calls)
822        const live = b.calls.find(c => c.state === 'running')
823        const failed = b.calls.find(c => c.state === 'error')
824        return (
825          <Box key={`grp-${b.key}`} flexDirection="column" flexShrink={0} marginTop={1}>
826            <Box>
827              <Text color={STATE_COLOR[groupState(b.calls)]}>⏺ </Text>
828              <Button key={`group-${b.key}`} label={groupSummary(b.calls)} plain hover={{ color: 'claude' }} onPress={() => toggle(b.key)} />
829              <Text dimColor>{isOpen ? ' (click to collapse)' : ' (click to expand)'}</Text>
830            </Box>
831            {isOpen ? (
832              <Box flexDirection="column" paddingLeft={2}>
833                {opened.hidden ? <Text dimColor>{`… +${opened.hidden} earlier call${opened.hidden === 1 ? '' : 's'}`}</Text> : null}
834                {opened.shown.map(call)}
835              </Box>
836            ) : live ? (
837              <Text color="claude" wrap="truncate-end">{`  ⎿  ${live.name}${live.arg ? `(${live.arg})` : ''} · Running…`}</Text>
838            ) : failed ? (
839              <Text color="error" wrap="truncate-end">{`  ⎿  ${failed.name} failed: ${failed.result[0] ?? ''}`}</Text>
840            ) : null}
841          </Box>
842        )
843      }
844      const arm = async () => {
845        armedAt = await $.clock.now()
846        await update($, confirmStop, () => agent.id)
847      }
848      return withHandle([
849        header(
850          <Box>
851            <Button key="back" label="←" hotkey="b" dimColor onPress={leave} />
852            <Text color={l.color} dimColor={l.dim}> ⏺ </Text>
853            <Text bold>{fit(oneLine(agent.type), 20)}</Text>
854            <Text>{`(${fit(oneLine(agent.description), Math.max(8, W - 42 - cols(agent.type)))})`}</Text>
855          </Box>,
856          <Box>
857            {clock(agent)}
858            {agent.status !== 'running' ? null : armed === agent.id ? (
859              <Button key="stop" label="Confirm stop" variant="primary" onPress={() => update($, confirmStop, () => null).then(() => stopAgent($, agent))} />
860            ) : (
861              <Button key="stop" label="■ Stop" dimColor onPress={arm} />
862            )}
863          </Box>,
864        ),
865        <Text wrap="truncate-end">
866          <Text dimColor>
867            {`  ⎿  ${[l.word, act[agent.id] ? uses(act[agent.id]!.tools) : '', runsOn(agent), viewed === agent.id ? 'also in the main view' : ''].filter(Boolean).join(' · ')}`}
868          </Text>
869          {alertOf(agent) ? <Text color="error">{` · ${alertOf(agent)}`}</Text> : null}
870        </Text>,
871        <Text dimColor wrap="truncate-end">
872          {used
873            ? `     ${fmtTokens(used.input)} in (${fmtTokens(used.cached)} cached) · ${fmtTokens(used.output)} out · context ${fmtTokens(used.context)} · ${used.requests} request${used.requests === 1 ? '' : 's'}`
874            : '     no token figures yet'}
875        </Text>,
876        // Live: the newest blocks, anchored to the bottom (the oldest cut at the top). Scrolled back: from the
877        // block at `top` down, so a block taller than the pane shows its start and new blocks do not move it.
878        <Box height={rows - 5} flexDirection="column" justifyContent={top === null ? 'flex-end' : 'flex-start'} overflow="hidden">
879          {shown.length ? shown.map(block) : <Text dimColor>Its conversation is not available yet.</Text>}
880        </Box>,
881        <Text> </Text>,
882        <Box width={W} justifyContent="space-between">
883          <Box>
884            <Button key="older" label="▲" hotkey="k" dimColor onPress={() => update($, scrollTop, v => Math.max(0, (v ?? blocks.length - 1) - 1))} />
885            <Button
886              key="newer"
887              label="▼"
888              hotkey="j"
889              dimColor
890              onPress={() => update($, scrollTop, v => (v === null || v + 1 >= blocks.length - 1 ? null : v + 1))}
891            />
892            {top === null ? (
893              <Text color="success"> ● live</Text>
894            ) : (
895              <Button key="live" label={newer > 0 ? `⇣ ${newer} newer` : '⇣ live'} onPress={() => update($, scrollTop, () => null)} />
896            )}
897          </Box>
898          {hint(e.props.isFocused ? 'b back · k/j scroll · esc to prompt ' : 'click here for keys b/k/j ')}
899        </Box>,
900      ])
901    }
902
903    // The list, as Claude Code draws an Agent call: ⏺ Type(description), ⎿ what it does, and a status line.
904    const shown = visibleAgents(all, cfg.keepFinished)
905    const hidingDone = await read($, hideDone)
906    const { running, finished } = arrange(shown, hidingDone)
907    const goTo = async (to: string) => {
908      await showList($)
909      await update($, viewing, () => to)
910      await openPane($, 'conversation', true)
911    }
912    // Model loops no agent claims, recently active: a workflow's agents, compaction and memory forks.
913    const quiet = Object.entries(await read($, loops)).filter(([, x]) => now - x.lastSeen < LOOP_SHOWN_MS)
914    const loopsLine = quiet.length
915      ? `⏺ ${quiet.length} other model loop${quiet.length === 1 ? '' : 's'} (workflow agents or forks) · ${quiet.reduce((n, [, x]) => n + x.requests, 0)} requests · ${fmtTokens(quiet.reduce((n, [lid]) => n + (tok[lid]?.input ?? 0), 0))} in`
916      : ''
917    // The latest batch on one time axis (two agents or more, where there is room), and once it has ended,
918    // what it came to.
919    const batch = latestBatch(all)
920    const receipt = batchReceipt(batch, act, tok)
921    const receiptLine = receipt ? (
922      <Text wrap="truncate-end">
923        <Text color={receipt.failed ? 'error' : 'success'}>{receipt.failed ? '✗ ' : '✓ '}</Text>
924        <Text dimColor>{receipt.text}</Text>
925      </Text>
926    ) : null
927    const nameCols = Math.min(30, Math.floor(W * 0.38))
928    const barCols = W - 2 - nameCols - 1 - DUR_COLS
929    const onAxis = batch.length >= 2 && barCols >= 8 ? batch.slice(-Math.min(MAX_LANES, Math.floor(rows / 3))) : []
930    const lanes: Lane[] = onAxis.map(a => {
931      const l = look(a.status)
932      const name = fit(oneLine(a.description || a.type), nameCols)
933      return {
934        name: name + ' '.repeat(Math.max(0, nameCols - cols(name))), mark: l.mark, color: l.color ?? '', dim: !!l.dim,
935        from: a.firstSeen, to: a.status === 'running' ? null : (a.endedAt ?? a.firstSeen),
936      }
937    })
938    const axisTitle = ` timeline${onAxis.length < batch.length ? ` · latest ${onAxis.length} of ${batch.length}` : ''} `
939    const axis = lanes.length ? (
940      <Box flexDirection="column" flexShrink={0} marginTop={1}>
941        <Text dimColor wrap="truncate-end">{`──${axisTitle}${'─'.repeat(Math.max(0, W - 2 - axisTitle.length))}`}</Text>
942        {Client ? (
943          <Client key="lanes" module="./lanes.tsx" props={{ lanes, now, bar: barCols }} />
944        ) : (
945          laneRows(lanes, now, barCols).map((g, i) => (
946            <Text wrap="truncate-end">
947              <Text color={lanes[i]!.color || undefined} dimColor={lanes[i]!.dim}>{`${lanes[i]!.mark} `}</Text>
948              <Text>{lanes[i]!.name}</Text>
949              <Text dimColor>{` ${'·'.repeat(g.before)}`}</Text>
950              <Text color={lanes[i]!.color || undefined} dimColor={lanes[i]!.dim}>{'━'.repeat(g.bar)}</Text>
951              <Text dimColor>{`${'·'.repeat(g.after)}${fmtDuration(g.ms).padStart(DUR_COLS)}`}</Text>
952            </Text>
953          ))
954        )}
955        {receiptLine}
956      </Box>
957    ) : receiptLine ? (
958      <Box flexShrink={0} marginTop={1}>
959        {receiptLine}
960      </Box>
961    ) : null
962    const mark = (a: Agent) => (viewed === a.id ? ' ◂ main view' : '')
963    const counts = <Box key="counts">
964      <Text dimColor>{runningCount ? `${runningCount} running` : ''}</Text>
965      <Text dimColor>{runningCount && doneCount ? ' · ' : ''}</Text>
966      {doneCount ? (
967        <Button key="toggle-done" label={hidingDone ? `${doneCount} done (show)` : `${doneCount} done`} plain dimColor hover={{ color: 'claude', underline: true }} onPress={() => update($, hideDone, v => !v)} />
968      ) : null}
969    </Box>
970
971    // Above the prompt (no fullscreen to dock in): a summary of at most INLINE_ROWS rows.
972    if (inline) {
973      const live = all.filter(a => a.status === 'running') // a finished parent's running children too
974      const SHOW = INLINE_ROWS - 3
975      const ended = (s2: string) => all.filter(a => a.status === s2).length
976      const tally = ['completed', 'failed', 'killed'].map(s2 => (ended(s2) ? `${look(s2).mark} ${ended(s2)} ${look(s2).word.toLowerCase()}` : '')).filter(Boolean)
977      return (
978        <Box flexDirection="column" width={W}>
979          {header(
980            <Text>
981              <Text color="claude">✻ </Text>
982              <Text bold>Agents</Text>
983            </Text>,
984            <Box>
985              {counts}
986              <Text> </Text>
987              <Button key="collapse" label="▾ hide" dimColor onPress={() => foldAway($)} />
988            </Box>,
989          )}
990          {live.slice(0, SHOW).map(a => {
991            const room = Math.max(10, Math.floor(W * 0.45))
992            return (
993              <Box key={`row-${a.id}`} width={W} justifyContent="space-between">
994                <Box>
995                  <Text color="claude">⏺ </Text>
996                  <Button key={`agent-${a.id}`} label={fit(nameOf(a, room), room)} plain hover={{ color: 'claude', underline: true }} onPress={() => goTo(a.id)} />
997                  <Text dimColor wrap="truncate-end">{act[a.id] ? `  ⎿ ${fit(act[a.id]!.text, Math.max(6, W - room - 14))}` : ''}</Text>
998                </Box>
999                {clock(a)}
1000              </Box>
1001            )
1002          })}
1003          {live.length > SHOW ? <Text dimColor>{`  +${live.length - SHOW} more running`}</Text> : null}
1004          {receiptLine ?? (tally.length || loopsLine ? <Text dimColor wrap="truncate-end">{[tally.join(' · '), loopsLine.replace('⏺ ', '')].filter(Boolean).join(' · ')}</Text> : null)}
1005          {shown.length ? null : <Text dimColor>No agents yet.</Text>}
1006        </Box>
1007      )
1008    }
1009    const agentBlock = (a: Agent, depth: number) => {
1010      const pad = '    '.repeat(depth)
1011      const l = look(a.status)
1012      const doing = act[a.id]
1013      const used = tok[a.id]
1014      const t = elapsed(a, now)
1015      const model = runsOn(a)
1016      const lines: RenderChildren[] = [
1017        <Box key={`row-${a.id}`}>
1018          <Text color={l.color} dimColor={l.dim}>{`${pad}⏺ `}</Text>
1019          <Button key={`agent-${a.id}`} label={fit(nameOf(a, W), W - cols(pad) - 2 - cols(mark(a)))} plain hover={{ color: 'claude', underline: true }} onPress={() => goTo(a.id)} />
1020          <Text color="claude">{mark(a)}</Text>
1021        </Box>,
1022      ]
1023      if (a.status === 'running') {
1024        if (doing) {
1025          lines.push(<Text dimColor wrap="truncate-end">{`${pad}  ⎿  ${fit(doing.text, W)}`}</Text>)
1026          if (doing.tools > 1) lines.push(<Text dimColor>{`${pad}     +${doing.tools - 1} more tool use${doing.tools === 2 ? '' : 's'}`}</Text>)
1027        }
1028        lines.push(liveLine(a, pad, [model, used ? `${fmtTokens(used.input)} in` : '', used ? `${fmtTokens(used.output)} out` : ''].filter(Boolean).join(' · ')))
1029      } else {
1030        const detail = [doing ? uses(doing.tools) : '', used ? `${fmtTokens(used.input + used.output)} tokens` : '', model, t].filter(Boolean).join(' · ')
1031        lines.push(
1032          <Text wrap="truncate-end">
1033            <Text color={a.status === 'failed' ? 'error' : undefined} dimColor={a.status !== 'failed'}>{`${pad}  ⎿  ${l.word}${detail ? ` (${detail})` : ''}`}</Text>
1034            {alertOf(a) ? <Text color="error">{` · ${alertOf(a)}`}</Text> : null}
1035          </Text>,
1036        )
1037      }
1038      return lines
1039    }
1040    const group = ({ agent: a, kids }: { agent: Agent; kids: { agent: Agent; depth: number }[] }) => (
1041      <Box flexDirection="column" marginTop={1}>
1042        {agentBlock(a, 0)}
1043        {kids.flatMap(k => agentBlock(k.agent, k.depth))}
1044      </Box>
1045    )
1046    return withHandle(
1047      <Box flexDirection="column" height={rows} justifyContent="space-between">
1048        <Box flexDirection="column" flexShrink={1} overflow="hidden">
1049          {header(
1050            <Text>
1051              <Text color="claude">✻ </Text>
1052              <Text bold>Agents</Text>
1053            </Text>,
1054            counts,
1055          )}
1056          {shown.length ? null : <Text dimColor>No agents yet. They show here while they run.</Text>}
1057          {running.map(group)}
1058          {finished.map(group)}
1059          {loopsLine ? (
1060            <Box marginTop={1}>
1061              <Text dimColor wrap="truncate-end">{loopsLine}</Text>
1062            </Box>
1063          ) : null}
1064        </Box>
1065        <Box flexDirection="column" flexShrink={0}>
1066          {axis}
1067          {hint(shown.length ? `click an agent to open it${doneCount ? ' · click "done" to hide finished ones' : ''}` : '')}
1068        </Box>
1069      </Box>,
1070    )
1071  })
1072
1073  // Folded: a tab at the right edge above the prompt, with the agents' counts, brings the pane back.
1074  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1075    isFullscreen = e.viewport?.isFullscreen ?? isFullscreen
1076    if (e.props.hasSurvey || e.surface === 'desktop') return next(e)
1077    const { Box, Button } = $.ui.resolve(e)
1078    // What the plugins beneath drew stays. Never hand the band itself back: it would not be drawn again
1079    // when the tab comes or goes.
1080    const below = await next(e).catch(() => null)
1081    if (!(await read($, tab))) return <Box flexDirection="column">{below}</Box>
1082    const all = await read($, agents)
1083    const count = (s: string) => all.filter(a => a.status === s).length
1084    const running = count('running')
1085    const label = [
1086      '◂ Agents',
1087      running ? `${look('running').mark} ${running}` : '',
1088      ...['completed', 'failed', 'killed'].map(s => (count(s) ? `${look(s).mark} ${count(s)}` : '')),
1089    ].filter(Boolean).join(' ')
1090    return (
1091      <Box flexDirection="column">
1092        {below}
1093        <Box justifyContent="flex-end" paddingRight={BAND_RIGHT_PAD}>
1094          <Button key="agents-tab" label={label} dimColor onPress={() => unfold($)} />
1095        </Box>
1096      </Box>
1097    )
1098  })
1099}
1100
hooks/time.ts 25 lines
1// Time as the pane draws it, pure: shared by the hooks module and the surface modules.
2
3// As Claude Code's own status lines read a duration: "8s", "1m 13s", "1h 2m".
4export const fmtDuration = (ms: number) => {
5  const s = Math.max(0, Math.floor(ms / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60)
6  return h ? `${h}h ${m % 60}m` : m ? `${m}m ${s % 60}s` : `${s}s`
7}
8
9// One agent on the time axis: when it started, and when it ended (null while it runs).
10export type Lane = { name: string; mark: string; color: string; dim: boolean; from: number; to: number | null }
11
12// Each lane's bar on one axis `width` cells wide, from the first start to now (or, all ended, to the last
13// end): the cells before it, of it (at least one) and after it; and how long it ran.
14export const laneRows = (lanes: readonly Lane[], now: number, width: number) => {
15  if (!lanes.length) return []
16  const start = Math.min(...lanes.map(l => l.from))
17  const end = lanes.some(l => l.to === null) ? now : Math.max(...lanes.map(l => l.to ?? now))
18  const at = (t: number) => Math.round(((t - start) / Math.max(1, end - start)) * width)
19  return lanes.map(l => {
20    const from = Math.min(width - 1, Math.max(0, at(l.from)))
21    const to = Math.min(width, Math.max(from + 1, at(l.to ?? end)))
22    return { before: from, bar: to - from, after: width - to, ms: (l.to ?? end) - l.from }
23  })
24}
25
hooks/live.tsx 56 lines
1// A running agent's live line, drawn on the surface's own frame clock so only this region redraws, not the
2// pane: Claude Code's spinner glyph, a shimmer across "Running…", and a clock. `since` and `now` come from
3// the hooks module's clock; between its redraws the clock counts on from them. `clockOnly` draws the clock
4// alone. Surfaces with no Client (VS Code, mobile) get the hooks module's static line instead.
5import type { ClientModule } from 'claude-code'
6
7import { fmtDuration } from './time'
8
9type Props = { since: number; now: number; pad: string; detail: string; alert: string; motion: boolean; clockOnly: boolean }
10type Ref = { base: number; lastNow: number; ms: number; step: number }
11type State = { ref: Ref }
12
13const STEP_MS = 120 // a frame of the shimmer; the spinner turns every other frame
14const SPIN = ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢']
15const WORD = 'Running…'
16
17const Live: ClientModule<Props, State> = (props, surface) => {
18  const { Text } = surface.elements
19  const ref = surface.state?.ref ?? { base: 0, lastNow: -1, ms: 0, step: 0 }
20  if (props.now !== ref.lastNow) {
21    // A fresh reading from the hooks module: count on from it.
22    ref.lastNow = props.now
23    ref.base = props.now - props.since
24    ref.ms = 0
25  }
26  if (surface.state === undefined) {
27    const step = props.motion && !props.clockOnly ? STEP_MS : 1000
28    surface.setState({ ref })
29    surface.every(step, () => {
30      ref.ms += step
31      ref.step += 1
32      surface.setState({ ref })
33    })
34  }
35  const time = fmtDuration(ref.base + ref.ms)
36  if (props.clockOnly) return <Text dimColor>{time}</Text>
37
38  const glyph = props.motion ? (SPIN[Math.floor(ref.step / 2) % SPIN.length] ?? '✻') : '✻'
39  const at = props.motion ? (ref.step % (WORD.length + 6)) - 3 : -9
40  const lit = (i: number) => Math.abs(i - at) <= 1
41  const chars = [...WORD]
42  return (
43    <Text wrap="truncate-end">
44      <Text color="claude">{`${props.pad}  ${glyph} `}</Text>
45      {chars.map((ch, i) => (
46        <Text color={lit(i) ? 'claudeShimmer' : 'claude'}>{ch}</Text>
47      ))}
48      <Text dimColor>{` (${[time, props.detail].filter(Boolean).join(' · ')}`}</Text>
49      {props.alert ? <Text color="error">{` · ${props.alert}`}</Text> : null}
50      <Text dimColor>)</Text>
51    </Text>
52  )
53}
54
55export default Live
56
hooks/lanes.tsx 52 lines
1// The agents on one time axis, a row each: its bar from when it started to when it ended, and how long it
2// ran. Drawn on the surface's own clock, so a running agent's bar grows and its clock counts without the
3// pane redrawing. Surfaces with no Client (VS Code, mobile) get the hooks module's still copy instead.
4import type { ClientModule } from 'claude-code'
5
6import { fmtDuration, laneRows } from './time'
7import type { Lane } from './time'
8
9type Props = { lanes: Lane[]; now: number; bar: number }
10type Ref = { lastNow: number; ms: number; live: boolean }
11type State = { ref: Ref }
12
13const STEP_MS = 1000
14
15const Lanes: ClientModule<Props, State> = (props, surface) => {
16  const { Box, Text } = surface.elements
17  const ref = surface.state?.ref ?? { lastNow: -1, ms: 0, live: false }
18  if (props.now !== ref.lastNow) {
19    // A fresh reading from the hooks module: count on from it.
20    ref.lastNow = props.now
21    ref.ms = 0
22  }
23  ref.live = props.lanes.some(l => l.to === null)
24  if (surface.state === undefined) {
25    surface.setState({ ref })
26    surface.every(STEP_MS, () => {
27      if (!ref.live) return // all ended: the axis stands still
28      ref.ms += STEP_MS
29      surface.setState({ ref })
30    })
31  }
32  const geo = laneRows(props.lanes, props.now + ref.ms, props.bar)
33  return (
34    <Box flexDirection="column">
35      {props.lanes.map((l, i) => {
36        const g = geo[i]!
37        return (
38          <Text wrap="truncate-end">
39            <Text color={l.color || undefined} dimColor={l.dim}>{`${l.mark} `}</Text>
40            <Text>{l.name}</Text>
41            <Text dimColor>{` ${'·'.repeat(g.before)}`}</Text>
42            <Text color={l.color || undefined} dimColor={l.dim}>{'━'.repeat(g.bar)}</Text>
43            <Text dimColor>{`${'·'.repeat(g.after)}${fmtDuration(g.ms).padStart(8)}`}</Text>
44          </Text>
45        )
46      })}
47    </Box>
48  )
49}
50
51export default Lanes
52
types/index.d.ts 68 lines
1/** An agent as the pane lists it: `$.agent.list()`'s row, with when the pane first saw it and when it ended. */
2export type AgentpaneAgent = {
3  id: string
4  description: string
5  type: string
6  /** `running`, `completed`, `failed`, `killed`, or another of the engine's task statuses. */
7  status: string
8  name?: string
9  parentId?: string
10  /** When the pane first listed it. */
11  firstSeen: number
12  /** Whether the pane saw it running, so `firstSeen` is when it started (to within a second). */
13  seenRunning: boolean
14  /** When the pane first saw it done; absent while it runs, or when it was done before the pane saw it. */
15  endedAt?: number
16  /** The model it runs on: its spawn's report, then the model each of its requests names; absent until either is seen. */
17  model?: string
18  /** How hard its requests ask it to think ('low' … 'max', or a budget number); absent until a request names one. */
19  effort?: string | number
20}
21
22/** The tokens an agent's responses used, summed as the API reported them. */
23export type AgentpaneTokens = {
24  /** Input over all its requests: uncached, cache-read and cache-written together. */
25  input: number
26  /** The part of `input` the prompt cache served. */
27  cached: number
28  output: number
29  /** Its latest request's input and output: how full its context is. */
30  context: number
31  requests: number
32  /** Responses cut off at the output limit (`max_tokens`). */
33  truncated?: number
34}
35
36/** A model loop no listed agent claims: a workflow's agent, a compaction or memory fork. */
37export type AgentpaneLoop = { firstSeen: number; lastSeen: number; requests: number }
38
39/** What an agent is doing: its latest tool call, and how many it has made. */
40export type AgentpaneActivity = { text: string; tools: number }
41
42declare module 'claude-code' {
43  interface PluginState {
44    agentpane: {
45      agents: AgentpaneAgent[]
46      activity: Record<string, AgentpaneActivity>
47      tokens: Record<string, AgentpaneTokens>
48      loops: Record<string, AgentpaneLoop>
49      /** The agent whose conversation the pane shows; null for the list. */
50      viewing: string | null
51      /** The block scrolled back to, drawn at the top; null follows the conversation live. */
52      scrollTop: number | null
53      /** Bumped when the conversation in view grows, so the pane draws it again. */
54      rev: number
55      /** The agent whose Stop was pressed once: the next press stops it. */
56      confirmStop: string | null
57      /** The list shows running agents only. */
58      hideDone: boolean
59      /** The tool-call groups opened in the conversation in view, by their first call's id. */
60      expanded: string[]
61      /** Folded away by its handle: the pane is closed and a tab above the prompt reopens it. */
62      folded: boolean
63      /** Whether that tab shows: folded, with agents running or just finished. */
64      tab: boolean
65    }
66  }
67}
68