SLOPSHOPPER

pulse-progress

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.

newpanebandcommandtimeragents
v0.3.1MITupdated 2026-10-13MrValraven/pulse-mods/pulse-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pulse-progress
│ ┃ Pulse · agents ✕ › fix the failing auth test and add an audit log call │ ┃ ≈$0.00 · 0 tokens · 0:00 Collapse │ ┃ ⏺ Read(src/auth.ts) │ ┃ No subagents yet. They appear here as soon ⎿ Read 6 lines │ ┃ as one starts. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pulse │ ⎿ pulse-progress: Pulse panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pulse · agents
≈$0.00 · 0 tokens · 0:00 Collapse No subagents yet. They appear here as soon as one starts.
README

pulse-progress

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.

  • Band above the prompt: "Pulse", a dithered bar from coral to rose to jade, finished subagents out of all subagents, and a pride crab that walks while work runs. ×N opens the panel. ✕ hides the band and clears the finished runs. The next subagent shows the band again.
  • Panel (/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.
  • Smooth motion: the bar and the pill glide to each new count, context bars ease to their new fill, and the crabs keep their stride when the picture redraws.
  • Crabs by role: *-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.

What it does not do

This mod only watches. Compared to savvy-progress, it removes all behaviour changes:

  • It registers no tools that the model can call. (savvy-progress adds progress and step, and puts step in the tool list of every subagent.)
  • It does not change subagent prompts. (savvy-progress adds a reporting instruction to each savvy-* brief.)
  • It does not read settings or environment variables. (savvy-progress reads them for the language.)
  • It has no network, file or process access.

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.

Settings

autoOpen: once (default) opens the panel the first time a subagent starts in a session. never opens it only with /pulse.

Install

This folder is a plugin and also a marketplace with one plugin in it.

  1. Unzip it to a folder you keep, for example ~/claude-mods/pulse-progress. Do not delete the folder after you install: Claude Code reads the plugin from it.
  2. In a terminal, run:
claude plugin marketplace add ~/claude-mods/pulse-progress
claude plugin install pulse-progress@pulse-mods
  1. Start a new Claude Code session, or type /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.

Check it

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 485 lines
1// 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}
485
hooks/draw.ts 682 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
682
types/index.d.ts 50 lines
1export 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