Progress bar above the prompt and a live agents panel, drawn in colour with pixel crabs on the desktop and in any truecolor terminal. Fork of…

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