SLOPSHOPPER

savvy-progress

Progress bar above the prompt for /savvy-flow, plus an agents panel (/agents-info): running, finished and planned subagents with model, task progress, context…

newpanebandguardcommandtool
v1.2.0MITupdated 2026-10-15patle1/claude-mods/mods/savvy-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-progress
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ Collapse │ ┃ ≈$0.00 · 0 tokens · 0:00 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No subagents yet. ⏺ 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 │ │ › /agents-info │ ⎿ savvy-progress: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
Collapse ≈$0.00 · 0 tokens · 0:00 No subagents yet.
README

savvy-progress

A Claude Code mod: a progress bar above the prompt and a live panel of subagents. Made for the savvy-flow skill, and useful with any subagents.

  • Progress bar: the flow's title, phase, accepted tasks out of planned, and a button with the crew size that opens the panel. It appears once something reports progress (savvy-flow does) or a savvy-* worker starts.
  • Agents panel (/agents-info toggles it): running, finished and planned subagents with model, effort, task progress, context, estimated cost and time. Working crabs walk, and each savvy tier animates its prop: the astronaut floats, the detective sweeps the magnifier, the engineer turns the wrench, the chef tosses the omelette, the racer runs with a fluttering flag. prefers-reduced-motion stops them.

Tools it adds

  • mcp__savvy-progress__progress: the orchestrator reports the plan, phase and accepted tasks.
  • mcp__savvy-progress__step: a worker reports its own steps (done, total, note). A worker that does not report shows its context fill in grey instead.

Cost is a rough estimate from token counts and a built-in per-model price table (PRICES in hooks/register.tsx), not a bill.

Settings

language: auto (default), en or ru. auto follows Claude Code's language setting, then the system locale, and falls back to English. Set it in /config, or, for a mod loaded by hand, in ~/.claude/settings.json:

{ "pluginConfigs": { "savvy-progress": { "options": { "language": "ru" } } } }

Install instructions are in the repository README.

