A pane of every subagent in the session: model, effort, context, tokens, estimated cost and time, with a crab per agent role on desktop. Works with any agent…

A Claude Code mod: a pane showing every subagent in the session, whatever its type and whatever started it (the Agent tool, a skill, a workflow, a nested subagent). Adapted from the agents panel in savvy-progress, without the savvy-flow workflow.
Each row shows:
● running, ✓ done, ✗ failedOn desktop each row is drawn as a card with a pixel crab dressed for the agent's role, walking while it runs:
The agent type is checked before the task description, so an implementer stays an engineer even when its task mentions a layout. The crab's accent and the model name take the model family's colour (Fable violet, Opus orange, Sonnet blue, Haiku green). The terminal draws the same data as text rows.
The header totals cost, tokens and wall time across all subagents. Nested subagents are marked ↳. Finished agents collapse under Finished, and Clear removes them. While any agent runs, the status line shows ▣ N running · ≈$cost.
/agents-panel shows or hides the pane.designWords and engineerWords (comma-separated) add words to the designer and engineer rules, e.g. storyboard, motion or terraform.autoOpen option (on by default) opens the pane when a subagent starts while none were running. If you close it mid-batch, it stays closed.Sprites and pane layout adapted from savvy-progress (its crab is Pixel Clawd from DockCrab). Cost is an estimate from token counts at list prices. Effort appears after the agent's first model request.
From any Claude Code terminal session:
/plugin install agents-panel --marketplace Blind3y3Design/agents-panel
Answer y to add the marketplace, then pick a scope (user makes it available in every session). Or from a shell:
claude plugin marketplace add Blind3y3Design/agents-panel
claude plugin install agents-panel@agents-panel --scope user
claude plugin validate . checks the manifest and hooks; claude plugin test . runs the tests. To run a working copy, claude --plugin-dir ..
MIT. The pane layout and crab sprites are adapted from savvy-progress by johnnyvizz (MIT); see LICENSE.
hooks/register.tsx 275 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Panel } from '../types'
5
6import { HEADER_H, ROW_H, agentAlt, agentSvg, headerSvg, paneWidth, roleOf, wordsRe } from './drawing'
7import { colorOf, costOf, ctxOf, fmtCost, fmtTime, fmtTokens, modelName, statsOf, tokensOf, totals, windowOf } from './format'
8
9export { roleOf } from './drawing'
10export { costOf, fmtCost, fmtTime, fmtTokens, modelName } from './format'
11
12// Every subagent of the session, whatever its type or the workflow that started
13// it: model, effort, context, tokens, estimated cost and time.
14
15const agents = atom({ plugin: 'agents-panel', key: 'agents' } as const, [])
16const panel = atom({ plugin: 'agents-panel', key: 'panel' } as const, { isDoneCollapsed: false })
17const now = atom({ plugin: 'agents-panel', key: 'now' } as const, 0)
18
19const PANE = 'agents-panel'
20const COMMAND = 'agents-panel'
21
22const bar = (pct: number, width: number): string => {
23 const filled = Math.round((width * Math.max(0, Math.min(100, pct))) / 100)
24 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
25}
26
27const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗' }
28
29async function openPane($: EngineInterface): Promise<void> {
30 const at = await $.clock.now()
31 await update($, now, () => at)
32 await $.ui.open({ id: PANE, title: 'Agents' })
33}
34
35async function togglePane($: EngineInterface): Promise<boolean> {
36 if ((await $.ui.panes()).some(p => p.id === PANE)) {
37 await $.ui.close({ id: PANE })
38 return false
39 }
40 await openPane($)
41 return true
42}
43
44// While anything runs: `▣ 2 running · ≈$0.31` in the status line.
45async function showStatus($: EngineInterface): Promise<void> {
46 const list = await read($, agents)
47 const running = list.filter(a => a.status === 'running')
48 if (!running.length) return $.ui.status(undefined)
49 const cost = list.reduce((s, a) => s + a.costUsd, 0)
50 $.ui.status(`▣ ${running.length} running · ≈${fmtCost(cost)}`)
51}
52
53export const register: Register = (on, options) => {
54 on('session.start', async ($, e, next) => {
55 const started = await next(e)
56 await $.command.register({
57 name: COMMAND,
58 description: 'Show or hide the subagents pane: model, effort, context, tokens, cost and time',
59 })
60 // Ticks running agents' clocks; quiet when nothing runs.
61 $.clock.every(1000, () => {
62 void (async () => {
63 const list = await read($, agents)
64 if (!list.some(a => a.status === 'running')) return
65 const at = await $.clock.now()
66 await update($, now, () => at)
67 })()
68 })
69 return started
70 })
71
72 on('command.run', { command: COMMAND }, async $ => {
73 const isOpen = await togglePane($)
74 return { text: isOpen ? 'Agents pane opened.' : 'Agents pane closed.' }
75 })
76
77 on('agent.spawn', async ($, e, next) => {
78 const started = await next(e)
79 if (started.deny !== undefined) return started
80
81 const at = await $.clock.now()
82 const before = await read($, agents)
83 const run: AgentRun = {
84 id: started.agentId ?? e.tool_use_id,
85 agentId: started.agentId,
86 parentAgentId: e.parentAgentId,
87 type: e.subagentType,
88 description: e.description,
89 model: started.model,
90 status: 'running',
91 startedAt: at,
92 contextTokens: 0,
93 contextMax: windowOf(started.model),
94 tokens: 0,
95 costUsd: 0,
96 steps: 0,
97 }
98 await update($, agents, list => [...list.filter(a => a.id !== run.id), run].slice(-200))
99 await update($, now, () => at)
100 await showStatus($)
101
102 // Opens with the first of a batch; closed mid-batch, it stays closed.
103 if (options.autoOpen !== false && !before.some(a => a.status === 'running')) {
104 if (!(await $.ui.panes()).some(p => p.id === PANE)) void openPane($)
105 }
106 return started
107 }).catch(($, e, next) => next(e))
108
109 // Each model request of a subagent: live context, effort, tokens and cost.
110 on('turn.step', async function* ($, e, next) {
111 const result = yield* next(e)
112 const agentId = e.agentId
113 const usage = result.usage
114 if (!agentId || !usage) return result
115
116 const model = usage.model || e.model
117 await update($, agents, list =>
118 list.map(a =>
119 a.agentId !== agentId
120 ? a
121 : {
122 ...a,
123 model,
124 effort: typeof e.effort === 'string' ? e.effort : typeof e.effort === 'number' ? String(e.effort) : a.effort,
125 contextTokens: tokensOf(usage),
126 contextMax: windowOf(model),
127 tokens: a.tokens + tokensOf(usage),
128 costUsd: a.costUsd + costOf(model, usage),
129 steps: a.steps + 1,
130 },
131 ),
132 )
133 await showStatus($)
134 return result
135 })
136
137 on('turn.complete', async ($, e, next) => {
138 const agentId = e.agentId
139 if (agentId === undefined) return next(e)
140 const at = await $.clock.now()
141 await update($, agents, list =>
142 list.map(a => {
143 if (a.agentId !== agentId) return a
144 // A run whose steps went unseen still gets the turn's own sum.
145 const fallback = a.steps === 0 ? e.usage : undefined
146 return {
147 ...a,
148 status: (e.reason === 'answer' ? 'done' : 'failed') as AgentRun['status'],
149 endedAt: at,
150 ...(fallback
151 ? {
152 model: fallback.model || a.model,
153 tokens: tokensOf(fallback),
154 costUsd: costOf(fallback.model || a.model, fallback),
155 }
156 : {}),
157 }
158 }),
159 )
160 await update($, now, () => at)
161 await showStatus($)
162 return next(e)
163 })
164
165 const words = { design: wordsRe(options.designWords), engineer: wordsRe(options.engineerWords) }
166
167 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
168 const ui = $.ui.resolve(e)
169 const { Box, Text, Button } = ui
170 const list = await read($, agents)
171 const p: Panel = await read($, panel)
172 const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
173
174 const running = list.filter(a => a.status === 'running').reverse()
175 const finished = list.filter(a => a.status !== 'running').reverse()
176 const t = totals(list, at)
177 const cols = Math.max(24, e.props.bodyColumns || 40)
178 const barW = Math.max(6, Math.min(20, cols - 34))
179 const isNested = (a: AgentRun) => a.parentAgentId !== undefined
180
181 const row = (a: AgentRun) => {
182 const color = colorOf(a.model)
183 const ctx = ctxOf(a)
184 const pad = isNested(a) ? ' ' : ' '
185 return (
186 <Box key={a.id} flexDirection="column" marginBottom={1}>
187 <Text wrap="truncate-end">
188 {isNested(a) ? <Text dimColor>↳ </Text> : ''}
189 <Text color={color}>▣ </Text>
190 <Text bold>{a.description || a.type}</Text>{' '}
191 <Text color={a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : color}>
192 {STATUS_GLYPH[a.status]}
193 </Text>
194 </Text>
195 <Text dimColor wrap="truncate-end">
196 {pad}
197 {a.type} · <Text color={color}>{modelName(a.model)}</Text>
198 {a.effort ? ` · ${a.effort}` : ''}
199 </Text>
200 <Text wrap="truncate-end">
201 {pad}
202 <Text color={ctx >= 80 ? 'red' : ctx >= 50 ? 'yellow' : color}>{bar(ctx, barW)}</Text>
203 <Text dimColor> {statsOf(a, at)}</Text>
204 </Text>
205 </Box>
206 )
207 }
208
209 if (list.length === 0) {
210 return <Text dimColor>No subagents yet this session.</Text>
211 }
212
213 const finishedToggle = finished.length > 0 && (
214 <Box flexDirection="row" gap={2}>
215 <Button
216 key="ap-done"
217 label={`${p.isDoneCollapsed ? '▸' : '▾'} Finished · ${finished.length}`}
218 plain
219 onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
220 />
221 <Button
222 key="ap-clear"
223 label="Clear"
224 plain
225 onPress={() => update($, agents, prev => prev.filter(a => a.status === 'running'))}
226 />
227 </Box>
228 )
229
230 // Desktop: each row one SVG, the crab dressed for the agent's role.
231 if (e.surface === 'desktop' && 'Svg' in ui) {
232 const { Svg } = ui
233 const W = paneWidth(e.props.bodyColumns)
234 const svgRow = (a: AgentRun) => {
235 const role = roleOf(a.type, a.description, words)
236 return <Svg key={a.id} source={agentSvg(W, a, at, role)} alt={agentAlt(a, at, role)} width={W} height={ROW_H} />
237 }
238 return (
239 <Box flexDirection="column" gap={1}>
240 <Svg
241 key="ap-header"
242 source={headerSvg(W, t)}
243 alt={`≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} tokens, ${running.length} running, ${finished.length} finished`}
244 width={W}
245 height={HEADER_H}
246 />
247 <Box flexDirection="column">
248 {running.length > 0 && <Text dimColor>Running · {running.length}</Text>}
249 {running.map(svgRow)}
250 </Box>
251 {finishedToggle}
252 {!p.isDoneCollapsed && finished.length > 0 && <Box flexDirection="column">{finished.map(svgRow)}</Box>}
253 </Box>
254 )
255 }
256
257 return (
258 <Box flexDirection="column">
259 <Text bold>
260 ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} tokens · {fmtTime(t.time)}
261 </Text>
262 <Text dimColor>
263 {running.length} running · {finished.length} finished
264 </Text>
265 <Box flexDirection="column" marginTop={1}>
266 {running.length > 0 && <Text dimColor>Running · {running.length}</Text>}
267 {running.map(row)}
268 {finishedToggle}
269 {!p.isDoneCollapsed && finished.map(row)}
270 </Box>
271 </Box>
272 )
273 })
274}
275hooks/drawing.ts 230 lines1import type { AgentRun } from '../types'
2
3import { colorOf, ctxOf, fmtCost, fmtTime, fmtTokens, modelName, statsOf } from './format'
4import type { Totals } from './format'
5
6// The desktop pane: each row one SVG, with a pixel crab dressed for the agent's
7// role. Sprites and layout adapted from savvy-progress (JohnnyVizz/claude-kit),
8// whose crab is Pixel Clawd from DockCrab.
9
10export type Role = 'crab' | 'pirate' | 'engineer' | 'designer'
11
12// Words that mark a role in an agent's type or task description; the type wins.
13const DESIGN = /\b(design\w*|figma|ui|ux|visual\w*|mock-?ups?|wireframe\w*|layouts?|typograph\w*|colou?rs?|css|styl\w*|themes?|icons?|brand\w*|accessib\w*|a11y|prototyp\w*)\b/i
14const ENGINEER =
15 /\b(implement\w*|engineer\w*|develop\w*|code|coding|build\w*|fix\w*|bugs?|debug\w*|refactor\w*|tests?|testing|tdd|features?|migrat\w*|deploy\w*|compil\w*|typecheck\w*|lint\w*|api|endpoints?|scripts?|ship\w*|merge\w*)\b/i
16
17export const wordsRe = (extra: unknown): RegExp | null => {
18 if (typeof extra !== 'string' || !extra.trim()) return null
19 const words = extra
20 .split(',')
21 .map(w => w.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
22 .filter(Boolean)
23 return words.length ? new RegExp(`\\b(${words.join('|')})\\b`, 'i') : null
24}
25
26export type RoleWords = { design?: RegExp | null; engineer?: RegExp | null }
27
28const matches = (text: string, base: RegExp, extra?: RegExp | null): boolean =>
29 base.test(text) || (extra ? extra.test(text) : false)
30
31export const roleOf = (type: string, description: string, words: RoleWords = {}): Role => {
32 // `my-plugin:implementer` names the same agent as `implementer`.
33 const bare = type.replace(/^.*:/, '').replace(/[-_]/g, ' ')
34 if (/^explore$/i.test(bare.trim())) return 'pirate'
35 if (matches(bare, DESIGN, words.design)) return 'designer'
36 if (matches(bare, ENGINEER, words.engineer)) return 'engineer'
37 if (matches(description, DESIGN, words.design)) return 'designer'
38 if (matches(description, ENGINEER, words.engineer)) return 'engineer'
39 return 'crab'
40}
41
42export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
43
44export const xml = (s: string): string =>
45 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
46
47// Rough advance of system UI text, in em; good enough to fit a line.
48const charEm = (ch: string): number =>
49 /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
50
51const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
52
53// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
54export const fitText = (s: string, size: number, maxW: number): string => {
55 if (textWidth(s, size) <= maxW) return s
56 let out = ''
57 for (const ch of s) {
58 if (textWidth(out + ch + '…', size) > maxW) break
59 out += ch
60 }
61 return out + '…'
62}
63
64const PANE_CSS = `<style>
65.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
66@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
67.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
68@media (prefers-reduced-motion: reduce){.live{animation:none}}
69</style>`
70
71// A 24×18 crab on a 30×28 grid. The body keeps the brand clay; the model
72// family's color lives in the costume's accent.
73const CLAY = '#D97757'
74const INK = '#1F1E1D'
75
76// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
77// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
78type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
79
80const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
81 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
82
83// `armCls` lets a raised claw travel with the prop it holds.
84const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
85 f(7, 10, 16, 12, CLAY)
86 f(3, 14, 4, 4, CLAY)
87 f(23, 14 + armFront, 4, 4, CLAY, armCls)
88 f(9, 12, 2, 2, INK)
89 f(19, 12, 2, 2, INK)
90 f(7, 22, 2, 4, CLAY, 'la')
91 f(17, 22, 2, 4, CLAY, 'la')
92 f(11, 22, 2, 4, CLAY, 'lb')
93 f(21, 22, 2, 4, CLAY, 'lb')
94}
95
96// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
97// once-a-second redraw of a running row restarts them in phase. Every crab walks;
98// each costume adds its prop's own motion on top.
99const CRAB_CSS = `<style>
100.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
101.run .bd{animation:bob .5s steps(1) infinite -.125s}
102.run g{transform-box:fill-box}
103@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
104.c-engineer.run .typ{transform-origin:0 50%;animation:type 1s steps(1) infinite}
105.c-engineer.run .cur{animation:blink .5s steps(1) infinite}
106.c-engineer.run .tap{animation:st .25s steps(1) infinite}
107@keyframes type{0%{transform:scaleX(.17)}25%{transform:scaleX(.5)}50%{transform:scaleX(.83)}75%{transform:scaleX(1)}}
108@keyframes blink{50%{opacity:0}}
109.c-pirate.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
110@keyframes fence{50%{transform:rotate(25deg)}}
111.c-designer.run .it{transform-origin:50% 100%;animation:dab .5s ease-in-out infinite}
112.c-designer.run .tip{animation:hue 2s steps(1) infinite}
113@keyframes dab{50%{transform:rotate(-22deg) translateY(1px)}}
114@keyframes hue{25%{fill:#378ADD}50%{fill:#1D9E75}75%{fill:#F5C542}}
115@media (prefers-reduced-motion: reduce){.run,.run g,.run rect{animation:none!important}}
116</style>`
117
118const COSTUMES: Record<Role, (f: Fill, accent: string) => void> = {
119 crab: f => crabBody(f),
120 // Pirate scouting the code; the cutlass fences.
121 pirate: f => {
122 crabBody(f)
123 stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
124 f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
125 f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
126 },
127 // Software developer in headphones and glasses, at an open laptop; the code
128 // line types out, the cursor blinks and a claw taps the keys.
129 engineer: (f, accent) => {
130 crabBody(f, 0, 'tap')
131 stamp(f, 6, 6, ['...hhhhhhhhhhhh...', '.hh............hh.', 'h................h'], { h: '#3A3A3C' })
132 stamp(f, 5, 9, ['cc', 'ca', 'ca', 'cc'], { c: '#3A3A3C', a: accent })
133 stamp(f, 23, 9, ['cc', 'ac', 'ac', 'cc'], { c: '#3A3A3C', a: accent })
134 stamp(f, 8, 11, ['kkkk......kkkk', 'k..kkkkkkkk..k', 'k..k......k..k', 'kkkk......kkkk'], { k: '#2B2B2E' })
135 stamp(
136 f,
137 9,
138 15,
139 ['fffffffffffff', 'fdgggdbbddddf', 'fdddddddddddf', 'fdbbbdgggdddf', 'fdddddddddddf', 'fffffffffffff'],
140 { f: '#8E929A', d: '#1F2430', g: '#5FBF8F', b: '#6FA8DC' },
141 )
142 f(11, 17, 6, 1, accent, 'typ')
143 f(11, 19, 2, 1, '#F8F6F1', 'cur')
144 f(7, 21, 17, 1, '#C9CCD2')
145 },
146 // Designer in a beret, palette in one claw; the brush dabs and changes color.
147 designer: (f, accent) => {
148 crabBody(f, -4, 'it')
149 stamp(f, 6, 4, ['........s.........', '....bbbbbbbbbb....', '..bbbbbbbbbbbbbb..', '.bbbbbbbbbbbbbbbbb', '..aaaaaaaaaaaaaa..'], { b: '#3A3550', s: '#3A3550', a: accent })
150 stamp(f, 0, 12, ['.ppp.', 'prpgp', 'ppbpp', '.pp..'], { p: '#E8D3A8', r: '#D0453F', g: '#1D9E75', b: '#378ADD' })
151 f(25, 4, 1, 6, '#A0703F', 'it'); f(25, 3, 1, 1, '#C9CCD2', 'it')
152 f(24, 1, 3, 2, '#D0453F', 'tip')
153 },
154}
155
156const CRAB_SCALE = 1.1
157
158// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
159// legs stay outside it and step on their own. The brush tip rides with the brush.
160export const crab = (x: number, y: number, role: Role, accent: string, isWalking = false, scale = CRAB_SCALE): string => {
161 const groups = new Map<string, string[]>([['bd', []]])
162 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
163 if (!groups.has(cls)) groups.set(cls, [])
164 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
165 }
166 COSTUMES[role](f, accent)
167 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
168 const tip = groups.get('tip') ?? []
169 const props = [...groups.keys()].filter(k => !['bd', 'la', 'lb', 'tip'].includes(k))
170 const prop = (cls: string) =>
171 cls === 'it' && tip.length
172 ? `<g class="it">${(groups.get('it') ?? []).join('')}${tip.map(r => r.replace('<rect', '<rect class="tip"')).join('')}</g>`
173 : group(cls)
174 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(prop).join('')}</g>`
175 return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g class="c-${role}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
176}
177
178const statusMark = (x: number, y: number, status: string, color: string): string => {
179 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
180 if (status === 'done')
181 return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
182 return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
183}
184
185const svg = (W: number, H: number, body: string): string =>
186 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
187
188export const paneWidth = (bodyColumns: number | undefined): number =>
189 Math.max(240, Math.min(900, (bodyColumns || 40) * 8 - 8))
190
191export const HEADER_H = 44
192export const ROW_H = 66
193
194export const headerSvg = (W: number, t: Totals): string => {
195 const gap = 6
196 const tw = (W - gap * 2) / 3
197 const tile = (i: number, k: string, v: string) =>
198 `<rect class="tile" x="${i * (tw + gap)}" y="0" width="${tw}" height="40" rx="8"/>
199<text class="s" x="${i * (tw + gap) + 9}" y="16" font-family="${FONT}" font-size="11">${k}</text>
200<text class="t" x="${i * (tw + gap) + 9}" y="33" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${xml(fitText(v, 15, tw - 18))}</text>`
201 return svg(W, HEADER_H, `${tile(0, 'Cost', '≈' + fmtCost(t.cost))}${tile(1, 'Tokens', fmtTokens(t.tokens))}${tile(2, 'Time', fmtTime(t.time))}`)
202}
203
204export const agentSvg = (W: number, a: AgentRun, at: number, role: Role): string => {
205 const color = colorOf(a.model)
206 const ctx = ctxOf(a)
207 const indent = a.parentAgentId !== undefined ? 14 : 0
208 const x = indent + 42
209 const textW = W - x - 22
210 const barColor = ctx >= 80 ? '#D0453F' : ctx >= 50 ? '#BA7517' : color
211 const model = modelName(a.model)
212 const after = `${a.effort ? ` · ${a.effort}` : ''}${a.status === 'failed' ? ' · error' : ''}`
213 const typeText = fitText(a.type, 11, Math.max(40, textW * 0.45))
214 return svg(
215 W,
216 ROW_H,
217 `${indent ? `<text class="m" x="0" y="30" font-family="${FONT}" font-size="12">↳</text>` : ''}
218${crab(indent, 14, role, color, a.status === 'running')}
219<text class="t" x="${x}" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
220<text x="${x}" y="34" font-family="${FONT}" font-size="11"><tspan class="s">${xml(typeText)} · </tspan><tspan fill="${color}" font-weight="600">${xml(model)}</tspan><tspan class="s">${xml(after)}</tspan></text>
221<text class="s" x="${x}" y="49" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(statsOf(a, at), 11, textW))}</text>
222<rect class="k" x="${x}" y="55" width="${textW}" height="4" rx="2"/><rect x="${x}" y="55" width="${Math.round((textW * ctx) / 100)}" height="4" rx="2" fill="${barColor}"/>
223${statusMark(W - 8, 16, a.status, color)}
224<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
225 )
226}
227
228export const agentAlt = (a: AgentRun, at: number, role: Role): string =>
229 `${a.description || a.type} (${role}): ${a.type}, ${modelName(a.model)}${a.effort ? ` ${a.effort}` : ''}, ${a.status}, ${statsOf(a, at)}`
230hooks/format.ts 86 lines1import type { AgentRun } from '../types'
2
3// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
4// The engine reports tokens, not money, so cost is an estimate.
5const PRICES: [RegExp, [number, number, number, number]][] = [
6 [/fable|mythos/, [10, 50, 0.25, 12.5]],
7 [/opus-5-5/, [4, 20, 0.2, 5]],
8 [/opus/, [5, 25, 0.5, 6.25]],
9 [/sonnet/, [2, 10, 0.2, 2.5]],
10 [/haiku/, [1, 5, 0.1, 1.25]],
11]
12
13export type Usage = {
14 input_tokens: number
15 output_tokens: number
16 cache_read_input_tokens: number
17 cache_creation_input_tokens: number
18}
19
20const priceOf = (model: string): [number, number, number, number] =>
21 PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
22
23export const costOf = (model: string, u: Usage): number => {
24 const [i, o, r, w] = priceOf(model)
25 return (
26 ((u.input_tokens || 0) * i +
27 (u.output_tokens || 0) * o +
28 (u.cache_read_input_tokens || 0) * r +
29 (u.cache_creation_input_tokens || 0) * w) /
30 1e6
31 )
32}
33
34export const tokensOf = (u: Usage): number =>
35 (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
36
37export const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
38
39const FAMILY_COLOR: [RegExp, string][] = [
40 [/fable|mythos/i, '#7F77DD'],
41 [/opus/i, '#D85A30'],
42 [/sonnet/i, '#378ADD'],
43 [/haiku/i, '#1D9E75'],
44]
45
46export const colorOf = (model: string): string => FAMILY_COLOR.find(([re]) => re.test(model))?.[1] ?? '#888780'
47
48export const modelName = (id: string): string => {
49 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
50 const [, family = '', major = '', minor] = m ?? []
51 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
52 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
53}
54
55export const fmtTokens = (n: number): string =>
56 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
57
58export const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
59
60export const fmtTime = (ms: number): string => {
61 const s = Math.max(0, Math.round(ms / 1000))
62 const h = Math.floor(s / 3600)
63 const m = Math.floor((s % 3600) / 60)
64 const ss = String(s % 60).padStart(2, '0')
65 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
66}
67
68export const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
69
70export const ctxOf = (a: AgentRun): number =>
71 a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0
72
73export type Totals = { cost: number; tokens: number; time: number }
74
75export const totals = (list: AgentRun[], at: number): Totals => {
76 const cost = list.reduce((s, a) => s + a.costUsd, 0)
77 const tokens = list.reduce((s, a) => s + a.tokens, 0)
78 const start = Math.min(...list.map(a => a.startedAt))
79 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
80 return { cost, tokens, time: list.length ? end - start : 0 }
81}
82
83// The stats a row shows after its context bar.
84export const statsOf = (a: AgentRun, at: number): string =>
85 `ctx ${ctxOf(a)}% (${fmtTokens(a.contextTokens)}) · ${fmtTokens(a.tokens)} tok · ≈${fmtCost(a.costUsd)} · ${fmtTime(elapsed(a, at))}`
86types/index.d.ts 31 lines1export type AgentStatus = 'running' | 'done' | 'failed'
2
3export type AgentRun = {
4 id: string
5 agentId?: string
6 /** The subagent that spawned this one, when it is nested. */
7 parentAgentId?: string
8 type: string
9 description: string
10 model: string
11 effort?: string
12 status: AgentStatus
13 startedAt: number
14 endedAt?: number
15 /** What the last request carried: the context in use now. */
16 contextTokens: number
17 contextMax: number
18 /** Every request's tokens, summed. */
19 tokens: number
20 costUsd: number
21 steps: number
22}
23
24export type Panel = { isDoneCollapsed: boolean }
25
26declare module 'claude-code' {
27 interface PluginState {
28 'agents-panel': { agents: AgentRun[]; panel: Panel; now: number }
29 }
30}
31