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

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.
savvy-* worker starts./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.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.
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.
hooks/register.tsx 1090 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
1090types/index.d.ts 59 lines1export 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