Source 2 files
hooks/register.tsx 1090 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Flow, Panel, Phase, PlannedTask } from '../types'
5
6const flow = atom({ plugin: 'savvy-progress', key: 'flow' } as const, null)
7const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [])
8const panel = atom({ plugin: 'savvy-progress', key: 'panel' } as const, {
9  isCompact: false,
10  isDoneCollapsed: false,
11  autoOpenedFor: '',
12})
13const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
14
15const TOOL = 'mcp__savvy-progress__progress'
16const STEP_TOOL = 'mcp__savvy-progress__step'
17const PANE = 'savvy-agents'
18const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
19const ACCENT = '#8f8cf4'
20const DONE = '#5fbf8f'
21
22type ProgressInput = {
23  title?: string
24  total?: number
25  done?: number
26  phase?: Phase
27  finished?: boolean
28  tasks?: { title?: string; tier?: string; after?: number[] }[]
29}
30
31// ---------------------------------------------------------------------------
32// Language: the `language` option, else Claude Code's `language` setting, else the
33// process locale; English when nothing says Russian.
34
35type Lang = 'en' | 'ru'
36
37const STRINGS = {
38  en: {
39    pane: 'Agents',
40    cost: 'Cost',
41    tokens: 'Tokens',
42    time: 'Time',
43    collapse: 'Collapse',
44    expand: 'Expand',
45    running: 'Running',
46    finished: 'Finished',
47    planned: 'Planned',
48    empty: 'No subagents yet.',
49    round: 'round',
50    failed: 'error',
51    after: 'after',
52    tokensWord: 'tokens',
53    agentsCount: 'agents',
54    isRunning: 'running',
55    isFinished: 'finished',
56    isPlanned: 'planned',
57    opened: 'Agents panel opened.',
58    closed: 'Agents panel closed.',
59    done: 'Done',
60    plan: 'Plan',
61    design: 'Design',
62    tasks: 'Tasks',
63    review: 'Review',
64    busy: 'running',
65  },
66  ru: {
67    pane: 'Агенты',
68    cost: 'Стоимость',
69    tokens: 'Токены',
70    time: 'Время',
71    collapse: 'Свернуть',
72    expand: 'Развернуть',
73    running: 'Работают',
74    finished: 'Завершены',
75    planned: 'Запланированы',
76    empty: 'Субагентов пока нет.',
77    round: 'раунд',
78    failed: 'ошибка',
79    after: 'после',
80    tokensWord: 'токенов',
81    agentsCount: 'агентов',
82    isRunning: 'работает',
83    isFinished: 'завершён',
84    isPlanned: 'запланирована',
85    opened: 'Панель агентов открыта.',
86    closed: 'Панель агентов закрыта.',
87    done: 'Готово',
88    plan: 'План',
89    design: 'Дизайн',
90    tasks: 'Задачи',
91    review: 'Ревью',
92    busy: 'в работе',
93  },
94} as const
95
96// Module scope is fine here: session.start sets it again on every (re)load.
97let lang: Lang = 'en'
98const tr = () => STRINGS[lang]
99
100const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
101
102async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
103  if (option === 'en' || option === 'ru') return option
104  try {
105    const settings = (await $.settings.read()) as Record<string, unknown>
106    if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
107  } catch {
108    // No settings: fall through to the locale.
109  }
110  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
111  return isRussian(locale) ? 'ru' : 'en'
112}
113
114const blank = (): Flow => ({
115  title: 'savvy-flow',
116  total: 0,
117  done: 0,
118  running: 0,
119  phase: 'plan',
120  isFinished: false,
121  tasks: [],
122})
123
124const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
125  !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
126
127const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
128  tasks
129    ?.filter(t => t.title?.trim())
130    .map(t => ({
131      title: (t.title ?? '').trim(),
132      tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
133      after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
134    }))
135
136const merge = (prev: Flow | null, input: ProgressInput): Flow => {
137  // A new title means a new flow: never carry counters over from an earlier one.
138  const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
139  const tasks = cleanTasks(input.tasks) ?? base.tasks
140  const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
141  const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
142  const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
143  return {
144    ...base,
145    title: input.title?.trim() || base.title,
146    total,
147    done,
148    phase: input.finished ? 'close' : phase,
149    isFinished: input.finished === true,
150    tasks,
151  }
152}
153
154const label = (f: Flow): string => {
155  const s = tr()
156  if (f.isFinished) return s.done
157  if (f.phase === 'plan') return s.plan
158  if (f.phase === 'design') return s.design
159  const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
160  return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
161}
162
163const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
164
165// Deterministic noise so the dither does not shimmer between redraws.
166const noise = (x: number, y: number): number => {
167  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
168  return s - Math.floor(s)
169}
170
171// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
172// so title, bar, percent and the crab live in one drawing; only the count and
173// the dismiss are Buttons beside it.
174const H = 22
175const BAR_H = 16
176const CRAB_W = 26
177const CELL = 3
178const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
179
180const xml = (s: string): string =>
181  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
182
183const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
184
185// Rough advance of system UI text, in em; good enough to size the title's slot.
186const charEm = (ch: string): number =>
187  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
188
189const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
190
191// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
192const fitText = (s: string, size: number, maxW: number): string => {
193  if (textWidth(s, size) <= maxW) return s
194  let out = ''
195  for (const ch of s) {
196    if (textWidth(out + ch + '…', size) > maxW) break
197    out += ch
198  }
199  return out + '…'
200}
201
202const rowSvg = (f: Flow, W: number, isWorking: boolean): string => {
203  // The title takes what it needs, up to 40% of the row; the bar takes the rest.
204  const title = fitText(f.title, 13, Math.max(60, W * 0.4))
205  const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
206  const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
207  const color = f.isFinished ? DONE : ACCENT
208  const y0 = (H - BAR_H) / 2
209  const fillW = Math.round(BAR_W * ratio(f))
210  const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
211  const dots: string[] = []
212
213  // Dithered fill: sparse at the start, dense toward the head.
214  const cols = Math.floor(fillW / CELL)
215  const rows = Math.floor(BAR_H / CELL)
216  for (let c = 0; c < cols; c++) {
217    const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
218    for (let r = 0; r < rows; r++) {
219      if (noise(c, r) < density) dots.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
220    }
221  }
222  // Handed to workers, not yet accepted: a faint second layer.
223  const faint: string[] = []
224  for (let c = cols; c < Math.floor(runW / CELL); c++) {
225    for (let r = 0; r < rows; r++) {
226      if (noise(c + 7, r + 3) < 0.2) faint.push(`<rect class="t${Math.floor(noise(r + 5, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="1.7" height="1.7"/>`)
227    }
228  }
229
230  const ticks: string[] = []
231  for (let i = 1; i < f.total; i++) {
232    const x = Math.round((BAR_W * i) / f.total)
233    if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
234  }
235
236  const text = label(f)
237  const pillW = Math.round(18 + text.length * 6.6)
238  const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
239  const percent = `${Math.round(ratio(f) * 100)}%`
240
241  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
242<style>
243.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
244@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
245/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
246.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
247.t1{animation-duration:${f.isFinished ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${f.isFinished ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${f.isFinished ? 3.5 : 3.3}s;animation-delay:-.4s}
248@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
249@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
250</style>
251<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
252<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
253<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
254<g transform="translate(${BAR_X},${y0})">
255<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
256<g clip-path="url(#c)">
257<g fill="${color}">${dots.join('')}</g>
258<g fill="${color}" opacity="0.45">${faint.join('')}</g>
259<g class="tk">${ticks.join('')}</g>
260</g>
261<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
262<text x="${pillX + pillW / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(text)}</text>
263</g>
264<text class="m" x="${W - CRAB_W - 6}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>
265${CRAB_CSS}${crab(W - CRAB_W + 1, 0, 'other', false, isWorking, 0.8)}
266</svg>`
267}
268
269const barText = (f: Flow, width: number): string => {
270  const filled = Math.round(width * ratio(f))
271  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
272}
273
274// ---------------------------------------------------------------------------
275// Agents panel: every subagent of the session, plus the tasks the flow planned.
276
277const TIER_COLOR: Record<string, string> = {
278  fable: '#7F77DD',
279  heavy: '#D85A30',
280  careful: '#BA7517',
281  medium: '#378ADD',
282  light: '#1D9E75',
283  other: '#888780',
284}
285
286// What each savvy tier runs on, for planned tasks that have no run yet.
287const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
288
289const TIER_MODEL: Record<string, string> = {
290  fable: 'Fable · high',
291  heavy: 'Opus · xhigh',
292  careful: 'Opus · high',
293  medium: 'Opus · medium',
294  light: 'Opus · low',
295}
296
297// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
298// The engine reports tokens, not money, so the panel's cost is an estimate.
299const PRICES: [RegExp, [number, number, number, number]][] = [
300  [/fable|mythos/, [10, 50, 0.25, 12.5]],
301  [/opus-5-5/, [4, 20, 0.2, 5]],
302  [/opus/, [5, 25, 0.5, 6.25]],
303  [/sonnet/, [2, 10, 0.2, 2.5]],
304  [/haiku/, [1, 5, 0.1, 1.25]],
305]
306
307type Usage = {
308  input_tokens: number
309  output_tokens: number
310  cache_read_input_tokens: number
311  cache_creation_input_tokens: number
312}
313
314const priceOf = (model: string): [number, number, number, number] =>
315  PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
316
317const costOf = (model: string, u: Usage): number => {
318  const [i, o, r, w] = priceOf(model)
319  return (
320    ((u.input_tokens || 0) * i +
321      (u.output_tokens || 0) * o +
322      (u.cache_read_input_tokens || 0) * r +
323      (u.cache_creation_input_tokens || 0) * w) /
324    1e6
325  )
326}
327
328const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
329
330// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
331const tierOf = (type: string): string => {
332  const bare = type.replace(/^[^:]*:/, '')
333  const t = bare.replace(/^savvy-/, '').toLowerCase()
334  return t in TIER_COLOR && bare.startsWith('savvy-') ? t : 'other'
335}
336
337const modelName = (id: string): string => {
338  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
339  const [, family = '', major = '', minor] = m ?? []
340  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
341  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
342}
343
344const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
345
346const fmtTokens = (n: number): string =>
347  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
348
349const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
350
351const fmtTime = (ms: number): string => {
352  const s = Math.max(0, Math.round(ms / 1000))
353  const h = Math.floor(s / 3600)
354  const m = Math.floor((s % 3600) / 60)
355  const ss = String(s % 60).padStart(2, '0')
356  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
357}
358
359const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
360
361type Planned = PlannedTask & { n: number }
362
363const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
364  if (!f || f.isFinished) return []
365  const started = new Set(list.map(a => norm(a.description)))
366  return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
367}
368
369const totals = (list: AgentRun[], at: number) => {
370  const cost = list.reduce((s, a) => s + a.costUsd, 0)
371  const tokens = list.reduce((s, a) => s + a.tokens, 0)
372  const start = Math.min(...list.map(a => a.startedAt))
373  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
374  return { cost, tokens, time: list.length ? end - start : 0 }
375}
376
377// --- desktop drawings: each row is one SVG, as the band above the prompt is.
378
379const PANE_CSS = `<style>
380.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
381@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
382.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
383@media (prefers-reduced-motion: reduce){.live{animation:none}}
384</style>`
385
386// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
387// The body keeps the brand clay; the tier's color lives in the costume's accent.
388const CLAY = '#D97757'
389const INK = '#1F1E1D'
390
391// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
392// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
393type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
394
395const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
396  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
397
398// `armCls` lets a raised claw travel with the prop it holds.
399const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
400  f(7, 10, 16, 12, CLAY)
401  f(3, 14, 4, 4, CLAY)
402  f(23, 14 + armFront, 4, 4, CLAY, armCls)
403  f(9, 12, 2, 2, INK)
404  f(19, 12, 2, 2, INK)
405  f(7, 22, 2, 4, CLAY, 'la')
406  f(17, 22, 2, 4, CLAY, 'la')
407  f(11, 22, 2, 4, CLAY, 'lb')
408  f(21, 22, 2, 4, CLAY, 'lb')
409}
410
411// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
412// once-a-second redraw of a running row restarts them in phase. Every crab walks;
413// each costume adds its prop's own motion on top.
414const CRAB_CSS = `<style>
415.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
416.run .bd{animation:bob .5s steps(1) infinite -.125s}
417.run g{transform-box:fill-box}
418@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
419.c-fable.run{animation:float 1s ease-in-out infinite}
420.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
421.c-fable.run .ant{animation:blink 1s steps(1) infinite}
422.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
423@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
424.c-heavy.run .it{animation:scan 1s steps(1) infinite}
425.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
426@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
427.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
428@keyframes twist{50%{transform:rotate(-35deg)}}
429.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
430.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
431@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
432.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
433.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
434@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
435.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
436@keyframes fence{50%{transform:rotate(25deg)}}
437@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
438</style>`
439
440const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
441  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
442  fable: (f, t) => {
443    crabBody(f)
444    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
445    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
446    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
447    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
448  },
449  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
450  heavy: (f, t) => {
451    crabBody(f, -4, 'it')
452    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
453    f(6, 9, 18, 1, t)
454    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
455    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')
456  },
457  // Careful: engineer in a hard hat; the wrench turns a bolt.
458  careful: (f, t) => {
459    crabBody(f)
460    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
461    f(13, 5, 4, 2, t)
462    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
463  },
464  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
465  medium: (f, t) => {
466    crabBody(f, -4, 'pan')
467    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
468    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
469  },
470  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
471  light: (f, t) => {
472    crabBody(f, -4)
473    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
474    f(25, 1, 1, 9, '#8E929A')
475    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
476  },
477  // Explore: pirate scouting the code; the cutlass fences.
478  explore: f => {
479    crabBody(f)
480    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
481    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
482    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
483  },
484  other: f => crabBody(f),
485}
486
487// Local change, not upstream: the build-review builders borrow a costume and open the panel too.
488const BUILD_REVIEW_COSTUME: Record<string, string> = {
489  'build-review-builder': 'medium',
490  'build-review-light': 'light',
491}
492
493const costumeOf = (type: string): string => (type === 'Explore' ? 'explore' : (BUILD_REVIEW_COSTUME[type] ?? tierOf(type)))
494
495const CRAB_SCALE = 1.1
496
497// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
498// legs stay outside it and step on their own.
499const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
500  const groups = new Map<string, string[]>([['bd', []]])
501  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
502    if (!groups.has(cls)) groups.set(cls, [])
503    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
504  }
505  const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
506  draw(f, colorOf(costume))
507  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
508  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
509  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
510  return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
511}
512
513const statusMark = (x: number, y: number, status: string, color: string): string => {
514  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
515  if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
516  if (status === 'failed') return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
517  return `<circle cx="${x}" cy="${y}" r="5" fill="none" stroke="#9a9a96" stroke-width="1.4"/><path d="M${x} ${y - 2.5}v2.8l1.8 1.2" fill="none" stroke="#9a9a96" stroke-width="1.4" stroke-linecap="round"/>`
518}
519
520const svg = (W: number, H: number, body: string): string =>
521  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
522
523// The pane's own title already says "Agents": the header names the flow, if any.
524const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
525  const s = tr()
526  const gap = 6
527  const tw = (W - gap * 2) / 3
528  const top = title ? 28 : 0
529  const tile = (i: number, k: string, v: string) =>
530    `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
531<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
532<text class="t" x="${i * (tw + gap) + 9}" y="${top + 33}" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
533  return svg(
534    W,
535    headerHeight(title),
536    `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
537${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`,
538  )
539}
540
541const headerHeight = (title: string): number => (title ? 72 : 44)
542
543// The task's own progress when the worker reports steps; a finished run is full.
544const progressOf = (a: AgentRun): number | null => {
545  if (a.status === 'done') return 1
546  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
547  return null
548}
549
550const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
551
552const agentSvg = (W: number, a: AgentRun, at: number): string => {
553  const s = tr()
554  const tier = tierOf(a.type)
555  const color = colorOf(tier)
556  const ctx = ctxOf(a)
557  const textW = W - 42 - 22
558  const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
559  if (a.round > 1) meta.push(`${s.round} ${a.round}`)
560  if (a.status === 'failed') meta.push(s.failed)
561  const barW = textW
562  const progress = progressOf(a)
563  const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)}  ≈${fmtCost(a.costUsd)}  ${fmtTime(elapsed(a, at))}`
564  const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
565  const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
566  // Without reported steps the bar falls back to the context, drawn grey.
567  const fillW = Math.round(barW * (progress ?? ctx / 100))
568  return svg(
569    W,
570    66,
571    `${crab(0, 14, costumeOf(a.type), false, a.status === 'running')}
572<text class="t" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
573<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier === 'other' ? a.type : tier)}</tspan><tspan class="s">  ${xml(meta.join('  ·  '))}</tspan></text>
574${steps && stepsW > 30 ? `<text class="t" x="42" y="49" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(steps, 11, stepsW))}</text>` : ''}
575<text class="s" x="${42 + barW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
576<rect class="k" x="42" y="55" width="${barW}" height="4" rx="2"/><rect${progress === null ? ' class="m"' : ''} x="42" y="55" width="${fillW}" height="4" rx="2"${progress === null ? '' : ` fill="${color}"`}/>
577${statusMark(W - 8, 16, a.status, color)}
578<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
579  )
580}
581
582const plannedSvg = (W: number, p: Planned): string => {
583  const tier = p.tier in TIER_COLOR ? p.tier : 'other'
584  const color = colorOf(tier)
585  const textW = W - 42 - 22
586  const meta = [TIER_MODEL[tier] ?? '']
587  if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
588  return svg(
589    W,
590    46,
591    `${crab(0, 6, tier, true)}
592<text class="s" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(`${p.n}. ${p.title}`, 13, textW))}</text>
593<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier)}</tspan><tspan class="m">  ${xml(meta.filter(Boolean).join('  ·  '))}</tspan></text>
594${statusMark(W - 8, 16, 'planned', color)}
595<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
596  )
597}
598
599const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
600  const icons = [
601    ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: 'running', dim: false })),
602    ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: a.status, dim: false })),
603    ...planned.map(p => ({ k: p.tier in TIER_COLOR ? p.tier : 'other', c: colorOf(p.tier), s: 'planned', dim: true })),
604  ]
605  const fit = Math.max(1, Math.floor((W - 150) / 36))
606  const shown = icons.slice(0, fit)
607  const more = icons.length - shown.length
608  const body = shown
609    .map((ic, i) => crab(i * 36, 0, ic.k, ic.dim, ic.s === 'running') + (ic.s === 'running' ? `<circle class="live" cx="${i * 36 + 32}" cy="4" r="3" fill="${ic.c}"/>` : ''))
610    .join('')
611  const x = shown.length * 36 + (more ? 4 : 0)
612  return svg(
613    W,
614    32,
615    `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
616<text class="s" x="${W}" y="21" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">≈${fmtCost(t.cost)} · ${fmtTokens(t.tokens)} · ${fmtTime(t.time)}</text>`,
617  )
618}
619
620// --- terminal drawing: the same rows in text.
621
622const ctxBar = (pct: number, width: number): string => {
623  const filled = Math.round((width * pct) / 100)
624  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
625}
626
627const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
628
629// Opens the agents pane, or closes it when it is up; true when it ends up open.
630async function togglePane($: EngineInterface): Promise<boolean> {
631  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
632  if (isOpen) {
633    await $.ui.close({ id: PANE })
634    return false
635  }
636  const at = await $.clock.now()
637  await update($, now, () => at)
638  await $.ui.open({ id: PANE, title: tr().pane })
639  return true
640}
641
642async function autoOpen($: EngineInterface, key: string): Promise<void> {
643  const p = await read($, panel)
644  if (p.autoOpenedFor === key) return
645  await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
646  void $.ui.open({ id: PANE, title: tr().pane })
647}
648
649export const register: Register = (on, options) => {
650  on('session.start', async ($, e, next) => {
651    const started = await next(e)
652    lang = await detectLang($, options.language)
653    await $.tool.register({
654      name: 'progress',
655      description:
656        'Report /savvy-flow progress to the progress bar above the prompt and the agents panel. ' +
657        'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
658        'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value.',
659      inputSchema: {
660        type: 'object',
661        properties: {
662          title: { type: 'string', description: 'Short name of the overall task, a few words.' },
663          total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
664          done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
665          phase: { type: 'string', enum: [...PHASES] },
666          finished: { type: 'boolean', description: 'True once the flow is closed.' },
667          tasks: {
668            type: 'array',
669            description:
670              'The planned worker tasks in order, numbered from 1. Each title must equal the Agent tool `description` the task will be delegated with, so the panel can match runs to tasks.',
671            items: {
672              type: 'object',
673              properties: {
674                title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
675                tier: { type: 'string', enum: ['fable', 'heavy', 'careful', 'medium', 'light'] },
676                after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
677              },
678              required: ['title', 'tier'],
679            },
680          },
681        },
682      },
683    })
684    await $.tool.register({
685      name: 'step',
686      description:
687        'For savvy-flow workers: report progress on your own task to the agents panel. ' +
688        'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
689        'call it again as each step finishes. Cheap and silent: it only draws a bar.',
690      inputSchema: {
691        type: 'object',
692        properties: {
693          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
694          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
695          note: { type: 'string', description: 'The step in progress, a few words.' },
696        },
697        required: ['done'],
698      },
699    })
700    await $.command.register({
701      name: 'agents-info',
702      description: 'Show or hide the panel of subagents: running, finished and planned, with model, context, cost and time',
703    })
704
705    // Ticks the running agents' clocks; quiet when nothing runs.
706    $.clock.every(1000, () => {
707      void (async () => {
708        const list = await read($, agents)
709        if (!list.some(a => a.status === 'running')) return
710        const at = await $.clock.now()
711        await update($, now, () => at)
712      })()
713    })
714    return started
715  })
716
717  on('command.run', { command: 'agents-info' }, async $ => {
718    const isOpen = await togglePane($)
719    return { text: isOpen ? tr().opened : tr().closed }
720  })
721
722  on('tool.call', { tool: TOOL }, async ($, e) => {
723    const input = e as unknown as ProgressInput
724    const prev = await read($, flow)
725    if (isNewFlow(prev, input) && input.title !== undefined) {
726      // A new flow starts with a clean list; agents still running stay.
727      await update($, agents, list => list.filter(a => a.status === 'running'))
728    }
729    const next = await update($, flow, p => merge(p, input))
730    if (next && input.tasks?.length) await autoOpen($, next.title)
731    return { result: `ok: ${label(next ?? blank())}` }
732  })
733
734  // A worker's own progress: the call runs in the worker's loop, so agentId names it.
735  on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
736    const input = e as unknown as { done?: number; total?: number; note?: string }
737    const agentId = e.agentId
738    if (!agentId) return { result: 'ignored: only subagents report steps' }
739    await update($, agents, list =>
740      list.map(a => {
741        if (a.agentId !== agentId) return a
742        const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
743        const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
744        return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
745      }),
746    )
747    return { result: 'ok' }
748  })
749
750  // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
751  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
752    const type = String(e.subagent_type ?? '')
753    if (!type.startsWith('savvy-')) return next(e)
754
755    await update($, flow, prev => {
756      const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
757      return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
758    })
759    try {
760      return await next(e)
761    } finally {
762      await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
763    }
764  })
765
766  on('agent.spawn', async ($, e, next) => {
767    const started = await next(e)
768    if (started.deny !== undefined) return started
769
770    const at = await $.clock.now()
771    await update($, agents, list => {
772      const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
773      const run: AgentRun = {
774        id: started.agentId ?? e.tool_use_id,
775        agentId: started.agentId,
776        type: e.subagentType,
777        description: e.description,
778        model: started.model,
779        status: 'running',
780        startedAt: at,
781        contextTokens: 0,
782        contextMax: windowOf(started.model),
783        tokens: 0,
784        costUsd: 0,
785        steps: 0,
786        round,
787      }
788      return [...list.filter(a => a.id !== run.id), run].slice(-200)
789    })
790    await update($, now, () => at)
791    if (e.subagentType.startsWith('savvy-')) {
792      const f = await read($, flow)
793      await autoOpen($, f && !f.isFinished ? f.title : 'savvy-flow')
794    } else if (e.subagentType in BUILD_REVIEW_COSTUME) {
795      // Once a session: a panel the user closed stays closed.
796      await autoOpen($, 'build-review')
797    }
798    return started
799  })
800
801  // Each model request of a subagent: live context, tokens and cost.
802  on('turn.step', async function* ($, e, next) {
803    const result = yield* next(e)
804    const agentId = e.agentId
805    const usage = result.usage
806    if (!agentId || !usage) return result
807
808    const model = usage.model || e.model
809    await update($, agents, list =>
810      list.map(a =>
811        a.agentId !== agentId
812          ? a
813          : {
814              ...a,
815              model,
816              effort: typeof e.effort === 'string' ? e.effort : a.effort,
817              status: 'running',
818              endedAt: undefined,
819              contextTokens:
820                (usage.input_tokens || 0) +
821                (usage.cache_read_input_tokens || 0) +
822                (usage.cache_creation_input_tokens || 0) +
823                (usage.output_tokens || 0),
824              contextMax: windowOf(model),
825              tokens:
826                a.tokens +
827                (usage.input_tokens || 0) +
828                (usage.output_tokens || 0) +
829                (usage.cache_read_input_tokens || 0) +
830                (usage.cache_creation_input_tokens || 0),
831              costUsd: a.costUsd + costOf(model, usage),
832              steps: a.steps + 1,
833            },
834      ),
835    )
836    return result
837  })
838
839  on('turn.complete', async ($, e, next) => {
840    const agentId = e.agentId
841    if (agentId) {
842      const at = await $.clock.now()
843      await update($, agents, list =>
844        list.map(a => {
845          if (a.agentId !== agentId) return a
846          // A run whose steps went unseen still gets the turn's own sum.
847          const fallback = a.steps === 0 && e.usage
848          return {
849            ...a,
850            status: e.reason === 'answer' ? 'done' : 'failed',
851            endedAt: at,
852            ...(fallback && e.usage
853              ? {
854                  model: e.usage.model || a.model,
855                  tokens:
856                    e.usage.input_tokens +
857                    e.usage.output_tokens +
858                    e.usage.cache_read_input_tokens +
859                    e.usage.cache_creation_input_tokens,
860                  costUsd: costOf(e.usage.model || a.model, e.usage),
861                }
862              : {}),
863          }
864        }),
865      )
866      await update($, now, () => at)
867    }
868    return next(e)
869  })
870
871  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
872    const s = tr()
873    const ui = $.ui.resolve(e)
874    const { Box, Text, Button } = ui
875    const list = await read($, agents)
876    const f = await read($, flow)
877    const p: Panel = await read($, panel)
878    const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
879
880    const running = list.filter(a => a.status === 'running').reverse()
881    const finished = list.filter(a => a.status !== 'running').reverse()
882    const planned = plannedOf(f, list)
883    const t = totals(list, at)
884    // The pane's title says "Agents"; inside, only the flow's own name.
885    const title = f && !f.isFinished ? f.title : ''
886
887    const toggleCompact = (
888      <Button
889        key="compact"
890        label={p.isCompact ? s.expand : s.collapse}
891        plain
892        onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
893      />
894    )
895    const toggleDone = (
896      <Button
897        key="done"
898        label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
899        plain
900        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
901      />
902    )
903    const isEmpty = list.length === 0 && planned.length === 0
904    const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
905
906    if (e.surface === 'desktop' && 'Svg' in ui) {
907      const { Svg } = ui
908      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
909      const section = (key: string, text: string) => (
910        <Text key={key} dimColor>
911          {text}
912        </Text>
913      )
914
915      if (p.isCompact) {
916        return (
917          <Box flexDirection="column" gap={1}>
918            <Svg source={compactSvg(W, list, planned, t)} alt={`${list.length} ${s.agentsCount}, ${summary}`} width={W} height={32} />
919            {toggleCompact}
920          </Box>
921        )
922      }
923      return (
924        <Box flexDirection="column">
925          <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={headerHeight(title)} />
926          {toggleCompact}
927          {isEmpty && <Text dimColor>{s.empty}</Text>}
928          {running.length > 0 && section('h-run', `${s.running} · ${running.length}`)}
929          {running.map(a => (
930            <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isRunning}`} width={W} height={66} />
931          ))}
932          {finished.length > 0 && toggleDone}
933          {!p.isDoneCollapsed &&
934            finished.map(a => (
935              <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isFinished}`} width={W} height={66} />
936            ))}
937          {planned.length > 0 && section('h-plan', `${s.planned} · ${planned.length}`)}
938          {planned.map(pl => (
939            <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: ${s.isPlanned}`} width={W} height={46} />
940          ))}
941        </Box>
942      )
943    }
944
945    // Terminal: the same content in text rows.
946    const cols = Math.max(24, e.props.bodyColumns || 40)
947    const barW = Math.max(6, Math.min(20, cols - 34))
948    const row = (a: AgentRun) => {
949      const tier = tierOf(a.type)
950      const color = colorOf(tier)
951      const ctx = ctxOf(a)
952      const progress = progressOf(a)
953      const model = a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)
954      const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' ' + a.stepNote : ''} · ` : ''
955      return (
956        <Box key={a.id} flexDirection="column" marginBottom={1}>
957          <Box flexDirection="row" gap={1}>
958            <Text color={color}>▣</Text>
959            <Text bold wrap="truncate-end">
960              {a.description || a.type}
961            </Text>
962            <Text color={a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : color}>{STATUS_GLYPH[a.status]}</Text>
963          </Box>
964          <Text dimColor wrap="truncate-end">
965            {'  '}
966            {tier === 'other' ? a.type : tier} · {model}
967            {a.round > 1 ? ` · ${s.round} ${a.round}` : ''}
968          </Text>
969          <Text wrap="truncate-end">
970            {'  '}
971            {progress === null ? <Text dimColor>{ctxBar(ctx, barW)}</Text> : <Text color={color}>{ctxBar(progress * 100, barW)}</Text>}
972            <Text dimColor>
973              {' '}
974              {steps}ctx {ctx}% · {fmtTokens(a.contextTokens)} ≈{fmtCost(a.costUsd)} {fmtTime(elapsed(a, at))}
975            </Text>
976          </Text>
977        </Box>
978      )
979    }
980
981    return (
982      <Box flexDirection="column">
983        <Box flexDirection="row" justifyContent="space-between">
984          <Text bold wrap="truncate-end">
985            {title}
986          </Text>
987          {toggleCompact}
988        </Box>
989        <Text dimColor>
990          ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} {s.tokensWord} · {fmtTime(t.time)}
991        </Text>
992        {p.isCompact ? (
993          <Text wrap="truncate-end">
994            {[...running, ...finished].map(a => (
995              <Text key={a.id} color={colorOf(tierOf(a.type))}>
996                {STATUS_GLYPH[a.status]}{' '}
997              </Text>
998            ))}
999            {planned.map(pl => (
1000              <Text key={`plan-${pl.n}`} dimColor>
1001                ◷{' '}
1002              </Text>
1003            ))}
1004          </Text>
1005        ) : (
1006          <Box flexDirection="column" marginTop={1}>
1007            {isEmpty && <Text dimColor>{s.empty}</Text>}
1008            {running.length > 0 && <Text dimColor>{s.running} · {running.length}</Text>}
1009            {running.map(row)}
1010            {finished.length > 0 && toggleDone}
1011            {!p.isDoneCollapsed && finished.map(row)}
1012            {planned.length > 0 && <Text dimColor>{s.planned} · {planned.length}</Text>}
1013            {planned.map(pl => {
1014              const tier = pl.tier in TIER_COLOR ? pl.tier : 'other'
1015              return (
1016                <Box key={`plan-${pl.n}`} flexDirection="column" marginBottom={1}>
1017                  <Text dimColor wrap="truncate-end">
1018                    <Text color={colorOf(tier)}>▢</Text> {pl.n}. {pl.title} ◷
1019                  </Text>
1020                  <Text dimColor wrap="truncate-end">
1021                    {'  '}
1022                    {tier} · {TIER_MODEL[tier] ?? ''}
1023                    {pl.after.length ? ` · ${s.after} ${pl.after.join(', ')}` : ''}
1024                  </Text>
1025                </Box>
1026              )
1027            })}
1028          </Box>
1029        )}
1030      </Box>
1031    )
1032  })
1033
1034  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1035    const f = await read($, flow)
1036    if (f === null || e.props.hasSurvey) return next(e)
1037
1038    const ui = $.ui.resolve(e)
1039    const { Box, Text, Button } = ui
1040    const list = await read($, agents)
1041    const crew = list.length + plannedOf(f, list).length
1042    const isWorking = list.some(a => a.status === 'running')
1043    const crewButton = (
1044      <Button key="savvy-agents" label={`×${crew}`} plain onPress={() => void togglePane($)} />
1045    )
1046    const percent = `${Math.round(ratio(f) * 100)}%`
1047    const dismiss = (
1048      <Button
1049        key="savvy-dismiss"
1050        label="✕"
1051        plain
1052        role="dismiss"
1053        onPress={() => update($, flow, () => null)}
1054      />
1055    )
1056
1057    if ('Svg' in ui) {
1058      const { Svg } = ui
1059      // About 8 CSS px per reported column; the rest is the count, the dismiss
1060      // and their gaps. No floor above the slot: a row wider than it would wrap.
1061      const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
1062      return (
1063        <Box flexDirection="row" alignItems="center" gap={1}>
1064          <Svg source={rowSvg(f, width, isWorking)} alt={`${f.title}: ${label(f)}, ${percent}`} width={width} height={H} />
1065          {crewButton}
1066          {dismiss}
1067        </Box>
1068      )
1069    }
1070
1071    const cols = e.props.bodyColumns
1072    const titleW = Math.max(8, Math.min(30, f.title.length + 2, Math.floor(cols / 3)))
1073    const width = Math.max(6, Math.min(40, cols - titleW - 32))
1074    return (
1075      <Box flexDirection="row" gap={2}>
1076        <Box width={titleW} flexShrink={0}>
1077          <Text color={f.isFinished ? DONE : ACCENT}>● </Text>
1078          <Text wrap="truncate-end">{f.title}</Text>
1079        </Box>
1080        <Text color={f.isFinished ? DONE : ACCENT}>{barText(f, width)}</Text>
1081        <Text bold>{label(f)}</Text>
1082        <Text dimColor>{percent}</Text>
1083        <Text color={CLAY}>▣</Text>
1084        {crewButton}
1085        {dismiss}
1086      </Box>
1087    )
1088  })
1089}
1090
types/index.d.ts 59 lines
1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4  title: string
5  tier: string
6  after: number[]
7}
8
9export type Flow = {
10  title: string
11  total: number
12  done: number
13  running: number
14  phase: Phase
15  isFinished: boolean
16  tasks: PlannedTask[]
17}
18
19export type AgentStatus = 'running' | 'done' | 'failed'
20
21export type AgentRun = {
22  id: string
23  agentId?: string
24  type: string
25  description: string
26  model: string
27  effort?: string
28  status: AgentStatus
29  startedAt: number
30  endedAt?: number
31  contextTokens: number
32  contextMax: number
33  tokens: number
34  costUsd: number
35  steps: number
36  round: number
37  /** Self-reported by the worker through the `step` tool. */
38  stepDone?: number
39  stepTotal?: number
40  stepNote?: string
41}
42
43export type Panel = {
44  isCompact: boolean
45  isDoneCollapsed: boolean
46  autoOpenedFor: string
47}
48
49declare module 'claude-code' {
50  interface PluginState {
51    'savvy-progress': {
52      flow: Flow | null
53      agents: AgentRun[]
54      panel: Panel
55      now: number
56    }
57  }
58}
59