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 1081 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
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}
1081types/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