Cosmetic mod: a QueerPulse-coloured progress band above the prompt and a /pulse panel of subagents. It only watches; it adds no tools and changes no prompts.

A cosmetic Claude Code mod in QueerPulse colours (coral, rose, jade). It shows the subagents of a session. It does not change what Claude does.
×N opens the panel. ✕ hides the band and clears the finished runs. The next subagent shows the band again./pulse opens or closes it, /pulse open only opens it): a header shows how many subagents work, the total time, cost and tokens, and a timeline of every run in lanes. Hover a lane to see its name, type and time. Each row shows the crab, the description, the type and model, aligned context, token and cost figures, and a context bar. Finished runs keep their last context fill. "Compact view" shrinks the panel to one strip of crabs, totals and a small timeline. "Hide finished" folds the finished runs.*-engineer and *-animator wear a hard hat, *-reviewer a detective hat, Explore a pirate hat, Plan a space helmet. All other types hold a pride flag. prefers-reduced-motion stops the motion.This mod only watches. Compared to savvy-progress, it removes all behaviour changes:
progress and step, and puts step in the tool list of every subagent.)savvy-* brief.)If the mod has an error, the subagent starts as usual.
Cost is an estimate from token counts and a fixed price table (PRICES in hooks/register.tsx). It is not a bill.
autoOpen: once (default) opens the panel the first time a subagent starts in a session. never opens it only with /pulse.
This folder is a plugin and also a marketplace with one plugin in it.
~/claude-mods/pulse-progress. Do not delete the folder after you install: Claude Code reads the plugin from it.claude plugin marketplace add ~/claude-mods/pulse-progress
claude plugin install pulse-progress@pulse-mods
/reload-plugins in an open one.To change the mod later, edit the files in that folder and raise version in .claude-plugin/plugin.json and in the marketplace entry. Then run claude plugin update pulse-progress@pulse-mods and start a new Claude Code session. Claude Code runs a copy of each version from ~/.claude/plugins/cache/, so an edit without a new version never loads.
To remove it: claude plugin uninstall pulse-progress@pulse-mods.
claude plugin validate .
claude plugin test .hooks/register.tsx 485 lines1// pulse-progress: a QueerPulse take on johnnyvizz's savvy-progress (MIT, see NOTICE).
2//
3// Cosmetic only. The mod watches subagents start, step and finish, and draws them.
4// It registers no tool the model can call, never rewrites a prompt or a tool call,
5// reads no settings, environment, files or network. Its one command, /pulse, is
6// typed by a person.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { AgentRun, BarState, View } from '../types'
11import {
12 BAND_H,
13 CORAL,
14 EMPTY_COPY,
15 JADE_SOFT,
16 ROLE_COLOR,
17 ROW_H,
18 agentSvg,
19 bandSvg,
20 compactSvg,
21 ctxOf,
22 elapsed,
23 emptyHeight,
24 emptySvg,
25 fmtCost,
26 fmtTime,
27 fmtTokens,
28 isTweenAt,
29 headerSvg,
30 progress,
31 progressOf,
32 roleOf,
33 runModel,
34 shownRatio,
35 statusPhrase,
36 totals,
37 TWEEN_MS,
38 withCtxTween,
39} from './draw'
40
41const agents = atom({ plugin: 'pulse-progress', key: 'agents' } as const, [])
42const view = atom({ plugin: 'pulse-progress', key: 'view' } as const, {
43 isCompact: false,
44 isDoneCollapsed: false,
45 isBandHidden: false,
46 didAutoOpen: false,
47})
48const now = atom({ plugin: 'pulse-progress', key: 'now' } as const, 0)
49// The band draws from this and the `now` tick, so a subagent's every model request leaves it alone.
50const bar = atom({ plugin: 'pulse-progress', key: 'bar' } as const, {
51 done: 0,
52 total: 0,
53 fromRatio: 0,
54 fromDone: 0,
55 fromTotal: 0,
56 changedAt: 0,
57})
58
59const PANE = 'pulse-agents'
60const PANE_TITLE = 'Pulse · agents'
61const MAX_RUNS = 200
62
63// ---------------------------------------------------------------------------
64// Numbers.
65
66// USD per million tokens: input, output, cache read, cache write (1-hour rate).
67// List prices as savvy-progress had them on 2026-10-10; the first match wins.
68type Price = [input: number, output: number, cacheRead: number, cacheWrite: number]
69const PRICES: [RegExp, Price][] = [
70 [/(fable|mythos)-5-1/, [10, 50, 0.25, 20]],
71 [/fable|mythos/, [10, 50, 1, 20]],
72 [/opus-5-5/, [4, 20, 0.2, 8]],
73 [/opus/, [5, 25, 0.5, 10]],
74 [/sonnet-5-5/, [2, 10, 0.1, 4]],
75 [/sonnet-5/, [2, 10, 0.2, 4]],
76 [/sonnet/, [3, 15, 0.3, 6]],
77 [/haiku-5-5/, [0.1, 0.5, 0.01, 0.2]],
78 [/haiku/, [1, 5, 0.1, 2]],
79]
80
81type Usage = {
82 input_tokens: number
83 output_tokens: number
84 cache_read_input_tokens: number
85 cache_creation_input_tokens: number
86}
87
88const sumTokens = (u: Usage): number =>
89 (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
90
91const costOf = (model: string, u: Usage): number => {
92 const [i, o, r, w] = PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 8]
93 return (
94 ((u.input_tokens || 0) * i +
95 (u.output_tokens || 0) * o +
96 (u.cache_read_input_tokens || 0) * r +
97 (u.cache_creation_input_tokens || 0) * w) /
98 1e6
99 )
100}
101
102const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000)
103
104// ---------------------------------------------------------------------------
105// Terminal drawing helpers.
106
107const textBar = (ratio: number, width: number): string => {
108 const filled = Math.round(width * Math.min(1, Math.max(0, ratio)))
109 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
110}
111
112const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗' }
113
114// ---------------------------------------------------------------------------
115// Pane open and close.
116
117async function openPane($: EngineInterface): Promise<void> {
118 const at = await $.clock.now()
119 await update($, now, () => at)
120 await $.ui.open({ id: PANE, title: PANE_TITLE })
121}
122
123// The band's bar follows the runs: written only when the finished count or the total moved,
124// starting a tween from the ratio the band showed at `at`.
125async function syncBar($: EngineInterface, at: number): Promise<void> {
126 const p = progress(await read($, agents))
127 const isSame = (b: BarState) => b.done === p.done && b.total === p.total
128 if (isSame(await read($, bar))) return
129 await update($, bar, prev =>
130 isSame(prev)
131 ? prev
132 : { done: p.done, total: p.total, fromRatio: shownRatio(prev, at), fromDone: prev.done, fromTotal: prev.total, changedAt: at },
133 )
134 // The surface rebuilds the band's image whenever it lays the prompt out again, and a rebuilt
135 // image plays its CSS from the start. A drawing left holding the tween would slide from the old
136 // count again each time, so the band redraws once the tween is over, settled.
137 $.clock.after(TWEEN_MS + 50, () => {
138 void (async () => {
139 const settledAt = await $.clock.now()
140 await update($, now, () => settledAt)
141 })()
142 })
143}
144
145async function togglePane($: EngineInterface): Promise<boolean> {
146 if ((await $.ui.panes()).some(p => p.id === PANE)) {
147 await $.ui.close({ id: PANE })
148 return false
149 }
150 await openPane($)
151 return true
152}
153
154export const register: Register = (on, options) => {
155 on('session.start', async ($, e, next) => {
156 const started = await next(e)
157 await $.command.register({
158 name: 'pulse',
159 description: 'Show or hide the Pulse panel of subagents: running and finished, with model, context, cost and time',
160 })
161 // Ticks the running agents' clocks; quiet when nothing runs.
162 $.clock.every(1000, () => {
163 void (async () => {
164 if (!(await read($, agents)).some(a => a.status === 'running')) return
165 const at = await $.clock.now()
166 await update($, now, () => at)
167 })()
168 })
169 return started
170 })
171
172 on('command.run', { command: 'pulse' }, async ($, e) => {
173 if ((e.args ?? '').trim() === 'open') {
174 await openPane($)
175 return { text: 'Pulse panel opened.' }
176 }
177 return { text: (await togglePane($)) ? 'Pulse panel opened.' : 'Pulse panel closed.' }
178 })
179
180 // Observe only: the spawn goes on exactly as it came in.
181 on('agent.spawn', async ($, e, next) => {
182 const started = await next(e)
183 if (started.deny !== undefined) return started
184
185 const at = await $.clock.now()
186 const run: AgentRun = {
187 id: started.agentId ?? e.tool_use_id,
188 agentId: started.agentId,
189 type: e.subagentType,
190 description: e.description,
191 model: started.model,
192 status: 'running',
193 startedAt: at,
194 contextTokens: 0,
195 contextMax: windowOf(started.model),
196 tokens: 0,
197 costUsd: 0,
198 steps: 0,
199 }
200 await update($, agents, list => [...list.filter(a => a.id !== run.id), run].slice(-MAX_RUNS))
201 await syncBar($, at)
202 await update($, now, () => at)
203 const v = await update($, view, prev => ({ ...prev, isBandHidden: false }))
204 if (options.autoOpen !== 'never' && v && !v.didAutoOpen) {
205 await update($, view, prev => ({ ...prev, didAutoOpen: true }))
206 void $.ui.open({ id: PANE, title: PANE_TITLE })
207 }
208 return started
209 }).catch(($, e, next) => next(e)) // If drawing bookkeeping fails, the spawn still goes on unchanged.
210
211 // Each model request of a subagent: live context, tokens and cost. The result passes through untouched.
212 on('turn.step', async function* ($, e, next) {
213 const result = yield* next(e)
214 const agentId = e.agentId
215 const usage = result.usage
216 if (!agentId || !usage) return result
217
218 const model = usage.model || e.model
219 const at = await $.clock.now()
220 await update($, agents, list =>
221 list.map((a): AgentRun =>
222 a.agentId !== agentId
223 ? a
224 : withCtxTween(
225 a,
226 {
227 ...a,
228 model,
229 effort: typeof e.effort === 'string' ? e.effort : a.effort,
230 status: 'running',
231 endedAt: undefined,
232 contextTokens: sumTokens(usage),
233 contextMax: windowOf(model),
234 tokens: a.tokens + sumTokens(usage),
235 costUsd: a.costUsd + costOf(model, usage),
236 steps: a.steps + 1,
237 },
238 at,
239 ),
240 ),
241 )
242 // A step can bring a finished run back to running.
243 await syncBar($, at)
244 return result
245 })
246
247 on('turn.complete', async ($, e, next) => {
248 const agentId = e.agentId
249 if (agentId) {
250 const at = await $.clock.now()
251 await update($, agents, list =>
252 list.map((a): AgentRun => {
253 if (a.agentId !== agentId) return a
254 // A run whose steps went unseen still gets the turn's own sum.
255 const usage = a.steps === 0 ? e.usage : null
256 return withCtxTween(
257 a,
258 {
259 ...a,
260 status: e.reason === 'answer' ? 'done' : 'failed',
261 endedAt: at,
262 ...(usage
263 ? { model: usage.model || a.model, contextTokens: sumTokens(usage), tokens: sumTokens(usage), costUsd: costOf(usage.model || a.model, usage) }
264 : {}),
265 },
266 at,
267 )
268 }),
269 )
270 await syncBar($, at)
271 await update($, now, () => at)
272 }
273 return next(e)
274 })
275
276 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
277 const ui = $.ui.resolve(e)
278 const { Box, Text, Button } = ui
279 const list = await read($, agents)
280 const v: View = await read($, view)
281 const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
282 // A fresh read for motion: loops keep their phase and tweens their progress across redraws.
283 const clockAt = await $.clock.now()
284 const running = list.filter(a => a.status === 'running').reverse()
285 const finished = list.filter(a => a.status !== 'running').reverse()
286 const t = totals(list, at)
287 const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} tokens, ${fmtTime(t.time)}`
288
289 const flipCompact = () => update($, view, prev => ({ ...prev, isCompact: !prev.isCompact }))
290 const flipDone = () => update($, view, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))
291
292 if (e.surface === 'desktop' && 'Svg' in ui) {
293 const { Svg } = ui
294 const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
295 if (list.length === 0) {
296 return (
297 <Box flexDirection="column">
298 <Svg key="empty" source={emptySvg(W, clockAt)} alt={`No subagents yet. ${EMPTY_COPY}`} width={W} height={emptyHeight(W)} />
299 </Box>
300 )
301 }
302 const phrase = statusPhrase(list)
303 const viewToggle = (
304 <Button key="compact" label={v.isCompact ? 'Show detailed view' : 'Show compact view'} plain dimColor onPress={flipCompact} />
305 )
306 if (v.isCompact) {
307 const compact = compactSvg(W, list, at, clockAt)
308 return (
309 <Box flexDirection="column">
310 <Svg key="overview" source={compact.source} alt={`${phrase}. ${summary}, timeline of ${list.length} runs`} width={W} height={compact.height} />
311 <Box key="controls" flexDirection="row" justifyContent="flex-end" marginTop={1}>
312 {viewToggle}
313 </Box>
314 </Box>
315 )
316 }
317 const header = headerSvg(W, list, at, clockAt)
318 // A settled row is drawn with a still clock, so it stays the same image from tick to tick.
319 const rowOf = (a: AgentRun, index: number, section: AgentRun[]) => (
320 <Svg
321 key={a.id}
322 source={agentSvg(W, a, at, a.status === 'running' || isTweenAt(a.ctxAt, clockAt) ? clockAt : 0, index === section.length - 1)}
323 alt={`${a.description || a.type}: ${[a.type, runModel(a), a.status === 'failed' ? 'stopped' : a.status, fmtTime(elapsed(a, at)), `context ${ctxOf(a)}%`].filter(Boolean).join(', ')}`}
324 width={W}
325 height={ROW_H}
326 />
327 )
328 const doneToggle = (
329 <Button key="done" label={v.isDoneCollapsed ? 'Show finished' : 'Hide finished'} plain dimColor onPress={flipDone} />
330 )
331 return (
332 <Box flexDirection="column">
333 {/* Tooltips need a sandboxed frame; while runs tick it would be rebuilt every second, so it waits until all settle. */}
334 <Svg
335 key="header"
336 source={header.source}
337 alt={`${phrase}. ${summary}. Timeline of ${list.length} runs`}
338 width={W}
339 height={header.height}
340 isInteractive={running.length === 0}
341 />
342 {running.length > 0 && (
343 <Box key="working" flexDirection="row" justifyContent="space-between" alignItems="center" marginTop={1}>
344 <Text dimColor>Working · {running.length}</Text>
345 {viewToggle}
346 </Box>
347 )}
348 {running.map(rowOf)}
349 {finished.length > 0 && (
350 <Box key="finished" flexDirection="row" justifyContent="space-between" alignItems="center" marginTop={1}>
351 <Text dimColor>Finished · {finished.length}</Text>
352 <Box flexDirection="row" gap={2}>
353 {running.length === 0 && viewToggle}
354 {doneToggle}
355 </Box>
356 </Box>
357 )}
358 {!v.isDoneCollapsed && finished.map(rowOf)}
359 </Box>
360 )
361 }
362
363 // Terminal: the same content in text rows.
364 const toggleCompact = (
365 <Button key="compact" label={v.isCompact ? 'Expand' : 'Collapse'} plain onPress={flipCompact} />
366 )
367 const toggleDone = (
368 <Button
369 key="done"
370 label={`${v.isDoneCollapsed ? '▸' : '▾'} Finished · ${finished.length}`}
371 plain
372 onPress={flipDone}
373 />
374 )
375 const empty = <Text dimColor>No subagents yet. {EMPTY_COPY}</Text>
376 const barW = Math.max(6, Math.min(20, Math.max(24, e.props.bodyColumns || 40) - 34))
377 const row = (a: AgentRun) => {
378 const color = ROLE_COLOR[roleOf(a.type)]
379 const ctx = ctxOf(a)
380 return (
381 <Box key={a.id} flexDirection="column" marginBottom={1}>
382 <Box flexDirection="row" gap={1}>
383 <Text color={color}>▣</Text>
384 <Text bold wrap="truncate-end">{a.description || a.type}</Text>
385 <Text color={a.status === 'failed' ? CORAL : a.status === 'done' ? JADE_SOFT : color}>{STATUS_GLYPH[a.status]}</Text>
386 </Box>
387 <Text dimColor wrap="truncate-end"> {[a.type, runModel(a)].filter(Boolean).join(' · ')}</Text>
388 <Text wrap="truncate-end">
389 {' '}
390 <Text color={a.status === 'done' ? JADE_SOFT : color}>{textBar(a.status === 'done' ? 1 : ctx / 100, barW)}</Text>
391 <Text dimColor> ctx {ctx}% · {fmtTokens(a.contextTokens)} ≈{fmtCost(a.costUsd)} {fmtTime(elapsed(a, at))}</Text>
392 </Text>
393 </Box>
394 )
395 }
396 return (
397 <Box flexDirection="column">
398 <Box flexDirection="row" justifyContent="space-between">
399 <Text dimColor>≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} tokens · {fmtTime(t.time)}</Text>
400 {toggleCompact}
401 </Box>
402 {v.isCompact ? (
403 <Text wrap="truncate-end">
404 {[...running, ...finished].map(a => (
405 <Text key={a.id} color={ROLE_COLOR[roleOf(a.type)]}>{STATUS_GLYPH[a.status]} </Text>
406 ))}
407 </Text>
408 ) : (
409 <Box flexDirection="column" marginTop={1}>
410 {list.length === 0 && empty}
411 {running.length > 0 && <Text dimColor>Working · {running.length}</Text>}
412 {running.map(row)}
413 {finished.length > 0 && toggleDone}
414 {!v.isDoneCollapsed && finished.map(row)}
415 </Box>
416 )}
417 </Box>
418 )
419 })
420
421 // Reads `bar`, `view` and the `now` tick: a subagent's steps that move no count never redraw it.
422 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
423 const b = await read($, bar)
424 const v = await read($, view)
425 // Subscribes to the tick. The surface rebuilds the band's image each second while the pane's
426 // clock runs, and a rebuilt image starts its loops at the phase baked into its source: drawn
427 // fresh each tick, that phase is the clock's, so the twinkle and the crab carry on unbroken.
428 await read($, now)
429 if (b.total === 0 || v.isBandHidden || e.props.hasSurvey) return next(e)
430 // Other mods' bands draw beneath this row. Claude Code's own placeholder is never nested.
431 const drawn = await Promise.resolve(next(e)).catch(() => null)
432 const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
433
434 const ui = $.ui.resolve(e)
435 const { Box, Text, Button } = ui
436 const p = progressOf(b.done, b.total)
437 const crewButton = <Button key="pulse-agents" label={`×${b.total}`} plain onPress={() => void togglePane($)} />
438 // Dismiss hides the band and forgets the finished runs; the next spawn brings it back.
439 const dismiss = (
440 <Button
441 key="pulse-dismiss"
442 label="✕"
443 plain
444 role="dismiss"
445 onPress={async () => {
446 await update($, agents, l => l.filter(a => a.status === 'running'))
447 await syncBar($, await $.clock.now())
448 await update($, view, prev => ({ ...prev, isBandHidden: true }))
449 }}
450 />
451 )
452
453 if (e.surface !== 'terminal' && 'Svg' in ui) {
454 const { Svg } = ui
455 const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
456 const at = await $.clock.now()
457 return (
458 <Box flexDirection="column">
459 <Box flexDirection="row" alignItems="center" gap={1}>
460 <Svg key="pulse-band" source={bandSvg(b, width, at)} alt={`Pulse: ${p.done} of ${p.total} subagents finished`} width={width} height={BAND_H} />
461 {crewButton}
462 {dismiss}
463 </Box>
464 {below}
465 </Box>
466 )
467 }
468
469 const width = Math.max(6, Math.min(40, e.props.bodyColumns - 40))
470 return (
471 <Box flexDirection="column">
472 <Box flexDirection="row" gap={2}>
473 <Text color={p.isFinished ? JADE_SOFT : CORAL}>● Pulse</Text>
474 <Text color={p.isFinished ? JADE_SOFT : CORAL}>{textBar(p.ratio, width)}</Text>
475 <Text bold>{p.isFinished ? 'All done' : `${p.done}/${p.total}`}</Text>
476 <Text dimColor>{Math.round(p.ratio * 100)}%</Text>
477 {crewButton}
478 {dismiss}
479 </Box>
480 {below}
481 </Box>
482 )
483 })
484}
485hooks/draw.ts 682 lines1// pulse-progress drawing: pure functions that turn runs into SVG and text.
2//
3// No runtime import from 'claude-code': a preview script can import this file as it is.
4import type { AgentRun, BarState } from '../types'
5
6// QueerPulse tokens (src/styles/tokens/colors.css), copied as hex: a mod cannot read CSS variables.
7export const CORAL = '#e8775a'
8export const ROSE = '#b0628f'
9export const JADE = '#4a8c6f'
10export const JADE_SOFT = '#7cd7ad'
11// Lilac is the one non-token colour: peach (--accent-soft) was unreadable as text on cream.
12export const LILAC = '#8a72cf'
13export const MUTED = '#9a8fa8'
14export const FONT = "'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif"
15export const SERIF = 'Fraunces,Georgia,serif'
16
17// ---------------------------------------------------------------------------
18// Roles: what a subagent type looks like. Unknown types are pride crabs.
19
20export type Role = 'engineer' | 'reviewer' | 'explore' | 'plan' | 'pride'
21const ROLES: Role[] = ['engineer', 'reviewer', 'explore', 'plan', 'pride']
22
23export const roleOf = (type: string): Role => {
24 const bare = type.replace(/^[^:]*:/, '').toLowerCase()
25 if (bare === 'explore') return 'explore'
26 if (bare === 'plan') return 'plan'
27 if (/reviewer$/.test(bare)) return 'reviewer'
28 if (/(engineer|animator)$/.test(bare)) return 'engineer'
29 return 'pride'
30}
31
32// The terminal's colours, and the crabs' costume trims.
33export const ROLE_COLOR: Record<Role, string> = {
34 engineer: CORAL,
35 reviewer: ROSE,
36 explore: JADE,
37 plan: LILAC,
38 pride: MUTED,
39}
40
41// The panel's colours. Fills draw capsules, bars and tiles; inks draw role text on a 14% tint.
42// Each ink holds at least 4.5:1 on its chip, on cream and on near-black.
43const ROLE_FILL: Record<Role, string> = { ...ROLE_COLOR, pride: '#8a7ba6' }
44const ROLE_FILL_DARK: Record<Role, string> = { engineer: CORAL, reviewer: '#c4729f', explore: '#5aa784', plan: '#9580d8', pride: '#a99cc0' }
45const ROLE_INK: Record<Role, string> = { engineer: '#9e472c', reviewer: '#8f4672', explore: '#2f6a50', plan: '#5d47a8', pride: '#665a7e' }
46const ROLE_INK_DARK: Record<Role, string> = { engineer: '#f0907a', reviewer: '#d98fbb', explore: JADE_SOFT, plan: '#a996e0', pride: '#b8acc9' }
47
48// ---------------------------------------------------------------------------
49// Numbers.
50
51export const modelName = (id: string): string => {
52 const match = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
53 const [, family = '', major = '', minor] = match ?? []
54 if (!id) return ''
55 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || 'unknown model'
56 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
57}
58
59// Empty when the run reported no model yet.
60export const runModel = (a: AgentRun): string => (a.model && a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model))
61
62export const fmtTokens = (n: number): string =>
63 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
64export const fmtCost = (usd: number): string =>
65 usd >= 1000 ? `$${(usd / 1000).toFixed(1)}k` : usd >= 100 ? `$${Math.round(usd)}` : `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
66export const fmtTime = (ms: number): string => {
67 const seconds = Math.max(0, Math.round(ms / 1000))
68 const hours = Math.floor(seconds / 3600)
69 const minutes = Math.floor((seconds % 3600) / 60)
70 const rest = String(seconds % 60).padStart(2, '0')
71 return hours ? `${hours}:${String(minutes).padStart(2, '0')}:${rest}` : `${minutes}:${rest}`
72}
73
74export const endOf = (a: AgentRun, at: number): number => a.endedAt ?? Math.max(at, a.startedAt)
75export const elapsed = (a: AgentRun, at: number): number => endOf(a, at) - a.startedAt
76export const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
77
78export type Totals = { cost: number; tokens: number; time: number }
79
80export const totals = (list: AgentRun[], at: number): Totals => {
81 const start = Math.min(...list.map(a => a.startedAt))
82 const end = Math.max(...list.map(a => endOf(a, at)))
83 return {
84 cost: list.reduce((sum, a) => sum + a.costUsd, 0),
85 tokens: list.reduce((sum, a) => sum + a.tokens, 0),
86 time: list.length ? end - start : 0,
87 }
88}
89
90// The band's progress: finished runs out of all runs since the last dismiss.
91export const progressOf = (done: number, total: number) => ({ done, total, ratio: total ? done / total : 0, isFinished: total > 0 && done === total })
92export const progress = (list: AgentRun[]) => progressOf(list.filter(a => a.status !== 'running').length, list.length)
93
94// ---------------------------------------------------------------------------
95// Motion. Every redraw is a new image whose CSS animations start over, so each one is
96// started part way through: a loop at the wall clock's phase, a tween at the time since its change.
97
98export const TWEEN_MS = 700
99const EASE = 'cubic-bezier(.33,1,.68,1)'
100// The same easeOutCubic in JS, to find where a tween stands when the next change lands on it.
101const eased = (t: number): number => 1 - Math.pow(1 - Math.min(1, Math.max(0, t)), 3)
102export const isTweenAt = (changedAt: number | undefined, at: number): changedAt is number =>
103 changedAt !== undefined && at - changedAt >= 0 && at - changedAt < TWEEN_MS
104const between = (from: number, to: number, changedAt: number | undefined, at: number): number =>
105 isTweenAt(changedAt, at) ? from + (to - from) * eased((at - changedAt) / TWEEN_MS) : to
106
107// The delay that starts a loop of `period` seconds where the clock has it, `offset` seconds further on.
108export const phase = (at: number, period: number, offset = 0): string => `${(-(((at / 1000) % period) + offset)).toFixed(3)}s`
109
110// The ratio the band shows at `at`: the target, or a point on the way to it.
111export const shownRatio = (b: BarState, at: number): number => between(b.fromRatio, progressOf(b.done, b.total).ratio, b.changedAt, at)
112
113// The percent an agent row's context bar shows when settled (a finished run keeps its last fill),
114// and at `at` while the fill tweens.
115export const ctxTarget = (a: AgentRun): number => ctxOf(a)
116export const shownCtx = (a: AgentRun, at: number): number => between(a.ctxFrom ?? ctxTarget(a), ctxTarget(a), a.ctxAt, at)
117
118// A run after a change: when its bar's fill or colour changed, it tweens from where the bar stood at `at`.
119export const withCtxTween = (before: AgentRun, after: AgentRun, at: number): AgentRun =>
120 ctxTarget(before) !== ctxTarget(after) || before.status !== after.status
121 ? { ...after, ctxFrom: shownCtx(before, at), ctxAt: at }
122 : after
123
124// ---------------------------------------------------------------------------
125// Text in SVG.
126
127export const xml = (s: string): string =>
128 s.replace(/[&<>"]/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch] ?? ch)
129
130const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿가-힣豈-!-⦆\u{1F300}-\u{1FAFF}]/u
131const charEm = (ch: string): number =>
132 WIDE.test(ch) ? 1 : /[0-9]/.test(ch) ? 0.67 : /[\s.,:;'|!il()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56
133export const textWidth = (s: string, size: number): number => [...s].reduce((width, ch) => width + charEm(ch) * size, 0)
134
135export const fitText = (s: string, size: number, maxW: number): string => {
136 if (textWidth(s, size) <= maxW) return s
137 let out = ''
138 for (const ch of s) {
139 if (textWidth(out + ch + '…', size) > maxW) break
140 out += ch
141 }
142 return out + '…'
143}
144
145// ---------------------------------------------------------------------------
146// Colour: the band runs coral, rose, jade along its length, so it warms into jade as work lands.
147
148const hex = (c: string): number[] => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16))
149const mix = (from: string, to: string, t: number): string =>
150 '#' + hex(from).map((v, i) => Math.round(v + ((hex(to)[i] ?? 0) - v) * t).toString(16).padStart(2, '0')).join('')
151const pulseAt = (t: number): string => (t < 0.5 ? mix(CORAL, ROSE, t * 2) : mix(ROSE, JADE, (t - 0.5) * 2))
152
153const noise = (x: number, y: number): number => {
154 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
155 return s - Math.floor(s)
156}
157
158// ---------------------------------------------------------------------------
159// Pixel crabs (sprites from savvy-progress, after DockCrab's Clawd), one costume per role.
160
161const CLAY = '#D97757'
162const INK = '#1F1E1D'
163
164type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
165
166const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
167 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
168
169const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
170 f(7, 10, 16, 12, CLAY)
171 f(3, 14, 4, 4, CLAY)
172 f(23, 14 + armFront, 4, 4, CLAY, armCls)
173 f(9, 12, 2, 2, INK)
174 f(19, 12, 2, 2, INK)
175 f(7, 22, 2, 4, CLAY, 'la')
176 f(17, 22, 2, 4, CLAY, 'la')
177 f(11, 22, 2, 4, CLAY, 'lb')
178 f(21, 22, 2, 4, CLAY, 'lb')
179}
180
181const PRIDE = ['#E40303', '#FF8C00', '#FFED00', '#008026', '#24408E', '#732982']
182
183const COSTUMES: Record<Role, (f: Fill, t: string) => void> = {
184 // Pride: holds a six-stripe flag that waves while the crab works.
185 pride: f => {
186 crabBody(f, -4)
187 f(25, 1, 1, 13, '#8E929A')
188 PRIDE.forEach((c, i) => f(26, 1 + i, 4, 1, c, 'flag'))
189 },
190 // Engineer: hard hat; the wrench turns a bolt.
191 engineer: (f, t) => {
192 crabBody(f)
193 stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
194 f(13, 5, 4, 2, t)
195 stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
196 },
197 // Reviewer: deerstalker; the magnifier sweeps and glints.
198 reviewer: (f, t) => {
199 crabBody(f, -4, 'it')
200 stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
201 f(6, 9, 18, 1, t)
202 stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
203 f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
204 },
205 // Explore: pirate scout; the cutlass fences.
206 explore: (f, t) => {
207 crabBody(f)
208 stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: t })
209 f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
210 f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
211 },
212 // Plan: astronaut in a glass dome; floats, and the antenna and the star blink.
213 plan: (f, t) => {
214 crabBody(f)
215 f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
216 f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
217 f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
218 f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
219 },
220}
221
222// Pure CSS, run by the compositor. Every working crab walks; each costume moves its prop.
223// Eased loops that rest at 0% and 100%, each started at the clock's phase so a redraw picks the motion up.
224export const crabCss = (at: number): string => {
225 const d = (period: number, offset = 0) => phase(at, period, offset)
226 return `<style>
227.run .la{animation:st .5s ease-in-out infinite ${d(0.5)}}.run .lb{animation:st .5s ease-in-out infinite ${d(0.5, 0.25)}}
228.run .bd{animation:bob .5s ease-in-out infinite ${d(0.5, 0.125)}}
229.run g{transform-box:fill-box}
230@keyframes st{0%,100%{transform:translateY(0)}50%{transform:translateY(-1px)}}@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(1px)}}
231@keyframes blink{0%,100%{opacity:1}50%{opacity:.15}}
232.c-pride.run .flag{transform-origin:0 50%;animation:wave .5s ease-in-out infinite ${d(0.5)}}
233.c-pride.run .flag:nth-child(even){animation-delay:${d(0.5, 0.25)}}
234@keyframes wave{0%,100%{transform:skewY(0) scaleX(1)}50%{transform:skewY(-10deg) scaleX(.85)}}
235.c-engineer.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite ${d(0.5)}}
236@keyframes twist{0%,100%{transform:rotate(0)}50%{transform:rotate(-35deg)}}
237.c-reviewer.run .it{animation:scan 1s ease-in-out infinite ${d(1)}}
238.c-reviewer.run .gl{animation:blink 1s ease-in-out infinite ${d(1, 0.5)}}
239@keyframes scan{0%,100%{transform:translate(0,0)}50%{transform:translate(-2px,2px)}}
240.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite ${d(0.5)}}
241@keyframes fence{0%,100%{transform:rotate(0)}50%{transform:rotate(25deg)}}
242.c-plan.run{animation:float 1s ease-in-out infinite ${d(1)}}
243.c-plan.run .la,.c-plan.run .lb,.c-plan.run .bd{animation:none}
244.c-plan.run .ant{animation:blink 1s ease-in-out infinite ${d(1)}}
245.c-plan.run .star{animation:blink .5s ease-in-out infinite ${d(0.5, 0.25)}}
246@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-2px)}}
247@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
248</style>`
249}
250
251export const crab = (x: number, y: number, role: Role, isWalking = false, scale = 1.1): string => {
252 const groups = new Map<string, string[]>([['bd', []]])
253 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
254 if (!groups.has(cls)) groups.set(cls, [])
255 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
256 }
257 COSTUMES[role](f, ROLE_COLOR[role])
258 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
259 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
260 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
261 return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g class="c-${role}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
262}
263
264// ---------------------------------------------------------------------------
265// The band above the prompt.
266
267export const BAND_H = 22
268const BAR_H = 16
269const CELL = 3
270const CRAB_W = 26
271
272const bandText = (done: number, total: number): string =>
273 progressOf(done, total).isFinished ? 'All done' : `${done}/${total} · ${total - done} working`
274const percentText = (done: number, total: number): string => `${Math.round(progressOf(done, total).ratio * 100)}%`
275
276// One tween: the element's attributes hold where it ends, the keyframe only where it starts.
277const tweenCss = (cls: string, from: string, delay: number): string =>
278 `.${cls}{animation:${cls}k ${TWEEN_MS}ms ${EASE} ${delay}ms both}@keyframes ${cls}k{from{${from}}}`
279
280export const bandSvg = (b: BarState, W: number, at: number): string => {
281 const H = BAND_H
282 const p = progressOf(b.done, b.total)
283 const was = progressOf(b.fromDone, b.fromTotal)
284 const running = p.total - p.done
285 const since = Math.round(at - b.changedAt)
286 // A band that was hidden at zero runs has nothing to tween from.
287 const isTweening = b.fromTotal > 0 && isTweenAt(b.changedAt, at)
288 const title = 'Pulse'
289 const BAR_X = Math.round(16 + textWidth(title, 14) + 12)
290 const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
291 const fillW = Math.round(BAR_W * p.ratio)
292 const fromFillW = isTweening ? Math.round(BAR_W * Math.min(1, Math.max(0, b.fromRatio))) : fillW
293 const cols = Math.floor(fillW / CELL)
294 const dotCols = Math.floor(Math.max(fillW, fromFillW) / CELL)
295 const rows = Math.floor(BAR_H / CELL)
296 // Dithered fill, sparse at the start and dense toward the head, as one mask path per twinkle class.
297 // Columns past the final fill (a bar on its way down) stay at the head's density.
298 const paths = ['', '', '', '']
299 for (let c = 0; c < dotCols; c++) {
300 const density = Math.min(0.95, 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2))
301 for (let r = 0; r < rows; r++) {
302 if (noise(c, r) < density) paths[Math.floor(noise(r, c) * 4)] += `M${c * CELL + 1} ${r * CELL + 1}h2v2h-2z`
303 }
304 }
305 const mask = paths.map((d, i) => (d ? `<path class="t${i}" fill="#fff" d="${d}"/>` : '')).join('')
306
307 const pillOf = (text: string, w: number) => {
308 const pillW = Math.round(18 + text.length * 6.6)
309 return { text, pillW, pillX: Math.max(0, Math.min(BAR_W - pillW, w - pillW)) }
310 }
311 const pillColorOf = (isFinished: boolean, pill: ReturnType<typeof pillOf>) =>
312 isFinished ? JADE : pulseAt(Math.min(1, (pill.pillX + pill.pillW) / BAR_W))
313 const to = pillOf(bandText(p.done, p.total), fillW)
314 const from = pillOf(bandText(was.done, was.total), fromFillW)
315 const pillColor = pillColorOf(p.isFinished, to)
316 const dot = p.isFinished ? JADE : CORAL
317 const percent = percentText(p.done, p.total)
318 const fromPercent = percentText(was.done, was.total)
319 const isJadeTween = isTweening && was.isFinished !== p.isFinished
320 const isLabelTween = isTweening && from.text !== to.text
321 const isPercentTween = isTweening && fromPercent !== percent
322
323 const tweens = isTweening
324 ? [
325 tweenCss('rv', `width:${fromFillW}px`, -since),
326 tweenCss('pl', `transform:translateX(${from.pillX - to.pillX}px)`, -since),
327 tweenCss('pw', `width:${from.pillW}px;fill:${pillColorOf(was.isFinished, from)}`, -since),
328 isJadeTween ? tweenCss('fj', `opacity:${was.isFinished ? 1 : 0}`, -since) : '',
329 isJadeTween ? tweenCss('dt', `fill:${was.isFinished ? JADE : CORAL}`, -since) : '',
330 // Crossfades: the old words leave over the first 45%, the new ones arrive from 15% to 60%.
331 isLabelTween || isPercentTween
332 ? `.xo{animation:xo ${TWEEN_MS}ms ease-in-out ${-since}ms both}.xi{animation:xi ${TWEEN_MS}ms ease-in-out ${-since}ms both}@keyframes xo{0%{opacity:1}45%,100%{opacity:0}}@keyframes xi{0%,15%{opacity:0}60%,100%{opacity:1}}`
333 : '',
334 ].join('')
335 : ''
336 const fillRects = [
337 !p.isFinished || isJadeTween ? `<rect width="${BAR_W}" height="${BAR_H}" fill="url(#g)" mask="url(#m)"/>` : '',
338 p.isFinished || isJadeTween
339 ? `<rect class="fj" width="${BAR_W}" height="${BAR_H}" fill="${JADE}" opacity="${p.isFinished ? 1 : 0}" mask="url(#m)"/>`
340 : '',
341 ].join('')
342 const label = (text: string, x: number, cls: string, opacity: number) =>
343 `<text${cls ? ` class="${cls}"` : ''} x="${x}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff"${opacity < 1 ? ` opacity="${opacity}"` : ''}>${xml(text)}</text>`
344 const percentLabel = (text: string, cls: string, opacity: number) =>
345 `<text class="m${cls ? ` ${cls}` : ''}" x="${W - CRAB_W - 6}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" style="font-variant-numeric:tabular-nums"${opacity < 1 ? ` opacity="${opacity}"` : ''}>${text}</text>`
346 // Inside the moving pill group the old label sits where the old pill's centre was at the start,
347 // clipped to the pill as it changes width so a longer old label never spills onto the track.
348 const pillRect = (cls: string, fill: string) =>
349 `<rect class="${cls}" x="${to.pillX}" width="${to.pillW}" height="${BAR_H}" rx="${BAR_H / 2}"${fill ? ` fill="${fill}"` : ''}/>`
350 const labels = isLabelTween
351 ? `<g clip-path="url(#pc)">${label(from.text, to.pillX + from.pillW / 2, 'xo', 0)}${label(to.text, to.pillX + to.pillW / 2, 'xi', 1)}</g>`
352 : label(to.text, to.pillX + to.pillW / 2, '', 1)
353 const percents = isPercentTween ? `${percentLabel(fromPercent, 'xo', 0)}${percentLabel(percent, 'xi', 1)}` : percentLabel(percent, '', 1)
354 const d = (period: number, offset = 0) => phase(at, period, offset)
355 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
356<style>
357.tx{fill:#2d1b3d}.m{fill:#8a7f95}.k{fill:#efe8e0}
358@media (prefers-color-scheme: dark){.tx{fill:#f7f3ee}.m{fill:#a89cb4}.k{fill:#2a1e38}}
359.t0,.t1,.t2,.t3{animation:tw 2.2s ease-in-out infinite ${d(2.2)}}
360.t1{animation-duration:2.8s;animation-delay:${d(2.8, 0.7)}}.t2{animation-duration:1.9s;animation-delay:${d(1.9, 1.3)}}.t3{animation-duration:3.3s;animation-delay:${d(3.3, 0.4)}}
361@keyframes tw{0%,100%{opacity:1}50%{opacity:${p.isFinished ? 0.8 : 0.35}}}
362${tweens}
363@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3,.rv,.pl,.pw,.fj,.dt,.xo,.xi{animation:none}}
364</style>
365<defs><clipPath id="c"><rect width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath>
366<clipPath id="r"><rect class="rv" width="${fillW}" height="${BAR_H}"/></clipPath>
367<linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="${BAR_W}" y2="0"><stop offset="0" stop-color="${CORAL}"/><stop offset=".5" stop-color="${ROSE}"/><stop offset="1" stop-color="${JADE}"/></linearGradient>
368<mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="${BAR_W}" height="${BAR_H}">${mask}</mask>${isLabelTween ? `<clipPath id="pc">${pillRect('pw', '')}</clipPath>` : ''}</defs>
369<circle class="dt" cx="5" cy="${H / 2}" r="4" fill="${dot}"/>
370<text class="tx" x="16" y="${H / 2 + 5}" font-family="${SERIF}" font-size="14" font-style="italic" font-weight="600">${title}</text>
371<g transform="translate(${BAR_X},${(H - BAR_H) / 2})">
372<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
373<g clip-path="url(#c)"><g clip-path="url(#r)">${fillRects}</g></g>
374<g class="pl">${pillRect('pw', pillColor)}
375${labels}</g>
376</g>
377${percents}
378${crabCss(at)}${crab(W - CRAB_W + 1, 0, 'pride', running > 0, 0.8)}
379</svg>`
380}
381
382// ---------------------------------------------------------------------------
383// The panel. Neutral tones on cream and on near-black; each role has a fill and an ink.
384
385const TEXT_LIGHT = '45,27,61'
386const TEXT_DARK = '243,239,233'
387const PANE_LIGHT = '#faf6f0'
388const PANE_DARK = '#141414'
389// Coral text on cream needs this deeper tint to read.
390const FAIL_INK = '#9e472c'
391
392const roleRules = (fills: Record<Role, string>, inks: Record<Role, string>): string =>
393 ROLES.map(role => `.f-${role}{fill:${fills[role]}}.t-${role}{fill:${inks[role]}}`).join('')
394
395const paneCss = (at: number): string => `<style>
396.tx{fill:rgb(${TEXT_LIGHT})}.s{fill:rgba(${TEXT_LIGHT},.68)}.m{fill:rgba(${TEXT_LIGHT},.63)}.k{fill:rgba(${TEXT_LIGHT},.08)}.ln{stroke:rgba(${TEXT_LIGHT},.10)}
397.ck{stroke:${JADE}}.xm{stroke:${CORAL}}.xmf{fill:${CORAL}}.cdone{fill:${mix(JADE, PANE_LIGHT, 0.2)}}.cfail{fill:${mix(FAIL_INK, PANE_LIGHT, 0.3)}}.t-fail{fill:${FAIL_INK}}
398.cd{opacity:.8}.cx{fill:${FAIL_INK};opacity:.55}
399${roleRules(ROLE_FILL, ROLE_INK)}
400@media (prefers-color-scheme: dark){.tx{fill:rgb(${TEXT_DARK})}.s{fill:rgba(${TEXT_DARK},.65)}.m{fill:rgba(${TEXT_DARK},.49)}.k{fill:rgba(255,255,255,.08)}.ln{stroke:rgba(255,255,255,.07)}
401.ck{stroke:${JADE_SOFT}}.xm{stroke:#f0907a}.cdone{fill:${mix(JADE_SOFT, PANE_DARK, 0.5)}}.cfail{fill:${mix(CORAL, PANE_DARK, 0.5)}}.t-fail{fill:#f0907a}
402.cd{opacity:.6}.cx{fill:${CORAL};opacity:.55}
403${roleRules(ROLE_FILL_DARK, ROLE_INK_DARK)}}
404.live{animation:p 1.6s ease-in-out infinite ${phase(at, 1.6)}}@keyframes p{0%,100%{opacity:1}50%{opacity:.3}}
405.sh{transform-box:fill-box;animation:sh 2.4s linear infinite ${phase(at, 2.4)}}@keyframes sh{from{transform:translateX(-100%)}to{transform:translateX(100%)}}
406.cap .cb{transition:opacity .15s}.cap:hover .cb{opacity:1}
407@media (prefers-reduced-motion: reduce){.live,.sh{animation:none}.sh{display:none}.cap .cb{transition:none}}
408</style>`
409
410const SHEEN_DEF = '<defs><linearGradient id="sheen" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs>'
411
412// `at` is the clock at draw time: the loops start at its phase.
413const svg = (W: number, h: number, body: string, at: number): string =>
414 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${h}" viewBox="0 0 ${W} ${h}">${paneCss(at)}${crabCss(at)}${SHEEN_DEF}${body}</svg>`
415
416const textAt = (x: number, y: number, cls: string, size: number, content: string, extra = ''): string =>
417 `<text class="${cls}" x="${x}" y="${y}" font-family="${FONT}" font-size="${size}"${extra}>${xml(content)}</text>`
418const TABULAR = ' style="font-variant-numeric:tabular-nums"'
419
420const checkMark = (x: number, y: number): string =>
421 `<path class="ck" d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
422const crossMark = (x: number, y: number): string =>
423 `<path class="xm" d="M${x - 3.5} ${y - 3.5}l7 7M${x + 3.5} ${y - 3.5}l-7 7" fill="none" stroke-width="1.8" stroke-linecap="round"/>`
424
425const statusMark = (x: number, y: number, a: AgentRun): string => {
426 if (a.status === 'running') return `<circle class="live f-${roleOf(a.type)}" cx="${x}" cy="${y}" r="3.5"/>`
427 return a.status === 'done' ? checkMark(x, y) : crossMark(x, y)
428}
429
430// ---------------------------------------------------------------------------
431// The timeline: every run on one time axis, packed into as many lanes as ran at once.
432
433const TIMELINE_RUNS = 60
434const MAX_LANES = 8
435
436type Placed = { run: AgentRun; lane: number; end: number; isOverflow: boolean }
437
438// A run takes the first lane whose last run ended before it started; past the cap, it is drawn over the lane freed longest ago.
439export const packLanes = (runs: AgentRun[], at: number, maxLanes = MAX_LANES): { placed: Placed[]; lanes: number } => {
440 const laneEnds: number[] = []
441 const placed = [...runs]
442 .sort((left, right) => left.startedAt - right.startedAt)
443 .map((run): Placed => {
444 const end = endOf(run, at)
445 let lane = laneEnds.findIndex(laneEnd => laneEnd < run.startedAt)
446 const isOverflow = lane < 0 && laneEnds.length >= maxLanes
447 if (lane < 0) lane = isOverflow ? laneEnds.indexOf(Math.min(...laneEnds)) : laneEnds.length
448 // An overflow run shares a lane without holding it, so the runs after it still find free lanes.
449 if (!isOverflow) laneEnds[lane] = end
450 return { run, lane, end, isOverflow }
451 })
452 return { placed, lanes: laneEnds.length }
453}
454
455type Timeline = { body: string; height: number; span: number }
456
457// Lanes of capsules from `top`, `width` wide. The full version has tooltips, shimmer and an axis,
458// and leaves out runs past the lane cap (the header counts them).
459const timeline = (list: AgentRun[], at: number, top: number, width: number, laneH: number, isFull: boolean): Timeline => {
460 const runs = list.slice(-TIMELINE_RUNS)
461 if (!runs.length) return { body: '', height: 0, span: 0 }
462 const laneGap = isFull ? 3 : 2
463 const { placed, lanes } = packLanes(runs, at)
464 const origin = Math.min(...runs.map(run => run.startedAt))
465 const span = Math.max(1, Math.max(...placed.map(item => item.end)) - origin)
466 const radius = laneH / 2
467 const capsules = placed
468 .filter(item => !(isFull && item.isOverflow))
469 .map(({ run, lane, end }, index) => {
470 const role = roleOf(run.type)
471 const capsuleW = Math.max(4, ((end - run.startedAt) / span) * width)
472 const x = Math.min(width - capsuleW, ((run.startedAt - origin) / span) * width)
473 const y = top + lane * (laneH + laneGap)
474 const box = `x="${x.toFixed(1)}" y="${y}" width="${capsuleW.toFixed(1)}" height="${laneH}" rx="${radius}"`
475 const isRunning = run.status === 'running'
476 const fillClass = run.status === 'failed' ? 'cx' : isRunning ? `f-${role}` : `f-${role} cd`
477 const base = `<rect class="cb ${fillClass}" ${box}/>`
478 // A running run shimmers and leads with a dot in its role's ink.
479 const glow =
480 isRunning && isFull
481 ? `<clipPath id="tl${index}"><rect ${box}/></clipPath><g clip-path="url(#tl${index})"><rect class="sh" ${box} fill="url(#sheen)" style="animation-delay:${phase(at, 2.4, lane * 0.35)}"/></g><circle class="t-${role}" cx="${(x + capsuleW - radius).toFixed(1)}" cy="${y + radius}" r="${radius}"/>`
482 : ''
483 const label = `${run.description || run.type}, ${run.type}, ${fmtTime(end - run.startedAt)}${run.status === 'failed' ? ', stopped' : ''}`
484 return isFull ? `<g class="cap"><title>${xml(label)}</title>${base}${glow}</g>` : base
485 })
486 .join('')
487 const lanesH = lanes * (laneH + laneGap) - laneGap
488 if (!isFull) return { body: capsules, height: lanesH, span }
489 const axisY = top + lanesH + 5.5
490 const axis = `<line class="ln" x1="0" y1="${axisY}" x2="${width}" y2="${axisY}"/>${textAt(0, axisY + 13, 'm', 10, '0:00', TABULAR)}${textAt(width, axisY + 13, 'm', 10, fmtTime(span), `${TABULAR} text-anchor="end"`)}`
491 return { body: capsules + axis, height: lanesH + 5.5 + 16, span }
492}
493
494// ---------------------------------------------------------------------------
495// The header: live state, totals, timeline.
496
497export type Drawing = { source: string; height: number }
498
499export const statusPhrase = (list: AgentRun[]): string => {
500 const working = list.filter(a => a.status === 'running').length
501 const failed = list.filter(a => a.status === 'failed').length
502 if (!list.length) return 'No agents yet'
503 if (working) return `${working} working`
504 return failed ? `All ${list.length} finished` : `All ${list.length} done`
505}
506
507const STAT_LABEL = 11
508const STAT_VALUE = 12.5
509
510export const headerSvg = (W: number, list: AgentRun[], at: number, clockAt: number): Drawing => {
511 const sums = totals(list, at)
512 const working = list.filter(a => a.status === 'running').length
513 const failed = list.filter(a => a.status === 'failed').length
514 const mark = working
515 ? `<circle class="live" cx="6" cy="15" r="4" fill="${CORAL}"/>`
516 : list.length
517 ? checkMark(7, 15)
518 : `<circle class="k" cx="6" cy="15" r="4"/>`
519 const stopped = !working && failed
520 ? `<tspan class="t-fail" dx="8" font-family="${FONT}" font-size="11.5" font-style="normal" font-weight="600">${failed} stopped</tspan>`
521 : ''
522 const status = `${mark}<text class="tx" x="18" y="20" font-family="${SERIF}" font-size="15" font-style="italic" font-weight="600">${xml(statusPhrase(list))}${stopped}</text>
523<text x="${W}" y="22" text-anchor="end"><tspan class="m" font-family="${FONT}" font-size="11">elapsed</tspan><tspan class="tx" dx="6" font-family="${SERIF}" font-size="20" font-weight="600"${TABULAR}>${fmtTime(sums.time)}</tspan></text>`
524 if (!list.length) return { source: svg(W, 30, status, clockAt), height: 30 }
525
526 // The stats row: the first layout that fits, from the fullest to the most compact.
527 const doneCount = list.length - working
528 const agentValues = working ? [`${doneCount} of ${list.length} done`, `${doneCount}/${list.length} done`, `${doneCount}/${list.length}`] : [`${list.length}`]
529 const pairsOf = (agents: string, hasTokens: boolean): [string, string][] => [
530 ['cost', '≈' + fmtCost(sums.cost)],
531 ...(hasTokens ? ([['tokens', fmtTokens(sums.tokens)]] as [string, string][]) : []),
532 ['agents', agents],
533 ]
534 const shortest = agentValues[agentValues.length - 1] ?? ''
535 const layouts: { pairs: [string, string][]; gap: number }[] = [
536 ...agentValues.map(agents => ({ pairs: pairsOf(agents, true), gap: 25 })),
537 { pairs: pairsOf(shortest, false), gap: 25 },
538 { pairs: pairsOf(shortest, false), gap: 16 },
539 ]
540 const widthOf = ({ pairs, gap }: { pairs: [string, string][]; gap: number }) =>
541 pairs.reduce((sum, [label, value]) => sum + textWidth(label, STAT_LABEL) + 5 + textWidth(value, STAT_VALUE), 0) + gap * (pairs.length - 1)
542 const layout = layouts.find(candidate => widthOf(candidate) <= W) ?? layouts[layouts.length - 1] ?? layouts[0]!
543 let cursor = 0
544 const stats = layout.pairs
545 .map(([label, value], index) => {
546 const labelW = textWidth(label, STAT_LABEL)
547 const divider = index ? `<line class="ln" x1="${cursor - layout.gap / 2}" y1="40" x2="${cursor - layout.gap / 2}" y2="53"/>` : ''
548 const pair = `${divider}${textAt(cursor, 51, 'm', STAT_LABEL, label)}${textAt(cursor + labelW + 5, 51, 'tx', STAT_VALUE, value, `${TABULAR} font-weight="600"`)}`
549 cursor += labelW + 5 + textWidth(value, STAT_VALUE) + layout.gap
550 return pair
551 })
552 .join('')
553 const lanes = timeline(list, at, 68, W, 6, true)
554 const height = 68 + lanes.height
555 return { source: svg(W, height, `${status}${stats}${lanes.body}`, clockAt), height }
556}
557
558// ---------------------------------------------------------------------------
559// One row per run: crab in its tile, what it does, its type and model, aligned metrics, context.
560
561export const ROW_H = 70
562const ROW_X = 52
563const BAR_Y = 61
564
565const chip = (x: number, y: number, label: string, fillClass: string, inkClass: string): { body: string; width: number } => {
566 const width = Math.round(textWidth(label, 10.5) + 14)
567 return {
568 body: `<rect class="${fillClass}" x="${x}" y="${y}" width="${width}" height="17" rx="8.5" fill-opacity=".14"/>${textAt(x + 7, y + 12.2, inkClass, 10.5, label, ' font-weight="600"')}`,
569 width,
570 }
571}
572
573// `at` times the run (the pane's ticking clock); `clockAt` is the clock now, for motion.
574// A settled run is drawn with `clockAt` 0, so its row stays the same image from tick to tick.
575export const agentSvg = (W: number, a: AgentRun, at: number, clockAt: number, isLast = false): string => {
576 const role = roleOf(a.type)
577 const isRunning = a.status === 'running'
578 const isFailed = a.status === 'failed'
579
580 // Line 1: what it does, then its status and time at the right.
581 const time = fmtTime(elapsed(a, at))
582 const timeW = textWidth(time, 12)
583 const markX = W - timeW - 17
584 const line1 = `${textAt(ROW_X, 20, 'tx', 13, fitText(a.description || a.type, 13, markX - ROW_X - 14), ' font-weight="600"')}
585${statusMark(markX, 15.5, a)}${textAt(W, 20, isRunning ? 'tx' : 'm', 12, time, `${TABULAR} text-anchor="end" font-weight="600"`)}`
586
587 // Line 2: the type chip, the model when one is known, and a stopped chip for a failed run.
588 const typeChip = chip(ROW_X, 27, fitText(a.type, 10.5, (W - ROW_X) * 0.5), `f-${role}`, `t-${role}`)
589 const stopW = isFailed ? chip(0, 27, 'Stopped', 'xmf', 't-fail').width + 8 : 0
590 const modelX = ROW_X + typeChip.width + 8
591 const model = fitText(runModel(a), 11, W - modelX - stopW)
592 const stopX = model ? modelX + textWidth(model, 11) + 8 : modelX
593 const stopChip = isFailed ? chip(stopX, 27, 'Stopped', 'xmf', 't-fail').body : ''
594 const line2 = `${typeChip.body}${model ? textAt(modelX, 39, 's', 11, model) : ''}${stopChip}`
595
596 // Line 3: metrics in fixed columns, so values line up down the panel. A narrow panel keeps ctx and cost.
597 const isNarrow = W - ROW_X < 230
598 const metrics: [string, string][] = [
599 ['ctx', `${ctxOf(a)}%`],
600 ...(isNarrow ? [] : ([['tokens', fmtTokens(a.tokens)]] as [string, string][])),
601 ['cost', '≈' + fmtCost(a.costUsd)],
602 ...(W >= 400 ? ([['steps', String(a.steps)]] as [string, string][]) : []),
603 ]
604 const columnW = Math.min(110, (W - ROW_X) / metrics.length)
605 const line3 = metrics
606 .map(([label, value], index) => {
607 const x = ROW_X + index * columnW
608 return `${textAt(x, 55, 'm', 10.5, label)}${textAt(x + textWidth(label, 10.5) + 5, 55, 's', 11, value, `${TABULAR} font-weight="600"`)}`
609 })
610 .join('')
611
612 // Context bar: live fill in the role colour; a finished run keeps its last fill in a quiet tone.
613 const barW = W - ROW_X
614 const fillW = Math.round((barW * ctxTarget(a)) / 100)
615 const fillClass = isRunning ? `f-${role}` : isFailed ? 'cfail' : 'cdone'
616 // The fill tweens from where it stood; a run that just finished also fades from its role colour.
617 const isTweening = a.ctxFrom !== undefined && isTweenAt(a.ctxAt, clockAt)
618 const tween = isTweening
619 ? `<style>@media (prefers-reduced-motion: no-preference){${tweenCss('cf', `width:${Math.round((barW * Math.min(100, Math.max(0, a.ctxFrom ?? 0))) / 100)}px${isRunning ? '' : `;fill:${ROLE_FILL[role]}`}`, -Math.round(clockAt - (a.ctxAt ?? clockAt)))}}</style>`
620 : ''
621 const bar = `<rect class="k" x="${ROW_X}" y="${BAR_Y}" width="${barW}" height="3" rx="1.5"/><rect class="${isTweening ? 'cf ' : ''}${fillClass}" x="${ROW_X}" y="${BAR_Y}" width="${fillW}" height="3" rx="1.5"/>${tween}`
622
623 // The crab's home: a tile in its role colour.
624 const tile = `<rect class="f-${role}" x="0" y="10" width="40" height="40" rx="12" fill-opacity=".14"/>${crab(5, 16, role, isRunning, 1)}`
625 const hairline = isLast ? '' : `<line class="ln" x1="0" y1="${ROW_H - 0.5}" x2="${W}" y2="${ROW_H - 0.5}"/>`
626
627 return svg(W, ROW_H, `${tile}${line1}${line2}${line3}${bar}${hairline}`, clockAt)
628}
629
630// ---------------------------------------------------------------------------
631// The empty state and the compact view.
632
633export const EMPTY_COPY = 'They appear here as soon as one starts.'
634export const EMPTY_H = 52
635
636// The empty state's second line, wrapped by word to the width beside the crab.
637const emptyLines = (W: number): string[] =>
638 EMPTY_COPY.split(' ').reduce<string[]>((lines, word) => {
639 const last = lines[lines.length - 1]
640 if (last !== undefined && textWidth(`${last} ${word}`, 12) <= W - ROW_X) lines[lines.length - 1] = `${last} ${word}`
641 else lines.push(word)
642 return lines
643 }, [])
644
645export const emptyHeight = (W: number): number => EMPTY_H + (emptyLines(W).length - 1) * 16
646
647export const emptySvg = (W: number, clockAt: number): string =>
648 svg(
649 W,
650 emptyHeight(W),
651 `<rect class="f-pride" x="0" y="6" width="40" height="40" rx="12" fill-opacity=".14"/>${crab(5, 12, 'pride', false, 1)}
652${textAt(ROW_X, 23, 'tx', 13, fitText('No subagents yet', 13, W - ROW_X), ' font-weight="600"')}
653${emptyLines(W)
654 .map((line, index) => textAt(ROW_X, 40 + index * 16, 's', 12, fitText(line, 12, W - ROW_X)))
655 .join('')}`,
656 clockAt,
657 )
658
659export const compactSvg = (W: number, list: AgentRun[], at: number, clockAt: number): Drawing => {
660 const sums = totals(list, at)
661 const summary = `≈${fmtCost(sums.cost)} · ${fmtTokens(sums.tokens)} · ${fmtTime(sums.time)}`
662 const ordered = [...list.filter(a => a.status === 'running'), ...list.filter(a => a.status !== 'running')]
663 const fit = Math.max(1, Math.floor((W - textWidth(summary, 12) - 40) / 34))
664 const shown = ordered.slice(0, fit)
665 const more = ordered.length - shown.length
666 const crabs = shown
667 .map((a, index) => {
668 const x = index * 34
669 const dot =
670 a.status === 'running'
671 ? `<circle class="live f-${roleOf(a.type)}" cx="${x + 15}" cy="35" r="2.2"/>`
672 : `<circle class="${a.status === 'done' ? 'cdone' : 'cfail'}" cx="${x + 15}" cy="35" r="2.2"/>`
673 return `${crab(x, 0, roleOf(a.type), a.status === 'running', 1)}${dot}`
674 })
675 .join('')
676 const overflow = more ? textAt(shown.length * 34 + 2, 20, 's', 12, `+${more}`, TABULAR) : ''
677 const totalsText = textAt(W, 20, 's', 12, summary, `${TABULAR} text-anchor="end"`)
678 const lanes = timeline(list, at, 46, W, 3, false)
679 const height = lanes.height ? 46 + lanes.height + 2 : 40
680 return { source: svg(W, height, `${crabs}${overflow}${totalsText}${lanes.body}`, clockAt), height }
681}
682types/index.d.ts 50 lines1export type AgentStatus = 'running' | 'done' | 'failed'
2
3export type AgentRun = {
4 id: string
5 agentId?: string
6 type: string
7 description: string
8 model: string
9 effort?: string
10 status: AgentStatus
11 startedAt: number
12 endedAt?: number
13 contextTokens: number
14 contextMax: number
15 tokens: number
16 costUsd: number
17 steps: number
18 // The context percent the pane showed just before it last changed, and when (ms), so the fill can tween.
19 ctxFrom?: number
20 ctxAt?: number
21}
22
23// What the band draws: the finished count, and the step it is tweening from.
24export type BarState = {
25 done: number
26 total: number
27 fromRatio: number
28 fromDone: number
29 fromTotal: number
30 changedAt: number
31}
32
33export type View = {
34 isCompact: boolean
35 isDoneCollapsed: boolean
36 isBandHidden: boolean
37 didAutoOpen: boolean
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'pulse-progress': {
43 agents: AgentRun[]
44 view: View
45 now: number
46 bar: BarState
47 }
48 }
49}
50