SLOPSHOPPER

savvy-progress

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…

newpanebandguardcommandtool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-progress
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Cost │ ⏺ Read(src/auth.ts) │ ┃ │ ≈$0.00 │ ⎿ Read 6 lines │ ┃ ╰──────────────────────────────────────────╯ ⏺ Update(src/auth.ts) │ ┃ Collapse ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No subagents yet. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents-info │ ⎿ savvy-progress: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
╭──────────────────────────────────────────────────────────╮ │ Cost │ │ ≈$0.00 │ ╰──────────────────────────────────────────────────────────╯ Collapse No subagents yet.
README

savvy-progress

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.

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

Tools it adds

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

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

Settings

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

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

Install instructions are in the repository README.

Source 4 files
hooks/register.tsx 1081 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Flow, Panel, Phase, PlannedTask } from '../types'
5
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
1081
hooks/sprites.ts 74 lines
1// 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}
74
hooks/terminal.tsx 478 lines
1// 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}
478
types/index.d.ts 59 lines
1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4  title: string
5  tier: string
6  after: number[]
7}
8
9export type Flow = {
10  title: string
11  total: number
12  done: number
13  running: number
14  phase: Phase
15  isFinished: boolean
16  tasks: PlannedTask[]
17}
18
19export type AgentStatus = 'running' | 'done' | 'failed'
20
21export type AgentRun = {
22  id: string
23  agentId?: string
24  type: string
25  description: string
26  model: string
27  effort?: string
28  status: AgentStatus
29  startedAt: number
30  endedAt?: number
31  contextTokens: number
32  contextMax: number
33  tokens: number
34  costUsd: number
35  steps: number
36  round: number
37  /** Self-reported by the worker through the `step` tool. */
38  stepDone?: number
39  stepTotal?: number
40  stepNote?: string
41}
42
43export type Panel = {
44  isCompact: boolean
45  isDoneCollapsed: boolean
46  autoOpenedFor: string
47}
48
49declare module 'claude-code' {
50  interface PluginState {
51    'savvy-progress': {
52      flow: Flow | null
53      agents: AgentRun[]
54      panel: Panel
55      now: number
56    }
57  }
58}
59