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
★ 53v1.2.0MITupdated 2026-10-04JohnnyVizz/claude-kit/plugins/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 1081 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
487const costumeOf = (type: string): string => (type === 'Explore' ? 'explore' : tierOf(type))
488
489const CRAB_SCALE = 1.1
490
491// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
492// legs stay outside it and step on their own.
493const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
494  const groups = new Map<string, string[]>([['bd', []]])
495  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
496    if (!groups.has(cls)) groups.set(cls, [])
497    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
498  }
499  const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
500  draw(f, colorOf(costume))
501  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
502  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
503  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
504  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>`
505}
506
507const statusMark = (x: number, y: number, status: string, color: string): string => {
508  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
509  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"/>`
510  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"/>`
511  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"/>`
512}
513
514const svg = (W: number, H: number, body: string): string =>
515  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
516
517// The pane's own title already says "Agents": the header names the flow, if any.
518const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
519  const s = tr()
520  const gap = 6
521  const tw = (W - gap * 2) / 3
522  const top = title ? 28 : 0
523  const tile = (i: number, k: string, v: string) =>
524    `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
525<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
526<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>`
527  return svg(
528    W,
529    headerHeight(title),
530    `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
531${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`,
532  )
533}
534
535const headerHeight = (title: string): number => (title ? 72 : 44)
536
537// The task's own progress when the worker reports steps; a finished run is full.
538const progressOf = (a: AgentRun): number | null => {
539  if (a.status === 'done') return 1
540  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
541  return null
542}
543
544const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
545
546const agentSvg = (W: number, a: AgentRun, at: number): string => {
547  const s = tr()
548  const tier = tierOf(a.type)
549  const color = colorOf(tier)
550  const ctx = ctxOf(a)
551  const textW = W - 42 - 22
552  const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
553  if (a.round > 1) meta.push(`${s.round} ${a.round}`)
554  if (a.status === 'failed') meta.push(s.failed)
555  const barW = textW
556  const progress = progressOf(a)
557  const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)}  ≈${fmtCost(a.costUsd)}  ${fmtTime(elapsed(a, at))}`
558  const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
559  const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
560  // Without reported steps the bar falls back to the context, drawn grey.
561  const fillW = Math.round(barW * (progress ?? ctx / 100))
562  return svg(
563    W,
564    66,
565    `${crab(0, 14, costumeOf(a.type), false, a.status === 'running')}
566<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>
567<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>
568${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>` : ''}
569<text class="s" x="${42 + barW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
570<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}"`}/>
571${statusMark(W - 8, 16, a.status, color)}
572<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
573  )
574}
575
576const plannedSvg = (W: number, p: Planned): string => {
577  const tier = p.tier in TIER_COLOR ? p.tier : 'other'
578  const color = colorOf(tier)
579  const textW = W - 42 - 22
580  const meta = [TIER_MODEL[tier] ?? '']
581  if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
582  return svg(
583    W,
584    46,
585    `${crab(0, 6, tier, true)}
586<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>
587<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>
588${statusMark(W - 8, 16, 'planned', color)}
589<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
590  )
591}
592
593const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
594  const icons = [
595    ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: 'running', dim: false })),
596    ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: a.status, dim: false })),
597    ...planned.map(p => ({ k: p.tier in TIER_COLOR ? p.tier : 'other', c: colorOf(p.tier), s: 'planned', dim: true })),
598  ]
599  const fit = Math.max(1, Math.floor((W - 150) / 36))
600  const shown = icons.slice(0, fit)
601  const more = icons.length - shown.length
602  const body = shown
603    .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}"/>` : ''))
604    .join('')
605  const x = shown.length * 36 + (more ? 4 : 0)
606  return svg(
607    W,
608    32,
609    `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
610<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>`,
611  )
612}
613
614// --- terminal drawing: the same rows in text.
615
616const ctxBar = (pct: number, width: number): string => {
617  const filled = Math.round((width * pct) / 100)
618  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
619}
620
621const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
622
623// Opens the agents pane, or closes it when it is up; true when it ends up open.
624async function togglePane($: EngineInterface): Promise<boolean> {
625  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
626  if (isOpen) {
627    await $.ui.close({ id: PANE })
628    return false
629  }
630  const at = await $.clock.now()
631  await update($, now, () => at)
632  await $.ui.open({ id: PANE, title: tr().pane })
633  return true
634}
635
636async function autoOpen($: EngineInterface, key: string): Promise<void> {
637  const p = await read($, panel)
638  if (p.autoOpenedFor === key) return
639  await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
640  void $.ui.open({ id: PANE, title: tr().pane })
641}
642
643export const register: Register = (on, options) => {
644  on('session.start', async ($, e, next) => {
645    const started = await next(e)
646    lang = await detectLang($, options.language)
647    await $.tool.register({
648      name: 'progress',
649      description:
650        'Report /savvy-flow progress to the progress bar above the prompt and the agents panel. ' +
651        'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
652        'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value.',
653      inputSchema: {
654        type: 'object',
655        properties: {
656          title: { type: 'string', description: 'Short name of the overall task, a few words.' },
657          total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
658          done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
659          phase: { type: 'string', enum: [...PHASES] },
660          finished: { type: 'boolean', description: 'True once the flow is closed.' },
661          tasks: {
662            type: 'array',
663            description:
664              '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.',
665            items: {
666              type: 'object',
667              properties: {
668                title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
669                tier: { type: 'string', enum: ['fable', 'heavy', 'careful', 'medium', 'light'] },
670                after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
671              },
672              required: ['title', 'tier'],
673            },
674          },
675        },
676      },
677    })
678    await $.tool.register({
679      name: 'step',
680      description:
681        'For savvy-flow workers: report progress on your own task to the agents panel. ' +
682        'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
683        'call it again as each step finishes. Cheap and silent: it only draws a bar.',
684      inputSchema: {
685        type: 'object',
686        properties: {
687          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
688          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
689          note: { type: 'string', description: 'The step in progress, a few words.' },
690        },
691        required: ['done'],
692      },
693    })
694    await $.command.register({
695      name: 'agents-info',
696      description: 'Show or hide the panel of subagents: running, finished and planned, with model, context, cost and time',
697    })
698
699    // Ticks the running agents' clocks; quiet when nothing runs.
700    $.clock.every(1000, () => {
701      void (async () => {
702        const list = await read($, agents)
703        if (!list.some(a => a.status === 'running')) return
704        const at = await $.clock.now()
705        await update($, now, () => at)
706      })()
707    })
708    return started
709  })
710
711  on('command.run', { command: 'agents-info' }, async $ => {
712    const isOpen = await togglePane($)
713    return { text: isOpen ? tr().opened : tr().closed }
714  })
715
716  on('tool.call', { tool: TOOL }, async ($, e) => {
717    const input = e as unknown as ProgressInput
718    const prev = await read($, flow)
719    if (isNewFlow(prev, input) && input.title !== undefined) {
720      // A new flow starts with a clean list; agents still running stay.
721      await update($, agents, list => list.filter(a => a.status === 'running'))
722    }
723    const next = await update($, flow, p => merge(p, input))
724    if (next && input.tasks?.length) await autoOpen($, next.title)
725    return { result: `ok: ${label(next ?? blank())}` }
726  })
727
728  // A worker's own progress: the call runs in the worker's loop, so agentId names it.
729  on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
730    const input = e as unknown as { done?: number; total?: number; note?: string }
731    const agentId = e.agentId
732    if (!agentId) return { result: 'ignored: only subagents report steps' }
733    await update($, agents, list =>
734      list.map(a => {
735        if (a.agentId !== agentId) return a
736        const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
737        const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
738        return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
739      }),
740    )
741    return { result: 'ok' }
742  })
743
744  // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
745  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
746    const type = String(e.subagent_type ?? '')
747    if (!type.startsWith('savvy-')) return next(e)
748
749    await update($, flow, prev => {
750      const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
751      return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
752    })
753    try {
754      return await next(e)
755    } finally {
756      await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
757    }
758  })
759
760  on('agent.spawn', async ($, e, next) => {
761    const started = await next(e)
762    if (started.deny !== undefined) return started
763
764    const at = await $.clock.now()
765    await update($, agents, list => {
766      const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
767      const run: AgentRun = {
768        id: started.agentId ?? e.tool_use_id,
769        agentId: started.agentId,
770        type: e.subagentType,
771        description: e.description,
772        model: started.model,
773        status: 'running',
774        startedAt: at,
775        contextTokens: 0,
776        contextMax: windowOf(started.model),
777        tokens: 0,
778        costUsd: 0,
779        steps: 0,
780        round,
781      }
782      return [...list.filter(a => a.id !== run.id), run].slice(-200)
783    })
784    await update($, now, () => at)
785    if (e.subagentType.startsWith('savvy-')) {
786      const f = await read($, flow)
787      await autoOpen($, f && !f.isFinished ? f.title : 'savvy-flow')
788    }
789    return started
790  })
791
792  // Each model request of a subagent: live context, tokens and cost.
793  on('turn.step', async function* ($, e, next) {
794    const result = yield* next(e)
795    const agentId = e.agentId
796    const usage = result.usage
797    if (!agentId || !usage) return result
798
799    const model = usage.model || e.model
800    await update($, agents, list =>
801      list.map(a =>
802        a.agentId !== agentId
803          ? a
804          : {
805              ...a,
806              model,
807              effort: typeof e.effort === 'string' ? e.effort : a.effort,
808              status: 'running',
809              endedAt: undefined,
810              contextTokens:
811                (usage.input_tokens || 0) +
812                (usage.cache_read_input_tokens || 0) +
813                (usage.cache_creation_input_tokens || 0) +
814                (usage.output_tokens || 0),
815              contextMax: windowOf(model),
816              tokens:
817                a.tokens +
818                (usage.input_tokens || 0) +
819                (usage.output_tokens || 0) +
820                (usage.cache_read_input_tokens || 0) +
821                (usage.cache_creation_input_tokens || 0),
822              costUsd: a.costUsd + costOf(model, usage),
823              steps: a.steps + 1,
824            },
825      ),
826    )
827    return result
828  })
829
830  on('turn.complete', async ($, e, next) => {
831    const agentId = e.agentId
832    if (agentId) {
833      const at = await $.clock.now()
834      await update($, agents, list =>
835        list.map(a => {
836          if (a.agentId !== agentId) return a
837          // A run whose steps went unseen still gets the turn's own sum.
838          const fallback = a.steps === 0 && e.usage
839          return {
840            ...a,
841            status: e.reason === 'answer' ? 'done' : 'failed',
842            endedAt: at,
843            ...(fallback && e.usage
844              ? {
845                  model: e.usage.model || a.model,
846                  tokens:
847                    e.usage.input_tokens +
848                    e.usage.output_tokens +
849                    e.usage.cache_read_input_tokens +
850                    e.usage.cache_creation_input_tokens,
851                  costUsd: costOf(e.usage.model || a.model, e.usage),
852                }
853              : {}),
854          }
855        }),
856      )
857      await update($, now, () => at)
858    }
859    return next(e)
860  })
861
862  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
863    const s = tr()
864    const ui = $.ui.resolve(e)
865    const { Box, Text, Button } = ui
866    const list = await read($, agents)
867    const f = await read($, flow)
868    const p: Panel = await read($, panel)
869    const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
870
871    const running = list.filter(a => a.status === 'running').reverse()
872    const finished = list.filter(a => a.status !== 'running').reverse()
873    const planned = plannedOf(f, list)
874    const t = totals(list, at)
875    // The pane's title says "Agents"; inside, only the flow's own name.
876    const title = f && !f.isFinished ? f.title : ''
877
878    const toggleCompact = (
879      <Button
880        key="compact"
881        label={p.isCompact ? s.expand : s.collapse}
882        plain
883        onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
884      />
885    )
886    const toggleDone = (
887      <Button
888        key="done"
889        label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
890        plain
891        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
892      />
893    )
894    const isEmpty = list.length === 0 && planned.length === 0
895    const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
896
897    if (e.surface === 'desktop' && 'Svg' in ui) {
898      const { Svg } = ui
899      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
900      const section = (key: string, text: string) => (
901        <Text key={key} dimColor>
902          {text}
903        </Text>
904      )
905
906      if (p.isCompact) {
907        return (
908          <Box flexDirection="column" gap={1}>
909            <Svg source={compactSvg(W, list, planned, t)} alt={`${list.length} ${s.agentsCount}, ${summary}`} width={W} height={32} />
910            {toggleCompact}
911          </Box>
912        )
913      }
914      return (
915        <Box flexDirection="column">
916          <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={headerHeight(title)} />
917          {toggleCompact}
918          {isEmpty && <Text dimColor>{s.empty}</Text>}
919          {running.length > 0 && section('h-run', `${s.running} · ${running.length}`)}
920          {running.map(a => (
921            <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isRunning}`} width={W} height={66} />
922          ))}
923          {finished.length > 0 && toggleDone}
924          {!p.isDoneCollapsed &&
925            finished.map(a => (
926              <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isFinished}`} width={W} height={66} />
927            ))}
928          {planned.length > 0 && section('h-plan', `${s.planned} · ${planned.length}`)}
929          {planned.map(pl => (
930            <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: ${s.isPlanned}`} width={W} height={46} />
931          ))}
932        </Box>
933      )
934    }
935
936    // Terminal: the same content in text rows.
937    const cols = Math.max(24, e.props.bodyColumns || 40)
938    const barW = Math.max(6, Math.min(20, cols - 34))
939    const row = (a: AgentRun) => {
940      const tier = tierOf(a.type)
941      const color = colorOf(tier)
942      const ctx = ctxOf(a)
943      const progress = progressOf(a)
944      const model = a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)
945      const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' ' + a.stepNote : ''} · ` : ''
946      return (
947        <Box key={a.id} flexDirection="column" marginBottom={1}>
948          <Box flexDirection="row" gap={1}>
949            <Text color={color}>▣</Text>
950            <Text bold wrap="truncate-end">
951              {a.description || a.type}
952            </Text>
953            <Text color={a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : color}>{STATUS_GLYPH[a.status]}</Text>
954          </Box>
955          <Text dimColor wrap="truncate-end">
956            {'  '}
957            {tier === 'other' ? a.type : tier} · {model}
958            {a.round > 1 ? ` · ${s.round} ${a.round}` : ''}
959          </Text>
960          <Text wrap="truncate-end">
961            {'  '}
962            {progress === null ? <Text dimColor>{ctxBar(ctx, barW)}</Text> : <Text color={color}>{ctxBar(progress * 100, barW)}</Text>}
963            <Text dimColor>
964              {' '}
965              {steps}ctx {ctx}% · {fmtTokens(a.contextTokens)} ≈{fmtCost(a.costUsd)} {fmtTime(elapsed(a, at))}
966            </Text>
967          </Text>
968        </Box>
969      )
970    }
971
972    return (
973      <Box flexDirection="column">
974        <Box flexDirection="row" justifyContent="space-between">
975          <Text bold wrap="truncate-end">
976            {title}
977          </Text>
978          {toggleCompact}
979        </Box>
980        <Text dimColor>
981          ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} {s.tokensWord} · {fmtTime(t.time)}
982        </Text>
983        {p.isCompact ? (
984          <Text wrap="truncate-end">
985            {[...running, ...finished].map(a => (
986              <Text key={a.id} color={colorOf(tierOf(a.type))}>
987                {STATUS_GLYPH[a.status]}{' '}
988              </Text>
989            ))}
990            {planned.map(pl => (
991              <Text key={`plan-${pl.n}`} dimColor>
992                ◷{' '}
993              </Text>
994            ))}
995          </Text>
996        ) : (
997          <Box flexDirection="column" marginTop={1}>
998            {isEmpty && <Text dimColor>{s.empty}</Text>}
999            {running.length > 0 && <Text dimColor>{s.running} · {running.length}</Text>}
1000            {running.map(row)}
1001            {finished.length > 0 && toggleDone}
1002            {!p.isDoneCollapsed && finished.map(row)}
1003            {planned.length > 0 && <Text dimColor>{s.planned} · {planned.length}</Text>}
1004            {planned.map(pl => {
1005              const tier = pl.tier in TIER_COLOR ? pl.tier : 'other'
1006              return (
1007                <Box key={`plan-${pl.n}`} flexDirection="column" marginBottom={1}>
1008                  <Text dimColor wrap="truncate-end">
1009                    <Text color={colorOf(tier)}>▢</Text> {pl.n}. {pl.title} ◷
1010                  </Text>
1011                  <Text dimColor wrap="truncate-end">
1012                    {'  '}
1013                    {tier} · {TIER_MODEL[tier] ?? ''}
1014                    {pl.after.length ? ` · ${s.after} ${pl.after.join(', ')}` : ''}
1015                  </Text>
1016                </Box>
1017              )
1018            })}
1019          </Box>
1020        )}
1021      </Box>
1022    )
1023  })
1024
1025  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1026    const f = await read($, flow)
1027    if (f === null || e.props.hasSurvey) return next(e)
1028
1029    const ui = $.ui.resolve(e)
1030    const { Box, Text, Button } = ui
1031    const list = await read($, agents)
1032    const crew = list.length + plannedOf(f, list).length
1033    const isWorking = list.some(a => a.status === 'running')
1034    const crewButton = (
1035      <Button key="savvy-agents" label={`×${crew}`} plain onPress={() => void togglePane($)} />
1036    )
1037    const percent = `${Math.round(ratio(f) * 100)}%`
1038    const dismiss = (
1039      <Button
1040        key="savvy-dismiss"
1041        label="✕"
1042        plain
1043        role="dismiss"
1044        onPress={() => update($, flow, () => null)}
1045      />
1046    )
1047
1048    if ('Svg' in ui) {
1049      const { Svg } = ui
1050      // About 8 CSS px per reported column; the rest is the count, the dismiss
1051      // and their gaps. No floor above the slot: a row wider than it would wrap.
1052      const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
1053      return (
1054        <Box flexDirection="row" alignItems="center" gap={1}>
1055          <Svg source={rowSvg(f, width, isWorking)} alt={`${f.title}: ${label(f)}, ${percent}`} width={width} height={H} />
1056          {crewButton}
1057          {dismiss}
1058        </Box>
1059      )
1060    }
1061
1062    const cols = e.props.bodyColumns
1063    const titleW = Math.max(8, Math.min(30, f.title.length + 2, Math.floor(cols / 3)))
1064    const width = Math.max(6, Math.min(40, cols - titleW - 32))
1065    return (
1066      <Box flexDirection="row" gap={2}>
1067        <Box width={titleW} flexShrink={0}>
1068          <Text color={f.isFinished ? DONE : ACCENT}>● </Text>
1069          <Text wrap="truncate-end">{f.title}</Text>
1070        </Box>
1071        <Text color={f.isFinished ? DONE : ACCENT}>{barText(f, width)}</Text>
1072        <Text bold>{label(f)}</Text>
1073        <Text dimColor>{percent}</Text>
1074        <Text color={CLAY}>▣</Text>
1075        {crewButton}
1076        {dismiss}
1077      </Box>
1078    )
1079  })
1080}
1081
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