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.

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.
English · 中文
<img src="assets/pane.svg" alt="The agents pane beside the transcript" width="760">
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.
| 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 agents | An agent's own subagents sit indented under it |
| Tokens, model and effort | Input (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 loops | Model loops no listed agent claims (a workflow's agents, compaction and memory forks), counted under the list |
| Timeline | The latest batch of agents on one time axis, a bar each: what ran side by side, and what took longest |
| Batch receipt | Once 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 view | The agent you open from Claude Code's footer list is marked ◂ main view |
| Conversations | Click 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 |
| Notices | A toast when an agent finishes, one for several at once |
| Opens and folds itself | Opens when an agent starts, folds 10 s after the last one ends, to a tab above the prompt |
| Theme colours | Claude Code's own theme keys: follows dark, light and colourblind themes |
| Light on redraws | The spinner, shimmer, clocks and timeline run on the terminal's own frame clock; the pane redraws only when something changes |
| Main screen too | Without fullscreen, a summary of up to eight rows above the prompt instead of a side pane |
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
<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)./agentpane opens it.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.In /config, or under pluginConfigs["agentpane"].options in settings.json:
| Option | Default | Meaning |
|---|---|---|
autoOpen | true | open the pane when an agent starts |
foldAfter | 10 | seconds after the last agent finishes before the pane folds; 0 keeps it open |
motion | true | animate the spinner and shimmer; off, they stand still and the clocks still run |
toasts | true | a toast when agents finish |
keepFinished | 8 | finished agents the list keeps (1–30) |
statusLine | true | the running agents under the prompt while the pane is not on screen |
Its footprint, as the awesome-claude-code-mods scan reads it from claude plugin validate:
agentpane watches, and acts only when you press Stop. Every hook passes its event on unchanged.
| It sees | Through |
|---|---|
| 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 reason | turn.step |
| an agent's transcript, while you have it open | $.session.messages({ agentId }) |
| which agent the main view shows | the 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.
2.5× in parallel is that sum over the batch's wall time.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.
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.
MIT © Anji Xu
hooks/register.tsx 1100 lines1import { 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}
1100hooks/time.ts 25 lines1// 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}
25hooks/live.tsx 56 lines1// 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
56hooks/lanes.tsx 52 lines1// 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
52types/index.d.ts 68 lines1/** 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