Shows your subagents above the prompt as pixel-art Claude crew members: what each one is doing, how far along it is and how many tokens it used.

<h1 align="center">Agent Crew</h1>
See who is doing what, how far along they are, and how long is left.</p>
<img src="docs/demo.gif" alt="Agent Crew in the Claude desktop app: Claude starts alone on the AGENT line, then a Plan, an Explore and a General agent arrive one by one with a helper; they search, read, think and ask for approval, their bars fill up, and each raises a checkered flag when done" width="100%">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a> <img src="https://img.shields.io/badge/Claude%20Code-%E2%89%A5%202.1.293-d97757" alt="Claude Code 2.1.293 or newer"> <a href="https://mods.aidojo.si/mods/bekir1184--agent-crew--agent-crew/"><img src="https://raw.githubusercontent.com/karanb192/awesome-claude-code-mods/main/badges/bekir1184--agent-crew--agent-crew-reach.svg" alt="reach"></a> <a href="https://mods.aidojo.si/mods/bekir1184--agent-crew--agent-crew/"><img src="https://raw.githubusercontent.com/karanb192/awesome-claude-code-mods/main/badges/bekir1184--agent-crew--agent-crew-validates.svg" alt="validates"></a>
/plugin install agent-crew --marketplace bekir1184/agent-crew
That's it. The crew shows up by itself whenever Claude is at work. Want a look right now? Type /crew demo.
Costs nothing. Agent Crew never calls a model and never touches your prompts: it only watches.
While Claude works, the framed line shows what it's doing this very second. When the turn ends it shrinks to one quiet line with the totals. Press the arrow to see what happened.
<img src="docs/agent-line.svg" alt="The AGENT line: searching, reading, thinking, writing, running tests, then a thin line: done, last turn 0:37, 5.6k tokens, 9 tools" width="100%">
When Claude hands work to subagents, they line up below, one row each. Each row names the model it runs on, so you can tell Opus from Haiku at a glance. Helpers started by a subagent tuck in under their parent. Cancelled agents leave the stage right away.
Too busy? The arrow on the title folds the rows away, and – shrinks the whole band to one plain line (▸ brings it back). Prefer it out of the way? ⇥ moves the crew into a narrow pane beside the conversation, one card per agent, and ⇤ brings it back above the prompt. Both choices stick for later sessions.
Each critter holds the tool it's using:
<img src="docs/crew-states.svg" alt="Searching with a magnifier, reading a page, writing in a code window, running a command on a laptop, browsing a globe, getting ready in a hard hat, done with a checkered flag, failed with dizzy eyes, cancelled and asleep" width="100%">
Thinking is where agents spend most of their time, so the critters keep it lively: an hourglass, a thought cloud with a question mark, a lightbulb that lights up. Each one at its own pace.
<img src="docs/thinking.svg" alt="Three critters thinking: one turns an hourglass, one fills a thought cloud with a question mark, one lights a bulb and hops" width="100%">
When an agent stops to ask for permission, it says so: a yellow question bubble, and the title tells you how many are waiting.
<img src="docs/approval.svg" alt="A row turns yellow with Needs approval: Bash rm -r build, then carries on once approved" width="100%">
Every row has an arrow. It slides open everything the row had no room for: the full task, its to-do list, the files it touched, and its tokens. A finished agent also gets a ✕ to send it off the stage.
<img src="docs/details.svg" alt="A row opens into a panel: task, current step, checklist, changed files, token breakdown and run summary" width="100%">
No model is asked and no tokens are spent: Agent Crew simply remembers how long this kind of agent took before.
<img src="docs/estimate.svg" alt="Past run times, the middle one picked, a live countdown, and a re-estimate when the run goes long" width="100%">
A row shows what the agent's own work took: what it read in and what it wrote. Nothing else.
Claude also keeps the whole conversation in a cache and re-reads it on every request. And after a break the cache expires, so the next request writes the whole conversation into it again, which on a long conversation can be hundreds of thousands of tokens at once. Neither is the agent's work, so neither inflates the row: both sit in the details panel, on their own line.
Turn on Show estimated cost in the settings and each row also gets a ≈ dollar figure.
/crew | Hide or show the crew |
/crew demo | A 30-second show: Claude starts alone, the crew arrives, thinks, asks you, and ends with all flags up |
/crew clear | Clear the stage |
Settings live in /plugin configure agent-crew@agent-crew: show Claude's own line (on), show estimated cost (off).
In the terminal there's no pixel art, just a colored dot per row. Press ctrl+x tab to reach the crew, then the letter on an arrow: h for the title, z to minimise, y to move to the side pane, a, b, c for the rows.
Run it from a clone, then check and test it (Claude Code 2.1.293 or newer):
claude --plugin-dir .
claude plugin validate .
claude plugin test .
The animated images in docs/ and site/ are drawn from the mod's own pixel art: node scripts/docs.mjs (Node 25+) redraws them.
hooks/register.tsx 1353 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Activity, Agent, AgentStatus, RecentTool, Step, Tokens, TouchedFile } from '../types'
5import {
6 QUIET_TOOLS, SPRITE_CELLS, SPRITE_H, SPRITE_W, STALE_MS, activityOf, addUsage, barSvg, capAgents, clip, costOf, formatCost,
7 crewSummary, doingText, emptyTokens, statusLabel, fileOf, filesSummary, withFiles, estimateText, formatDuration, formatTokens, workTokens, tokenText, sumTokens, cacheIsNotable, headerSvg, headline, layout,
8 learn, modelShort, modelLabel, redact, sanitizeHistory, statusText, shownProgress, spriteSvg, statusColor, stepsFromTodos, targetOf, textBar, DOT, dotColor, labelSvg, LABEL_W, LABEL_H,
9 tree, typeColor, typeLabel, withActivity, withStepCreated, withStepUpdated,
10} from './draw'
11import type { History, Layout } from './draw'
12
13// ── State ────────────────────────────────────────────────────
14// Everything a drawing reads lives in $.state: it survives a hot reload, and a write redraws
15// whatever read it. The module variables below are caches and buffers; session.start fires
16// again after every reload and rebuilds them.
17const agents = atom({ plugin: 'agent-crew', key: 'agents' } as const, [])
18const main = atom({ plugin: 'agent-crew', key: 'main' } as const, null)
19const hidden = atom({ plugin: 'agent-crew', key: 'hidden' } as const, false)
20const expanded = atom({ plugin: 'agent-crew', key: 'expanded' } as const, [])
21/** The title row's arrow: hides the agent rows below it, leaving the title as the whole view. */
22const collapsed = atom({ plugin: 'agent-crew', key: 'collapsed' } as const, false)
23/** The title's minimise button: the whole band shrinks to one plain line. Kept across sessions in $.store. */
24const minimized = atom({ plugin: 'agent-crew', key: 'minimized' } as const, false)
25/** The crew lives in a narrow pane beside the transcript instead of above the prompt. Kept in $.store. */
26const side = atom({ plugin: 'agent-crew', key: 'side' } as const, false)
27/** Whether the side pane is placed now: until it is, the band keeps showing. */
28const paneUp = atom({ plugin: 'agent-crew', key: 'paneUp' } as const, false)
29/** Bumped on each frame of a sliding details panel: the drawings that read it redraw. */
30const foldFrame = atom({ plugin: 'agent-crew', key: 'foldFrame' } as const, 0)
31/** The side pane's id, and the width it asks for: about 300 px of the desktop's text. */
32const PANE = 'crew'
33const SIDE_COLUMNS = 38
34
35/** Settings from the /config menu (userConfig); a change there reloads the module. */
36let showCost = false
37let showMain = true
38/** The main conversation's key in the hot-path buffer; it is drawn on the title row, never as a row. */
39const MAIN_ID = 'main'
40/** The main turn running now, or null between turns: main-loop events count only while it runs. */
41let mainTurn: string | null = null
42/** What this mod last put on the status line, so it only writes on a change. */
43let lastStatus: string | undefined
44/** The project folder: files under it are shown relative to it. */
45let cwd = ''
46/** How many recent tool calls an agent keeps for its details panel. */
47const RECENT_TOOLS = 5
48
49/** Past run durations per (type, model), mirrored from $.store. */
50let history: History = {}
51
52/** Ids of the agents on stage. Engine-internal loops (compaction, forks) are never touched. */
53const known = new Set<string>()
54
55/**
56 * Hot-path buffer. tool.call and turn.step run for every tool and model request of every
57 * subagent, so they never read or write state: they note what happened here, and the ticker
58 * writes it to $.state once a second. Their only `$` call is a synchronous $.clock.every that
59 * restarts a stopped ticker. A subagent's work is never held up by this mod.
60 */
61type StepOp = { kind: 'create'; id: string; label: string } | { kind: 'update'; change: Record<string, unknown> }
62type Pending = {
63 tokens?: Tokens
64 requests: number
65 tools: number
66 activity?: Activity
67 target?: string
68 /** A whole new to-do list (TodoWrite), and task changes queued after it (TaskCreate / TaskUpdate). */
69 steps?: Step[]
70 stepOps?: StepOp[]
71 /** The tool the agent asked approval for (a string), or null once that was answered. */
72 approval?: string | null
73 /** Tool calls finished since the last flush, for the details panel. */
74 recent?: RecentTool[]
75 files?: TouchedFile[]
76 /** The model a request named: the main conversation learns its model from its requests. */
77 model?: string
78 /** Set by a model request: the agent is running (again, if it was resumed). */
79 revive?: boolean
80 turnId?: string
81}
82const pending = new Map<string, Pending>()
83const pendingFor = (id: string): Pending => {
84 let p = pending.get(id)
85 if (!p) pending.set(id, (p = { requests: 0, tools: 0 }))
86 return p
87}
88
89/** The last turn each agent finished: its buffered events can't bring it back to life. */
90const closedTurn = new Map<string, string>()
91/** Whether $.agent.list() answers here. While it does, silence alone never retires an agent. */
92let listWorks = false
93
94let ticker: { cancel: () => void } | null = null
95let tickBusy = false
96let ticks = 0
97/**
98 * Model requests since the last tick. A clock period another plugin refuses ends the interval
99 * without telling us; this many requests with no tick means the ticker died and is rebuilt.
100 */
101let stepsSinceTick = 0
102const DEAD_TICKER_STEPS = 30
103let demoTick: { cancel: () => void } | null = null
104/**
105 * A demo is on stage. Made for a screen recording, so meanwhile the prompt box shows the demo's
106 * own English hint instead of Claude Code's guess at your next message.
107 */
108let demoOnStage = false
109const DEMO_HINT = '/crew clear'
110/**
111 * On the desktop the band's text column is about twice as roomy as its reported width suggests:
112 * the task box grows to fill the row, so its text may use twice the planned cells before it is cut.
113 */
114const DESKTOP_TEXT_ROOM = 2
115
116/**
117 * Terminal hotkeys, live once the band has the focus (ctrl+x tab): the title's arrow, then one
118 * letter per row in order. Letters only: a digit typed into an empty prompt would also press a
119 * band button, so a message starting with "1." would open a row.
120 */
121const TITLE_KEY = 'h'
122/** The terminal's key for the minimise button. */
123const MIN_KEY = 'z'
124/** The terminal's key for moving the crew to the side pane and back. */
125const SIDE_KEY = 'y'
126const ROW_KEYS = [...'abcdefgijklm']
127
128/** A desktop row is exactly this many text rows tall, with the 34 px sprite centered in it. */
129const ROW_ROWS = 2
130/** Silent this long and missing from the engine's agent list: the agent is gone. */
131const GONE_MS = 30_000
132
133export const register: Register = (on, options) => {
134 showCost = options?.showCost === true
135 showMain = options?.showMain !== false
136
137 // Every hook only observes and ends in `.catch(...)`. tool.call and turn.step, which run for
138 // every tool call and model request, never read or write state; so an error in this mod
139 // never changes what Claude Code does: the event goes on as if the mod were not installed.
140
141 on('session.start', async ($, e, next) => {
142 await $.command.register({
143 name: 'crew',
144 description: 'Agent Crew: show/hide, run a demo, or clear',
145 argumentHint: '[demo | clear]',
146 })
147 cwd = e.cwd ?? ''
148 history = sanitizeHistory(await $.store.get('history').catch(() => null))
149 const wasMinimized = (await $.store.get('minimized').catch(() => null)) === true
150 if (wasMinimized !== (await read($, minimized))) await update($, minimized, () => wasMinimized)
151 // The crew lives above the prompt unless the person moved it to the side pane (⇥). The pane
152 // opens when an agent starts; where the engine doesn't place it (a narrow window), the band shows
153 const inSide = (await $.store.get('side').catch(() => null)) === true
154 if (inSide !== (await read($, side))) await update($, side, () => inSide)
155 // After a reload mid-crew the pane comes back (an unload closed it)
156 if ((await read($, agents)).some(a => a.status === 'running')) await openPane($).catch(() => undefined)
157 const now = await $.clock.now()
158 const listed = await listAgents($)
159 // A demo's timer died with the previous module; an agent that ended unreported is closed
160 await update($, agents, list =>
161 list.map(a => {
162 if (a.status !== 'running') return a
163 const ended = a.demo ? 'cancelled' : verdict(a, now, listed)
164 return ended ? { ...a, status: ended, endedAt: now, retired: true as const } : a
165 }),
166 )
167 const current = await read($, agents)
168 known.clear()
169 for (const a of current) known.add(a.id)
170 // After a reload the main turn is read back from the state: its turn.complete still comes.
171 // A demo's main line died with its timer.
172 const m = await read($, main)
173 if (m?.demo) await update($, main, () => null)
174 mainTurn = m && !m.demo ? (m.turnId ?? null) : null
175 if (current.some(a => a.status === 'running') || mainTurn) startTicker($)
176 return next(e)
177 }).catch(($, e, next) => next(e))
178
179 // 0) The main conversation starts a turn: the title row shows Claude's own work
180 on('turn.start', async ($, e, next) => {
181 if (!showMain) return next(e)
182 // Tracked before any await: the turn's first request may arrive during the next one
183 mainTurn = e.turnId
184 pending.delete(MAIN_ID)
185 const now = await $.clock.now()
186 const record: Agent = {
187 id: MAIN_ID,
188 type: 'main',
189 description: clip(redact(e.text.replace(/\s+/g, ' ').trim()), 80),
190 model: '',
191 status: 'running',
192 activity: 'thinking',
193 target: '',
194 activityAt: now,
195 seenAt: now,
196 startedAt: now,
197 tokens: emptyTokens(),
198 tools: 0,
199 requests: 0,
200 turnId: e.turnId,
201 }
202 await update($, main, () => record)
203 startTicker($)
204 return next(e)
205 }).catch(($, e, next) => next(e))
206
207 // 1) A subagent started
208 on('agent.spawn', async ($, e, next) => {
209 const result = await next(e)
210 if (result.agentId === undefined) return result
211 // Tracked before any await: the subagent's first request may arrive during the next one
212 known.add(result.agentId)
213 const now = await $.clock.now()
214 const agent: Agent = {
215 id: result.agentId,
216 type: e.subagentType,
217 description: clip(redact((e.description || e.prompt).replace(/\s+/g, ' ').trim()), 80),
218 model: result.model,
219 status: 'running',
220 activity: 'starting',
221 target: '',
222 activityAt: now,
223 seenAt: now,
224 startedAt: now,
225 tokens: emptyTokens(),
226 tools: 0,
227 requests: 0,
228 parentId: e.parentAgentId,
229 }
230 const before = await read($, agents)
231 // "Hide" hides this crew: when a new crew starts with nobody running, the stage comes back
232 // A new crew starts open: the rows show until you fold them away with the title's arrow
233 if (!before.some(a => a.status === 'running')) {
234 await update($, hidden, () => false)
235 await update($, collapsed, () => false)
236 const folded = await read($, collapsed)
237 if (folded) await update($, collapsed, () => false)
238 }
239 await update($, agents, list => capAgents([...list.filter(a => a.id !== agent.id), agent]))
240 // Agents the cap removed are no longer tracked
241 const kept = await read($, agents)
242 known.clear()
243 for (const a of kept) known.add(a.id)
244 for (const id of closedTurn.keys()) if (!known.has(id)) closedTurn.delete(id)
245 startTicker($)
246 await openPane($).catch(() => undefined)
247 return result
248 }).catch(($, e, next) => next(e))
249
250 // 2) A subagent's tool call: buffered, never awaited
251 on('tool.call', async ($, e, next) => {
252 // A main-loop call (no agentId) belongs to the title row while the main turn runs
253 const id = e.agentId ?? (mainTurn !== null ? MAIN_ID : undefined)
254 if (id === undefined || (id !== MAIN_ID && !known.has(id))) return next(e)
255 const input = e as unknown as Record<string, unknown>
256 const p = pendingFor(id)
257 if (!QUIET_TOOLS.has(e.tool)) {
258 const target = targetOf(e.tool, input)
259 p.activity = activityOf(e.tool)
260 p.target = target
261 p.tools += 1
262 // Awaited only to see the outcome: no `$` call, so the tool runs exactly as it would
263 const result = await next(e)
264 const q = pendingFor(id)
265 q.approval = null // the call went ahead, so any approval it waited for was answered
266 ;(q.recent ??= []).push({ label: clip(`${e.tool} ${target}`.trim(), 80), ...(failed(result) ? { failed: true } : {}) })
267 const file = failed(result) ? null : fileOf(e.tool, input, cwd)
268 if (file) (q.files ??= []).push(file)
269 return result
270 }
271 // The agent's own to-do list: a whole list replaces what came before; task changes are
272 // queued and applied by the ticker on top of the latest list
273 if (e.tool === 'TodoWrite') {
274 p.steps = stepsFromTodos(input.todos)
275 p.stepOps = []
276 }
277 const result = await next(e)
278 // The ticker may have flushed the buffer meanwhile: take it again
279 const q = pendingFor(id)
280 if (e.tool === 'TaskCreate') {
281 const task = (result as { result?: { task?: { id?: string; subject?: string } } }).result?.task
282 if (task?.id) (q.stepOps ??= []).push({ kind: 'create', id: task.id, label: task.subject ?? String(input.subject ?? '') })
283 } else if (e.tool === 'TaskUpdate' && !failed(result)) {
284 const { taskId, status, activeForm, subject } = input
285 ;(q.stepOps ??= []).push({ kind: 'update', change: { taskId, status, activeForm, subject } })
286 }
287 return result
288 }).catch(($, e, next) => next(e))
289
290 // 3) A subagent's model request: counted in memory. No `$` call, so nothing here can delay
291 // or break the request itself.
292 on('turn.step', async function* ($, e, next) {
293 const isMain = e.agentId === undefined && mainTurn !== null && e.turnId === mainTurn
294 const id = isMain ? MAIN_ID : e.agentId
295 const tracked = id !== undefined && (isMain || known.has(id))
296 if (tracked) {
297 const p = pendingFor(id)
298 p.requests += 1
299 if (isMain) p.model = e.model
300 // A subagent resumed with SendMessage runs again under the same id, in a new turn
301 p.revive = true
302 p.turnId = e.turnId
303 if (p.approval) p.approval = null
304 if (e.index > 0) {
305 p.activity = 'thinking'
306 p.target = ''
307 }
308 // Everyone had finished, so the ticker stopped: this agent is back, so it starts again.
309 // A ticker whose interval was ended by a refused period is rebuilt the same way.
310 if (++stepsSinceTick > DEAD_TICKER_STEPS) {
311 ticker?.cancel()
312 ticker = null
313 }
314 if (!ticker) startTicker($)
315 }
316 const answer = yield* next(e)
317 const usage = answer.usage
318 if (tracked && usage) {
319 const p = pendingFor(id)
320 p.tokens = addUsage(p.tokens ?? emptyTokens(), usage)
321 }
322 return answer
323 }).catch(async function* ($, e, next) {
324 return yield* next(e)
325 })
326
327 // A subagent asks the person to approve a tool call: its row says so until the call goes on.
328 // Observed only: the decision is the person's and Claude Code's, never this mod's.
329 on('classic.PermissionRequest', ($, e, next) => {
330 const id = e.agent_id ?? (mainTurn !== null ? MAIN_ID : undefined)
331 if (id !== undefined && (id === MAIN_ID || known.has(id))) pendingFor(id).approval = clip(`${e.tool_name} ${targetOf(e.tool_name, (e.tool_input ?? {}) as Record<string, unknown>)}`.trim(), 80)
332 return next(e)
333 }).catch(($, e, next) => next(e))
334 on('classic.PermissionDenied', ($, e, next) => {
335 const id = e.agent_id ?? (mainTurn !== null ? MAIN_ID : undefined)
336 if (id !== undefined && (id === MAIN_ID || known.has(id))) pendingFor(id).approval = null
337 return next(e)
338 }).catch(($, e, next) => next(e))
339
340 // /clear starts a fresh conversation: the finished crew leaves with the old one
341 on('classic.SessionStart', async ($, e, next) => {
342 if (e.source === 'clear') {
343 const m = await read($, main)
344 if (m && m.status !== 'running') await update($, main, () => null)
345 const list = await read($, agents)
346 if (list.some(a => a.status !== 'running')) await update($, agents, l => l.filter(a => a.status === 'running'))
347 const kept = await read($, agents)
348 known.clear()
349 for (const a of kept) known.add(a.id)
350 const open = await read($, expanded)
351 if (open.length) await update($, expanded, () => [])
352 if (!kept.length) setStatus($, undefined)
353 }
354 return next(e)
355 }).catch(($, e, next) => next(e))
356
357 // 4) A subagent finished: done, failed or cancelled (rare, so written right away)
358 on('turn.complete', async ($, e, next) => {
359 // The main turn ended: its record keeps the turn's final numbers, which the thin line shows
360 if (e.agentId === undefined && mainTurn !== null && e.turnId === mainTurn) {
361 mainTurn = null
362 const buffered = pending.get(MAIN_ID)
363 pending.delete(MAIN_ID)
364 const now = await $.clock.now()
365 const status: AgentStatus = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'cancelled' : 'failed'
366 await update($, main, m => (m && m.status === 'running' ? { ...applyPending(m, buffered, now), status, endedAt: now, waitingFor: undefined } : m))
367 return next(e)
368 }
369 const id = e.agentId
370 if (id === undefined || !known.has(id)) return next(e)
371 const now = await $.clock.now()
372 const status: AgentStatus = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'cancelled' : 'failed'
373 const buffered = pending.get(id)
374 pending.delete(id)
375 let finished: Agent | undefined
376 await update($, agents, list => {
377 finished = undefined // update() may run this again on a version miss
378 return list.map(a => {
379 if (a.id !== id) return a
380 const caughtUp = applyPending(a, buffered, now)
381 // A running agent ends here; so does one the mod had closed, which now gets its real outcome
382 if (caughtUp.status !== 'running' && !caughtUp.retired) return caughtUp
383 finished = { ...caughtUp, status, endedAt: now, retired: undefined, waitingFor: undefined }
384 return finished
385 })
386 })
387 // Closed only now: this turn's own buffer (above) may revive a resumed agent, while
388 // events of this turn that arrive later must not
389 closedTurn.set(id, e.turnId)
390 if (finished && status === 'done' && !finished.demo) await remember($, finished, now - finished.startedAt)
391 return next(e)
392 }).catch(($, e, next) => next(e))
393
394 // 5) A new message: the finished crew leaves the stage
395 on('prompt.submit', async ($, e, next) => {
396 if (e.origin.kind !== 'composer') return next(e)
397 const list = await read($, agents)
398 if (list.length && !list.some(a => a.status === 'running')) {
399 demoOnStage = false
400 await update($, agents, () => [])
401 known.clear()
402 pending.clear()
403 closedTurn.clear()
404 setStatus($, undefined)
405 await closePane($).catch(() => undefined)
406 }
407 const open = await read($, expanded)
408 if (open.length) await update($, expanded, () => [])
409 return next(e)
410 }).catch(($, e, next) => next(e))
411
412 // While a demo is on stage, Claude Code's own prompt suggestion stays out of the recording;
413 // any other time, suggestions pass untouched
414 on('prompt.suggest', ($, e, next) => {
415 if (demoOnStage && e.origin?.kind === 'suggestion') return { isShown: false }
416 return next(e)
417 }).catch(($, e, next) => next(e))
418
419 // 6) /crew
420 on('command.run', { command: 'crew' }, async ($, e) => {
421 const arg = e.args.trim().toLowerCase()
422 if (arg === 'demo') {
423 await startDemo($)
424 return { text: 'Demo crew on stage: Claude starts alone, then 3 agents and a helper join, for about 30 seconds.' }
425 }
426 if (arg === 'clear') {
427 await clearStage($)
428 return { text: 'Stage cleared.' }
429 }
430 const wasHidden = await read($, hidden)
431 await update($, hidden, () => !wasHidden)
432 await refreshStatus($)
433 return { text: wasHidden ? 'Agent Crew is visible.' : 'Agent Crew hidden. Run /crew to show it again.' }
434 }).catch(() => ({ text: 'Agent Crew ran into an error. Run claude --debug for details.' }))
435
436 // 7) Drawing: one aligned row per agent, helpers indented below their parent
437 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
438 // Cheap checks first: while hidden, the drawing doesn't subscribe to the agent list
439 const isHidden = await read($, hidden)
440 if (isHidden || e.props.hasSurvey) return next(e)
441 // Moved to the side pane: the band stays empty while the pane is up
442 if ((await read($, side)) && (await read($, paneUp))) return next(e)
443 const list = shown(await read($, agents))
444 const m = await read($, main)
445 const working = m?.status === 'running' ? m : null
446 if (list.length === 0 && !m) return next(e)
447 const open = await read($, expanded)
448 await read($, foldFrame)
449
450 const els = $.ui.resolve(e)
451 const { Box, Text, Button } = els
452 // The terminal's table has an Svg key too, but draws it as an empty box: decide by surface
453 const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
454 const now = await $.clock.now()
455 // bodyColumns leaves out the engine's own marks and a docked pane: the room the band really has
456 const L = layout(e.props.bodyColumns || e.viewport?.columns || 120, !!Svg, showCost)
457 const barPx = L.barCells * 8
458
459 const runningHelpers = (id: string) => list.filter(a => a.parentId === id && a.status === 'running').length
460 const { anyRunning, hasCrew, right, own, said, ownModel } = titleFacts(list, m, now)
461 const isCollapsed = await read($, collapsed)
462 const isMinimized = await read($, minimized)
463 const room = Math.max(12, Math.floor(((e.props.bodyColumns || e.viewport?.columns || 120) - right.length - 26 - ownModel.length) * (Svg ? 0.75 * DESKTOP_TEXT_ROOM : 1)))
464
465 const toggleMinimized = () => setMinimized($, !isMinimized)
466 if (isMinimized) {
467 // One plain line, no frame and no sprite: the title, what it says now, and its numbers
468 return (
469 <Box flexDirection="row" justifyContent="space-between" alignItems="center" paddingX={1}>
470 <Text wrap="truncate-end">
471 <Text bold color="claude">{hasCrew ? 'AGENT CREW' : 'AGENT'}</Text>
472 <Text color={own?.alert ? 'warning' : 'subtle'}>{` ${clip(working || hasCrew ? said : statusLabel(m!), room)}`}</Text>
473 </Text>
474 <Box flexDirection="row" gap={2} alignItems="center" flexShrink={0}>
475 <Text color="subtle">{right}</Text>
476 {Svg ? (
477 <Button key="minimize" label="▸" onPress={toggleMinimized} />
478 ) : (
479 <Button key="minimize" label="▸" hotkey={MIN_KEY} plain onPress={toggleMinimized} />
480 )}
481 </Box>
482 </Box>
483 )
484 }
485
486 // The title row alone is framed, in Claude's own color; hiding and clearing live in /crew
487 const header = (
488 <Box flexDirection="row" justifyContent="space-between" alignItems="center" borderStyle="round" borderColor="claude" paddingX={1}>
489 <Box flexDirection="row" gap={1} alignItems="center" flexShrink={1}>
490 {Svg ? (
491 <Svg source={working ? spriteSvg(working, now) : headerSvg(!anyRunning)} alt={hasCrew ? 'Agent Crew' : 'Agent'} width={SPRITE_W} height={SPRITE_H} />
492 ) : (
493 <Text color={dotColor(working ?? { status: anyRunning ? 'running' : 'done' })}>{DOT}</Text>
494 )}
495 <Box flexShrink={0}>
496 <Text bold color="claude">{hasCrew ? 'AGENT CREW' : 'AGENT'}</Text>
497 </Box>
498 {ownModel ? (
499 <Box flexShrink={0}>
500 <Text color="subtle">{ownModel}</Text>
501 </Box>
502 ) : null}
503 <Text wrap="truncate-end" color={own?.alert ? 'warning' : undefined}>{clip(said, room)}</Text>
504 </Box>
505 <Box flexDirection="row" gap={2} alignItems="center" flexShrink={0}>
506 <Text color="subtle">{right}</Text>
507 {Svg ? (
508 <Button key="side" label="⇥" onPress={() => setSide($, true)} />
509 ) : (
510 <Button key="side" label="⇥" hotkey={SIDE_KEY} plain onPress={() => setSide($, true)} />
511 )}
512 {Svg ? (
513 <Button key="minimize" label="–" onPress={toggleMinimized} />
514 ) : (
515 <Button key="minimize" label="–" hotkey={MIN_KEY} plain onPress={toggleMinimized} />
516 )}
517 {/* The title's arrow shows or hides the subagent rows; alone, it opens Claude's own details */}
518 {!hasCrew ? (
519 Svg ? (
520 <Button key="open-main" label={open.includes(MAIN_ID) ? '▾' : '▸'} onPress={() => setOpen($, MAIN_ID, !open.includes(MAIN_ID))} />
521 ) : (
522 <Button key="open-main" label={open.includes(MAIN_ID) ? '▾' : '▸'} hotkey={TITLE_KEY} plain onPress={() => setOpen($, MAIN_ID, !open.includes(MAIN_ID))} />
523 )
524 ) : Svg ? (
525 <Button key="collapse" label={isCollapsed ? '▸' : '▾'} onPress={() => update($, collapsed, c => !c)} />
526 ) : (
527 <Button key="collapse" label={isCollapsed ? '▸' : '▾'} hotkey={TITLE_KEY} plain onPress={() => update($, collapsed, c => !c)} />
528 )}
529 </Box>
530 </Box>
531 )
532 if (isCollapsed) return header
533 if (!hasCrew) {
534 // Claude alone: the arrow opens the details of its turn under the title
535 const isOpen = open.includes(MAIN_ID)
536 const details = showsDetails(MAIN_ID, isOpen) ? unfold({ Box }, MAIN_ID, detailsPanel({ Box, Text }, { a: m!, depth: 0, isOpen, share: 0, est: { main: '', extra: '' }, doing: own ?? doingText(m!, now, 0), color: statusColor(m!) }, now, 2), now) : null
537 if (working) return details ? <Box flexDirection="column">{header}{details}</Box> : header
538 return thinLine({ Box, Text, Button }, m!, now, isOpen, () => setOpen($, MAIN_ID, !isOpen), !!Svg, details)
539 }
540
541 const rows = tree(list).map(({ a, depth }, index) => {
542 const isOpen = open.includes(a.id)
543 const row: RowData = {
544 a,
545 depth,
546 isOpen,
547 share: shownProgress(a, now, history),
548 est: estimateText(a, now, history),
549 doing: doingText(a, now, runningHelpers(a.id)),
550 color: statusColor(a),
551 }
552 const toggle = () => setOpen($, a.id, !isOpen)
553 const line = Svg
554 ? desktopRow({ Box, Text, Svg, Button }, row, L, barPx, now, toggle, () => dismissAgent($, a.id))
555 : terminalRow({ Box, Text, Button }, row, L, toggle, ROW_KEYS[index])
556 // Every row lives in the same wrapper, open or not, so a toggle never rebuilds the row.
557 // Open, the wrapper frames the row and its details in dashed yellow.
558 return (
559 <Box key={`${a.id}-wrap`} flexDirection="column" borderStyle={isOpen ? 'dashed' : undefined} borderColor={isOpen ? 'warning' : undefined}>
560 {line}
561 {showsDetails(a.id, isOpen) ? unfold({ Box }, a.id, detailsPanel({ Box, Text }, row, now, depth * 3 + (Svg ? SPRITE_CELLS : 6) + 1), now) : null}
562 </Box>
563 )
564 })
565
566 return (
567 <Box flexDirection="column" gap={Svg ? 1 : 0}>
568 {header}
569 <Box flexDirection="column">{rows}</Box>
570 </Box>
571 )
572 }).catch(($, e, next) => next(e))
573
574 // 8) The side pane: the same crew as a narrow column, one stacked card per agent
575 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
576 const list = shown(await read($, agents))
577 const m = await read($, main)
578 const open = await read($, expanded)
579 await read($, foldFrame)
580 const els = $.ui.resolve(e)
581 const { Box, Text, Button } = els
582 const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
583 const now = await $.clock.now()
584 const cols = e.props.bodyColumns || SIDE_COLUMNS
585 const back = Svg ? (
586 <Button key="unside" label="⇤" dimColor hover={{ dimColor: false }} onPress={() => setSide($, false)} />
587 ) : (
588 <Button key="unside" label="⇤" hotkey={SIDE_KEY} plain onPress={() => setSide($, false)} />
589 )
590 if (list.length === 0 && !m) {
591 return (
592 <Box key="title" flexDirection="row" justifyContent="space-between" alignItems="center">
593 <Text color="subtle">No agents yet.</Text>
594 {back}
595 </Box>
596 )
597 }
598 const { working, hasCrew, right, own, said, ownModel } = titleFacts(list, m, now)
599 const runningHelpers = (id: string) => list.filter(a => a.parentId === id && a.status === 'running').length
600 const title = (
601 <Box key="title" flexDirection="column">
602 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
603 <Text wrap="truncate-end">
604 <Text bold color="claude">{hasCrew ? 'AGENT CREW' : 'AGENT'}</Text>
605 <Text color="subtle">{ownModel ? ` ${ownModel}` : ''}</Text>
606 </Text>
607 {back}
608 </Box>
609 <Text wrap="truncate-end" color={own?.alert ? 'warning' : undefined}>{clip(working || hasCrew ? said : statusLabel(m!), cols)}</Text>
610 <Text wrap="truncate-end" color="subtle">{right}</Text>
611 </Box>
612 )
613 const cards = tree(list).map(({ a, depth }, index) => {
614 const isOpen = open.includes(a.id)
615 const row: RowData = {
616 a,
617 depth,
618 isOpen,
619 share: shownProgress(a, now, history),
620 est: estimateText(a, now, history),
621 doing: doingText(a, now, runningHelpers(a.id)),
622 color: statusColor(a),
623 }
624 return sideCard({ Box, Text, Svg, Button }, row, cols, now, () => setOpen($, a.id, !isOpen), () => dismissAgent($, a.id), ROW_KEYS[index])
625 })
626 return (
627 <Box flexDirection="column" gap={1}>
628 {title}
629 {cards}
630 </Box>
631 )
632 }).catch(($, e, next) => next(e))
633
634 // The person closed the side pane: the crew goes back above the prompt
635 on('ui.close', async ($, e, next) => {
636 if (e.id === PANE) {
637 await update($, paneUp, () => false)
638 if (e.origin.kind === 'person') {
639 await update($, side, () => false)
640 await $.store.set('side', false).catch(() => undefined)
641 }
642 }
643 return next(e)
644 }).catch(($, e, next) => next(e))
645}
646
647/** One agent in the side pane: the sprite beside four short lines, its details below when open. */
648function sideCard({ Box, Text, Svg, Button }: Els, r: RowData, cols: number, now: number, onToggle: () => void, onDismiss: () => void, hotkey: string | undefined) {
649 const { a, depth, isOpen, share, est, doing, color } = r
650 const running = a.status === 'running'
651 // The sprite (or the terminal's dot) and the gap beside it take this many cells
652 const lead = (Svg ? SPRITE_CELLS : 2) + 1 + depth * 2
653 const textCells = Math.max(10, cols - lead - (isOpen ? 2 : 0))
654 const barCells = Math.max(6, Math.min(16, textCells - 12))
655 const arrow = Svg ? (
656 <Button key={`open-${a.id}`} label={isOpen ? '▾' : '▸'} dimColor hover={{ dimColor: false }} onPress={onToggle} />
657 ) : (
658 <Button key={`open-${a.id}`} label={isOpen ? '▾' : '▸'} hotkey={hotkey} plain onPress={onToggle} />
659 )
660 // A finished agent can be sent off the stage
661 const close = running ? null : Svg ? (
662 <Button key={`close-${a.id}`} label="✕" role="dismiss" dimColor hover={{ dimColor: false }} onPress={onDismiss} />
663 ) : (
664 <Button key={`close-${a.id}`} label="✕" plain dimColor onPress={onDismiss} />
665 )
666 return (
667 <Box key={`${a.id}-wrap`} flexDirection="column" borderStyle={isOpen ? 'dashed' : undefined} borderColor={isOpen ? 'warning' : undefined}>
668 <Box key={a.id} flexDirection="row" gap={1} paddingLeft={depth * 2}>
669 {Svg ? (
670 <Svg source={spriteSvg(a, now)} alt={`${typeLabel(a.type)}: ${doing.main}`} width={SPRITE_W} height={SPRITE_H} />
671 ) : (
672 <Text color={dotColor(a)}>{DOT}</Text>
673 )}
674 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
675 <Box flexDirection="row" justifyContent="space-between">
676 <Text wrap="truncate-end">
677 <Text bold color={typeColor(a.type)}>{typeLabel(a.type)}</Text>
678 <Text color="subtle">{modelLabel(a.model) ? ` ${modelLabel(a.model)}` : ''}</Text>
679 </Text>
680 <Box flexDirection="row" gap={1} flexShrink={0}>
681 {arrow}
682 {close}
683 </Box>
684 </Box>
685 <Text bold={depth === 0} dimColor={depth > 0} wrap="truncate-end">{clip(a.description, textCells * (Svg ? DESKTOP_TEXT_ROOM : 1))}</Text>
686 <Text wrap="truncate-end">
687 <Text bold color={doing.alert ? 'warning' : color.theme}>{doing.main}</Text>
688 <Text color="subtle">{doing.extra ? ` ${oneLine(doing.extra)}` : ''}</Text>
689 </Text>
690 <Box flexDirection="row" gap={1} alignItems="center">
691 {Svg ? (
692 <Svg source={barSvg(share, color.raw, running, barCells * 8, barCells, 7)} alt={`${Math.round(share * 100)} percent`} width={barCells * 8} height={7} />
693 ) : (
694 <Text color={color.theme}>{textBar(share, barCells, running)}</Text>
695 )}
696 <Text bold>{`${Math.round(share * 100)}%`}</Text>
697 <Text color={running ? 'text' : 'subtle'}>{est.main}</Text>
698 </Box>
699 </Box>
700 </Box>
701 {showsDetails(a.id, isOpen) ? unfold({ Box }, a.id, detailsPanel({ Box, Text }, r, now, 0), now) : null}
702 </Box>
703 )
704}
705
706// ── Rows ─────────────────────────────────────────────────────
707// The element constructors come from $.ui.resolve(e). Each surface's table is its own type and
708// these helpers serve two of them, so they take the constructors loosely typed.
709// eslint-disable-next-line @typescript-eslint/no-explicit-any
710type Els = Record<string, any>
711type RowData = {
712 a: Agent
713 depth: number
714 isOpen: boolean
715 share: number
716 est: { main: string; extra: string }
717 doing: { main: string; extra: string; alert?: boolean }
718 color: { theme: string; raw: string }
719}
720
721const oneLine = (s: string) => s.replace(/\s+/g, ' ')
722
723function desktopRow({ Box, Text, Svg, Button }: Els, r: RowData, L: Layout, barPx: number, now: number, onToggle: () => void, onDismiss: () => void) {
724 const { a, depth, isOpen, share, est, doing, color } = r
725 const helper = depth > 0
726 const indent = depth * 3
727 // An open row sits inside a dashed frame, which takes a cell on each side
728 const taskCells = Math.max(8, L.task - (helper ? indent + 1 : 0) - (isOpen ? 2 : 0))
729 // The desktop's proportional (and bold) font runs wider than its cells: the "doing" text plans with
730 // 75% of the column, so it never wraps to a second line. The bold part comes first; the target only
731 // shows when at least four characters of it fit.
732 const doingChars = Math.floor(L.doing * 0.75)
733 const mainText = clip(doing.main, doingChars)
734 const extraRoom = doingChars - mainText.length - 2
735 const extraText = doing.extra && extraRoom >= 4 ? ` ${clip(oneLine(doing.extra), extraRoom)}` : ''
736 const running = a.status === 'running'
737 return (
738 <Box key={a.id} flexDirection="row" gap={1} alignItems="center" height={ROW_ROWS} paddingRight={1}>
739 {helper ? (
740 <Box width={indent} justifyContent="flex-end" flexShrink={0}>
741 <Text color="subtle">└</Text>
742 </Box>
743 ) : null}
744 <Svg source={spriteSvg(a, now)} alt={`${typeLabel(a.type)}: ${doing.main}`} width={SPRITE_W} height={SPRITE_H} />
745 {/* The type, and under it the model the agent runs on, drawn small */}
746 <Box width={L.type} flexShrink={0}>
747 <Svg source={labelSvg(a.type, a.model)} alt={[typeLabel(a.type), modelLabel(a.model)].filter(Boolean).join(', ')} width={LABEL_W} height={LABEL_H} />
748 </Box>
749 <Box width={taskCells} flexGrow={1} flexShrink={0}>
750 <Text bold={!helper} dimColor={helper} wrap="truncate-end">{clip(a.description, (taskCells - 1) * DESKTOP_TEXT_ROOM)}</Text>
751 </Box>
752 <Box width={L.doing} flexShrink={0}>
753 <Text wrap="truncate-end">
754 <Text bold color={doing.alert ? 'warning' : color.theme}>{mainText}</Text>
755 <Text color="subtle">{extraText}</Text>
756 </Text>
757 </Box>
758 <Svg source={barSvg(share, color.raw, running, barPx, L.barCells, 7)} alt={`${Math.round(share * 100)} percent`} width={barPx} height={7} />
759 <Box width={L.pct} flexShrink={0}>
760 <Text bold>{Math.round(share * 100)}%</Text>
761 </Box>
762 <Box width={L.eta} flexShrink={0}>
763 <Text bold={running} color={running ? 'text' : 'subtle'}>{est.main}</Text>
764 </Box>
765 {L.showTokens ? (
766 <Box width={L.tokens} flexShrink={0}>
767 <Text color="subtle">{tokenCell(a)}</Text>
768 </Box>
769 ) : null}
770 {/* The disclosure arrow ends the row: a button, so it works by click and by keyboard */}
771 <Button key={`open-${a.id}`} label={isOpen ? '▾' : '▸'} dimColor hover={{ dimColor: false }} onPress={onToggle} />
772 {running ? null : <Button key={`close-${a.id}`} label="✕" role="dismiss" dimColor hover={{ dimColor: false }} onPress={onDismiss} />}
773 </Box>
774 )
775}
776
777function terminalRow({ Box, Text, Button }: Els, r: RowData, L: Layout, onToggle: () => void, hotkey: string | undefined) {
778 const { a, depth, isOpen, share, est, doing, color } = r
779 const helper = depth > 0
780 const taskCells = Math.max(8, L.task - (helper ? depth * 3 + 1 : 0) - (isOpen ? 2 : 0))
781 return (
782 <Box key={a.id} flexDirection="row">
783 <Text>
784 <Text color="subtle">{helper ? `${' '.repeat(depth - 1)} └ ` : ''}</Text>
785 <Text color={dotColor(a)}>{`${DOT} `}</Text>
786 <Text bold color={typeColor(a.type)}>{typeLabel(a.type).padEnd(L.type + 1)}</Text>
787 <Text color="subtle">{L.model ? (modelLabel(a.model) ? modelShort(a.model) : '').padEnd(L.model) : ''}</Text>
788 </Text>
789 <Box width={taskCells + 1} flexShrink={0}>
790 <Text bold={!helper} dimColor={helper} wrap="truncate-end">{clip(a.description, taskCells - 1)}</Text>
791 </Box>
792 <Text wrap="truncate-end">
793 <Text color={doing.alert ? 'warning' : color.theme}>{clip(`${doing.main}${doing.extra ? ` ${oneLine(doing.extra)}` : ''}`, L.doing - 1).padEnd(L.doing)}</Text>
794 <Text color={color.theme}>{textBar(share, L.barCells - 2, a.status === 'running')} </Text>
795 <Text bold>{`${Math.round(share * 100)}%`.padEnd(L.pct)}</Text>
796 <Text>{est.main.padEnd(L.eta)}</Text>
797 <Text color="subtle">{L.showTokens ? tokenCell(a).padEnd(L.tokens) : ''}</Text>
798 </Text>
799 {hotkey ? (
800 <Button key={`open-${a.id}`} label={isOpen ? '▾' : '▸'} hotkey={hotkey} plain onPress={onToggle} />
801 ) : (
802 <Button key={`open-${a.id}`} label={isOpen ? '▾' : '▸'} plain onPress={onToggle} />
803 )}
804 </Box>
805 )
806}
807
808/**
809 * Claude's last turn, once it ended and no subagents are on stage: one thin line, no critter and
810 * no frame, with the turn's final numbers. Its arrow opens the turn's details.
811 */
812function thinLine({ Box, Text, Button }: Els, m: Agent, now: number, isOpen: boolean, onToggle: () => void, desktop: boolean, details: unknown) {
813 const took = formatDuration((m.endedAt ?? now) - m.startedAt)
814 const cost = showCost ? costOf(m.model, m.tokens) : null
815 const facts = [modelLabel(m.model), `last turn ${took}`, tokenText(m.tokens, 'tokens'), cost ? formatCost(cost.total) : '', `${m.tools} tool${m.tools === 1 ? '' : 's'}`].filter(Boolean).join(' · ')
816 const line = (
817 <Box key="main-line" flexDirection="row" justifyContent="space-between" alignItems="center" paddingX={1}>
818 <Text wrap="truncate-end">
819 <Text bold color="claude">AGENT</Text>
820 <Text color={statusColor(m).theme}>{` ${statusLabel(m)}`}</Text>
821 <Text color="subtle">{` · ${facts}`}</Text>
822 </Text>
823 {desktop ? <Button key="open-main" label={isOpen ? '▾' : '▸'} onPress={onToggle} /> : <Button key="open-main" label={isOpen ? '▾' : '▸'} hotkey={TITLE_KEY} plain onPress={onToggle} />}
824 </Box>
825 )
826 return details ? (
827 <Box flexDirection="column" borderStyle="dashed" borderColor="warning">
828 {line}
829 {details}
830 </Box>
831 ) : (
832 line
833 )
834}
835
836/** The panel under an open row: everything the row had to cut. */
837function detailsPanel({ Box, Text }: Els, r: RowData, now: number, indent: number) {
838 const { a, est, doing } = r
839 const t = a.tokens
840 const elapsed = (a.endedAt ?? now) - a.startedAt
841 return (
842 <Box key={`${a.id}-details`} flexDirection="column" paddingLeft={indent}>
843 <Text>
844 <Text color="subtle">Task </Text>
845 <Text>{a.description}</Text>
846 </Text>
847 {a.status === 'running' && a.target ? (
848 <Text>
849 <Text color="subtle">Now </Text>
850 <Text>{`${doing.main}: ${a.target}`}</Text>
851 </Text>
852 ) : null}
853 {(a.steps ?? []).map(s => (
854 <Text key={`${a.id}-${s.id}`}>
855 <Text color="subtle">{s.status === 'completed' ? ' [x] ' : s.status === 'in_progress' ? ' [>] ' : ' [ ] '}</Text>
856 <Text bold={s.status === 'in_progress'} dimColor={s.status === 'completed'}>{s.label}</Text>
857 </Text>
858 ))}
859 {a.recent?.length ? (
860 <Text wrap="truncate-end">
861 <Text color="subtle">Recent </Text>
862 <Text>{a.recent.map(t => `${t.label}${t.failed ? ' (failed)' : ''}`).join(' · ')}</Text>
863 </Text>
864 ) : null}
865 {fileLines({ Text }, a)}
866 {showCost ? costLine({ Text }, a) : null}
867 <Text>
868 <Text color="subtle">Tokens </Text>
869 <Text>{formatTokens(workTokens(t))}</Text>
870 <Text color="subtle">{` (input ${formatTokens(t.input)} · output ${formatTokens(t.output)})`}</Text>
871 </Text>
872 <Text>
873 <Text color="subtle">Cache </Text>
874 <Text>{`${formatTokens(t.cacheWrite)} written · ${formatTokens(t.cacheRead)} re-read`}</Text>
875 <Text color="subtle">
876 {cacheIsNotable(t)
877 ? ' · after a pause the cache expires, and the next request writes the conversation to it again'
878 : ' · the conversation, kept by Claude between requests'}
879 </Text>
880 </Text>
881 <Text>
882 <Text color="subtle">Run </Text>
883 <Text>{`${modelShort(a.model)} · ${a.tools} tools · ${a.requests} requests · ${formatDuration(elapsed)} elapsed`}</Text>
884 <Text color="subtle">{a.status === 'running' && est.main ? ` · ${est.main}${est.extra ? ` (${est.extra})` : ''}` : ''}</Text>
885 </Text>
886 </Box>
887 )
888}
889
890/** The files the agent changed and read, newest first; each list cut to the panel's width. */
891function fileLines({ Text }: Els, a: Agent) {
892 if (!a.files?.length) return null
893 const { changed, read: seen } = filesSummary(a.files)
894 const line = (key: string, label: string, paths: string[]) =>
895 paths.length ? (
896 <Text key={`${a.id}-${key}`} wrap="truncate-end">
897 <Text color="subtle">{label}</Text>
898 <Text>{paths.join(' · ')}</Text>
899 </Text>
900 ) : null
901 return [line('changed', `Changed ${changed.length} `.padEnd(8), changed), line('read', `Read ${seen.length} `.padEnd(8), seen)]
902}
903
904/** The token column: new tokens, plus the ≈ cost when costs are on. */
905function tokenCell(a: Agent): string {
906 const tokens = tokenText(a.tokens)
907 if (!showCost) return tokens
908 const cost = costOf(a.model, a.tokens)
909 return cost ? `${tokens} ${formatCost(cost.total)}` : tokens
910}
911
912/** The details panel's cost breakdown; nothing for a model without a known price. */
913function costLine({ Text }: Els, a: Agent) {
914 const c = costOf(a.model, a.tokens)
915 if (!c) return null
916 const part = (usd: number) => formatCost(usd).replace('≈', '')
917 return (
918 <Text>
919 <Text color="subtle">Cost </Text>
920 <Text>{formatCost(c.total)}</Text>
921 <Text color="subtle">{` (input ${part(c.input)} · output ${part(c.output)} · cache write ${part(c.cacheWrite)} · cache read ${part(c.cacheRead)}) · list prices, an estimate`}</Text>
922 </Text>
923 )
924}
925
926/** The crew's ≈ cost over the agents whose model price is known; null when none is. */
927function sumCost(list: readonly Agent[]): number | null {
928 const known = list.map(a => costOf(a.model, a.tokens)).filter((c): c is NonNullable<typeof c> => c !== null)
929 return known.length ? known.reduce((t, c) => t + c.total, 0) : null
930}
931
932// ── Pure helpers ─────────────────────────────────────────────
933/** Whether a tool call failed: an error result, or a task update that reports no success. */
934function failed(result: unknown): boolean {
935 const r = result as { isError?: boolean; deny?: string; result?: { success?: boolean } }
936 return r.isError === true || r.deny !== undefined || r.result?.success === false
937}
938
939/** Folds an agent's buffered events into it; any event also counts as a sign of life. */
940function applyPending(a: Agent, p: Pending | undefined, now: number): Agent {
941 if (!p) return a
942 let next: Agent = { ...a, seenAt: now, requests: a.requests + p.requests, tools: a.tools + p.tools }
943 // A model request means the agent runs: resumed after it finished, unless that request
944 // belongs to the very turn that already ended (its events arrived after the end)
945 const lateEvents = p.turnId !== undefined && p.turnId === closedTurn.get(a.id)
946 if (p.revive && !lateEvents && a.status !== 'running') next = { ...next, status: 'running', endedAt: undefined, shownShare: 0, retired: undefined }
947 if (p.model) next = { ...next, model: p.model }
948 if (p.tokens) {
949 const t = p.tokens
950 next = { ...next, tokens: addUsage(next.tokens, { input_tokens: t.input, output_tokens: t.output, cache_creation_input_tokens: t.cacheWrite, cache_read_input_tokens: t.cacheRead }) }
951 }
952 if (p.steps || p.stepOps?.length) {
953 let steps = p.steps ?? next.steps ?? []
954 for (const op of p.stepOps ?? []) steps = op.kind === 'create' ? withStepCreated(steps, op.id, op.label) : withStepUpdated(steps, op.change)
955 next = { ...next, steps }
956 }
957 if (p.activity !== undefined && next.status === 'running') next = withActivity(next, p.activity, p.target ?? '', now)
958 if (p.approval !== undefined) next = { ...next, waitingFor: p.approval ?? undefined }
959 if (p.recent?.length) next = { ...next, recent: [...(next.recent ?? []), ...p.recent].slice(-RECENT_TOOLS) }
960 if (p.files?.length) next = { ...next, files: withFiles(next.files ?? [], p.files) }
961 return next
962}
963
964/**
965 * How a running agent ended when its turn.complete never came, or null while it still runs.
966 * The engine's agent list decides: an agent it reports ended is closed with that outcome, one it
967 * no longer lists is closed after GONE_MS of silence, and one it lists as running is left alone
968 * however long it is quiet (a 20-minute build is not a dead agent). Only without that list does
969 * silence alone (STALE_MS) retire an agent.
970 */
971function verdict(a: Agent, now: number, listed: Map<string, string> | null): AgentStatus | null {
972 if (a.demo) return null
973 if (listed) {
974 const status = listed.get(a.id)
975 if (status === undefined) return now - a.seenAt > GONE_MS ? 'cancelled' : null
976 if (status === 'completed') return 'done'
977 if (status === 'failed') return 'failed'
978 if (status === 'killed') return 'cancelled'
979 return null
980 }
981 if (listWorks) return null
982 return now - a.seenAt > STALE_MS ? 'cancelled' : null
983}
984
985// ── Helpers that use $ (they must stay in the hooks file) ────
986/** The engine's agents by id and status, or null where the list isn't available. */
987async function listAgents($: EngineInterface): Promise<Map<string, string> | null> {
988 const listed = await $.agent.list().catch(() => null)
989 if (!listed) return null
990 listWorks = true
991 return new Map(listed.map(x => [x.id, x.status]))
992}
993
994/** Writes the status line only when its text changes. */
995function setStatus($: EngineInterface, text: string | undefined) {
996 if (text === lastStatus) return
997 lastStatus = text
998 $.ui.status(text)
999}
1000
1001/** While the crew is hidden, its one-line summary sits on the status line instead. */
1002async function refreshStatus($: EngineInterface) {
1003 const isHidden = await read($, hidden)
1004 const list = shown(await read($, agents))
1005 const now = await $.clock.now()
1006 setStatus($, isHidden ? statusText(list, now, history, showCost ? sumCost(list) : null) : undefined)
1007}
1008
1009/** What the title says and its numbers, shared by the band and the side pane. `m` or `list` is non-empty. */
1010function titleFacts(list: readonly Agent[], m: Agent | null, now: number) {
1011 const working = m?.status === 'running' ? m : null
1012 const summary = crewSummary(list, now, history)
1013 const anyRunning = list.some(a => a.status === 'running')
1014 const crewCost = showCost ? sumCost(list) : null
1015 const hasCrew = list.length > 0
1016 // Alone, the title is Claude's own line: how long it has worked and what it spent
1017 const mainCost = m && showCost ? costOf(m.model, m.tokens)?.total ?? null : null
1018 const right = hasCrew
1019 ? [
1020 tokenText(sumTokens([...list.map(a => a.tokens), ...(working ? [working.tokens] : [])]), 'tokens'),
1021 crewCost !== null ? formatCost(crewCost + (mainCost ?? 0)) : '',
1022 anyRunning ? `crew ~${formatDuration(summary.leftMs)} left` : '',
1023 `${Math.round(summary.share * 100)}%`,
1024 ].filter(Boolean).join(' · ')
1025 : [
1026 formatDuration((m!.endedAt ?? now) - m!.startedAt),
1027 tokenText(m!.tokens, 'tokens'),
1028 mainCost !== null ? formatCost(mainCost) : '',
1029 ].filter(Boolean).join(' · ')
1030 // What the title says: an approval a subagent waits on first, then Claude's own work, then the crew's state
1031 const crewAsks = list.some(a => a.status === 'running' && a.waitingFor)
1032 // Claude waiting on the subagents it started (the Agent tool shows no activity of its own)
1033 const waitsOn = list.filter(a => !a.parentId && a.status === 'running').length
1034 const idle = working && (working.activity === 'thinking' || working.activity === 'starting') && !working.waitingFor
1035 const own = !working || crewAsks ? null : idle && waitsOn ? { main: 'Waiting', extra: `on ${waitsOn} agent${waitsOn > 1 ? 's' : ''}`, alert: false } : doingText(working, now, 0)
1036 // Claude's own model sits beside the title while it works
1037 const ownModel = working ? modelLabel(working.model) : ''
1038 const said = own ? `${own.main}${own.extra ? ` ${oneLine(own.extra)}` : ''}` : headline(list, now)
1039 return { working, anyRunning, hasCrew, right, own, said, ownModel }
1040}
1041
1042/** Moves the crew into the side pane or back above the prompt, and remembers the choice. */
1043async function setSide($: EngineInterface, value: boolean) {
1044 await update($, side, () => value)
1045 await $.store.set('side', value).catch(() => undefined)
1046 await (value ? openPane($) : closePane($))
1047}
1048
1049/** Opens the side pane where the crew lives there and it isn't up yet. */
1050async function openPane($: EngineInterface) {
1051 if (!(await read($, side)) || (await read($, paneUp))) return
1052 const opened = await $.ui.open({ id: PANE, title: 'Agent Crew', columns: SIDE_COLUMNS })
1053 await update($, paneUp, () => opened.isPlaced)
1054}
1055
1056/** Closes the side pane, keeping the choice to use it: the next crew opens it again. */
1057async function closePane($: EngineInterface) {
1058 if (!(await read($, paneUp))) return
1059 await update($, paneUp, () => false)
1060 await $.ui.close({ id: PANE }).catch(() => undefined)
1061}
1062
1063/** The agents the band shows: cancelled ones drop out of view (they stay in the state). */
1064const shown = (list: readonly Agent[]) => list.filter(a => a.status !== 'cancelled')
1065
1066/** Minimises or restores the band, and remembers the choice for later sessions. */
1067async function setMinimized($: EngineInterface, value: boolean) {
1068 await update($, minimized, () => value)
1069 await $.store.set('minimized', value).catch(() => undefined)
1070}
1071
1072/** One row open at a time: opening a row closes the others. Writes only on a real change. */
1073async function setOpen($: EngineInterface, id: string, open: boolean) {
1074 const ids = await read($, expanded)
1075 const already = open ? ids.length === 1 && ids[0] === id : !ids.includes(id)
1076 if (already) return
1077 // The details slide open, and whatever closes slides shut
1078 const now = await $.clock.now()
1079 for (const x of open ? ids.filter(x => x !== id) : [id]) startFold($, x, false, now)
1080 if (open) startFold($, id, true, now)
1081 await update($, expanded, current => (open ? [id] : current.filter(x => x !== id)))
1082}
1083
1084// ── Unfolding ────────────────────────────────────────────────
1085// The surfaces lay a plugin out in text rows and run no transitions of their own, so a details
1086// panel slides open by its height in rows: a short run of redraws, eased, then the timer stops.
1087const UNFOLD_MS = 160
1088const FRAME_MS = 20
1089/** Panels sliding now, by row id: which way, and since when. */
1090const folds = new Map<string, { open: boolean; at: number }>()
1091let foldTimer: { cancel: () => void } | null = null
1092
1093function startFold($: EngineInterface, id: string, open: boolean, now: number) {
1094 folds.set(id, { open, at: now })
1095 if (foldTimer) return
1096 foldTimer = $.clock.every(FRAME_MS, () => {
1097 void (async () => {
1098 const t = await $.clock.now()
1099 for (const [k, f] of folds) if (t - f.at >= UNFOLD_MS) folds.delete(k)
1100 await update($, foldFrame, n => n + 1)
1101 if (folds.size === 0) {
1102 foldTimer?.cancel()
1103 foldTimer = null
1104 }
1105 })().catch(() => undefined)
1106 })
1107}
1108
1109/** Whether a row's details are drawn: open, or still sliding shut. */
1110const showsDetails = (id: string, isOpen: boolean) => isOpen || folds.get(id)?.open === false
1111
1112/** The details as far as they have slid: clipped to a growing (or shrinking) number of rows. */
1113function unfold({ Box }: Els, id: string, details: RenderChildren, now: number): RenderChildren {
1114 const f = folds.get(id)
1115 if (!f) return details
1116 const p = Math.min(1, Math.max(0, (now - f.at) / UNFOLD_MS))
1117 const eased = 1 - (1 - p) ** 3
1118 const rows = rowsOf(details)
1119 const shownRows = Math.round(rows * (f.open ? eased : 1 - eased))
1120 if (shownRows >= rows) return details
1121 if (shownRows <= 0 && !f.open) return null
1122 return (
1123 <Box key={`${id}-fold`} flexDirection="column" height={Math.max(1, shownRows)} overflow="hidden" flexShrink={0}>
1124 {details}
1125 </Box>
1126 )
1127}
1128
1129/** The rows a details panel takes: one per line it holds (a long line that wraps counts once). */
1130function rowsOf(el: unknown): number {
1131 const node = el as { children?: unknown; props?: { children?: unknown } } | null
1132 const kids = node?.children ?? node?.props?.children
1133 const flat = (Array.isArray(kids) ? kids : [kids]).flat(4).filter(k => k !== null && k !== undefined && k !== false)
1134 return Math.max(1, flat.length)
1135}
1136
1137/** The ✕ on a finished agent: it leaves the stage, and the pane closes once nobody is left. */
1138async function dismissAgent($: EngineInterface, id: string) {
1139 folds.delete(id)
1140 await update($, agents, list => list.filter(a => a.id !== id))
1141 await update($, expanded, ids => ids.filter(x => x !== id))
1142 known.delete(id)
1143 const left = shown(await read($, agents))
1144 if (left.length === 0) await closePane($).catch(() => undefined)
1145}
1146
1147/**
1148 * The once-a-second heartbeat: flushes the hot-path buffer, records each bar's high-water
1149 * mark, retires agents whose end was never reported, and redraws the clocks. At most one
1150 * write and one redraw per tick, and ticks never overlap.
1151 */
1152function startTicker($: EngineInterface) {
1153 if (ticker) return
1154 ticker = $.clock.every(1000, () => {
1155 if (tickBusy) return
1156 tickBusy = true
1157 void tick($)
1158 .catch(() => undefined)
1159 .finally(() => {
1160 tickBusy = false
1161 })
1162 })
1163}
1164
1165async function tick($: EngineInterface) {
1166 stepsSinceTick = 0
1167 const now = await $.clock.now()
1168 ticks += 1
1169 // Every 10 s, ask the engine where each agent stands: a missing turn.complete would
1170 // otherwise leave an agent "running" forever
1171 const listed = ticks % 10 === 0 ? await listAgents($) : null
1172 const buffered = new Map(pending)
1173 pending.clear()
1174 // The main conversation's events go to the title row's record
1175 const own = buffered.get(MAIN_ID)
1176 buffered.delete(MAIN_ID)
1177 if (own) await update($, main, m => (m && m.status === 'running' ? applyPending(m, own, now) : m))
1178
1179 const before = await read($, agents)
1180 // A just-spawned agent may not be in the state yet: its buffer waits for the next tick
1181 for (const [id, p] of buffered) {
1182 if (!before.some(a => a.id === id) && known.has(id)) {
1183 pending.set(id, p)
1184 buffered.delete(id)
1185 }
1186 }
1187 const needsWrite =
1188 buffered.size > 0 ||
1189 before.some(a => {
1190 if (a.status !== 'running') return false
1191 if (verdict(a, now, listed)) return true
1192 // The bar's high-water mark, written when it reaches a new segment (1/14 of the bar)
1193 return Math.floor(shownProgress(a, now, history) * 14) > Math.floor((a.shownShare ?? 0) * 14)
1194 })
1195 if (needsWrite) {
1196 await update($, agents, list =>
1197 list.map(a => {
1198 let next = applyPending(a, buffered.get(a.id), now)
1199 if (next.status !== 'running') return next
1200 const ended = verdict(next, now, listed)hooks/draw.ts 951 lines1// Agent Crew's pure logic and pixel-art drawings. No `$` here, so everything is testable.
2// Text is not drawn here: the surface's own Text elements draw it, full size and in
3// your theme. This file only produces the crew member sprites and the progress bar.
4
5import type { Activity, Agent, AgentStatus, Step, Tokens, TouchedFile } from '../types'
6
7// ── Palette (inside SVGs; colors that read on light and dark themes) ──
8const C = {
9 body: '#D97757',
10 bodyAsleep: '#C9A08F',
11 eye: '#2B211C',
12 white: '#FFFDF7',
13 green: '#2FB67C',
14 red: '#E0533F',
15 yellow: '#F4C542',
16 yellowDark: '#D9A21E',
17 blue: '#3E6FE0',
18 blueDark: '#2C47A8',
19 blueLight: '#9CC4FF',
20 pink: '#FF7AC6',
21 code: '#4BE08A',
22 screen: '#1F2A24',
23 screenEdge: '#3A4A40',
24 grey: '#8A8F98',
25 greyDark: '#5E636C',
26 brown: '#6B4F3A',
27 paperEdge: '#DCD0B4',
28 ink: '#1E1A18',
29 track: '#8882', // bar background: translucent, fits either theme
30}
31
32/** The accent color of an agent type. */
33export function typeColor(type: string): string {
34 if (type === 'Explore') return '#4C7DF0'
35 if (type === 'Plan') return '#9466F7'
36 if (type === 'fork') return '#2FB67C'
37 return '#E08A2E'
38}
39
40/** The status color: a theme key for text, a raw color for SVGs. */
41export function statusColor(a: Agent): { theme: string; raw: string } {
42 if (a.status === 'done') return { theme: 'success', raw: C.green }
43 if (a.status === 'failed') return { theme: 'error', raw: C.red }
44 if (a.status === 'cancelled') return { theme: 'inactive', raw: C.grey }
45 return { theme: typeColor(a.type), raw: typeColor(a.type) }
46}
47
48/** The short label shown in the type column. */
49export function typeLabel(type: string): string {
50 if (type === 'general-purpose') return 'GENERAL'
51 return type.toUpperCase().slice(0, 8)
52}
53
54// ── Text helpers ─────────────────────────────────────────────
55export const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
56
57export function formatTokens(n: number): string {
58 // From 999,950 up, "1000.0k" would show: switch to millions there
59 if (n >= 999_950) return `${(n / 1_000_000).toFixed(1)}M`
60 if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
61 return String(n)
62}
63
64export function formatDuration(ms: number): string {
65 const s = Math.max(0, Math.round(ms / 1000))
66 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
67}
68
69/**
70 * The tokens a row shows: what the agent's own work took, its input and its output. The cache
71 * lives in the details panel: every request re-reads the whole conversation from it, and after a
72 * pause the whole conversation is written to it again, so either would swamp the row.
73 */
74export const workTokens = (t: Tokens) => t.input + t.output
75
76/** The token figure on a row, the title and the thin line: "3.1k tok". */
77export function tokenText(t: Tokens, unit = 'tok'): string {
78 return `${formatTokens(workTokens(t))} ${unit}`
79}
80
81/** A big cache write (a long conversation picked up after its cache expired) gets a word of explanation. */
82export const CACHE_NOTE_MIN = 10_000
83export function cacheIsNotable(t: Tokens): boolean {
84 return t.cacheWrite >= CACHE_NOTE_MIN && t.cacheWrite >= t.input + t.output
85}
86
87/** Several agents' tokens added up. */
88export function sumTokens(list: readonly Tokens[]): Tokens {
89 return list.reduce((s, t) => ({ input: s.input + t.input, output: s.output + t.output, cacheWrite: s.cacheWrite + t.cacheWrite, cacheRead: s.cacheRead + t.cacheRead }), emptyTokens())
90}
91
92export const emptyTokens = (): Tokens => ({ input: 0, output: 0, cacheWrite: 0, cacheRead: 0 })
93
94export function addUsage(t: Tokens, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null }): Tokens {
95 return {
96 input: t.input + u.input_tokens,
97 output: t.output + u.output_tokens,
98 cacheWrite: t.cacheWrite + (u.cache_creation_input_tokens ?? 0),
99 cacheRead: t.cacheRead + (u.cache_read_input_tokens ?? 0),
100 }
101}
102
103// ── Cost (optional; off by default) ──────────────────────────
104// Anthropic's first-party list prices in USD per million tokens (October 2026). Cache writes are
105// priced as 5-minute writes (1.25× input); the API's usage doesn't say which TTL each write used,
106// so every figure built on this is an estimate and is shown with "≈".
107type Price = { input: number; output: number; cacheRead: number }
108const PRICES: [string, Price][] = [
109 // Longest names first: "claude-fable-5-1" must win over "claude-fable-5"
110 ['fable-5-1', { input: 10, output: 50, cacheRead: 0.25 }],
111 ['mythos-5-1', { input: 10, output: 50, cacheRead: 0.25 }],
112 ['fable-5', { input: 10, output: 50, cacheRead: 1 }],
113 ['mythos-5', { input: 10, output: 50, cacheRead: 1 }],
114 ['opus-5-5', { input: 4, output: 20, cacheRead: 0.2 }],
115 ['opus-5', { input: 5, output: 25, cacheRead: 0.5 }],
116 ['opus-4', { input: 5, output: 25, cacheRead: 0.5 }],
117 ['sonnet-5-5', { input: 2, output: 10, cacheRead: 0.2 }],
118 ['sonnet-5', { input: 2, output: 10, cacheRead: 0.2 }],
119 ['sonnet-4', { input: 3, output: 15, cacheRead: 0.3 }],
120 ['haiku-5-5', { input: 0.1, output: 0.5, cacheRead: 0.01 }],
121 ['haiku-4', { input: 1, output: 5, cacheRead: 0.1 }],
122]
123
124/** The list price for a model id, or null for a model the table doesn't know. */
125export function priceOf(model: string): Price | null {
126 const id = model.toLowerCase()
127 return PRICES.find(([key]) => id.includes(key))?.[1] ?? null
128}
129
130export type Cost = { total: number; input: number; output: number; cacheWrite: number; cacheRead: number }
131
132/** The estimated cost of an agent's tokens in USD, or null when the model's price is unknown. */
133export function costOf(model: string, t: Tokens): Cost | null {
134 const p = priceOf(model)
135 if (!p) return null
136 const m = 1_000_000
137 const input = (t.input * p.input) / m
138 const output = (t.output * p.output) / m
139 const cacheWrite = (t.cacheWrite * p.input * 1.25) / m
140 const cacheRead = (t.cacheRead * p.cacheRead) / m
141 return { total: input + output + cacheWrite + cacheRead, input, output, cacheWrite, cacheRead }
142}
143
144export function formatCost(usd: number): string {
145 if (usd < 0.005) return '≈$0.00'
146 if (usd < 10) return `≈$${usd.toFixed(2)}`
147 return `≈$${usd.toFixed(1)}`
148}
149
150// ── Secrets ──────────────────────────────────────────────────
151// A command or URL an agent runs can carry a key. Targets and descriptions are masked before
152// they are stored or drawn, so a token never sits in state or on screen.
153const SECRET_PATTERNS: RegExp[] = [
154 /\b(sk-(?:ant-)?[A-Za-z0-9_-]{8,})/g, // Anthropic / OpenAI style keys
155 /\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, // GitHub tokens
156 /\b(xox[abprs]-[A-Za-z0-9-]{10,})/g, // Slack tokens
157 /\b(AKIA[0-9A-Z]{16})\b/g, // AWS access key ids
158 /\b(eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,})/g, // JWTs
159]
160// A key word followed by "=" or ":" (password=…, Authorization: Bearer …), or a bare "Bearer …".
161// A key word alone is not a secret: `grep -rn password src/` stays as it is.
162const SECRET_ASSIGNMENT = /((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd|authorization)["']?\s*[=:]\s*(?:bearer\s+)?["']?|\bbearer\s+)([^\s"'&;,]{4,})/gi
163
164export function redact(text: string): string {
165 let out = text.replace(SECRET_ASSIGNMENT, (_m, lead: string) => `${lead}•••`)
166 for (const re of SECRET_PATTERNS) out = out.replace(re, '•••')
167 return out
168}
169
170const ACTIVITY_LABEL: Record<Activity, string> = {
171 starting: 'Getting ready',
172 thinking: 'Thinking',
173 searching: 'Searching',
174 reading: 'Reading',
175 writing: 'Writing',
176 running: 'Running',
177 web: 'Browsing',
178}
179
180export function statusLabel(a: Agent): string {
181 if (a.status === 'done') return 'Done'
182 if (a.status === 'failed') return 'Failed'
183 if (a.status === 'cancelled') return 'Cancelled'
184 return ACTIVITY_LABEL[a.activity]
185}
186
187/** Tools that keep the agent's to-do list or start helpers: they don't change the accessory. */
188export const QUIET_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'Agent', 'Task'])
189
190export function activityOf(tool: string): Activity {
191 if (tool === 'Grep' || tool === 'Glob' || tool === 'ToolSearch') return 'searching'
192 if (tool === 'Read' || tool === 'NotebookRead') return 'reading'
193 if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') return 'writing'
194 if (tool === 'WebFetch' || tool === 'WebSearch' || tool.startsWith('mcp__')) return 'web'
195 return 'running'
196}
197
198/** What a tool call is about: the pattern, the file name, the command, the address. */
199export function targetOf(tool: string, input: Record<string, unknown>): string {
200 const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
201 const file = (p: string) => p.split('/').filter(Boolean).pop() ?? p
202 const arg =
203 tool === 'Bash' ? str('command')
204 : tool === 'Read' || tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' ? file(str('file_path'))
205 : tool === 'NotebookEdit' ? file(str('notebook_path'))
206 : tool === 'Grep' || tool === 'Glob' ? str('pattern')
207 : tool === 'WebFetch' ? str('url').replace(/^https?:\/\/(www\.)?/, '')
208 : tool === 'WebSearch' ? str('query')
209 : tool.startsWith('mcp__') ? (tool.split('__').pop() ?? tool)
210 : tool
211 // Bounded: a one-line `python -c` can be kilobytes, and this string is stored and redrawn
212 return clip(redact(arg.split('\n')[0].trim()), 160)
213}
214
215// ── Files ────────────────────────────────────────────────────
216/** How many files an agent remembers; past that the oldest go first. */
217export const MAX_FILES = 40
218const READS = new Set(['Read'])
219const CHANGES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
220
221/** The file a tool call read or changed, or null for every other call. */
222export function fileOf(tool: string, input: Record<string, unknown>, cwd = ''): TouchedFile | null {
223 if (!READS.has(tool) && !CHANGES.has(tool)) return null
224 const raw = input[tool === 'NotebookEdit' ? 'notebook_path' : 'file_path']
225 if (typeof raw !== 'string' || !raw.trim()) return null
226 const path = clip(redact(relativePath(raw.trim(), cwd)), 160)
227 return CHANGES.has(tool) ? { path, changed: true } : { path }
228}
229
230/** A path under the project, shown from the project folder; anything else, as it is. */
231export function relativePath(path: string, cwd: string): string {
232 const root = cwd.replace(/\/+$/, '')
233 return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
234}
235
236/** Adds touched files: one entry per path, moved to the newest place, changed once stays changed. */
237export function withFiles(list: readonly TouchedFile[], touched: readonly TouchedFile[]): TouchedFile[] {
238 let out = [...list]
239 for (const f of touched) {
240 const old = out.find(o => o.path === f.path)
241 out = out.filter(o => o.path !== f.path)
242 out.push(old?.changed || f.changed ? { path: f.path, changed: true } : { path: f.path })
243 }
244 return out.slice(-MAX_FILES)
245}
246
247/** The details panel's file lists, newest first. */
248export function filesSummary(files: readonly TouchedFile[]): { changed: string[]; read: string[] } {
249 const newest = [...files].reverse()
250 return { changed: newest.filter(f => f.changed).map(f => f.path), read: newest.filter(f => !f.changed).map(f => f.path) }
251}
252
253// ── Estimates ────────────────────────────────────────────────
254// For each (type, model) the last 20 finished runs are kept: duration and request count.
255// The median, not the mean: one very long run doesn't skew the estimate.
256export type Sample = { ms: number; requests: number }
257export type History = Record<string, Sample[]>
258const DEFAULT_MS = 60_000
259
260const FAMILIES = ['haiku', 'sonnet', 'opus', 'fable', 'mythos']
261
262/**
263 * A model id as people say it: "claude-opus-5-5" is "Opus 5.5", "claude-sonnet-4-5-20250929"
264 * is "Sonnet 4.5", the older "claude-3-5-haiku" is "Haiku 3.5". Empty for an unknown id.
265 */
266export function modelLabel(model: string): string {
267 const id = (model || '').toLowerCase()
268 const family = FAMILIES.find(f => id.includes(f))
269 if (!family) return ''
270 // Version numbers: single digits around the family name, leaving dates (8 digits) out
271 const nums = id.split(/[^0-9]+/).filter(n => n && n.length <= 2)
272 const name = family[0].toUpperCase() + family.slice(1)
273 return nums.length ? `${name} ${nums.slice(0, 2).join('.')}` : name
274}
275
276export function modelShort(model: string): string {
277 for (const m of ['haiku', 'sonnet', 'opus', 'fable']) if (model.includes(m)) return m
278 return model || '?'
279}
280const historyKey = (a: Pick<Agent, 'type' | 'model'>) => `${a.type}|${modelShort(a.model)}`
281
282function percentile(sorted: number[], p: number): number {
283 const i = (sorted.length - 1) * p
284 const lo = Math.floor(i)
285 return sorted[lo] + (sorted[Math.ceil(i)] - sorted[lo]) * (i - lo)
286}
287
288export type Expectation = { ms: number; requests?: number; low?: number; high?: number; learned: boolean; durations?: number[] }
289
290export function expectation(a: Agent, h: History): Expectation {
291 if (a.expectedMs) return { ms: a.expectedMs, learned: true }
292 // The same type and model first; otherwise every model of the same type
293 let samples = h[historyKey(a)] ?? []
294 if (samples.length === 0) samples = Object.entries(h).filter(([k]) => k.startsWith(`${a.type}|`)).flatMap(([, v]) => v)
295 if (samples.length === 0) return { ms: DEFAULT_MS, learned: false }
296 const durations = samples.map(s => s.ms).sort((x, y) => x - y)
297 const requests = samples.map(s => s.requests).filter(n => n > 0).sort((x, y) => x - y)
298 return {
299 ms: percentile(durations, 0.5),
300 requests: requests.length ? percentile(requests, 0.5) : undefined,
301 low: samples.length >= 3 ? percentile(durations, 0.25) : undefined,
302 high: samples.length >= 3 ? percentile(durations, 0.75) : undefined,
303 learned: true,
304 durations,
305 }
306}
307
308/** Keeps only well-formed samples from whatever the store held: a bad value never breaks drawing. */
309export function sanitizeHistory(raw: unknown): History {
310 const out: History = {}
311 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out
312 for (const [key, samples] of Object.entries(raw as Record<string, unknown>)) {
313 if (!Array.isArray(samples)) continue
314 const ok = samples
315 .filter((x): x is Sample => !!x && typeof x === 'object' && Number.isFinite((x as Sample).ms) && (x as Sample).ms > 0)
316 .map(x => ({ ms: x.ms, requests: Number.isFinite(x.requests) ? x.requests : 0 }))
317 .slice(-20)
318 if (ok.length) out[key] = ok
319 }
320 return out
321}
322
323export function learn(h: History, a: Agent, sample: Sample): History {
324 const k = historyKey(a)
325 return { ...h, [k]: [...(h[k] ?? []), sample].slice(-20) }
326}
327
328/**
329 * How long an agent still needs, and on what that guess rests. All of it is local arithmetic
330 * over the clock and the stored history: no model is asked, so it costs no tokens.
331 *
332 * - steps: the agent keeps a to-do list, so the time per finished step predicts the rest.
333 * - history: past the typical run time, look only at past runs that lasted at least this long
334 * and take their median: "runs that got this far usually finished at…".
335 * - stretch: no run in the history lasted this long; extend by a quarter of the time so far.
336 * Within the typical time, the model request count refines the time-based guess.
337 */
338export type Remaining = { ms: number; basis: 'expected' | 'steps' | 'history' | 'stretch'; revised: boolean }
339
340export function remaining(a: Agent, now: number, h: History): Remaining {
341 const elapsed = Math.max(0, now - a.startedAt)
342 const steps = stepSummary(a)
343 if (steps) {
344 const doneUnits = steps.done + (steps.current ? 0.5 : 0)
345 if (doneUnits > 0) return { ms: Math.max(1000, (elapsed / doneUnits) * (steps.total - doneUnits)), basis: 'steps', revised: false }
346 }
347 const e = expectation(a, h)
348 let timeLeft: number
349 let basis: Remaining['basis'] = 'expected'
350 if (elapsed <= e.ms) {
351 timeLeft = e.ms - elapsed
352 } else {
353 const longer = (e.durations ?? []).filter(d => d > elapsed)
354 if (longer.length) {
355 timeLeft = percentile(longer, 0.5) - elapsed
356 basis = 'history'
357 } else {
358 timeLeft = Math.max(10_000, elapsed * 0.25)
359 basis = 'stretch'
360 }
361 }
362 // Requests are a second clock: an agent that works fast gets there sooner than the time says
363 if (basis === 'expected' && e.requests && a.requests > 0 && a.requests < e.requests) {
364 const byRequests = (elapsed * (e.requests - a.requests)) / a.requests
365 timeLeft = 0.6 * timeLeft + 0.4 * byRequests
366 }
367 return { ms: Math.max(1000, timeLeft), basis, revised: basis === 'history' || basis === 'stretch' }
368}
369
370/** 0..1 from the time left: elapsed / (elapsed + remaining). Never 100% before done. */
371export function progress(a: Agent, now: number, h: History): number {
372 if (a.status === 'done') return 1
373 const end = a.endedAt ?? now
374 const elapsed = Math.max(0, end - a.startedAt)
375 const r = remaining(a, end, h)
376 return Math.min(0.99, elapsed / (elapsed + r.ms))
377}
378
379/**
380 * What the bar shows: the progress, but never less than it has already shown. When the time
381 * left is revised upwards, the bar holds still until real progress catches up.
382 */
383export function shownProgress(a: Agent, now: number, h: History): number {
384 return Math.max(a.shownShare ?? 0, progress(a, now, h))
385}
386
387/** The estimate column: the time left (re-estimated when an agent runs long), or the outcome. */
388export function estimateText(a: Agent, now: number, h: History): { main: string; extra: string } {
389 const elapsed = (a.endedAt ?? now) - a.startedAt
390 if (a.status === 'done') return { main: `took ${formatDuration(elapsed)}`, extra: '' }
391 if (a.status === 'failed') return { main: 'failed', extra: '' }
392 if (a.status === 'cancelled') return { main: 'cancelled', extra: '' }
393 const r = remaining(a, now, h)
394 const e = expectation(a, h)
395 const extra =
396 r.basis === 'steps' ? 'from steps'
397 : r.revised ? 're-estimated'
398 : !e.learned ? 'first estimate'
399 : e.low !== undefined && e.high !== undefined ? `usually ${formatDuration(e.low)}–${formatDuration(e.high)}`
400 : ''
401 return { main: `~${formatDuration(r.ms)} left`, extra }
402}
403
404/** For the header: the longest time left among running agents, and the crew's share of work done. */
405export function crewSummary(list: readonly Agent[], now: number, h: History): { leftMs: number; share: number } {
406 let total = 0
407 let done = 0
408 let leftMs = 0
409 for (const a of list.filter(x => x.status === 'running' || x.status === 'done')) {
410 const elapsed = Math.max(0, (a.endedAt ?? now) - a.startedAt)
411 const left = a.status === 'running' ? remaining(a, now, h).ms : 0
412 const share = a.status === 'done' ? 1 : shownProgress(a, now, h)
413 done += share * (elapsed + left)
414 total += elapsed + left
415 leftMs = Math.max(leftMs, left)
416 }
417 return { leftMs, share: total ? done / total : 0 }
418}
419
420/** A running agent with no activity for this long is treated as gone (its end was never reported). */
421export const STALE_MS = 15 * 60_000
422
423/** Past the cap, finished agents leave the stage first; a running one is never dropped. */
424export function capAgents(list: Agent[], max = 12): Agent[] {
425 const extra = list.length - max
426 if (extra <= 0) return list
427 const drop = new Set(list.filter(a => a.status !== 'running').slice(0, extra).map(a => a.id))
428 return list.filter(a => !drop.has(a.id))
429}
430
431// ── Row layout ───────────────────────────────────────────────
432// Column widths come from the surface's real width, so a row never runs past the right edge.
433// Fixed columns take their room first; the task and the "doing" text share what is left.
434export type Layout = { type: number; task: number; doing: number; pct: number; eta: number; tokens: number; barCells: number; showTokens: boolean; model: number }
435
436/** Cells a row spends besides its columns: the sprite, the gaps between columns, the arrow button and its margin. */
437export const FIXED_EXTRAS = 6 + 9 + 4 + 1
438
439/**
440 * The desktop reports its width in code-font cells but lays boxes out a little wider, so a
441 * desktop row plans with 94% of the reported width (bodyColumns already leaves out the
442 * engine's marks and a docked pane). The terminal's cells are exact.
443 */
444const DESKTOP_BUDGET = 0.94
445
446export function layout(columns: number, desktop = true, showCost = false): Layout {
447 // The terminal's dot and its space take two cells where the desktop sprite takes six, and its
448 // arrow carries a hotkey ("a: ▸"), three cells more
449 const budget = Math.floor(columns * (desktop ? DESKTOP_BUDGET : 1)) + (desktop ? 0 : 6 - 2 - 3)
450 // With costs on, the token column also holds "≈$0.04"
451 // The type column also holds the model: under the type on the desktop ("Sonnet 5.5" needs ten
452 // cells), beside it on the terminal when there is room
453 const base = { type: desktop ? 9 : 8, pct: 5, eta: 11, tokens: showCost ? 17 : 10 }
454 let barCells = 14
455 let showTokens = true
456 const flexible = () => budget - FIXED_EXTRAS - base.type - base.pct - base.eta - barCells - (showTokens ? base.tokens : 0)
457 // Too narrow: first a shorter bar, then shorter text columns; the token column is the last
458 // thing to go (it moves into the details panel only on very narrow surfaces)
459 if (flexible() < 30) barCells = 8
460 if (flexible() < 18) showTokens = false
461 const model = !desktop && flexible() >= 46 ? 7 : 0
462 const room = Math.max(18, flexible() - model)
463 const task = Math.min(36, Math.max(8, Math.round(room * 0.5)))
464 const doing = Math.min(46, Math.max(10, room - task))
465 return { ...base, task, doing, barCells, showTokens, model }
466}
467
468// ── Step tracking: the agent's own to-do list ─────────────────
469type TodoInput = { content?: string; activeForm?: string; status?: string }
470const stepStatus = (s: unknown): Step['status'] => (s === 'completed' || s === 'in_progress' ? s : 'pending')
471
472/** TodoWrite sends the whole list: steps are rebuilt from it. */
473export const MAX_STEPS = 50
474export function stepsFromTodos(todos: unknown): Step[] {
475 if (!Array.isArray(todos)) return []
476 return (todos as TodoInput[]).slice(0, MAX_STEPS).map((t, i) => ({
477 id: `t${i}`,
478 label: clip((t.status === 'in_progress' ? t.activeForm : t.content) || t.content || '', 120),
479 status: stepStatus(t.status),
480 }))
481}
482
483export function withStepCreated(steps: readonly Step[], id: string, label: string): Step[] {
484 return [...steps.filter(s => s.id !== id), { id, label: clip(label, 120), status: 'pending' as const }].slice(-MAX_STEPS)
485}
486
487export function withStepUpdated(steps: readonly Step[], u: { taskId?: unknown; status?: unknown; activeForm?: unknown; subject?: unknown }): Step[] {
488 if (u.status === 'deleted') return steps.filter(s => s.id !== u.taskId)
489 return steps.map(s =>
490 s.id !== u.taskId
491 ? s
492 : {
493 ...s,
494 status: u.status !== undefined ? stepStatus(u.status) : s.status,
495 label: (u.status === 'in_progress' && typeof u.activeForm === 'string' ? u.activeForm : typeof u.subject === 'string' ? u.subject : s.label) || s.label,
496 },
497 )
498}
499
500/** With a list of at least two steps: total, done and the current step. */
501export function stepSummary(a: Agent): { total: number; done: number; current?: string } | null {
502 const l = a.steps ?? []
503 if (l.length < 2) return null
504 return { total: l.length, done: l.filter(s => s.status === 'completed').length, current: l.find(s => s.status === 'in_progress')?.label }
505}
506
507// ── Calm transitions ─────────────────────────────────────────
508// The short thinking pauses between tools don't flicker the accessory: "thinking" shows
509// only once it has lasted 1.5 s.
510const CALM_MS = 1500
511
512export function withActivity(a: Agent, activity: Activity, target: string, now: number): Agent {
513 if (a.activity === activity && a.target === target) return a
514 return { ...a, activity, target, activityAt: now, prevActivity: a.activity, prevTarget: a.target }
515}
516
517export function shownActivity(a: Agent, now: number): { activity: Activity; target: string } {
518 if (a.activity === 'thinking' && a.prevActivity && a.prevActivity !== 'thinking' && now - a.activityAt < CALM_MS) {
519 return { activity: a.prevActivity, target: a.prevTarget ?? '' }
520 }
521 return { activity: a.activity, target: a.target }
522}
523
524/** A tool call running this long gets its time shown next to it. */
525export const SLOW_TOOL_MS = 30_000
526/** An agent with no event for this long is marked quiet. */
527export const QUIET_MS = 120_000
528
529/**
530 * The row's "doing" column: an approval the agent waits on comes first; then a step when there
531 * is a list, otherwise the activity and its target. A slow tool call shows its time, and a long
532 * silence says so: health signals that don't depend on the time estimate.
533 * `alert` asks the row to draw the main part in the warning color.
534 */
535export function doingText(a: Agent, now: number, runningHelpers: number): { main: string; extra: string; alert?: boolean } {
536 if (a.status !== 'running') return { main: statusLabel(a), extra: '' }
537 if (a.waitingFor) return { main: 'Needs approval', extra: a.waitingFor, alert: true }
538 const steps = stepSummary(a)
539 const shown = shownActivity(a, now)
540 const helpers = runningHelpers > 0 ? `waiting on ${runningHelpers} helper${runningHelpers > 1 ? 's' : ''}` : ''
541 const quietFor = now - a.seenAt
542 if (!helpers && quietFor > QUIET_MS) return { main: 'Quiet', extra: `no activity for ${formatDuration(quietFor)}`, alert: true }
543 const busyFor = now - a.activityAt
544 const slow = shown.activity !== 'thinking' && shown.activity !== 'starting' && busyFor > SLOW_TOOL_MS ? `${formatDuration(busyFor)} · ` : ''
545 if (steps) return { main: `Step ${Math.min(steps.total, steps.done + 1)}/${steps.total}`, extra: helpers || steps.current || ACTIVITY_LABEL[shown.activity] }
546 if (helpers) return { main: 'Waiting', extra: helpers.replace('waiting ', '') }
547 return { main: ACTIVITY_LABEL[shown.activity], extra: `${slow}${shown.target}` }
548}
549
550/** The header sentence: the crew's state at a glance. */
551export function headline(list: readonly Agent[], now: number): string {
552 const running = list.filter(a => a.status === 'running')
553 const isHelper = (a: Agent) => !!a.parentId && list.some(b => b.id === a.parentId)
554 const helpers = running.filter(isHelper).length
555 if (running.length === 0) {
556 const trouble = list.filter(a => a.status === 'failed' || a.status === 'cancelled').length
557 return trouble ? `Finished, ${trouble} didn't complete` : 'All done'
558 }
559 const top = running.filter(a => !isHelper(a))
560 const helperText = helpers ? `, ${helpers} helper${helpers > 1 ? 's' : ''} working` : ''
561 const approvals = running.filter(a => a.waitingFor).length
562 if (approvals) return `${approvals} agent${approvals > 1 ? 's need' : ' needs'} your approval`
563 if (top.length === 1) {
564 const a = top[0]
565 const d = doingText(a, now, 0)
566 return `${a.description}: ${d.main.toLowerCase()}${helperText}`
567 }
568 return `${top.length} agents working${helperText}`
569}
570
571/** The one-line summary for Claude Code's status line, shown while the crew is hidden. */
572export function statusText(list: readonly Agent[], now: number, h: History, cost: number | null): string | undefined {
573 if (list.length === 0) return undefined
574 const running = list.filter(a => a.status === 'running')
575 const approvals = running.filter(a => a.waitingFor).length
576 const parts = [running.length ? `${running.length} working` : `${list.length} done`]
577 if (approvals) parts.push(`${approvals} need${approvals > 1 ? '' : 's'} approval`)
578 if (running.length) parts.push(`~${formatDuration(crewSummary(list, now, h).leftMs)} left`)
579 if (cost !== null) parts.push(formatCost(cost))
580 return `Agent Crew: ${parts.join(' · ')}`
581}
582
583/** Deeper helpers are drawn at this depth: the indent stops growing, the rows stay. */
584export const MAX_DEPTH = 4
585
586/**
587 * Rows in order: each agent directly followed by its helpers, with their depth. Every agent
588 * gets exactly one row: a parent cycle or a missing parent makes it a root instead of hiding it.
589 */
590export function tree(list: readonly Agent[]): { a: Agent; depth: number }[] {
591 const ids = new Set(list.map(a => a.id))
592 const seen = new Set<string>()
593 const out: { a: Agent; depth: number }[] = []
594 const walk = (parent: string | undefined, depth: number) => {
595 for (const a of list) {
596 if (seen.has(a.id)) continue
597 const isRoot = !a.parentId || !ids.has(a.parentId)
598 if ((parent === undefined && isRoot) || (parent !== undefined && a.parentId === parent)) {
599 seen.add(a.id)
600 out.push({ a, depth: Math.min(depth, MAX_DEPTH) })
601 walk(a.id, depth + 1)
602 }
603 }
604 }
605 walk(undefined, 0)
606 // Agents caught in a parent cycle were never reached: show them as roots
607 for (const a of list) {
608 if (seen.has(a.id)) continue
609 seen.add(a.id)
610 out.push({ a, depth: 0 })
611 walk(a.id, 1)
612 }
613 return out
614}
615
616// ── Pixel drawing core ───────────────────────────────────────
617const px = (u: number, x: number, y: number, w: number, h: number, fill: string) =>
618 `<rect x="${x * u}" y="${y * u}" width="${w * u}" height="${h * u}" fill="${fill}"/>`
619
620/** Pixels from a row map like "..##..": each letter maps to a color. */
621function pixels(u: number, ox: number, oy: number, rows: string[], colors: Record<string, string>): string {
622 let out = ''
623 rows.forEach((row, y) => {
624 for (let x = 0; x < row.length; x++) {
625 const c = colors[row[x]]
626 if (c) out += px(u, ox + x, oy + y, 1, 1, c)
627 }
628 })
629 return out
630}
631
632// The sources don't depend on time: a crew member in the same state yields the same SVG
633// every second, so the surface doesn't reload the image and the animation keeps running.
634// Each SVG carries only the animations its own elements use (see styleFor).
635const KEYFRAMES: Record<string, string> = {
636 hop: '0%,100%{transform:translateY(0)}50%{transform:translateY(-2px)}',
637 cheer: '0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}',
638 on: '0%,49.9%{opacity:1}50%,100%{opacity:0}',
639 off: '0%,49.9%{opacity:0}50%,100%{opacity:1}',
640 blink: '0%,92%{opacity:1}92.1%,97%{opacity:0}97.1%,100%{opacity:1}',
641 blinkShut: '0%,92%{opacity:0}92.1%,97%{opacity:1}97.1%,100%{opacity:0}',
642 scan: '0%,100%{transform:translateX(0)}50%{transform:translateX(-4px)}',
643 l1: '0%,15%{opacity:0}15.1%,100%{opacity:1}',
644 l2: '0%,40%{opacity:0}40.1%,100%{opacity:1}',
645 l3: '0%,65%{opacity:0}65.1%,100%{opacity:1}',
646 read: '0%{transform:translateY(0)}33%{transform:translateY(4px)}66%{transform:translateY(8px)}100%{transform:translateY(0)}',
647 spin: '0%,49.9%{transform:translateX(0)}50%,100%{transform:translateX(2px)}',
648 stars: '0%,33%{transform:translateX(0)}33.1%,66%{transform:translateX(6px)}66.1%,100%{transform:translateX(12px)}',
649 zzz: '0%{transform:translate(0,0);opacity:0}20%{opacity:1}100%{transform:translate(4px,-6px);opacity:0}',
650}
651/** class → [keyframes name, animation rule]. */
652const CLASSES: Record<string, [string, string]> = {
653 hop: ['hop', 'hop .7s steps(1,end) infinite'],
654 cheer: ['cheer', 'cheer .6s steps(1,end) infinite'],
655 legA: ['on', 'on .5s steps(1,end) infinite'],
656 legB: ['off', 'off .5s steps(1,end) infinite'],
657 eye: ['blink', 'blink 3.2s steps(1,end) infinite'],
658 eyeShut: ['blinkShut', 'blinkShut 3.2s steps(1,end) infinite'],
659 scan: ['scan', 'scan 1.2s steps(1,end) infinite'],
660 cursor: ['on', 'on .8s steps(1,end) infinite'],
661 l1: ['l1', 'l1 2.4s steps(1,end) infinite'],
662 l2: ['l2', 'l2 2.4s steps(1,end) infinite'],
663 l3: ['l3', 'l3 2.4s steps(1,end) infinite'],
664 read: ['read', 'read 1.8s steps(1,end) infinite'],
665 sparkA: ['on', 'on 1.4s steps(1,end) infinite'],
666 sparkB: ['off', 'off 1.4s steps(1,end) infinite'],
667 spin: ['spin', 'spin 1.6s steps(1,end) infinite'],
668 stars: ['stars', 'stars 1s steps(1,end) infinite'],
669 zzz: ['zzz', 'zzz 2.4s linear infinite'],
670 zzz2: ['zzz', 'zzz 2.4s linear 1.2s infinite;opacity:0'],
671 waveA: ['on', 'on .7s steps(1,end) infinite'],
672 waveB: ['off', 'off .7s steps(1,end) infinite'],
673 seg: ['on', 'on .9s steps(1,end) infinite'],
674}
675
676/** The <style> for an SVG body: crisp pixels plus exactly the animations its classes use. */
677function styleFor(body: string): string {
678 const used = new Set<string>()
679 for (const m of body.matchAll(/class="(\w+)"/g)) used.add(m[1])
680 const frames = new Set<string>()
681 let css = 'rect{shape-rendering:crispEdges}'
682 for (const c of used) {
683 const def = CLASSES[c]
684 if (!def) continue
685 frames.add(def[0])
686 css += `.${c}{animation:${def[1]}}`
687 }
688 for (const f of frames) css += `@keyframes ${f}{${KEYFRAMES[f]}}`
689 return `<style>${css}</style>`
690}
691
692/** Small memo for SVG sources: the same state always draws the same string. */
693function memo<K>(cache: Map<K, string>, key: K, make: () => string): string {
694 let v = cache.get(key)
695 if (v === undefined) {
696 v = make()
697 if (cache.size > 256) cache.clear()
698 cache.set(key, v)
699 }
700 return v
701}
702
703// ── Crew member (Clawd) ──────────────────────────────────────
704// Stage: 22×18 cells. Body x3..12 (10×7), y5..11; claws on the sides; four thin legs.
705// Accessories sit on the right (x14..21) or above the head.
706const U = 2
707
708/** How a thinking crew member holds itself in a scene's frame. */
709type Pose = { lookUp?: boolean }
710
711function creature(a: Agent, pose: Pose = {}): string {
712 const u = U
713 const running = a.status === 'running'
714 const done = a.status === 'done'
715 const body = a.status === 'cancelled' ? C.bodyAsleep : C.body
716
717 let s = px(u, 3, 14, 10, 1, '#00000018')
718 const leg = (x: number, h: number) => px(u, x, 12, 1, h, body)
719 const still = leg(4, 2) + leg(6, 2) + leg(9, 2) + leg(11, 2)
720 const stride = leg(4, 1) + leg(6, 2) + leg(9, 1) + leg(11, 2)
721 s += running ? `<g class="legA">${still}</g><g class="legB">${stride}</g>` : still
722
723 // Body and claws; when done, the right claw is up holding the flag
724 let torso = px(u, 3, 5, 10, 7, body) + px(u, 2, 7, 1, 2, body)
725 if (done) torso += px(u, 13, 3, 1, 3, body)
726 else torso += px(u, 13, 7, 1, 2, body)
727
728 let face = ''
729 if (done) {
730 face = pixels(u, 4, 7, ['.#....#.', '#.#..#.#'], { '#': C.eye })
731 } else if (a.status === 'failed') {
732 face = pixels(u, 4, 6, ['#.#..#.#', '.#....#.', '#.#..#.#'], { '#': C.eye })
733 } else if (a.status === 'cancelled') {
734 face = px(u, 4, 8, 2, 1, C.eye) + px(u, 9, 8, 2, 1, C.eye) // asleep: - -
735 } else {
736 const side = a.activity !== 'thinking' && a.activity !== 'starting' && !a.waitingFor ? 1 : 0
737 const ey = a.activity === 'thinking' || a.waitingFor || pose.lookUp ? 6 : 7
738 const open = px(u, 5 + side, ey, 1, 2, C.eye) + px(u, 10 + side, ey, 1, 2, C.eye)
739 const shut = px(u, 5 + side, ey + 1, 1, 1, C.eye) + px(u, 10 + side, ey + 1, 1, 1, C.eye)
740 face = `<g class="eye">${open}</g><g class="eyeShut">${shut}</g>`
741 }
742
743 const hat =
744 a.activity === 'starting' && running
745 ? pixels(u, 3, 2, ['..######..', '.########.', '##########'], { '#': C.yellow }) + px(u, 4, 4, 8, 1, C.yellowDark) + px(u, 5, 2, 1, 1, C.white)
746 : ''
747
748 const motion = running ? ' class="hop"' : done ? ' class="cheer"' : ''
749 return s + `<g${motion}>${torso}${face}${hat}</g>`
750}
751
752// ── Thinking scenes ──────────────────────────────────────────
753// Thinking is the most common state, so it isn't always the hourglass: a crew member keeps
754// taking turns between the hourglass, a thought cloud and a lightbulb, each three frames long.
755export const THINKING_SCENES = ['hourglass', 'cloud', 'idea'] as const
756export type ThinkingScene = (typeof THINKING_SCENES)[number]
757/** How long one scene plays before the next: one loop of its three frames, so they keep taking turns. */
758export const SCENE_MS = 3000
759
760/** One frame of a scene lasts a second. */
761export const FRAME_MS = 1000
762
763/**
764 * The scene a thinking crew member plays now, and which of its three frames. Each agent starts
765 * at its own scene, so a crew isn't in step. The frame comes from the clock, not from a CSS
766 * animation: the band redraws every second, and a redraw restarts an SVG's animations, so a
767 * three-second CSS sequence would never get past its first frame.
768 */
769export function thinkingScene(a: Pick<Agent, 'id' | 'activityAt'>, now: number): ThinkingScene {
770 return THINKING_SCENES[(seedOf(a.id) + Math.floor(sinceThinking(a, now) / SCENE_MS)) % THINKING_SCENES.length]
771}
772export function sceneFrame(a: Pick<Agent, 'activityAt'>, now: number): 0 | 1 | 2 {
773 return (Math.floor(sinceThinking(a, now) / FRAME_MS) % 3) as 0 | 1 | 2
774}
775const sinceThinking = (a: Pick<Agent, 'activityAt'>, now: number) => Math.max(0, now - (a.activityAt ?? 0))
776function seedOf(id: string | undefined): number {
777 let seed = 0
778 for (const ch of id ?? '') seed = (seed * 31 + ch.charCodeAt(0)) >>> 0
779 return seed
780}
781
782const framed = (body: string, transform = '') => (transform ? `<g transform="${transform}">${body}</g>` : body)
783const dots = (pts: [number, number][], color: string = C.white) => pts.map(([x, y]) => px(U, x, y, 1, 1, color)).join('')
784
785/** One frame of a scene, the crew member drawn in it. */
786function sceneSvg(a: Agent, scene: ThinkingScene, n: 0 | 1 | 2): string {
787 const u = U
788 switch (scene) {
789 case 'cloud': {
790 // A thought cloud grows over the head and a question mark appears in it
791 const rising = dots([[14, 3], [16, 2]])
792 const cloud = pixels(u, 16, -2, ['.####.', '######', '.####.'], { '#': C.white })
793 const big = pixels(u, 14, -2, ['..#####..', '.#######.', '#########', '.#######.', '..#####..'], { '#': C.white })
794 const mark = pixels(u, 17, -2, ['.##.', '...#', '..#.', '....', '..#.'], { '#': C.yellowDark })
795 return [creature(a, { lookUp: true }) + rising, creature(a, { lookUp: true }) + rising + cloud, creature(a, { lookUp: true }) + px(u, 13, 4, 1, 1, C.white) + big + mark][n]
796 }
797 case 'idea': {
798 // A bulb appears over the head, lights up, and the crew member hops
799 const outline = pixels(u, 6, -1, ['.##.', '#..#', '#..#', '.##.', '.##.'], { '#': C.white })
800 const lit = pixels(u, 6, -1, ['.##.', '####', '####', '.##.'], { '#': C.yellow }) + px(u, 7, 3, 2, 1, C.greyDark)
801 const rays = dots([[4, 0], [11, 0], [3, 2], [12, 2]], C.yellow)
802 return [creature(a), creature(a, { lookUp: true }) + outline, framed(creature(a, { lookUp: true }) + lit + rays, 'translate(0 -2)')][n]
803 }
804 default:
805 return accessory(a) + creature(a)
806 }
807}
808
809/** The checkered finish flag, held up by the right claw, waving in two frames. */
810function finishFlag(): string {
811 const u = U
812 const pole = px(u, 13, -2, 1, 6, C.ink)
813 const check = (shift: number) =>
814 pixels(u, 14, -2 + shift, ['#.#.#.', '.#.#.#', '#.#.#.'], { '#': C.ink, '.': C.white })
815 return `<g class="cheer">${pole}<g class="waveA">${check(0)}</g><g class="waveB">${check(1)}</g></g>`
816}
817
818function accessory(a: Agent, flipped = false): string {
819 const u = U
820 if (a.status === 'done') return finishFlag()
821 if (a.waitingFor && a.status === 'running') {
822 // A yellow question bubble over the head: it waits on the person
823 const bubble = pixels(u, 14, 0, ['.#####.', '#######', '###.###', '####.##', '###.###', '#######', '.#####.', '..#....'], { '#': C.yellow, '.': '' })
824 const mark = pixels(u, 14, 0, ['.......', '..###..', '.#...#.', '....#..', '...#...', '.......', '...#...'], { '#': C.ink })
825 return `<g class="sparkA">${bubble}${mark}</g><g class="sparkB">${bubble}${mark}</g>`
826 }
827 if (a.status === 'failed') {
828 const stars = `<g class="stars">${px(u, 3, 2, 1, 1, C.yellow)}${px(u, 6, 1, 1, 1, C.yellowDark)}</g>`
829 return stars + px(u, 16, 3, 5, 6, C.red) + px(u, 18, 4, 1, 2, C.white) + px(u, 18, 7, 1, 1, C.white) + px(u, 18, 9, 1, 3, C.greyDark)
830 }
831 if (a.status === 'cancelled') {
832 const z = (x: number, y: number) => pixels(u, x, y, ['###', '.#.', '###'], { '#': C.grey })
833 return `<g class="zzz">${z(14, 2)}</g><g class="zzz2">${z(17, 0)}</g>`
834 }
835 switch (a.activity) {
836 case 'searching':
837 return `<g class="scan">${pixels(u, 16, 3, ['.###.', '#ooo#', '#ooo#', '#ooo#', '.###.'], { '#': C.blueDark, o: C.blueLight })}${px(u, 17, 4, 1, 1, C.white)}${px(u, 15, 8, 1, 1, C.brown)}${px(u, 14, 9, 1, 1, C.brown)}</g>`
838 case 'reading':
839 return px(u, 16, 2, 5, 9, C.white) + px(u, 16, 2, 5, 1, C.paperEdge) + px(u, 20, 2, 1, 9, C.paperEdge) +
840 px(u, 17, 4, 3, 1, C.paperEdge) + px(u, 17, 6, 2, 1, C.paperEdge) + px(u, 17, 8, 3, 1, C.paperEdge) +
841 `<g class="read">${px(u, 17, 4, 3, 1, C.yellow)}</g>`
842 case 'writing':
843 return px(u, 15, 1, 7, 10, C.blueDark) + px(u, 15, 1, 7, 1, C.blue) + px(u, 16, 1, 1, 1, C.pink) + px(u, 18, 1, 1, 1, C.pink) +
844 `<g class="l1">${px(u, 16, 3, 4, 1, C.code)}</g><g class="l2">${px(u, 17, 5, 4, 1, C.code)}</g><g class="l3">${px(u, 17, 7, 2, 1, C.code)}</g>` +
845 `<g class="cursor">${px(u, 20, 7, 1, 1, C.white)}</g>` + px(u, 16, 9, 1, 1, C.code) + px(u, 20, 9, 1, 1, C.code)
846 case 'running':
847 return px(u, 15, 2, 7, 7, C.screenEdge) + px(u, 16, 3, 5, 5, C.screen) +
848 px(u, 16, 4, 1, 1, C.code) + px(u, 17, 5, 1, 1, C.code) + px(u, 16, 6, 1, 1, C.code) +
849 `<g class="l2">${px(u, 18, 4, 2, 1, '#2C7A50')}</g><g class="cursor">${px(u, 18, 6, 2, 1, C.code)}</g>` +
850 px(u, 14, 9, 9, 1, C.grey) + px(u, 15, 10, 7, 1, C.greyDark)
851 case 'web':
852 return pixels(u, 16, 3, ['.###.', '#####', '#####', '#####', '.###.'], { '#': C.blue }) +
853 `<g class="spin">${px(u, 16, 4, 2, 1, C.code)}${px(u, 17, 5, 1, 2, C.code)}${px(u, 19, 6, 1, 1, C.code)}</g>` +
854 px(u, 15, 8, 7, 1, C.blueLight)
855 case 'thinking':
856 // Turned over every second by the clock (a redraw would restart a CSS flip before it shows)
857 return `<g${flipped ? ' transform="rotate(180 37 11)"' : ''}>${pixels(u, 16, 2, ['#####', 'coooc', '.coc.', '..o..', '.c.c.', 'coooc', '#####'], { '#': C.blue, o: C.yellow, c: C.blueLight })}</g>` +
858 `<g class="sparkA">${px(u, 15, 1, 1, 1, C.yellow)}${px(u, 21, 5, 1, 1, C.yellow)}</g><g class="sparkB">${px(u, 21, 1, 1, 1, C.yellow)}${px(u, 15, 7, 1, 1, C.yellow)}</g>`
859 case 'starting':
860 return pixels(u, 16, 3, ['#.#', '###', '.#.', '.#.', '.#.', '.#.'], { '#': C.greyDark })
861 }
862 return ''
863}
864
865/** A row's sprite: the crew member with its accessory, 44×34 px, transparent background. */
866export const SPRITE_W = 44
867export const SPRITE_H = 34
868/** The sprite's width in character cells, rounded up: for indenting text under it. */
869export const SPRITE_CELLS = 6
870
871const spriteCache = new Map<string, string>()
872
873/**
874 * The sprite follows the calm-transition rule like the text does: a short thinking pause
875 * keeps the previous accessory. The source depends on the shown state only, not on time,
876 * and is cached per state.
877 */
878export function spriteSvg(a: Agent, now: number): string {
879 const activity = a.status === 'running' ? shownActivity(a, now).activity : a.activity
880 const waiting = a.status === 'running' && !!a.waitingFor
881 const scene: ThinkingScene = a.status === 'running' && activity === 'thinking' && !waiting ? thinkingScene(a, now) : 'hourglass'
882 const thinking = a.status === 'running' && activity === 'thinking' && !waiting
883 const n = scene !== 'hourglass' ? sceneFrame(a, now) : thinking ? sceneFrame(a, now) % 2 : 0
884 return memo(spriteCache, `${a.status}|${activity}|${waiting}|${scene}|${n}`, () => {
885 const shown = { ...a, activity, waitingFor: waiting ? a.waitingFor : undefined }
886 const body = scene === 'hourglass' ? accessory(shown, n === 1) + creature(shown) : sceneSvg(shown, scene, n)
887 return `<svg xmlns="http://www.w3.org/2000/svg" width="${SPRITE_W}" height="${SPRITE_H}" viewBox="0 -4 44 34">${styleFor(body)}${body}</svg>`
888 })
889}
890
891/** The header's small crew member: running or done (with the flag). */
892export function headerSvg(done: boolean): string {
893 const a = { status: done ? 'done' : 'running', activity: 'thinking' } as Agent
894 return spriteSvg(a, 0)
895}
896
897// ── Type label (desktop) ─────────────────────────────────────
898// The type and the model, drawn small as an SVG: the desktop's Text has one size, and at that
899// size two lines of label crowd the row.
900export const LABEL_W = 70
901export const LABEL_H = 30
902const labelCache = new Map<string, string>()
903const escXml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
904
905export function labelSvg(type: string, model: string): string {
906 const name = typeLabel(type)
907 const sub = modelLabel(model)
908 return memo(labelCache, `${name}|${sub}`, () => {
909 const font = 'font-family="ui-sans-serif,system-ui,-apple-system,sans-serif"'
910 const top = sub ? 13 : 19
911 return `<svg xmlns="http://www.w3.org/2000/svg" width="${LABEL_W}" height="${LABEL_H}" viewBox="0 0 ${LABEL_W} ${LABEL_H}" ${font}>` +
912 `<text x="0" y="${top}" font-size="10.5" font-weight="700" letter-spacing=".4" fill="${typeColor(type)}">${escXml(name)}</text>` +
913 (sub ? `<text x="0" y="25" font-size="9.5" fill="${C.grey}">${escXml(sub)}</text>` : '') +
914 `</svg>`
915 })
916}
917
918const barCache = new Map<string, string>()
919
920/**
921 * The segmented pixel progress bar. While running, the next segment blinks, and the bar is
922 * never drawn full: the segments round down and stop one short of the end.
923 */
924export function barSvg(share: number, color: string, running: boolean, width: number, segments = 14, height = 7): string {
925 const filled = running ? Math.min(segments - 1, Math.floor(share * segments)) : Math.min(segments, Math.round(share * segments))
926 return memo(barCache, `${filled}|${color}|${running}|${width}|${segments}|${height}`, () => {
927 const segW = (width - (segments - 1) * 2) / segments
928 let body = ''
929 for (let i = 0; i < segments; i++) {
930 const x = (i * (segW + 2)).toFixed(1)
931 const next = running && i === filled
932 body += `<rect${next ? ' class="seg"' : ''} x="${x}" y="0" width="${segW.toFixed(1)}" height="${height}" fill="${i < filled || next ? color : C.track}"/>`
933 }
934 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${styleFor(body)}${body}</svg>`
935 })
936}
937
938// ── Terminal ─────────────────────────────────────────────────
939export const textBar = (share: number, width = 16, running = false) =>
940 '█'.repeat(running ? Math.min(width - 1, Math.floor(share * width)) : Math.round(share * width)).padEnd(width, '░')
941// ── Terminal marker ──────────────────────────────────────────
942// The terminal draws no critter: each row starts with a dot whose color carries the state.
943export const DOT = '●'
944
945/** The terminal row's dot color: working, waiting on you, done, failed or cancelled. */
946export function dotColor(a: Pick<Agent, 'status' | 'waitingFor'>): string {
947 if (a.status === 'running') return a.waitingFor ? C.yellow : C.body
948 return a.status === 'done' ? C.green : a.status === 'failed' ? C.red : C.grey
949}
950
951types/index.d.ts 77 lines1export type AgentStatus = 'running' | 'done' | 'failed' | 'cancelled'
2
3/** What an agent is doing right now: picks the accessory its crew member holds. */
4export type Activity = 'starting' | 'thinking' | 'searching' | 'reading' | 'writing' | 'running' | 'web'
5
6/** One step of the agent's own to-do list (TodoWrite / TaskCreate). */
7export type Step = { id: string; label: string; status: 'pending' | 'in_progress' | 'completed' }
8
9/** Token counts as the API reports them, summed over the agent's requests. */
10export type Tokens = { input: number; output: number; cacheWrite: number; cacheRead: number }
11
12/** One recent tool call, for the details panel. */
13export type RecentTool = { label: string; failed?: boolean }
14
15/** A file an agent read or changed, for the details panel. */
16export type TouchedFile = { path: string; changed?: true }
17
18export type Agent = {
19 id: string
20 /** The subagent type: Explore, Plan, general-purpose, a plugin's agent. */
21 type: string
22 description: string
23 model: string
24 status: AgentStatus
25 activity: Activity
26 /** What the activity is about: the pattern searched, the file read, the command run. */
27 target: string
28 /** When the activity last changed, and what it was before: short thinking pauses don't flicker. */
29 activityAt: number
30 /** The last time any event of this agent arrived: a sign of life for spotting agents that vanished. */
31 seenAt: number
32 prevActivity?: Activity
33 prevTarget?: string
34 /** The agent's own to-do list, when it keeps one: real step progress. */
35 steps?: Step[]
36 startedAt: number
37 endedAt?: number
38 tokens: Tokens
39 tools: number
40 /** The tool an agent is waiting on the person's approval for; absent when it isn't waiting. */
41 waitingFor?: string
42 /** The last few tool calls, newest last: shown in the details panel. */
43 recent?: RecentTool[]
44 /** The files it read or changed, newest last; a file changed once stays changed. */
45 files?: TouchedFile[]
46 /** Closed by the mod (the engine reported it ended, or it vanished), not by its own turn.complete. */
47 retired?: true
48 /** The highest progress shown so far: a revised estimate never moves the bar backwards. */
49 shownShare?: number
50 /** Model requests made (turn.step): a progress clock independent of time. */
51 requests: number
52 /** The subagent that started this one; absent for agents started by the main conversation. */
53 parentId?: string
54 demo?: boolean
55 /** Fixed duration for demo agents; real agents learn from history. */
56 expectedMs?: number
57 /** The main conversation's turn, on the title's own record: its events count only while it runs. */
58 turnId?: string
59}
60
61declare module 'claude-code' {
62 interface PluginState {
63 'agent-crew': {
64 agents: Agent[]
65 /** Claude's own work in the main conversation, drawn on the title row; null between turns. */
66 main: Agent | null
67 hidden: boolean
68 expanded: string[]
69 collapsed: boolean
70 minimized: boolean
71 side: boolean
72 paneUp: boolean
73 foldFrame: number
74 }
75 }
76}
77