Always-visible animated progress band above the prompt: flow progress, turn activity, RAM in use and per-agent progress with tokens.

Una banda sempre visibile sopra il prompt di Claude Code che mostra a che punto è il lavoro: una barra colorata e animata, la RAM del computer e i subagenti con i loro token. Funziona da sola: non serve il plugin savvy-progress.
Piano, Design, Attività 2/5, Revisione) e percentuale (stimata con ~, mai oltre ~99%). Quando ogni attività pianificata ha un subagente concluso (anche fallito), nessun agente lavora e il turno è fermo, il flow si mostra completato anche senza done/finished: barra verde, Attività n/n (gli esiti riusciti) e 100%.✓ <titolo>).8/16 GB), letta subito all'avvio e poi ogni 10 secondi (mai due letture insieme). Su Windows prova PowerShell (Get-CimInstance, fino a 15 s), poi typeperf (contatore inglese, poi italiano) che legge solo la memoria disponibile e usa il totale dell'ultima lettura PowerShell; su Linux legge /proc/meminfo; su macOS sysctl e vm_stat. Una lettura fallita non si vede finché l'ultima riuscita ha meno di 60 secondi; dopo (o se nessuna è mai riuscita) la scheda mostra RAM ?. Il testo alternativo resta breve (RAM 8/16 GB o RAM ?); il motivo completo compare una volta nella trascrizione (progress-bar: RAM unavailable: …).●▾3 · 45k: ● se qualcuno lavora, numero di agenti e token totali). Premendolo si apre una riga per agente con avanzamento, token usati e tempo (3/5 · 12k tok · 1:20); premendolo di nuovo si chiude. La barra di un agente in corso ha il colore del suo livello (viola per gli agenti non savvy), quella di un agente concluso è piena e verde, quella di uno fallito rossa.Sul desktop e in VS Code la banda occupa la larghezza reale del pannello (la colonna sinistra l'80% della riga, la zona RAM il 20%): testi, pillola e scheda RAM restano della loro dimensione, si allungano solo le barre.
mcp__progress-bar__progress: per chi orchestra. Campi: title, total, done, phase (plan, design, delegate, review, close), tasks ([{ title, tier, after }]) e finished: true alla fine. I campi omessi tengono il valore precedente; un title nuovo apre un nuovo flow.mcp__progress-bar__step: per i subagenti. Campi: done, total, note.Se savvy-progress è installato, le chiamate a mcp__savvy-progress__progress e mcp__savvy-progress__step vengono osservate anche da questa banda.
language: auto (predefinito: segue la lingua di Claude Code, poi il locale del sistema), it oppure en.In una sessione claude nel terminale:
/plugin install progress-bar --marketplace DamianoSrb/Mods
Rispondi y e scegli lo scope utente.
In sviluppo, senza installarlo:
claude --plugin-dir <cartella>hooks/register.tsx 816 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { ProgressBarPhase, ProgressBarPlannedTask } from '../types'
4import {
5 agentsSummary,
6 buildAgentRows,
7 buildMainModel,
8 isPlanComplete,
9 normTitle,
10 planAfterCall,
11 type AgentRowModel,
12 type AgentRun,
13 type Flow,
14 type Plan,
15 type PlanCall,
16} from './model'
17import { STRINGS, fmtTokens, pickLang, type Lang, type TerminalUi, type TimeStyle } from './shared'
18import { MAIN_H, mainRowSvg, mainRowTerminal, turnSeed } from './main-row'
19import { AGENT_ROW_H, agentRowSvg, agentRowTerminal, type Branch } from './agent-rows'
20import { RAM_GRACE_MS, RAM_POLL_MS, fmtRamPair, parseRam, ramProbes, type RamInfo, type RamProbe } from './ram'
21import { ramAltText, ramCardTerminal, rightZoneSvg } from './ram-card'
22
23// This mod's own values; it tracks everything itself.
24const flow = atom({ plugin: 'progress-bar', key: 'flow' } as const, null as Flow | null)
25const agents = atom({ plugin: 'progress-bar', key: 'agents' } as const, [] as AgentRun[])
26// The main loop's task list (update_plan / TodoWrite), for the working estimate.
27const plan = atom({ plugin: 'progress-bar', key: 'plan' } as const, null as Plan | null)
28const startedAt = atom({ plugin: 'progress-bar', key: 'startedAt' } as const, null)
29const endedAt = atom({ plugin: 'progress-bar', key: 'endedAt' } as const, null)
30const tools = atom({ plugin: 'progress-bar', key: 'tools' } as const, 0)
31const now = atom({ plugin: 'progress-bar', key: 'now' } as const, 0)
32const isExpanded = atom({ plugin: 'progress-bar', key: 'isExpanded' } as const, false)
33// System RAM in use, polled every RAM_POLL_MS; null until the first reading.
34const ram = atom({ plugin: 'progress-bar', key: 'ram' } as const, null as RamInfo | null)
35// A short code ('timeout' | 'unavailable') once no reading succeeded for
36// RAM_GRACE_MS (or none ever), or null: the card then shows `RAM ?`.
37const ramError = atom({ plugin: 'progress-bar', key: 'ramError' } as const, null as string | null)
38
39// ---------------------------------------------------------------------------
40// System RAM: the platform found once per load, then a poll that never overlaps.
41
42// Module scope is fine: none is drawn, and session.start sets them again on (re)load.
43let ramPlatform: string | null = null
44let isRamPolling = false
45// The probe that last worked: tried first on the next poll.
46let ramProbeIndex = 0
47// The total of the last reading that had one (PowerShell's): typeperf reads only the available memory.
48let ramKnownTotal: number | null = null
49// When the last reading succeeded ($.clock time), or null before any.
50let ramLastOkAt: number | null = null
51// Back-off after a poll where every probe failed: the next poll waits
52// RAM_POLL_MS × 2^failures, at most RAM_BACKOFF_MAX_MS, so hung probes never pile up.
53// Nothing is logged to the transcript: a missed reading is not the person's problem.
54const RAM_BACKOFF_MAX_MS = 5 * 60_000
55let ramFailures = 0
56let ramNextAt = 0
57
58// The short code a failed poll's reasons come to.
59const ramErrorCode = (reason: string): string =>
60 /still running after|timed? ?out/i.test(reason) ? 'timeout' : 'unavailable'
61
62/**
63 * A poll failed for `reason` (every probe's, joined). While the last good
64 * reading is younger than RAM_GRACE_MS nothing changes: the card keeps it.
65 * Past that, or with no reading ever, ramError takes a short code (the card
66 * shows `RAM ?`) and the full reason is logged, once per load.
67 */
68async function ramPollFailed($: EngineInterface, reason: string): Promise<void> {
69 let at: number | null = null
70 try {
71 at = await $.clock.now()
72 } catch {
73 // No clock: treat the reading as stale, and no back-off.
74 }
75 ramFailures += 1
76 if (at !== null) ramNextAt = at + Math.min(RAM_BACKOFF_MAX_MS, RAM_POLL_MS * 2 ** ramFailures)
77 if (ramLastOkAt !== null && at !== null && at - ramLastOkAt < RAM_GRACE_MS) return
78 const code = ramErrorCode(reason.trim() || 'failed')
79 if ((await read($, ramError)) !== code) await update($, ramError, () => code)
80}
81
82async function detectPlatform($: EngineInterface): Promise<string | null> {
83 try {
84 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
85 } catch {
86 // No environment: try the others.
87 }
88 try {
89 if ((await $.fs.read('/proc/meminfo')).includes('MemTotal')) return 'linux'
90 } catch {
91 // No /proc: not Linux, or no fs.
92 }
93 try {
94 const { stdout } = await $.process.run(['uname', '-s'], { timeoutMs: 4000 })
95 if (stdout.trim() === 'Darwin') return 'darwin'
96 } catch {
97 // No process: no RAM card figures.
98 }
99 return null
100}
101
102// Worth a redraw: the first reading, a new total, or a move of at least 1% of the total.
103const isRamChanged = (prev: RamInfo | null, next: RamInfo): boolean =>
104 !prev ||
105 prev.totalBytes !== next.totalBytes ||
106 Math.abs(prev.usedBytes - next.usedBytes) >= next.totalBytes * 0.01
107
108/** One probe's reading, or why it gave none. */
109async function readProbe($: EngineInterface, probe: RamProbe): Promise<RamInfo | string> {
110 // typeperf gives the available memory only: no total yet, no reading (and nothing run).
111 if (probe.format === 'typeperf' && ramKnownTotal === null) return 'no total yet (typeperf)'
112 let output: string
113 if (probe.kind === 'run') {
114 let res: { exitCode: number; stdout: string; stderr: string }
115 try {
116 res = await $.process.run(probe.argv, { timeoutMs: probe.timeoutMs })
117 } catch (err) {
118 return `process.run refused: ${errorText(err)} (${probe.argv[0]})`
119 }
120 if (res.exitCode !== 0) {
121 const why = (res.stderr || res.stdout || '').trim().split(/\r?\n/)[0]?.slice(0, 80) ?? ''
122 return `${probe.argv[0]} exit ${res.exitCode}${why ? `: ${why}` : ''}`
123 }
124 output = res.stdout
125 } else {
126 try {
127 output = await $.fs.read(probe.path)
128 } catch (err) {
129 return `fs.read refused: ${errorText(err)} (${probe.path})`
130 }
131 }
132 return parseRam(probe.format, output, ramKnownTotal) ?? `parse failed (${probe.format})`
133}
134
135async function pollRam($: EngineInterface): Promise<void> {
136 // Never two polls at once: a slow probe (up to its timeout) makes the next ticks skip.
137 if (isRamPolling) return
138 // Backing off after failed polls.
139 if (ramNextAt > 0) {
140 try {
141 if ((await $.clock.now()) < ramNextAt) return
142 } catch {
143 // No clock: poll.
144 }
145 }
146 if (!ramPlatform) {
147 await ramPollFailed($, 'platform not detected')
148 return
149 }
150 const probes = ramProbes(ramPlatform)
151 if (probes.length === 0) {
152 await ramPollFailed($, `no probe for ${ramPlatform}`)
153 return
154 }
155 isRamPolling = true
156 try {
157 // The probe that worked last time first, then the rest in their order.
158 // In rotation from there, so a probe that just hung comes last.
159 const first = Math.min(ramProbeIndex, probes.length - 1)
160 const order = probes.map((_, k) => (first + k) % probes.length)
161 const reasons: string[] = []
162 for (const i of order) {
163 const probe = probes[i]
164 if (!probe) continue
165 const got = await readProbe($, probe)
166 if (typeof got === 'string') {
167 reasons.push(got)
168 // A probe that hung means the machine is busy: the others would hang too
169 // now, so stop here and start the next poll from the following probe.
170 if (ramErrorCode(got) === 'timeout') {
171 ramProbeIndex = (i + 1) % probes.length
172 break
173 }
174 continue
175 }
176 ramProbeIndex = i
177 ramFailures = 0
178 ramNextAt = 0
179 if (probe.format !== 'typeperf') ramKnownTotal = got.totalBytes
180 try {
181 ramLastOkAt = await $.clock.now()
182 } catch {
183 ramLastOkAt = null
184 }
185 if ((await read($, ramError)) !== null) await update($, ramError, () => null)
186 const prev = (await read($, ram)) ?? null
187 if (isRamChanged(prev, got)) await update($, ram, () => got)
188 return
189 }
190 // Every probe failed: the last value stays, silently within the grace time.
191 await ramPollFailed($, reasons.join('; '))
192 } catch (err) {
193 await ramPollFailed($, errorText(err)).catch(() => {})
194 } finally {
195 isRamPolling = false
196 }
197}
198
199// The "+N altri" text line under the agent rows, as tall as a row.
200const MORE_H = 18
201
202// The desktop band: [left column: main bar + agents?, agent rows] [right zone:
203// divider + RAM card], the left column LEFT_SHARE of the row and the right zone
204// the rest. The desktop puts ~GAP_PX between siblings.
205//
206// The pictures are drawn at their slot's real width and a fixed px height:
207// each root says width="100%" and its height in px, no viewBox, and each Svg
208// gets `height` alone, in a column Box that stretches it across (VS Code draws
209// an Svg as an <img> of that height, as wide as the Box stretches it). Text
210// and marks keep their px; the bars span in percentages of the real width.
211// Their geometry (title slots, the bars' shares, the card's width) is
212// DESIGNED at widths estimated from bodyColumns; the slots themselves are
213// percentages of the real row. A cell is not 8px everywhere (VS Code's are
214// narrower), so the estimate only sets those shares, never the drawn size.
215const GAP_PX = 8
216// The row width is estimated from bodyColumns; ROW_SAFETY_PX more is held back
217// (a design margin, kept so the pictures' proportions stay as they were).
218const ROW_SAFETY_PX = 24
219const LEFT_SHARE = 0.8
220const MAIN_MIN = 40
221
222type RowGeometry = { rowW: number; leftW: number; mainW: number; rightW: number; agentsPx: number }
223
224/**
225 * Design widths of the desktop band for `cols` body columns and an agents
226 * label of `agentsLen` characters (0: none): what the pictures' geometry is
227 * laid out at, not the drawn widths. leftW is the left column (the agent rows' width), rightW the right
228 * zone (divider + RAM card); the agents estimate only sizes the main bar,
229 * never the divider's x.
230 */
231function desktopRowGeometry(cols: number, agentsLen: number): RowGeometry {
232 const rowW = Math.max(180, Math.min(1600, cols * 8 - 96 - ROW_SAFETY_PX))
233 const agentsPx = agentsLen > 0 ? Math.ceil(agentsLen * 7.5) + 24 : 0
234 const leftW = Math.round(rowW * LEFT_SHARE)
235 const rightW = rowW - leftW - GAP_PX
236 const mainW = Math.max(MAIN_MIN, leftW - (agentsPx ? agentsPx + GAP_PX : 0))
237 return { rowW, leftW, mainW, rightW, agentsPx }
238}
239
240/** The agents control's label: `▾3 · 45k`, `▴` when open, a `●` first while any runs. */
241const agentsLabelOf = (sum: { count: number; tokens: number; running: number }, isOpen: boolean): string =>
242 `${sum.running > 0 ? '●' : ''}${isOpen ? '▴' : '▾'}${sum.count} · ${fmtTokens(sum.tokens)}`
243
244// The tools this mod registers and serves.
245const TOOL = 'mcp__progress-bar__progress'
246const STEP_TOOL = 'mcp__progress-bar__step'
247// Compatibility, not dependency: the same reports sent to savvy-progress's tools
248// (which /savvy-flow calls) are observed too, and savvy-progress still answers them.
249// Matched by pattern, not by name: the laid MCP types name these tools only while
250// savvy-progress is installed, and the hooks must type-check either way.
251const COMPAT_TOOL = /^mcp__savvy-progress__progress$/
252const COMPAT_STEP_TOOL = /^mcp__savvy-progress__step$/
253// The main loop's task list: the desktop app's update_plan (by pattern, for the same reason) and the CLI's TodoWrite.
254// Literal here, not imported: the validator reads each hook's matcher from this file.
255const PLAN_TOOL = /^mcp__ccd_turn__update_plan$/
256const TODO_TOOL = 'TodoWrite'
257
258const errorText = (err: unknown): string => {
259 const text = err instanceof Error ? err.message : String(err ?? '')
260 return text.trim().slice(0, 120) || 'failed'
261}
262
263const PHASES: readonly ProgressBarPhase[] = ['plan', 'design', 'delegate', 'review', 'close']
264
265type ProgressInput = {
266 title?: string
267 total?: number
268 done?: number
269 phase?: ProgressBarPhase
270 finished?: boolean
271 tasks?: { title?: string; tier?: string; after?: number[] }[]
272}
273
274type StepInput = { done?: number; total?: number; note?: string }
275
276// Module scope is fine here: session.start sets it again on every (re)load.
277let lang: Lang = 'en'
278
279// Lives here, not in shared.ts: the engine follows `$` only into functions of
280// the hooks module's own file, never across an import.
281async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
282 if (option === 'it' || option === 'en') return option
283 let setting: unknown
284 try {
285 setting = (await $.settings.read()).language
286 } catch {
287 // No settings: fall through to the locale.
288 }
289 let locale: string | undefined
290 try {
291 locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
292 } catch {
293 // No environment: English.
294 }
295 return pickLang(option, setting, locale)
296}
297
298const isTurnRunning = (start: number | null, end: number | null): boolean => start !== null && (end === null || end < start)
299
300// ---------------------------------------------------------------------------
301// The flow, merged from `progress` reports (same rules as savvy-progress).
302
303const blank = (): Flow => ({
304 title: 'savvy-flow',
305 total: 0,
306 done: 0,
307 running: 0,
308 phase: 'plan',
309 isFinished: false,
310 tasks: [],
311})
312
313const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
314 !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
315
316const cleanTasks = (tasks: ProgressInput['tasks']): ProgressBarPlannedTask[] | undefined =>
317 Array.isArray(tasks)
318 ? tasks
319 .filter(t => typeof t?.title === 'string' && t.title.trim())
320 .map(t => ({
321 title: (t.title ?? '').trim(),
322 tier: String(t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
323 after: (Array.isArray(t.after) ? t.after : []).filter(n => Number.isInteger(n) && n > 0),
324 }))
325 : undefined
326
327const merge = (prev: Flow | null, input: ProgressInput): Flow => {
328 // A new title means a new flow: never carry counters over from an earlier one.
329 const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
330 const tasks = cleanTasks(input.tasks) ?? base.tasks
331 const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
332 const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
333 const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
334 return {
335 ...base,
336 title: (typeof input.title === 'string' && input.title.trim()) || base.title,
337 total,
338 done,
339 phase: input.finished ? 'close' : phase,
340 isFinished: input.finished === true,
341 tasks,
342 }
343}
344
345const flowLabel = (f: Flow): string =>
346 f.isFinished ? `${f.title}: finished` : `${f.title}: ${f.phase}${f.total ? ` ${f.done}/${f.total}` : ''}`
347
348// ---------------------------------------------------------------------------
349// Subagent usage: context, tokens and an estimated cost (same table as savvy-progress).
350
351// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
352const PRICES: [RegExp, [number, number, number, number]][] = [
353 [/fable|mythos/, [10, 50, 0.25, 12.5]],
354 [/opus-5-5/, [4, 20, 0.2, 5]],
355 [/opus/, [5, 25, 0.5, 6.25]],
356 [/sonnet/, [2, 10, 0.2, 2.5]],
357 [/haiku/, [1, 5, 0.1, 1.25]],
358]
359
360type Usage = {
361 input_tokens: number
362 output_tokens: number
363 cache_read_input_tokens: number
364 cache_creation_input_tokens: number
365}
366
367const priceOf = (model: string): [number, number, number, number] =>
368 PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
369
370const costOf = (model: string, u: Usage): number => {
371 const [i, o, r, w] = priceOf(model)
372 return (
373 ((u.input_tokens || 0) * i +
374 (u.output_tokens || 0) * o +
375 (u.cache_read_input_tokens || 0) * r +
376 (u.cache_creation_input_tokens || 0) * w) /
377 1e6
378 )
379}
380
381const tokensOf = (u: Usage): number =>
382 (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
383
384const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
385
386// ---------------------------------------------------------------------------
387// Updates shared by this mod's own tools and the observed savvy-progress ones.
388
389async function applyProgress($: EngineInterface, input: ProgressInput): Promise<Flow> {
390 const prev = await read($, flow)
391 if (isNewFlow(prev ?? null, input) && input.title !== undefined) {
392 // A new flow starts with a clean list; agents still running stay.
393 await update($, agents, list => (Array.isArray(list) ? list : []).filter(a => a.status === 'running'))
394 }
395 const next = await update($, flow, p => merge(p ?? null, input))
396 return next ?? blank()
397}
398
399/** A main-loop task list call (update_plan / TodoWrite) folded into `plan`; a subagent's is ignored. */
400async function observePlan($: EngineInterface, call: PlanCall): Promise<void> {
401 if (call.agentId) return
402 const at = await $.clock.now()
403 // Folded inside the update, so two calls in a row never lose one another.
404 await update($, plan, p => {
405 const next = planAfterCall(p ?? null, call, at)
406 return next === undefined ? (p ?? null) : next
407 })
408}
409
410async function applyStep($: EngineInterface, agentId: string, input: StepInput): Promise<void> {
411 await update($, agents, list =>
412 (Array.isArray(list) ? list : []).map(a => {
413 if (a.agentId !== agentId) return a
414 const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
415 const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
416 const note = typeof input.note === 'string' ? input.note.trim() : ''
417 return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: note || undefined }
418 }),
419 )
420}
421
422export const register: Register = (on, options) => {
423 on('session.start', async ($, e, next) => {
424 const started = await next(e)
425 lang = await detectLang($, options.language)
426
427 await $.tool.register({
428 name: 'progress',
429 description:
430 'Report the progress of a planned multi-step task (e.g. a /savvy-flow run) to the progress bar above the prompt: ' +
431 'after presenting the plan (title, total, tasks, phase), each time a task is accepted (done), when switching phase, ' +
432 'and once at the end with finished: true. Fields left out keep their previous value.',
433 inputSchema: {
434 type: 'object',
435 properties: {
436 title: { type: 'string', description: 'Short name of the overall task, a few words.' },
437 total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
438 done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
439 phase: { type: 'string', enum: [...PHASES] },
440 finished: { type: 'boolean', description: 'True once the flow is closed.' },
441 tasks: {
442 type: 'array',
443 description:
444 'The planned worker tasks in order, numbered from 1. Each title must equal the Agent tool `description` the task will be delegated with, so runs can be matched to tasks.',
445 items: {
446 type: 'object',
447 properties: {
448 title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
449 tier: { type: 'string', enum: ['fable', 'heavy', 'careful', 'medium', 'light'] },
450 after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
451 },
452 required: ['title', 'tier'],
453 },
454 },
455 },
456 },
457 })
458 await $.tool.register({
459 name: 'step',
460 description:
461 'For subagents: report progress on your own task to the progress bar above the prompt. ' +
462 'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
463 'call it again as each step finishes. Cheap and silent: it only draws a bar.',
464 inputSchema: {
465 type: 'object',
466 properties: {
467 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
468 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
469 note: { type: 'string', description: 'The step in progress, a few words.' },
470 },
471 required: ['done'],
472 },
473 })
474
475 // Ticks the elapsed times; quiet unless a main turn or a subagent runs.
476 $.clock.every(1000, () => {
477 void (async () => {
478 const start = await read($, startedAt)
479 const end = await read($, endedAt)
480 const list = await read($, agents)
481 const isAgentRunning = Array.isArray(list) && list.some(a => a.status === 'running')
482 if (!isTurnRunning(start, end) && !isAgentRunning) return
483 const at = await $.clock.now()
484 await update($, now, () => at)
485 })().catch(() => {})
486 })
487
488 // The RAM card: the platform found once, then a reading now and every
489 // RAM_POLL_MS. Unawaited, so a slow probe never holds the session's start.
490 // With no platform or no probe for it, the first poll records why and no interval starts.
491 isRamPolling = false
492 ramProbeIndex = 0
493 ramKnownTotal = null
494 ramLastOkAt = null
495 ramFailures = 0
496 ramNextAt = 0
497 void (async () => {
498 ramPlatform = await detectPlatform($)
499 await pollRam($)
500 if (!ramPlatform || ramProbes(ramPlatform).length === 0) return
501 $.clock.every(RAM_POLL_MS, () => {
502 void pollRam($).catch(() => {})
503 })
504 })().catch(() => {})
505 return started
506 })
507
508 // This mod's own tools: they answer the call.
509 on('tool.call', { tool: TOOL }, async ($, e) => {
510 const f = await applyProgress($, e as unknown as ProgressInput)
511 return { result: `ok: ${flowLabel(f)}` }
512 }).catch(($, e, next) => ({ result: `error: ${errorText(next.error.message)}` }))
513
514 // A worker's own progress: the call runs in the worker's loop, so agentId names it.
515 on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
516 const agentId = e.agentId
517 if (!agentId) return { result: 'ignored: only subagents report steps' }
518 await applyStep($, agentId, e as unknown as StepInput)
519 return { result: 'ok' }
520 }).catch(($, e, next) => ({ result: `error: ${errorText(next.error.message)}` }))
521
522 // The same reports sent to savvy-progress's tools: observed only, never
523 // answered. Each always continues with `e` unchanged so savvy-progress answers.
524 on('tool.call', { tool: COMPAT_TOOL }, async ($, e, next) => {
525 await applyProgress($, e as unknown as ProgressInput)
526 return next(e)
527 }).catch(($, e, next) => next(e))
528
529 on('tool.call', { tool: COMPAT_STEP_TOOL }, async ($, e, next) => {
530 if (e.agentId) await applyStep($, e.agentId, e as unknown as StepInput)
531 return next(e)
532 }).catch(($, e, next) => next(e))
533
534 // The main loop's task list, for the working estimate: observed only, never
535 // answered. Each always continues with `e` unchanged, its `.catch` too.
536 on('tool.call', { tool: PLAN_TOOL }, async ($, e, next) => {
537 await observePlan($, e as unknown as PlanCall)
538 return next(e)
539 }).catch(($, e, next) => next(e))
540
541 on('tool.call', { tool: TODO_TOOL }, async ($, e, next) => {
542 await observePlan($, e as unknown as PlanCall)
543 return next(e)
544 }).catch(($, e, next) => next(e))
545
546 // Worker launches move the faint layer even if the orchestrator forgets to report.
547 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
548 const type = String(e.subagent_type ?? '')
549 if (!type.replace(/^[^:]*:/, '').startsWith('savvy-')) return next(e)
550
551 await update($, flow, prev => {
552 const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
553 return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
554 })
555 try {
556 return await next(e)
557 } finally {
558 await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
559 }
560 }).catch(($, e, next) => next(e))
561
562 // Every subagent gets a row.
563 on('agent.spawn', async ($, e, next) => {
564 const started = await next(e)
565 if (started.deny !== undefined) return started
566
567 const at = await $.clock.now()
568 await update($, agents, prev => {
569 const list = Array.isArray(prev) ? prev : []
570 const round = 1 + list.filter(a => e.description && normTitle(a.description) === normTitle(e.description)).length
571 const run: AgentRun = {
572 id: started.agentId ?? e.tool_use_id,
573 agentId: started.agentId,
574 type: e.subagentType,
575 description: e.description,
576 model: started.model,
577 status: 'running',
578 startedAt: at,
579 contextTokens: 0,
580 contextMax: windowOf(started.model),
581 tokens: 0,
582 costUsd: 0,
583 steps: 0,
584 round,
585 }
586 return [...list.filter(a => a.id !== run.id), run].slice(-200)
587 })
588 await update($, now, () => at)
589 return started
590 }).catch(($, e, next) => next(e))
591
592 // Each model request of a subagent: live context, tokens and cost.
593 on('turn.step', async function* ($, e, next) {
594 const result = yield* next(e)
595 const agentId = e.agentId
596 const usage = result.usage
597 if (!agentId || !usage) return result
598
599 try {
600 const model = usage.model || e.model
601 await update($, agents, list =>
602 (Array.isArray(list) ? list : []).map(a =>
603 a.agentId !== agentId
604 ? a
605 : {
606 ...a,
607 model,
608 effort: typeof e.effort === 'string' ? e.effort : a.effort,
609 status: 'running' as const,
610 endedAt: undefined,
611 contextTokens: tokensOf(usage),
612 contextMax: windowOf(model),
613 tokens: a.tokens + tokensOf(usage),
614 costUsd: a.costUsd + costOf(model, usage),
615 steps: a.steps + 1,
616 },
617 ),
618 )
619 } catch {
620 // Watching only: a failed update never touches the response.
621 }
622 return result
623 })
624
625 // Turn activity. These hooks only watch: each always continues the chain
626 // with `e` unchanged, and their `.catch` continues it too (replaying the
627 // settled `next` when it was already called), so a failure never refuses.
628 on('prompt.submit', async ($, e, next) => {
629 // A prompt typed over a running turn, or delivered into it, carries that
630 // turn's id: the turn keeps its clock and its tool count.
631 if (e.turnId === undefined) {
632 const at = await $.clock.now()
633 await update($, startedAt, () => at)
634 await update($, endedAt, () => null)
635 await update($, tools, () => 0)
636 await update($, now, () => at)
637 // A completed task list ends with its turn; one left open carries on until replaced.
638 const p = (await read($, plan)) ?? null
639 if (p && isPlanComplete(p)) await update($, plan, () => null)
640 }
641 return next(e)
642 }).catch(($, e, next) => next(e))
643
644 on('tool.call', async ($, e, next) => {
645 if (!e.agentId) await update($, tools, n => (n ?? 0) + 1)
646 return next(e)
647 }).catch(($, e, next) => next(e))
648
649 on('turn.complete', async ($, e, next) => {
650 const at = await $.clock.now()
651 const agentId = e.agentId
652 if (agentId) {
653 await update($, agents, list =>
654 (Array.isArray(list) ? list : []).map(a => {
655 if (a.agentId !== agentId) return a
656 // A run whose steps went unseen still gets the turn's own sum.
657 const fallback = a.steps === 0 ? e.usage : undefined
658 return {
659 ...a,
660 status: e.reason === 'answer' ? ('done' as const) : ('failed' as const),
661 endedAt: at,
662 ...(fallback
663 ? {
664 model: fallback.model || a.model,
665 tokens: tokensOf(fallback),
666 costUsd: costOf(fallback.model || a.model, fallback),
667 }
668 : {}),
669 }
670 }),
671 )
672 } else {
673 await update($, endedAt, () => at)
674 }
675 await update($, now, () => at)
676 return next(e)
677 }).catch(($, e, next) => next(e))
678
679 // The band. Returns its own tree without calling `next`, so the bands of the
680 // plugins beneath it (savvy-progress's, when installed) are not drawn; the survey keeps its place.
681 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
682 if (e.props.hasSurvey) return next(e)
683
684 const s = STRINGS[lang]
685 const ui = $.ui.resolve(e)
686 const { Box, Text, Button } = ui
687
688 const f = (await read($, flow)) ?? null
689 const rawList = await read($, agents)
690 const list: AgentRun[] = Array.isArray(rawList) ? rawList : []
691 const start = await read($, startedAt)
692 const end = await read($, endedAt)
693 const toolCount = await read($, tools)
694 const expanded = await read($, isExpanded)
695 const at = Math.max(await read($, now), start ?? 0)
696 const taskList = (await read($, plan)) ?? null
697
698 // Pictures (every surface but the terminal) are redrawn about once a second
699 // while something runs, and a surface loads each new source as a new image,
700 // showing the alt until it is drawn (VS Code gives it 1 s). So a time drawn
701 // inside an Svg reads in whole minutes, and its alt with it; the terminal's
702 // text keeps m:ss.
703 const timeStyle: TimeStyle = e.surface !== 'terminal' && 'Svg' in ui ? 'minutes' : 'clock'
704
705 const m = buildMainModel(
706 {
707 flow: f,
708 isWorking: e.props.isWorking,
709 startedAt: start,
710 endedAt: end,
711 tools: toolCount,
712 now: at,
713 agents: list,
714 plan: taskList,
715 timeStyle,
716 },
717 s,
718 )
719 const alt = [m.title, m.pill, m.right].filter(Boolean).join(' · ')
720
721 const ramErr = (await read($, ramError)) ?? null
722 // Once ramError is set the last reading is stale: the card shows RAM ?.
723 const ramInfo = ramErr ? null : ((await read($, ram)) ?? null)
724
725 const rowsAt = Math.max(at, ...list.map(a => a.startedAt))
726 const rows = expanded ? buildAgentRows(list, rowsAt, timeStyle) : []
727
728 // The agents toggle, compact: "▾3 · 45k", "●▾3 · 45k" while any runs.
729 const agentsLabel = agentsLabelOf(agentsSummary(list), expanded === true)
730 const agentsButton =
731 list.length > 0 ? <Button key="agents" label={agentsLabel} plain onPress={() => update($, isExpanded, v => !v)} /> : null
732
733 // Main row plus agent rows fit in maxRows; when cut, the last line says how many more.
734 const avail = Math.max(0, (e.props.maxRows || 0) - 1)
735 const isCut = rows.length > avail
736 const shown = isCut ? rows.slice(0, Math.max(0, avail - 1)) : rows
737 const moreLine = isCut && avail >= 1 ? `+${rows.length - shown.length} ${s.more}` : ''
738 // Tree: each shown row is a middle branch (├─) but the last (└─), unless the "+N" line follows it.
739 const branchOf = (i: number): Branch => (i < shown.length - 1 || moreLine ? 'mid' : 'last')
740 const hasBranch = shown.length > 0
741
742 const rowAlt = (r: AgentRowModel): string => `${r.title}: ${r.status}, ${r.stat}, ${r.tokens} ${s.tok}, ${r.time}`
743 // Short: `RAM 9.8/16 GB` or `RAM ?`, never a failure's reason (a surface may draw the alt as text).
744 const ramAlt = ramAltText(ramInfo, ramErr)
745
746 // By surface, not by table: the terminal draws text even where a table lists Svg.
747 if (e.surface !== 'terminal' && 'Svg' in ui) {
748 const { Svg } = ui
749 const cols = e.props.bodyColumns || 100
750 const g = desktopRowGeometry(cols, agentsButton ? agentsLabel.length : 0)
751 // The right zone (and its divider) spans the left column: it is drawn
752 // as tall as the main row plus the rows below (px, whatever the width).
753 const totalH = MAIN_H + shown.length * AGENT_ROW_H + (moreLine ? MORE_H : 0)
754 // Every source below is a pure function of what it shows (seed from the
755 // turn, never the clock; whole percents; whole minutes), so a tick that
756 // changes nothing visible hands back the same bytes and no image reloads.
757 // Each Svg keeps its key and its place: VS Code matches them by position.
758 // Each Svg gets its height alone and sits in a column Box, which
759 // stretches it to the slot's width. The rows say nowrap: a row Box wraps
760 // by default on the desktop surfaces, and 80% + gap + 20% would push the
761 // right zone under the left column.
762 return (
763 <Box flexDirection="row" flexWrap="nowrap" alignItems="flex-start" justifyContent="flex-start" width="100%" gap={1}>
764 {/* Percentages, not px: a Box's numeric width is not pixels, so 80% of the real row it is. */}
765 <Box key="left" flexDirection="column" gap={0} flexShrink={0} width="80%">
766 <Box key="top" flexDirection="row" flexWrap="nowrap" alignItems="center" width="100%" gap={1}>
767 <Box key="main-slot" flexDirection="column" flexGrow={1} flexShrink={1}>
768 <Svg key="main" source={mainRowSvg(m, g.mainW, turnSeed(start, m), hasBranch)} alt={alt} height={MAIN_H} />
769 </Box>
770 {agentsButton}
771 </Box>
772 {shown.map((r, i) => (
773 <Svg key={r.id} source={agentRowSvg(r, g.leftW, branchOf(i))} alt={rowAlt(r)} height={AGENT_ROW_H} />
774 ))}
775 {moreLine ? (
776 <Box paddingLeft={2}>
777 <Text dimColor>{moreLine}</Text>
778 </Box>
779 ) : null}
780 </Box>
781 {/* 20% of the row, giving up the gap (it may shrink): the divider and the RAM card. */}
782 <Box key="right" flexDirection="column" flexShrink={1} width="20%">
783 <Svg key="ram" source={rightZoneSvg(ramInfo, ramErr, g.rightW, totalH)} alt={ramAlt} height={totalH} />
784 </Box>
785 </Box>
786 )
787 }
788
789 // Terminal: the same structure in text.
790 const tui = ui as TerminalUi
791 const cols = Math.max(24, e.props.bodyColumns || 80)
792 // "RAM ▕█████▏ 9.8/16 GB", or "RAM –" / "RAM ?".
793 const ramCols = ramInfo ? 'RAM ▕█████▏ '.length + fmtRamPair(ramInfo).length : 'RAM –'.length
794 const mainCols = Math.max(12, cols - (agentsButton ? agentsLabel.length + 1 : 0) - 2 - (ramCols + 1))
795 // [main] [agents?] │ [RAM], as on the desktop.
796 return (
797 <Box flexDirection="column">
798 <Box flexDirection="row" gap={1}>
799 <Box flexGrow={1} flexShrink={1}>
800 {mainRowTerminal(tui, m, mainCols)}
801 </Box>
802 {agentsButton}
803 <Text key="sep" dimColor>
804 │
805 </Text>
806 {ramCardTerminal(tui, ramInfo, ramErr)}
807 </Box>
808 {shown.map((r, i) => (
809 <Box key={r.id}>{agentRowTerminal(tui, r, cols, branchOf(i))}</Box>
810 ))}
811 {moreLine ? <Text dimColor>{`└─ ${moreLine}`}</Text> : null}
812 </Box>
813 )
814 })
815}
816hooks/model.ts 474 lines1// Pure view models for the band: no `$`, no drawing. The drawing modules
2// (main-row.tsx, agent-rows.tsx) draw exactly what these say.
3import type {
4 ProgressBarAgentRun,
5 ProgressBarFlow,
6 ProgressBarPlan,
7 ProgressBarPlanItem,
8 ProgressBarPlanStatus,
9} from '../types'
10import { fmtElapsed, fmtTime, fmtTokens, tierOf, type Strings, type TimeStyle } from './shared'
11
12// This plugin's own types, from its contract.
13export type Flow = ProgressBarFlow
14export type AgentRun = ProgressBarAgentRun
15export type Plan = ProgressBarPlan
16export type PlanItem = ProgressBarPlanItem
17export type PlanStatus = ProgressBarPlanStatus
18
19export type MainMode = 'flow' | 'working' | 'idle'
20
21export type MainModel = {
22 mode: MainMode
23 /** flow title | working: the plan's in-progress item, else s.working | s.idle */
24 title: string
25 /**
26 * 0..1 fill. flow: the estimate (accepted tasks plus the share of the
27 * unaccepted tasks' runs, in [acceptedRatio, 0.99]; 1 if finished or
28 * completed by its runs, see flowCompletion); working with a plan:
29 * (completed + ½ in progress) / items; 0 otherwise.
30 */
31 ratio: number
32 /**
33 * 0..1 confirmed part, never above ratio. flow: done/total (1 if
34 * finished or completed); working with a plan: completed / items; 0 otherwise.
35 */
36 acceptedRatio: number
37 /** True when ratio is an estimate: flow with ratio > acceptedRatio; working with a plan always. */
38 isEstimate: boolean
39 /** 0..1 including running agents (flow faint layer), 0 otherwise */
40 runRatio: number
41 /** flow total (tick marks), 0 otherwise */
42 ticks: number
43 /**
44 * flow: phase label ("Attività s/n" once completed by its runs, s the tasks
45 * whose run succeeded); working: "Passo k/n" with a plan, else the elapsed
46 * time (m:ss, or "<1m"/"3m" with timeStyle 'minutes'); idle: '' (the title says it)
47 */
48 pill: string
49 /**
50 * flow: "NN%", "~NN%" when estimated (at most "~99%"); working: "~NN%" with
51 * a plan, else "N tool"; idle: "[✓ <flow> · ]ultimo turno m:ss · N tool", or
52 * "" before any turn
53 */
54 right: string
55 /** A finished flow: idle after one, or (mode flow) one completed by its runs (flowCompletion). */
56 isDone: boolean
57 /** flow: round(ratio*100) (100 once completed); idle with isDone: 100; otherwise null */
58 ringPercent: number | null
59 /** true only in working mode */
60 isAnimating: boolean
61}
62
63export type AgentRowModel = {
64 id: string
65 /** fable|heavy|careful|medium|light|other */
66 tier: string
67 /** description || type */
68 title: string
69 status: 'running' | 'done' | 'failed'
70 /** 0..1: done → 1; stepTotal → stepDone/stepTotal; else contextTokens/contextMax */
71 progress: number
72 /** true when progress is the context fallback (drawn grey while running) */
73 isContext: boolean
74 /** "3/5" or "ctx 12%" */
75 stat: string
76 /** fmtTokens(tokens) */
77 tokens: string
78 /**
79 * Running: now - startedAt, m:ss (or "<1m"/"3m" with timeStyle 'minutes');
80 * finished: endedAt - startedAt, m:ss (frozen, so it never ticks).
81 */
82 time: string
83}
84
85const clamp01 = (n: number): number => (Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0)
86
87const toolsText = (n: number, s: Strings): string => `${n} ${n === 1 ? s.tool : s.tools}`
88
89const phaseLabel = (f: Flow, s: Strings): string => {
90 if (f.phase === 'plan') return s.plan
91 if (f.phase === 'design') return s.design
92 const name = f.phase === 'review' ? s.review : s.tasks
93 return f.total ? `${name} ${f.done}/${f.total}` : name
94}
95
96/**
97 * A percentage, with a leading `~` when it is an estimate: "45%", "~45%". An
98 * estimate never reads 100: it stops at "~99%".
99 */
100export const percentText = (ratio: number, isEstimate: boolean): string => {
101 const pct = Math.round(clamp01(ratio) * 100)
102 return isEstimate ? `~${Math.min(99, pct)}%` : `${pct}%`
103}
104
105// ---------------------------------------------------------------------------
106// The flow estimate: accepted tasks count whole, a planned task not yet
107// accepted counts by its latest run.
108
109// A run reporting steps: stepDone/stepTotal of this share.
110const STEP_SHARE = 0.9
111// A run finished but not yet accepted.
112const DONE_SHARE = 0.9
113// A run without steps: CURVE_SHARE × (1 − e^(−elapsed / CURVE_TAU_MS)).
114const CURVE_SHARE = 0.8
115const CURVE_TAU_MS = 240_000
116// The estimate never reaches 100% before the flow is finished.
117const ESTIMATE_CAP = 0.99
118
119/** Letters and digits only, lower case: how a run's description meets a task's title. */
120export const normTitle = (s: string): string =>
121 String(s ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
122
123/** How far one run has taken its task, 0..0.9: steps, else a time curve; finished 0.9, failed 0. */
124export function runShare(a: AgentRun, now: number): number {
125 if (a.status === 'failed') return 0
126 if (a.status === 'done') return DONE_SHARE
127 if (a.stepTotal && a.stepTotal > 0) return clamp01((a.stepDone ?? 0) / a.stepTotal) * STEP_SHARE
128 const elapsed = Math.max(0, now - a.startedAt)
129 return Number.isFinite(elapsed) ? CURVE_SHARE * (1 - Math.exp(-elapsed / CURVE_TAU_MS)) : 0
130}
131
132/**
133 * The shares of the planned tasks not yet accepted, one per task with a run,
134 * at most total − done of them. Runs meet tasks by title (the `progress`
135 * tool asks for task titles equal to the Agent descriptions); when none
136 * does, every run counts as one task (the savvy workers only, when there are
137 * any). A retried task counts by its latest run. The `done` accepted tasks
138 * are taken to be the earliest finished runs, so those are left out.
139 */
140/** Each task's latest run, keyed by its normalized description (a run without one stands alone). */
141function latestRuns(pool: readonly AgentRun[]): Map<string, AgentRun> {
142 const latest = new Map<string, AgentRun>()
143 for (const a of pool) {
144 const key = normTitle(a.description) || `#${a.id}`
145 const prev = latest.get(key)
146 if (!prev || a.startedAt >= prev.startedAt) latest.set(key, a)
147 }
148 return latest
149}
150
151/** The savvy workers' runs when there are any, else every run: the runs that stand for tasks without titles. */
152const workerPool = (agents: readonly AgentRun[]): readonly AgentRun[] => {
153 const workers = agents.filter(a => tierOf(a.type) !== 'other')
154 return workers.length ? workers : agents
155}
156
157function unacceptedShares(f: Flow, agents: readonly AgentRun[], now: number): number[] {
158 const open = Math.max(0, f.total - f.done)
159 if (!open) return []
160 const titles = new Set(f.tasks.map(t => normTitle(t.title)).filter(Boolean))
161 const matched = agents.filter(a => titles.has(normTitle(a.description)))
162 const pool = matched.length ? matched : workerPool(agents)
163
164 const runs = [...latestRuns(pool).values()]
165 const accepted = new Set(
166 runs
167 .filter(a => a.status === 'done')
168 .sort((x, y) => (x.endedAt ?? x.startedAt) - (y.endedAt ?? y.startedAt))
169 .slice(0, Math.max(0, f.done))
170 .map(a => a.id),
171 )
172 return runs
173 .filter(a => !accepted.has(a.id))
174 .map(a => runShare(a, now))
175 .sort((x, y) => y - x)
176 .slice(0, open)
177}
178
179/**
180 * The flow's fill: `acceptedRatio` = done/total, `ratio` = (done + the
181 * unaccepted tasks' shares)/total, in [acceptedRatio, 0.99]; both 1 once
182 * finished, both 0 without a total.
183 */
184export function flowEstimate(f: Flow, agents: readonly AgentRun[], now: number): { ratio: number; acceptedRatio: number } {
185 if (f.isFinished) return { ratio: 1, acceptedRatio: 1 }
186 if (!(f.total > 0)) {
187 // No plan reported: the mean share of the runs started so far, so the bar still moves.
188 const runs = [...latestRuns(workerPool(agents)).values()]
189 if (!runs.length) return { ratio: 0, acceptedRatio: 0 }
190 const mean = runs.reduce((n, a) => n + runShare(a, now), 0) / runs.length
191 return { ratio: Math.min(ESTIMATE_CAP, clamp01(mean)), acceptedRatio: 0 }
192 }
193 const acceptedRatio = clamp01(f.done / f.total)
194 const sum = unacceptedShares(f, agents, now).reduce((n, x) => n + x, 0)
195 const estimate = clamp01((Math.min(f.done, f.total) + sum) / f.total)
196 return { ratio: Math.max(acceptedRatio, Math.min(ESTIMATE_CAP, estimate)), acceptedRatio }
197}
198
199/**
200 * Whether a flow's planned work is over though no `done`/`finished` report
201 * said so: every planned task has a run that ended (each of `f.tasks` by its
202 * title, its latest run; without titles, `f.total` runs, the savvy workers'
203 * when there are any), no agent runs, and the main turn is not working. A
204 * failed run counts as ended. `planned`: the tasks (titles, else total);
205 * `succeeded`: those whose run ended done, never below the reported `done`.
206 * Derived on every draw: the stored flow is never changed.
207 */
208export function flowCompletion(
209 f: Flow,
210 agents: readonly AgentRun[],
211 isWorking: boolean,
212): { isComplete: boolean; planned: number; succeeded: number } {
213 const titles = f.tasks.map(t => normTitle(t.title)).filter(Boolean)
214 const planned = titles.length || Math.max(0, Math.floor(Number.isFinite(f.total) ? f.total : 0))
215 const accepted = Math.min(planned, Math.max(0, Number.isFinite(f.done) ? Math.floor(f.done) : 0))
216 const no = { isComplete: false, planned, succeeded: accepted }
217 if (isWorking || planned === 0 || agents.some(a => a.status === 'running')) return no
218
219 let ended: AgentRun[] = []
220 if (titles.length) {
221 const wanted = new Set(titles)
222 const latest = latestRuns(agents.filter(a => wanted.has(normTitle(a.description))))
223 const runs = titles.map(t => latest.get(t))
224 if (runs.every(Boolean)) ended = runs as AgentRun[]
225 }
226 // No titles, or titles the Agent descriptions did not repeat: count the worker runs instead.
227 if (!ended.length) {
228 ended = [...latestRuns(workerPool(agents)).values()]
229 if (ended.length < planned) return no
230 }
231 // Nothing runs (checked above): every run left here ended, done or failed.
232 const ok = Math.min(planned, ended.filter(a => a.status === 'done').length)
233 return { isComplete: true, planned, succeeded: Math.max(accepted, ok) }
234}
235
236// ---------------------------------------------------------------------------
237// The main loop's task list, from its tool calls.
238
239/** The desktop app's task list tool. Matched by pattern: the laid MCP types name it only while connected. */
240export const UPDATE_PLAN_TOOL = /^mcp__ccd_turn__update_plan$/
241/** The CLI's and VS Code's task list tool. */
242export const TODO_TOOL = 'TodoWrite'
243
244const STATUSES: readonly PlanStatus[] = ['pending', 'in_progress', 'completed']
245
246const statusOf = (v: unknown): PlanStatus => (STATUSES.includes(v as PlanStatus) ? (v as PlanStatus) : 'pending')
247
248/** A list from tool input (`[{ content, status }]`), or null when it holds no item. Every entry keeps its place. */
249const listOf = (raw: unknown, at: number): Plan | null => {
250 if (!Array.isArray(raw)) return null
251 const items = raw.map((it: unknown): PlanItem => {
252 const o = (it && typeof it === 'object' ? it : {}) as { content?: unknown; status?: unknown }
253 return { title: typeof o.content === 'string' ? o.content.trim() : '', status: statusOf(o.status) }
254 })
255 return items.length ? { items, at } : null
256}
257
258/** A 1-based item number of a list of `n`, or -1. */
259const toIndex = (v: unknown, n: number): number => (Number.isInteger(v) && (v as number) >= 1 && (v as number) <= n ? (v as number) - 1 : -1)
260
261/** One tool call of a loop, as `tool.call` carries it: the tool, the loop, the arguments beside them. */
262export type PlanCall = { tool: string; agentId?: string } & Record<string, unknown>
263
264/**
265 * The task list after one tool call, or undefined when the call leaves it
266 * as it is (another tool, a subagent's call, an update with no list yet).
267 *
268 * update_plan: `items` replaces the list; then `advance` completes the
269 * in-progress item and starts the next pending one after it (the first
270 * pending one when none is in progress); then `done` completes and `start`
271 * starts those items (1-based). TodoWrite: `todos` replaces the list.
272 */
273export function planAfterCall(prev: Plan | null, call: PlanCall, at: number): Plan | null | undefined {
274 if (call.agentId) return undefined
275 if (call.tool === TODO_TOOL) return Array.isArray(call.todos) ? listOf(call.todos, at) : undefined
276 if (!UPDATE_PLAN_TOOL.test(call.tool)) return undefined
277
278 const hasItems = Array.isArray(call.items)
279 if (!hasItems && !prev) return undefined
280 const base = hasItems ? listOf(call.items, at) : prev
281 if (!base) return null
282 const items = base.items.map(it => ({ ...it }))
283
284 if (call.advance === true) {
285 const current = items.findIndex(it => it.status === 'in_progress')
286 const cur = items[current]
287 if (cur) cur.status = 'completed'
288 const nextPending = items.find((it, i) => i > current && it.status === 'pending')
289 if (nextPending) nextPending.status = 'in_progress'
290 }
291 if (Array.isArray(call.done)) {
292 for (const n of call.done) {
293 const it = items[toIndex(n, items.length)]
294 if (it) it.status = 'completed'
295 }
296 }
297 const started = items[toIndex(call.start, items.length)]
298 if (started) started.status = 'in_progress'
299
300 return { items, at }
301}
302
303/** Every item completed (an empty list counts as completed). */
304export const isPlanComplete = (p: Plan): boolean => p.items.every(it => it.status === 'completed')
305
306/**
307 * Where a list stands: ratio = (completed + ½ in progress) / items,
308 * acceptedRatio = completed / items, step = the in-progress item's number
309 * (else the completed count), current = the in-progress item's text.
310 */
311export function planProgress(p: Plan): { ratio: number; acceptedRatio: number; step: number; total: number; current: string } {
312 const total = p.items.length
313 if (!total) return { ratio: 0, acceptedRatio: 0, step: 0, total: 0, current: '' }
314 const completed = p.items.filter(it => it.status === 'completed').length
315 const inProgress = p.items.filter(it => it.status === 'in_progress').length
316 const at = p.items.findIndex(it => it.status === 'in_progress')
317 return {
318 ratio: clamp01((completed + 0.5 * inProgress) / total),
319 acceptedRatio: clamp01(completed / total),
320 step: at >= 0 ? at + 1 : completed,
321 total,
322 current: at >= 0 ? (p.items[at]?.title ?? '') : '',
323 }
324}
325
326export function buildMainModel(
327 input: {
328 flow: Flow | null
329 isWorking: boolean
330 startedAt: number | null
331 endedAt: number | null
332 tools: number
333 now: number
334 /** The subagent runs, for the flow estimate. */
335 agents?: readonly AgentRun[]
336 /** The main loop's task list, for the working estimate. */
337 plan?: Plan | null
338 /** How the running turn's elapsed time reads; 'clock' (m:ss) when left out. */
339 timeStyle?: TimeStyle
340 },
341 s: Strings,
342): MainModel {
343 const { flow, isWorking, startedAt, endedAt, tools, now } = input
344
345 if (flow && !flow.isFinished) {
346 // Every planned task's run ended and all is quiet: drawn finished (green,
347 // 100%), the stored flow left as it is.
348 const end = flowCompletion(flow, input.agents ?? [], isWorking)
349 if (end.isComplete) {
350 return {
351 mode: 'flow',
352 title: flow.title,
353 ratio: 1,
354 acceptedRatio: 1,
355 isEstimate: false,
356 runRatio: 1,
357 ticks: Math.max(0, flow.total),
358 pill: `${s.tasks} ${end.succeeded}/${end.planned}`,
359 right: percentText(1, false),
360 isAnimating: false,
361 isDone: true,
362 ringPercent: 100,
363 }
364 }
365 const { ratio, acceptedRatio } = flowEstimate(flow, input.agents ?? [], now)
366 const isEstimate = ratio > acceptedRatio
367 return {
368 mode: 'flow',
369 title: flow.title,
370 ratio,
371 acceptedRatio,
372 isEstimate,
373 runRatio: flow.total ? Math.max(ratio, clamp01((flow.done + flow.running) / flow.total)) : ratio,
374 ticks: Math.max(0, flow.total),
375 pill: phaseLabel(flow, s),
376 right: percentText(ratio, isEstimate),
377 isAnimating: false,
378 isDone: false,
379 ringPercent: Math.round(ratio * 100),
380 }
381 }
382
383 if (isWorking) {
384 const plan = input.plan && input.plan.items.length ? input.plan : null
385 if (plan) {
386 const p = planProgress(plan)
387 return {
388 mode: 'working',
389 title: p.current || s.working,
390 ratio: p.ratio,
391 acceptedRatio: p.acceptedRatio,
392 isEstimate: true,
393 runRatio: p.ratio,
394 ticks: 0,
395 pill: `${s.step} ${p.step}/${p.total}`,
396 right: percentText(p.ratio, true),
397 isAnimating: true,
398 isDone: false,
399 ringPercent: null,
400 }
401 }
402 const elapsed = startedAt === null ? 0 : Math.max(0, now - startedAt)
403 return {
404 mode: 'working',
405 title: s.working,
406 ratio: 0,
407 acceptedRatio: 0,
408 isEstimate: false,
409 runRatio: 0,
410 ticks: 0,
411 pill: fmtElapsed(elapsed, input.timeStyle),
412 right: toolsText(tools, s),
413 isAnimating: true,
414 isDone: false,
415 ringPercent: null,
416 }
417 }
418
419 const hasLast = startedAt !== null && endedAt !== null && endedAt >= startedAt
420 const last = hasLast ? `${s.lastTurn} ${fmtTime(endedAt - startedAt)} · ${toolsText(tools, s)}` : ''
421 const isDone = !!flow && flow.isFinished
422 const lead = isDone && flow ? `✓ ${flow.title}` : ''
423 return {
424 mode: 'idle',
425 title: s.idle,
426 ratio: 0,
427 acceptedRatio: 0,
428 isEstimate: false,
429 runRatio: 0,
430 ticks: 0,
431 pill: '',
432 right: lead && last ? `${lead} · ${last}` : lead || last,
433 isAnimating: false,
434 isDone,
435 ringPercent: isDone ? 100 : null,
436 }
437}
438
439const rowOf = (a: AgentRun, now: number, timeStyle: TimeStyle): AgentRowModel => {
440 const hasSteps = !!a.stepTotal && a.stepTotal > 0
441 const ctx = a.contextMax > 0 ? clamp01((a.contextTokens || 0) / a.contextMax) : 0
442 const isContext = a.status !== 'done' && !hasSteps
443 const progress = a.status === 'done' ? 1 : hasSteps ? clamp01((a.stepDone ?? 0) / (a.stepTotal ?? 1)) : ctx
444 return {
445 id: a.id,
446 tier: tierOf(a.type),
447 title: a.description || a.type,
448 status: a.status,
449 progress,
450 isContext,
451 stat: hasSteps ? `${a.stepDone ?? 0}/${a.stepTotal}` : `ctx ${Math.round(ctx * 100)}%`,
452 tokens: fmtTokens(a.tokens),
453 time: a.endedAt !== undefined ? fmtTime(a.endedAt - a.startedAt) : fmtElapsed(now - a.startedAt, timeStyle),
454 }
455}
456
457/** Running first (most recent first), then finished, most recent first; a running row's time reads as `timeStyle` says. */
458export function buildAgentRows(agents: AgentRun[], now: number, timeStyle: TimeStyle = 'clock'): AgentRowModel[] {
459 const running = agents.filter(a => a.status === 'running').sort((x, y) => y.startedAt - x.startedAt)
460 const finished = agents
461 .filter(a => a.status !== 'running')
462 .sort((x, y) => (y.endedAt ?? y.startedAt) - (x.endedAt ?? x.startedAt))
463 return [...running, ...finished].map(a => rowOf(a, now, timeStyle))
464}
465
466/** Agent count, how many still run, and their summed tokens. */
467export function agentsSummary(agents: AgentRun[]): { count: number; running: number; tokens: number } {
468 return {
469 count: agents.length,
470 running: agents.filter(a => a.status === 'running').length,
471 tokens: agents.reduce((n, a) => n + (Number.isFinite(a.tokens) ? a.tokens : 0), 0),
472 }
473}
474hooks/shared.ts 285 lines1// Shared strings, language detection and drawing helpers. The helpers below
2// `xml` are copied from savvy-progress's band so the two look alike.
3import type { Elements } from 'claude-code'
4
5/** The element table `$.ui.resolve(e)` returns on the terminal surface. */
6export type TerminalUi = Elements['terminal']
7
8export type Lang = 'it' | 'en'
9
10export const STRINGS = {
11 it: {
12 working: 'In lavoro',
13 idle: 'Pronto',
14 plan: 'Piano',
15 design: 'Design',
16 tasks: 'Attività',
17 review: 'Revisione',
18 step: 'Passo',
19 lastTurn: 'ultimo turno',
20 tool: 'tool',
21 tools: 'tool',
22 agent: 'agente',
23 agents: 'agenti',
24 activeOne: 'attivo',
25 active: 'attivi',
26 more: 'altri',
27 tok: 'tok',
28 },
29 en: {
30 working: 'Working',
31 idle: 'Ready',
32 plan: 'Plan',
33 design: 'Design',
34 tasks: 'Tasks',
35 review: 'Review',
36 step: 'Step',
37 lastTurn: 'last turn',
38 tool: 'tool',
39 tools: 'tools',
40 agent: 'agent',
41 agents: 'agents',
42 activeOne: 'active',
43 active: 'active',
44 more: 'more',
45 tok: 'tok',
46 },
47} as const
48
49export type Strings = { readonly [K in keyof (typeof STRINGS)['it']]: string }
50
51const isItalian = (v: unknown): boolean => typeof v === 'string' && /^(it|ital)/i.test(v.trim())
52
53/**
54 * The language from what `detectLang` (register.tsx) gathered: the `language`
55 * option when it is `it` or `en`; on `auto`, Claude Code's `language` setting,
56 * then the locale (LC_ALL / LC_MESSAGES / LANG). Italian when the value starts
57 * with "it" or "ital", English otherwise. Pure: `$` never crosses an import.
58 */
59export function pickLang(option: unknown, setting: unknown, locale: unknown): Lang {
60 if (option === 'it' || option === 'en') return option
61 if (typeof setting === 'string' && setting.trim()) return isItalian(setting) ? 'it' : 'en'
62 return isItalian(locale) ? 'it' : 'en'
63}
64
65// ---------------------------------------------------------------------------
66// MyDesign (minimal) tokens.
67
68export const MD_FONT = '"Museo Sans","Nunito Sans",Verdana,sans-serif'
69export const MD_NUM_FONT = 'Industry,Barlow,Verdana,sans-serif'
70
71// [name, light, dark]
72const MD_COLORS: [string, string, string][] = [
73 ['page', '#F5F5F5', '#111314'],
74 ['surface', '#FFFFFF', '#1A1D1F'],
75 ['muted', '#F2F5F5', '#2C3236'],
76 ['ink', '#0A0A0A', '#F2F2F2'],
77 ['ink-2', '#525252', '#B3B3B3'],
78 ['ink-3', '#A3A3A3', '#8A8A8A'],
79 ['line', '#D4D4D4', '#3A3F42'],
80 ['line-soft', '#E5E5E5', '#2C3033'],
81 ['primary', '#4B5458', '#9CAAB0'],
82 ['on-primary', '#FFFFFF', '#0A0A0A'],
83 ['status-online', '#5B852B', '#76A83A'],
84 ['ok-ink', '#476625', '#93C255'],
85 ['ko-ink', '#E22D20', '#FC776D'],
86 ['timeline-fill', '#D5DBDD', '#3A4246'],
87]
88
89const MD_CLASSES =
90 '.md-ink{fill:var(--ink)}.md-ink2{fill:var(--ink-2)}.md-ink3{fill:var(--ink-3)}' +
91 '.md-track{fill:var(--line-soft)}.md-primary{fill:var(--primary)}.md-done{fill:var(--status-online)}' +
92 '.md-ko{fill:var(--ko-ink)}.md-time{fill:var(--timeline-fill)}' +
93 '.md-s-track{stroke:var(--line-soft)}.md-s-primary{stroke:var(--primary)}.md-s-done{stroke:var(--status-online)}' +
94 '.md-s-ko{stroke:var(--ko-ink)}.md-s-ink3{stroke:var(--ink-3)}'
95
96/**
97 * One `<style>` for every SVG of the band: the MyDesign color tokens as CSS
98 * custom properties on the svg root (light values, dark overrides under
99 * `prefers-color-scheme: dark`), the md-* utility classes, reduced motion,
100 * then `extra` appended.
101 */
102export function mdStyle(extra?: string): string {
103 const light = MD_COLORS.map(([n, l]) => `--${n}:${l}`).join(';')
104 const dark = MD_COLORS.map(([n, , d]) => `--${n}:${d}`).join(';')
105 return (
106 `<style>svg{${light}}` +
107 `@media (prefers-color-scheme: dark){svg{${dark}}}` +
108 MD_CLASSES +
109 '@media (prefers-reduced-motion: reduce){*{animation:none!important}}' +
110 (extra ?? '') +
111 '</style>'
112 )
113}
114
115/** 950 → "950", 12_400 → "12k", 1_250_000 → "1.3M". */
116export function fmtTokens(n: number): string {
117 const v = Number.isFinite(n) ? Math.max(0, n) : 0
118 if (v < 1000) return String(Math.round(v))
119 if (v < 999_500) return `${Math.round(v / 1000)}k`
120 const m = v / 1_000_000
121 return m < 9.95 ? `${m.toFixed(1).replace(/\.0$/, '')}M` : `${Math.round(m)}M`
122}
123
124export const xml = (s: string): string =>
125 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
126
127/**
128 * The opening tag of a band picture `h` CSS px tall and as wide as its slot:
129 * `width="100%"`, a px height and no viewBox, so one user unit is one CSS
130 * pixel at any drawn width. Text and the fixed marks keep their design size
131 * (px); only what is placed in percentages (a bar's span, `x="100%"`)
132 * follows the real width. The Svg element gets `height={h}` and no width:
133 * VS Code draws it as an <img> `h` px tall that a column Box stretches to its
134 * width (a row Box would leave it 300px wide), and draws the markup at that
135 * box's size, one unit to the pixel. `attrs` go on the root too.
136 */
137export const pxSvgOpen = (h: number, attrs = ''): string =>
138 `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${h}"${attrs}>`
139
140/** Two decimals, no trailing zeros. */
141const dec2 = (n: number): string => String(Math.round(n * 100) / 100)
142
143/** A share (0..1) as a short percentage: `0.1733` → `17.33%`. */
144export const pct = (share: number): string => `${dec2((Number.isFinite(share) ? share : 0) * 100)}%`
145
146// Rough advance of system UI text, in em; good enough to size a slot.
147const charEm = (ch: string): number =>
148 /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZÀ-ÝmwМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
149
150export const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
151
152/** Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut. */
153export const fitText = (s: string, size: number, maxW: number): string => {
154 if (textWidth(s, size) <= maxW) return s
155 let out = ''
156 for (const ch of s) {
157 if (textWidth(out + ch + '…', size) > maxW) break
158 out += ch
159 }
160 return out + '…'
161}
162
163/** m:ss, or h:mm:ss past an hour; negative counts as zero. */
164export const fmtTime = (ms: number): string => {
165 const s = Math.max(0, Math.round(ms / 1000))
166 const h = Math.floor(s / 3600)
167 const m = Math.floor((s % 3600) / 60)
168 const ss = String(s % 60).padStart(2, '0')
169 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
170}
171
172/**
173 * Whole minutes, for a time drawn inside an Svg: "<1m", "3m", "1h05m".
174 * Floored, so the text (and the picture's source) changes once a minute,
175 * never each second: a remote surface reloads a picture whose source changed.
176 */
177export const fmtMinutes = (ms: number): string => {
178 const m = Math.floor(Math.max(0, Number.isFinite(ms) ? ms : 0) / 60_000)
179 if (m < 1) return '<1m'
180 if (m < 60) return `${m}m`
181 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
182}
183
184/**
185 * How an elapsed time reads: `clock` m:ss (text surfaces, where a redraw is
186 * free), `minutes` "<1m"/"3m" (inside an Svg, where a new source is a new picture).
187 */
188export type TimeStyle = 'clock' | 'minutes'
189
190export const fmtElapsed = (ms: number, style: TimeStyle = 'clock'): string =>
191 style === 'minutes' ? fmtMinutes(ms) : fmtTime(ms)
192
193/**
194 * A 0..1 ratio rounded to whole percent, as a drawn percentage rounds it, so
195 * a fill drawn from it moves only when its percentage does (sub-percent drift
196 * of an estimate never changes a picture's source).
197 */
198export const wholePercent = (n: number): number => (Number.isFinite(n) ? Math.round(Math.max(0, Math.min(1, n)) * 100) / 100 : 0)
199
200const TIERS = new Set(['fable', 'heavy', 'careful', 'medium', 'light'])
201
202/** `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin. */
203export const tierOf = (type: string): string => {
204 const bare = type.replace(/^[^:]*:/, '')
205 const t = bare.replace(/^savvy-/, '').toLowerCase()
206 return TIERS.has(t) &&bare.startsWith('savvy-') ? t : 'other'
207}
208
209// ---------------------------------------------------------------------------
210// savvy-progress palette and font, the band's current look.
211
212export const ACCENT = '#8f8cf4'
213export const DONE = '#5fbf8f'
214export const IDLE = '#9a9a96'
215export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
216
217export const TIER_COLOR: Record<string, string> = {
218 fable: '#7F77DD',
219 heavy: '#D85A30',
220 careful: '#BA7517',
221 medium: '#378ADD',
222 light: '#1D9E75',
223 other: '#888780',
224}
225
226export const colorOf = (tier: string): string => TIER_COLOR[tier] ?? "#888780"
227
228// savvy-progress's text / track colors: [class, light, dark]. The band's set
229// (main row, ring) and the agents pane's set (agent rows) differ slightly.
230const K_BAND = ["#e4e4e2", "#2c2c2c"] as const
231const K_PANE = ["#ecebe8", "#2c2c2b"] as const
232const BAND_TONES: [string, string, string][] = [
233 ['t', '#1f1f1f', '#ececec'],
234 ['s', '#6b6b68', '#a8a8a4'],
235 ['m', '#8a8a8a', '#9a9a9a'],
236 ['k', '#e4e4e2', '#2c2c2c'],
237 ['tk', '#b4b4b0', '#5a5a5a'],
238]
239const PANE_TONES: [string, string, string][] = [
240 ['t', '#1f1f1f', '#ececec'],
241 ['s', '#6b6b68', '#a8a8a4'],
242 ['m', '#9a9a96', '#7d7d79'],
243 ['k', '#ecebe8', '#2c2c2b'],
244 ['tk', '#b4b4b0', '#5a5a5a'],
245]
246
247/**
248 * savvy's .t .s .m .k .tk fill classes, prefixed (`pbm-t`, ...) so SVGs of
249 * different modules never clash, plus `<prefix>-ks`: a stroke in the .k color.
250 * Light values, dark overrides under prefers-color-scheme.
251 */
252export function savvyClasses(prefix: string, set: 'band' | 'pane' = 'band'): string {
253 const tones = set === 'pane' ? PANE_TONES : BAND_TONES
254 const k = set === 'pane' ? K_PANE : K_BAND
255 const rules = (dark: boolean): string =>
256 tones.map(([n, l, d]) => `.${prefix}-${n}{fill:${dark ? d : l}}`).join('') +
257 `.${prefix}-ks{stroke:${dark ? k[1] : k[0]}}`
258 return rules(false) + `@media (prefers-color-scheme: dark){${rules(true)}}`
259}
260
261/** Deterministic noise so the dither does not shimmer between redraws. */
262export const noise = (x: number, y: number): number => {
263 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
264 return s - Math.floor(s)
265}
266
267/** Small seeded PRNG (mulberry32): returns a function yielding floats in [0, 1). */
268export const mulberry32 = (seed: number): (() => number) => {
269 let a = seed >>> 0
270 return () => {
271 a = (a + 0x6d2b79f5) >>> 0
272 let t = a
273 t = Math.imul(t ^ (t >>> 15), t | 1)
274 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
275 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
276 }
277}
278
279/** FNV-1a hash of a string, as an unsigned 32-bit integer. */
280export const strHash = (s: string): number => {
281 let h = 0x811c9dc5
282 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0
283 return h >>> 0
284}
285hooks/main-row.tsx 583 lines1// The main row of the band: one SVG on the desktop, one line on the terminal.
2// savvy-progress's look (dot, title, rounded dithered track, pill at the fill
3// head, muted right text), made livelier: the fill's violet → blue → teal
4// gradient flows through the dithered cells, white shimmers and sparks sweep
5// over it, comets run through it and a cap pulses at its head; "working"
6// fills the whole track with a flowing violet → pink → amber dithered field
7// crossed by comets (or, with an estimated ratio, a dense fill with the same
8// shimmer, comet, cap and sparks). A finished flow (idle after it, or a flow
9// whose planned runs all ended: model.ts's flowCompletion) is a green dither.
10// The source is a pure function of what the row shows: the seed (twinkle
11// phases, dither pattern, shimmer, comets, sparks) comes from the turn, the
12// mode and the title (turnSeed), never from the clock, and the fill is drawn
13// at whole percent. The band redraws about once a second while something
14// runs, and a remote surface (the desktop, VS Code) loads a new picture for
15// every new source, showing the alt meanwhile: a redraw that changes nothing
16// visible must hand back the same bytes. The CSS animations run on their own
17// inside the picture. The title and the right text sit in fixed slots, so the
18// bar width depends only on the row width.
19//
20// Drawn at its real width, in CSS px (pxSvgOpen: no root viewBox): the dot,
21// the title, the pill, the right text and the cap keep their design pixel
22// size. The geometry is designed at the estimated row width `W`; the bar's
23// span is placed in percentages of the real width (L% to L+BW%, as at `W`),
24// so a wider row draws a longer bar. Its insides (dithered cells, comets,
25// shimmer, ticks, sparks across the field) are designed at the estimated bar
26// width (BAR_W) in a nested <svg> stretched over the span, horizontally only
27// (preserveAspectRatio="none"): no text goes in there. The track and its
28// round clip are px-rounded rects in percentages; the pill, the cap and the
29// sparks at the fill head sit in nested <svg>s placed at the head in
30// percent, drawn in px around it (anchored).
31// No `$` here.
32import type { MainModel } from './model'
33import {
34 ACCENT,
35 DONE,
36 FONT,
37 IDLE,
38 fitText,
39 mulberry32,
40 noise,
41 pct,
42 pxSvgOpen,
43 savvyClasses,
44 strHash,
45 textWidth,
46 wholePercent,
47 xml,
48 type TerminalUi,
49} from './shared'
50
51export const MAIN_H = 30
52/** The main dot's center x: the agent rows' tree trunk runs down from it. */
53export const DOT_X = 5
54const DOT_R = 3
55/** Tree connectors: the accent at 0.55, 1.5px, round caps. */
56export const BRANCH_COLOR = ACCENT
57export const BRANCH_OPACITY = 0.55
58export const BRANCH_W = 1.5
59
60const BAR_H = 22
61const BAR_R = BAR_H / 2
62const CELL = 3
63const ROWS = Math.floor(BAR_H / CELL)
64const CELL_Y0 = (BAR_H - ROWS * CELL) / 2 + 0.5
65const TITLE_X = 10
66// Larger than the agent rows' 12/11px, so the main bar reads first.
67const TITLE_SIZE = 15
68const PILL_SIZE = 12
69const RIGHT_SIZE = 13
70const RIGHT_SLOT = 52
71const BAR_GAP_L = 8
72const BAR_GAP_R = 6
73const BAR_MIN = 24
74
75/** Flow fill: violet → blue → teal, spread over the full bar width. */
76const FLOW_STOPS: [number, string][] = [
77 [0, ACCENT],
78 [0.5, '#378ADD'],
79 [1, '#1D9E75'],
80]
81/** The same colors, cyclic, for the flowing (repeating) gradient. */
82const FLOW_CYCLE: [number, string][] = [
83 [0, ACCENT],
84 [0.33, '#378ADD'],
85 [0.66, '#1D9E75'],
86 [1, ACCENT],
87]
88/** Working segment: violet → pink → amber. */
89const WORK_STOPS: [number, string][] = [
90 [0, ACCENT],
91 [0.55, '#E36FB0'],
92 [1, '#F5B731'],
93]
94/** The working colors, cyclic, for the flowing field. */
95const WORK_CYCLE: [number, string][] = [
96 [0, ACCENT],
97 [0.33, '#E36FB0'],
98 [0.66, '#F5B731'],
99 [1, ACCENT],
100]
101
102const clamp = (n: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, n))
103
104const unit = (n: number): number => (Number.isFinite(n) ? clamp(n, 0, 1) : 0)
105
106const hex = (c: string): number[] => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16))
107
108/** Two decimals, no trailing zeros, so the SVG stays short. */
109const f2 = (n: number): string => String(Math.round(n * 100) / 100)
110
111/** The gradient's color at `t` (0..1), so the pill matches the fill under it. */
112const colorAt = (stops: [number, string][], t: number): string => {
113 const u = unit(t)
114 let lo: [number, string] = stops[0] ?? [0, ACCENT]
115 let hi: [number, string] = lo
116 for (const st of stops) {
117 hi = st
118 if (st[0] >= u) break
119 lo = st
120 }
121 const k = hi[0] > lo[0] ? clamp((u - lo[0]) / (hi[0] - lo[0]), 0, 1) : 0
122 const A = hex(lo[1])
123 const B = hex(hi[1])
124 return '#' + A.map((v, j) => Math.round(v + ((B[j] ?? v) - v) * k).toString(16).padStart(2, '0')).join('')
125}
126
127const gradient = (id: string, stops: [number, string][], x2: number, repeat = false): string =>
128 `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="${x2}" y2="0"${repeat ? ' spreadMethod="repeat"' : ''}>` +
129 stops.map(([o, c]) => `<stop offset="${f2(o)}" stop-color="${c}"/>`).join('') +
130 '</linearGradient>'
131
132type Rand = () => number
133
134/** The seeded random look: twinkle groups, dither offset. */
135type Look = { groups: number; ox: number; oy: number }
136
137/**
138 * Dithered cells over columns [from, to), each kept when noise < density(c);
139 * the noise is offset by the seed, so the pattern differs from turn to turn
140 * and mode to mode, and holds within one. One `<path>` per twinkle group,
141 * cells written with relative moves (`m3 0h2v2h-2z`) so the SVG stays small
142 * even on a wide bar.
143 */
144const cells = (from: number, to: number, cls: string, density: (c: number) => number, look: Look, size = 2, attrs = ''): string => {
145 const G = look.groups
146 const d: string[][] = Array.from({ length: G }, () => [])
147 const last: (number[] | null)[] = Array.from({ length: G }, () => null)
148 for (let r = 0; r < ROWS; r++) {
149 for (let c = from; c < to; c++) {
150 if (noise(c + look.ox, r + look.oy) >= density(c)) continue
151 const g = Math.floor(noise(r + look.oy * 0.37, c + look.ox * 0.61) * G) % G
152 const x = c * CELL + 1
153 const y = f2(CELL_Y0 + r * CELL)
154 const p = last[g]
155 const box = `h${size}v${size}h-${size}z`
156 if (p && p[1] === r) d[g]?.push(`m${x - (p[0] ?? 0)} 0${box}`)
157 else d[g]?.push(`M${x} ${y}${box}`)
158 last[g] = [x, r]
159 }
160 }
161 return d.map((g, i) => (g.length ? `<path class="${cls}${i} pbm-an"${attrs} d="${g.join('')}"/>` : '')).join('')
162}
163
164/** Twinkle keyframes for each group: random period (.5s/1s), phase and floor. */
165const twinkleCss = (cls: string, look: Look, rnd: Rand, lo: number, hi: number): string => {
166 let css = ''
167 for (let i = 0; i < look.groups; i++) {
168 const per = rnd() < 0.35 ? 0.5 : 1
169 const min = f2(lo + rnd() * (hi - lo))
170 const delay = f2(-rnd() * per)
171 css +=
172 `.${cls}${i}{animation:${cls}${i}k ${per}s ease-in-out ${delay}s infinite}` +
173 `@keyframes ${cls}${i}k{0%,100%{opacity:1}50%{opacity:${min}}}`
174 }
175 return css
176}
177
178/** Sparse at the start, dense toward the head. */
179const ramp = (cols: number) => (c: number): number => 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
180
181/**
182 * Bright 2×2 cells near `headX` (in [lo, headX], design units along the bar),
183 * fading in and out at random. Their x is written less `origin`: 0 inside the
184 * stretched insides, the anchor's design x when drawn in px around it.
185 */
186const sparks = (
187 rnd: Rand,
188 lo: number,
189 headX: number,
190 idx: { n: number },
191 count = 3 + Math.floor(rnd() * 4),
192 origin = 0,
193): { svg: string; css: string } => {
194 let svg = ''
195 let css = ''
196 const cLo = Math.max(0, Math.floor(lo / CELL))
197 const cHi = Math.max(cLo, Math.floor(headX / CELL) - 1)
198 for (let i = 0; i < count; i++) {
199 const c = cLo + Math.floor(rnd() * (cHi - cLo + 1))
200 const r = Math.floor(rnd() * ROWS)
201 const per = rnd() < 0.5 ? 0.5 : 1
202 const n = idx.n++
203 const fill = rnd() < 0.6 ? '#ffffff' : '#e8e6ff'
204 svg += `<rect class="pbm-sp${n} pbm-an" x="${f2(c * CELL + 1 - origin)}" y="${f2(CELL_Y0 + r * CELL)}" width="2" height="2" fill="${fill}"/>`
205 css += `.pbm-sp${n}{animation:pbm-spk ${per}s ease-in-out ${f2(-rnd() * per)}s infinite}`
206 }
207 return { svg, css }
208}
209
210const SPARK_KF = '@keyframes pbm-spk{0%,100%{opacity:0}50%{opacity:1}}'
211
212/** Comet colors along its own length (bounding-box units): faint violet tail → bright white head. */
213const cometGrad = (id: string, mid: string, hot: string): string =>
214 `<linearGradient id="${id}"><stop offset="0" stop-color="${ACCENT}" stop-opacity=".2"/>` +
215 `<stop offset=".55" stop-color="${mid}" stop-opacity=".85"/><stop offset=".88" stop-color="${hot}"/>` +
216 '<stop offset="1" stop-color="#ffffff"/></linearGradient>'
217
218const PULSE_KF = '@keyframes pbm-pulse{0%,100%{opacity:.15}50%{opacity:.35}}'
219
220/**
221 * A comet: a dithered trail `L` px long, sparse at the tail and dense at the
222 * head, with a soft pulsing glow under the head. It crosses [0, spanW] in
223 * `per` s (left → right; mirrored with `reverse`). The static position (no
224 * animation, reduced motion) is a random point inside the span.
225 */
226const comet = (
227 i: number,
228 L: number,
229 spanW: number,
230 per: number,
231 rnd: Rand,
232 look: Look,
233 grad: string,
234 glow: string,
235 reverse = false,
236): { svg: string; css: string } => {
237 const cols = Math.max(3, Math.floor(L / CELL))
238 const w = cols * CELL
239 const base = Math.round(rnd() * Math.max(0, spanW - w))
240 const cl: Look = { groups: 1, ox: look.ox + 31 * (i + 1), oy: look.oy + 17 * (i + 1) }
241 const trail = cells(0, cols, 'pbm-q', c => {
242 const t = (c + 1) / cols
243 return t > 0.86 ? 0.97 : 0.06 + 0.85 * Math.pow(t, 1.6)
244 }, cl)
245 const gw = Math.round(w * 0.6)
246 const svg =
247 `<g transform="translate(${base},0)"><g class="pbm-mv${i} pbm-an">` +
248 `<rect class="pbm-gl${i} pbm-an" x="${Math.round(w * 0.45)}" width="${gw}" height="${BAR_H}" rx="${BAR_R}" fill="${glow}" opacity=".25"/>` +
249 `<g fill="url(#${grad})">${trail}</g></g></g>`
250 const css =
251 `.pbm-mv${i}{animation:pbm-mv${i}k ${per}s linear ${f2(-rnd() * per)}s infinite}` +
252 `@keyframes pbm-mv${i}k{from{transform:translateX(${-w - base}px)}to{transform:translateX(${spanW - base}px)}}` +
253 `.pbm-gl${i}{animation:pbm-pulse .5s ease-in-out ${f2(-rnd() * 0.5)}s infinite}`
254 return { svg: reverse ? `<g transform="translate(${spanW},0) scale(-1,1)">${svg}</g>` : svg, css }
255}
256
257/** A bright pulsing cap at the fill head `x`: a 3px core in a soft 10px glow. */
258const cap = (x: number): string =>
259 `<g class="pbm-cap pbm-an"><rect x="${f2(x - 5)}" width="10" height="${BAR_H}" rx="5" fill="#ffffff" fill-opacity=".3"/>` +
260 `<rect x="${f2(x - 1.5)}" y="2" width="3" height="${BAR_H - 4}" rx="1.5" fill="#ffffff"/></g>`
261
262const CAP_CSS = '.pbm-cap{animation:pbm-capk .5s ease-in-out infinite}@keyframes pbm-capk{0%,100%{opacity:.5}50%{opacity:1}}'
263
264const SHINE =
265 '<linearGradient id="pbm-shine" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#ffffff" stop-opacity="0"/>' +
266 '<stop offset="0.5" stop-color="#ffffff" stop-opacity="1"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></linearGradient>'
267
268const REDUCED = '@media (prefers-reduced-motion: reduce){.pbm-an{animation:none!important}}'
269
270const dotColor = (m: MainModel): string => (m.isDone ? DONE : m.mode === 'idle' ? IDLE : ACCENT)
271
272/**
273 * Fixed geometry for a row designed `width` px wide (the estimate): the bar
274 * width depends only on `width`, never on the texts. Drawn wider or narrower,
275 * the bar keeps its share of the row (barX/W to (barX+barW)/W); the title
276 * slot and the right slot keep their px.
277 */
278export const mainRowGeometry = (width: number): { W: number; titleSlot: number; barX: number; barW: number } => {
279 const W = Math.max(1, Math.round(Number.isFinite(width) ? width : 180))
280 const titleSlot = Math.round(clamp(W * 0.14, 70, 140))
281 const barX = TITLE_X + titleSlot + BAR_GAP_L
282 const barW = Math.max(BAR_MIN, W - barX - RIGHT_SLOT - BAR_GAP_R)
283 return { W, titleSlot, barX, barW }
284}
285
286/**
287 * The main row's seed: the turn (when it started), the mode and whether a
288 * finished flow is shown, never the clock. mainRowSvg mixes the title in, so
289 * a new flow or task-list item reshuffles too; within one, every redraw draws
290 * the same picture.
291 */
292export const turnSeed = (startedAt: number | null, m: Pick<MainModel, 'mode' | 'isDone'>): number =>
293 strHash(`${startedAt ?? 0}|${m.mode}|${m.isDone ? 'done' : ''}`)
294
295/**
296 * The row's SVG, MAIN_H px tall and as wide as its slot (pxSvgOpen), its
297 * geometry designed at `width` (see the file's head). With `hasBranch` (agent
298 * rows shown below) a trunk runs from just below the dot down to the row's
299 * bottom edge. The same model, width and seed always give the same string.
300 */
301export function mainRowSvg(m: MainModel, width: number, seed = 0, hasBranch = false): string {
302 const { W, titleSlot, barX: BAR_X, barW: BAR_W } = mainRowGeometry(width)
303 const H = MAIN_H
304 const y0 = (H - BAR_H) / 2
305 const seedN = Number.isFinite(seed) ? Math.floor(seed) : 0
306 const rnd = mulberry32((seedN ^ strHash(m.title)) >>> 0)
307 const look: Look = { groups: 4 + Math.floor(rnd() * 3), ox: Math.floor(rnd() * 997), oy: Math.floor(rnd() * 991) }
308
309 const title = fitText(m.title, TITLE_SIZE, titleSlot)
310 const right = m.right ? fitText(m.right, RIGHT_SIZE, RIGHT_SLOT - 2) : ''
311
312 // Pill: the label in a capsule inside the bar, at the fill head (flow) or
313 // at the left (working); cut only when it would not fit the bar.
314 const pillRoom = BAR_W * 0.7 - 16
315 const pillText = m.pill && m.mode !== 'idle' && pillRoom >= 12 ? fitText(m.pill, PILL_SIZE, pillRoom / 1.08) : ''
316 const pillW = pillText ? Math.min(BAR_W, Math.round(16 + textWidth(pillText, PILL_SIZE) * 1.08)) : 0
317
318 // The stretched insides (design units along the bar: defs, body), what is
319 // drawn in px at the fill head (head, at design x headAt) and the pill (in
320 // px at design x pillAt, from dx px off it).
321 let defs = ''
322 let body = ''
323 let head = ''
324 let headAt = 0
325 let pill = ''
326 let pillAt = 0
327 let css = ''
328 const spIdx = { n: 0 }
329
330 const pillSvg = (dx: number, color: string): string =>
331 `<rect x="${dx}" width="${pillW}" height="${BAR_H}" rx="${BAR_R}" fill="${color}"/>` +
332 `<text x="${f2(dx + pillW / 2)}" y="${f2(BAR_H / 2 + 3.6)}" text-anchor="middle" font-family="${FONT}" font-size="${PILL_SIZE}" font-weight="600" fill="#ffffff">${xml(pillText)}</text>`
333
334 if (m.mode === 'flow' && !m.isDone) {
335 // Whole percent, as the right text rounds it: an estimate's drift (the
336 // time curve of a running worker) moves nothing until its percentage does.
337 const ratio = wholePercent(m.ratio)
338 const fillW = Math.round(BAR_W * ratio)
339 // The pill ends at the fill head, or starts at the bar's left while the
340 // fill is shorter than the pill; the cap and sparks keep beside it.
341 pillAt = pillText && fillW >= pillW ? fillW : 0
342 const runW = Math.round(BAR_W * Math.max(ratio, wholePercent(m.runRatio)))
343 const cols = Math.floor(fillW / CELL)
344 const runCols = Math.floor(runW / CELL)
345 // Flowing colors: a repeating gradient rect, one period wider than the
346 // bar, slides left by one period in 1s, seen through the dithered cells (a mask).
347 const P = Math.max(90, Math.round(BAR_W * (0.45 + rnd() * 0.3)))
348 const fillCells = cells(0, cols, 'pbm-w', ramp(cols), look)
349 // Handed to workers, not yet accepted: a faint second layer.
350 const runCells = runCols > cols ? cells(cols, runCols, 'pbm-w', () => 0.2, { ...look, ox: look.ox + 7 }, 1.7) : ''
351 defs +=
352 gradient('pbm-grad', FLOW_STOPS, BAR_W) +
353 gradient('pbm-rep', FLOW_CYCLE, P, true) +
354 SHINE +
355 cometGrad('pbm-cg', '#378ADD', '#1D9E75') +
356 `<clipPath id="pbm-fclip"><rect width="${fillW}" height="${BAR_H}" rx="${BAR_R}"/></clipPath>` +
357 `<mask id="pbm-cm" maskUnits="userSpaceOnUse" x="0" y="0" width="${BAR_W}" height="${BAR_H}"><g fill="#ffffff">${fillCells}</g></mask>`
358 // Drawn always (it takes its draws from rnd), kept only when cells twinkle:
359 // an empty bar then has no animation, so a surface treats it as a still picture.
360 const twinkle = twinkleCss('pbm-w', look, rnd, 0.15, 0.5)
361 if (fillCells || runCells) css += twinkle
362 if (cols > 0) {
363 body += `<g mask="url(#pbm-cm)"><rect class="pbm-fl pbm-an" width="${BAR_W + P}" height="${BAR_H}" fill="url(#pbm-rep)"/></g>`
364 css += `.pbm-fl{animation:pbm-flow 1s linear infinite}@keyframes pbm-flow{from{transform:translateX(0px)}to{transform:translateX(${-P}px)}}`
365 }
366 if (runCells) {
367 body += `<g fill="url(#pbm-grad)" opacity="0.45">${runCells}</g>`
368 }
369 if (fillW > 0) {
370 // Shimmer: 1-2 soft white bands of random width, phase and strength,
371 // sweeping over the filled part only, in 1s or 0.5s.
372 const n = rnd() < 0.5 ? 1 : 2
373 let sh = ''
374 for (let i = 0; i < n; i++) {
375 const w = Math.round(24 + rnd() * 36)
376 const per = rnd() < 0.6 ? 1 : 0.5
377 const op = f2(0.35 + rnd() * 0.35)
378 sh += `<rect class="pbm-sh${i} pbm-an" x="${-w}" width="${w}" height="${BAR_H}" fill="url(#pbm-shine)" opacity="${op}"/>`
379 css +=
380 `.pbm-sh${i}{animation:pbm-sh${i}k ${per}s linear ${f2(-rnd() * per)}s infinite}` +
381 `@keyframes pbm-sh${i}k{from{transform:translateX(0px)}to{transform:translateX(${fillW + w}px)}}`
382 }
383 // One comet running inside the fill, as in working mode.
384 const cm = comet(0, clamp(fillW * (0.3 + rnd() * 0.2), 12, BAR_W * 0.28), fillW, rnd() < 0.5 ? 0.5 : 1, rnd, look, 'pbm-cg', '#378ADD')
385 body += `<g clip-path="url(#pbm-fclip)">${sh}${cm.svg}</g>`
386 css += cm.css + PULSE_KF
387 // Sparks near the fill head (left of the pill), and a pulsing cap on it:
388 // in px around the pill's anchor (the head itself with no pill).
389 const headX = pillText ? Math.max(CELL * 2, fillW - pillW) : fillW
390 headAt = pillText ? pillAt : fillW
391 const sp = sparks(rnd, headX - 48, headX, spIdx, 3 + Math.floor(rnd() * 4), headAt)
392 head = sp.svg + cap(headX - headAt)
393 css += sp.css + SPARK_KF + CAP_CSS
394 }
395 // Tick marks between tasks, only past the fill, only when they fit.
396 const total = Number.isFinite(m.ticks) ? Math.floor(m.ticks) : 0
397 if (total > 1 && total * 6 <= BAR_W) {
398 const d: string[] = []
399 for (let i = 1; i < total; i++) {
400 const x = Math.round((BAR_W * i) / total)
401 if (x > fillW + 4) d.push(`M${x} ${BAR_H / 2 - 5}h1.5v10h-1.5z`)
402 }
403 if (d.length) body += `<path class="pbm-tk" d="${d.join('')}"/>`
404 }
405 if (pillText) {
406 const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
407 pill = pillSvg(pillX - pillAt, colorAt(FLOW_STOPS, (pillX + pillW / 2) / BAR_W))
408 }
409 } else if (m.mode === 'working') {
410 // The whole track is alive. A repeating violet → pink → amber gradient,
411 // one period per bar width, flows left → right in 1s, seen through a
412 // twinkling dithered cell mask. Indeterminate (ratio 0): the field is
413 // faint, with 2-3 comets crossing at different speeds and sparks popping.
414 // Determinate (ratio > 0, an estimate): the fill is dense with a shimmer
415 // and a comet inside it, a pulsing cap and sparks at its head; the rest of
416 // the track keeps a very faint field.
417 // Whole percent, as the right text rounds it (a task list moves in steps anyway).
418 const ratio = wholePercent(m.ratio)
419 const fillW = unit(m.ratio) > 0 ? Math.max(CELL * 2, Math.round(BAR_W * ratio)) : 0
420 const fillCols = Math.floor(fillW / CELL)
421 const allCols = Math.floor(BAR_W / CELL)
422 const P = Math.max(60, BAR_W)
423 defs +=
424 gradient('pbm-wrep', WORK_CYCLE, P, true) +
425 cometGrad('pbm-cg', '#E36FB0', '#F5B731') +
426 `<mask id="pbm-wm" maskUnits="userSpaceOnUse" x="0" y="0" width="${BAR_W}" height="${BAR_H}"><g fill="#ffffff">` +
427 cells(0, allCols, 'pbm-w', c => (c < fillCols ? 0.8 + 0.15 * (c / Math.max(1, fillCols)) : 0.45), look) +
428 '</g></mask>'
429 css +=
430 twinkleCss('pbm-w', look, rnd, 0.2, 0.6) +
431 `.pbm-fl{animation:pbm-flow 1s linear infinite}@keyframes pbm-flow{from{transform:translateX(0px)}to{transform:translateX(${P}px)}}` +
432 SPARK_KF +
433 PULSE_KF
434 const field = (op: string): string =>
435 `<g mask="url(#pbm-wm)"${op ? ` opacity="${op}"` : ''}><rect class="pbm-fl pbm-an" x="${-P}" width="${BAR_W + P}" height="${BAR_H}" fill="url(#pbm-wrep)"/></g>`
436 if (fillW === 0) {
437 body += field('.35')
438 const n = rnd() < 0.5 ? 2 : 3
439 const reverse = (seedN & 1) === 1
440 for (let i = 0; i < n; i++) {
441 const per = i === 0 ? 1 : i === 1 ? 0.5 : rnd() < 0.5 ? 0.5 : 1
442 const cm = comet(i, BAR_W * (0.12 + rnd() * 0.16), BAR_W, per, rnd, look, 'pbm-cg', '#E36FB0', reverse && i === n - 1)
443 body += cm.svg
444 css += cm.css
445 }
446 const sp = sparks(rnd, 0, BAR_W, spIdx, 6 + Math.floor(rnd() * 5))
447 body += sp.svg
448 css += sp.css
449 } else {
450 defs +=
451 SHINE +
452 `<clipPath id="pbm-fclip"><rect width="${fillW}" height="${BAR_H}" rx="${BAR_R}"/></clipPath>` +
453 `<clipPath id="pbm-eclip"><rect x="${fillW}" width="${Math.max(0, BAR_W - fillW)}" height="${BAR_H}"/></clipPath>`
454 const w = Math.round(24 + rnd() * 30)
455 const cm = comet(0, clamp(fillW * (0.3 + rnd() * 0.2), 12, BAR_W * 0.28), fillW, rnd() < 0.5 ? 0.5 : 1, rnd, look, 'pbm-cg', '#E36FB0')
456 body +=
457 `<g clip-path="url(#pbm-eclip)">${field('.12')}</g>` +
458 `<g clip-path="url(#pbm-fclip)">${field('')}` +
459 `<rect class="pbm-sh0 pbm-an" x="${-w}" width="${w}" height="${BAR_H}" fill="url(#pbm-shine)" opacity="${f2(0.4 + rnd() * 0.25)}"/>` +
460 `${cm.svg}</g>`
461 css +=
462 `.pbm-sh0{animation:pbm-sh0k .5s linear ${f2(-rnd() * 0.5)}s infinite}` +
463 `@keyframes pbm-sh0k{from{transform:translateX(0px)}to{transform:translateX(${fillW + w}px)}}` +
464 cm.css +
465 CAP_CSS
466 // Sparks just ahead of the head, then the cap on it, in px around the head.
467 headAt = fillW
468 const sp = sparks(rnd, fillW + CELL, Math.min(BAR_W, fillW + 36), spIdx, 3 + Math.floor(rnd() * 3), headAt)
469 head = sp.svg + cap(0)
470 css += sp.css
471 }
472 if (pillText) pill = pillSvg(0, ACCENT)
473 } else if (m.isDone) {
474 // A finished flow: green dither with a slow, faint glow at a random phase.
475 // Idle after it: no pill. A flow completed by its runs (mode flow): the
476 // pill at the full bar's head, green.
477 const cols = Math.floor(BAR_W / CELL)
478 body += `<g fill="${DONE}">${cells(0, cols, 'pbm-g', ramp(cols), look)}</g>`
479 for (let i = 0; i < look.groups; i++) {
480 css += `.pbm-g${i}{animation:pbm-glow 4s ease-in-out ${f2(-rnd() * 4)}s infinite}`
481 }
482 css += '@keyframes pbm-glow{0%,100%{opacity:1}50%{opacity:.82}}'
483 if (m.mode === 'flow' && pillText) {
484 pillAt = BAR_W
485 pill = pillSvg(-pillW, DONE)
486 }
487 }
488
489 const textY = f2(H / 2 + TITLE_SIZE * 0.36)
490 const rightY = f2(H / 2 + RIGHT_SIZE * 0.36)
491 // Ends 1px short of the real right edge.
492 const rightText = right
493 ? `<text class="pbm-m" x="100%" y="${rightY}" transform="translate(-1,0)" text-anchor="end" font-family="${FONT}" font-size="${RIGHT_SIZE}" font-variant-numeric="tabular-nums" style="font-variant-numeric:tabular-nums">${xml(right)}</text>`
494 : ''
495
496 // The bar's span in percentages of the real width, as designed at W; a
497 // design x along the bar (0..BAR_W) lands at pct((BAR_X + x) / W).
498 const span = `x="${pct(BAR_X / W)}" y="${y0}" width="${pct(BAR_W / W)}" height="${BAR_H}"`
499 // A zero-width viewport at design x `at` along the bar: its content is
500 // drawn in px around it, unscaled.
501 const anchored = (at: number, inner: string): string =>
502 inner ? `<svg x="${pct((BAR_X + at) / W)}" y="${y0}" width="1" height="${BAR_H}" overflow="visible">${inner}</svg>` : ''
503 // The insides, stretched over the span horizontally only: never any text.
504 const insides = body
505 ? `<svg ${span} viewBox="0 0 ${BAR_W} ${BAR_H}" preserveAspectRatio="none">${defs ? `<defs>${defs}</defs>` : ''}${body}</svg>`
506 : ''
507 const clipped = insides || head ? `\n<g clip-path="url(#pbm-clip)">${insides}${anchored(headAt, head)}</g>` : ''
508
509 return `${pxSvgOpen(H)}
510<style>${savvyClasses('pbm')}${css}${REDUCED}</style>
511<defs><clipPath id="pbm-clip"><rect ${span} rx="${BAR_R}"/></clipPath></defs>
512<circle cx="${DOT_X}" cy="${H / 2}" r="${DOT_R}" fill="${dotColor(m)}"/>
513${hasBranch ? `<path class="pbm-br" d="M${DOT_X} ${H / 2 + DOT_R + 2}V${H}" fill="none" stroke="${BRANCH_COLOR}" stroke-opacity="${BRANCH_OPACITY}" stroke-width="${BRANCH_W}" stroke-linecap="round"/>` : ''}
514<text class="pbm-t" x="${TITLE_X}" y="${textY}" font-family="${FONT}" font-size="${TITLE_SIZE}" font-weight="600">${xml(title)}</text>
515<rect class="pbm-k" ${span} rx="${BAR_R}"/>${clipped}
516${anchored(pillAt, pill)}
517${rightText}
518</svg>`
519}
520
521const len = (s: string): number => [...s].length
522
523/** The terminal bar, savvy's `█░` band. */
524const barText = (m: MainModel, width: number): string => {
525 if (m.mode === 'working') {
526 const seg = Math.min(3, width)
527 return '█'.repeat(seg) + '░'.repeat(width - seg)
528 }
529 const ratio = m.mode === 'flow' ? unit(m.ratio) : m.isDone ? 1 : 0
530 const filled = clamp(Math.round(width * ratio), 0, width)
531 return '█'.repeat(filled) + '░'.repeat(width - filled)
532}
533
534const TERM_TITLE = 14
535const TERM_RIGHT = 6
536
537export function mainRowTerminal(ui: TerminalUi, m: MainModel, cols: number): JSX.Element {
538 const { Box, Text } = ui
539 // A finished flow, idle after it or completed by its runs: green.
540 const done = m.isDone
541 const idle = m.mode === 'idle' && !m.isDone
542 const color = done ? DONE : idle ? undefined : ACCENT
543 const total = Math.max(12, Math.floor(Number.isFinite(cols) ? cols : 80))
544 // Fixed slots, as on the desktop: title up to 14 cols, right text up to 6,
545 // so the bar width does not follow the texts' length.
546 const pillW = m.pill ? len(m.pill) : 0
547 // Dot (2) plus the gaps between title, bar, label (when shown) and right text, plus one spare.
548 const fixed = m.pill ? 6 + pillW : 5
549 const rightW = m.right ? TERM_RIGHT : 0
550 const titleW = Math.max(Math.min(4, TERM_TITLE), Math.min(TERM_TITLE, total - fixed - 6 - rightW))
551 const barW = clamp(total - fixed - titleW - rightW, 6, 40)
552
553 return (
554 <Box flexDirection="row" gap={1}>
555 <Box flexDirection="row" flexShrink={0}>
556 <Text color={color} dimColor={idle}>
557 ●{' '}
558 </Text>
559 <Box width={titleW}>
560 <Text wrap="truncate-end">{m.title}</Text>
561 </Box>
562 </Box>
563 <Box flexShrink={0}>
564 <Text color={color} dimColor={idle}>
565 {barText(m, barW)}
566 </Text>
567 </Box>
568 {m.pill ? (
569 <Box flexShrink={0}>
570 <Text bold>{m.pill}</Text>
571 </Box>
572 ) : null}
573 {rightW > 0 ? (
574 <Box width={rightW} flexShrink={1}>
575 <Text dimColor wrap="truncate-end">
576 {m.right}
577 </Text>
578 </Box>
579 ) : null}
580 </Box>
581 )
582}
583hooks/agent-rows.tsx 169 lines1// One row per agent under the main row: one SVG on the desktop, one line on the terminal.
2// savvy-progress's agents-pane colors: the tier colors the running dot and the
3// bar (the accent for a non-savvy agent), a running row's context fallback is
4// grey; a finished row's bar is full green, a failed one's red, with a green
5// check / red cross at the start.
6import type { AgentRowModel } from './model'
7import { BRANCH_COLOR, BRANCH_OPACITY, BRANCH_W, DOT_X } from './main-row'
8import {
9 ACCENT,
10 DONE,
11 FONT,
12 colorOf,
13 fitText,
14 pct,
15 pxSvgOpen,
16 savvyClasses,
17 textWidth,
18 wholePercent,
19 xml,
20 type TerminalUi,
21} from './shared'
22
23/** Where a row hangs on the tree: 'mid' (├─, the trunk goes on) or 'last' (└─). */
24export type Branch = 'mid' | 'last'
25
26export const AGENT_ROW_H = 18
27
28// The pulse only where a running dot uses it: a finished row carries no
29// animation, so a surface treats it as a still picture.
30const rowCss = (isLive: boolean): string =>
31 `<style>${savvyClasses('pba', 'pane')}` +
32 (isLive
33 ? '.pba-live{animation:pba-pulse 1s ease-in-out infinite}@keyframes pba-pulse{50%{opacity:.3}}' +
34 '@media (prefers-reduced-motion: reduce){.pba-live{animation:none}}'
35 : '') +
36 '</style>'
37
38const OK = '#3B9C5F'
39const KO = '#D0453F'
40
41// Geometry of a row, left to right: the trunk under the main dot, the elbow
42// out to ELBOW_X, then the status mark, title, bar and right text.
43const INDENT = 16
44const ELBOW_X = 16
45const CORNER_R = 4
46const MARK_X = 22
47const TITLE_X = 31
48const GAP = 10
49const BAR_MIN = 60
50const BAR_H = 4
51const MID_Y = AGENT_ROW_H / 2
52const TEXT_Y = 13
53
54// The right slot is at least this wide so bars line up from row to row.
55const RIGHT_MIN = textWidth('ctx 100% · 999k tok · 00:00', 11)
56
57const statusMark = (status: AgentRowModel['status'], color: string): string => {
58 const x = MARK_X
59 const y = MID_Y
60 if (status === 'running') return `<circle class="pba-live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
61 if (status === 'done')
62 return `<path d="M${x - 4.5} ${y}l3 3 6-6.5" fill="none" stroke="${OK}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
63 return `<path d="M${x - 3.5} ${y - 3.5}l7 7M${x + 3.5} ${y - 3.5}l-7 7" fill="none" stroke="${KO}" stroke-width="1.8" stroke-linecap="round"/>`
64}
65
66const clamp01 = (n: number): number => (Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0)
67
68/**
69 * The bar's color (and the running dot's): green once done and red once
70 * failed, whatever the tier; while running the tier's, the accent for a
71 * non-savvy agent (its tier `other`). Desktop and terminal alike.
72 */
73const barColor = (r: AgentRowModel): string =>
74 r.status === 'done' ? DONE : r.status === 'failed' ? KO : r.tier === 'other' ? ACCENT : colorOf(r.tier)
75
76/** Only a running row's context fallback is drawn grey; a finished row takes its status color. */
77const isGreyBar = (r: AgentRowModel): boolean => r.isContext && r.status === 'running'
78
79const rightText = (r: AgentRowModel): string => `${r.stat} · ${r.tokens} tok · ${r.time}`
80
81const ELBOW_COLOR: Record<AgentRowModel['status'], string> = { running: BRANCH_COLOR, done: '#5fbf8f', failed: KO }
82
83/**
84 * The tree connector: the trunk (accent) at the main dot's x, spanning the
85 * full row for 'mid' so consecutive rows join, down to the corner for 'last';
86 * the elbow out to ELBOW_X in the status color.
87 */
88const branchSvg = (branch: Branch, status: AgentRowModel['status']): string => {
89 const line = (d: string, color: string): string =>
90 `<path class="pba-br" d="${d}" fill="none" stroke="${color}" stroke-opacity="${BRANCH_OPACITY}" stroke-width="${BRANCH_W}" stroke-linecap="round" stroke-linejoin="round"/>`
91 const elbow = ELBOW_COLOR[status]
92 if (branch === 'mid') return line(`M${DOT_X} 0V${AGENT_ROW_H}`, BRANCH_COLOR) + line(`M${DOT_X} ${MID_Y}H${ELBOW_X}`, elbow)
93 const cy = MID_Y - CORNER_R
94 return (
95 line(`M${DOT_X} 0V${cy}`, BRANCH_COLOR) +
96 line(`M${DOT_X} ${cy}Q${DOT_X} ${MID_Y} ${DOT_X + CORNER_R} ${MID_Y}H${ELBOW_X}`, elbow)
97 )
98}
99
100/**
101 * One agent row, AGENT_ROW_H px tall and as wide as its slot (pxSvgOpen), its
102 * geometry designed at `width` (the estimated column width). The tree, the
103 * status mark and the title keep their px at the left; the right text ends
104 * 4px short of the real right edge (`x="100%"`); the bar (track and fill, no
105 * text) spans in percentages of the real width, as designed at `width`, so a
106 * wider column draws a longer bar. On the desktop the rows sit in the column
107 * left of the divider. A pure function of the row model: a running row's time
108 * comes in whole minutes there (buildAgentRows' timeStyle), so a redraw keeps
109 * the same source.
110 */
111export function agentRowSvg(r: AgentRowModel, width: number, branch: Branch = 'last'): string {
112 const W = Math.max(INDENT + 40, Math.round(width))
113 const color = barColor(r)
114 const right = rightText(r)
115 const rightW = Math.ceil(Math.max(RIGHT_MIN, textWidth(right, 11)))
116 const rightX = W - 4
117 // Title slot: ~35% of the content, shrunk when the bar would fall under its minimum.
118 const room = rightX - rightW - GAP - TITLE_X - GAP
119 const titleSlot = Math.max(0, Math.min(Math.round((rightX + 2) * 0.35), room - BAR_MIN))
120 const barX = TITLE_X + titleSlot + GAP
121 const barW = Math.max(0, rightX - rightW - GAP - barX)
122 // Whole percent, as `ctx N%` reads: a context creeping by a few tokens moves nothing.
123 const fillW = Math.round(barW * wholePercent(r.progress))
124 const barY = MID_Y - BAR_H / 2
125 const title = titleSlot > 12 ? fitText(r.title, 12, titleSlot) : ''
126 // Without reported steps a running bar falls back to the context, drawn grey.
127 const fillPaint = isGreyBar(r) ? 'class="pba-m"' : `fill="${color}"`
128 // A design x or width as a share of the real width.
129 const share = (x: number): string => pct(x / W)
130 const fill = fillW > 0 ? `<rect ${fillPaint} x="${share(barX)}" y="${barY}" width="${share(fillW)}" height="${BAR_H}" rx="2"/>` : ''
131 const body =
132 branchSvg(branch, r.status) +
133 statusMark(r.status, color) +
134 (title
135 ? `<text class="pba-t" x="${TITLE_X}" y="${TEXT_Y}" font-family="${FONT}" font-size="12">${xml(title)}</text>`
136 : '') +
137 (barW > 0 ? `<rect class="pba-k" x="${share(barX)}" y="${barY}" width="${share(barW)}" height="${BAR_H}" rx="2"/>${fill}` : '') +
138 `<text class="pba-s" x="100%" y="${TEXT_Y}" transform="translate(${rightX - W},0)" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums" style="font-variant-numeric:tabular-nums">${xml(right)}</text>`
139 return `${pxSvgOpen(AGENT_ROW_H)}${rowCss(r.status === 'running')}${body}</svg>`
140}
141
142const GLYPH: Record<AgentRowModel['status'], string> = { running: '●', done: '✓', failed: '✗' }
143
144const cut = (s: string, max: number): string => {
145 const chars = [...s]
146 if (chars.length <= max) return s
147 return chars.slice(0, Math.max(0, max - 1)).join('') + '…'
148}
149
150export function agentRowTerminal(ui: TerminalUi, r: AgentRowModel, cols: number, branch: Branch = 'last'): JSX.Element {
151 const { Text } = ui
152 const color = barColor(r)
153 const glyphColor = r.status === 'failed' ? 'red' : r.status === 'done' ? 'green' : color
154 const right = rightText(r)
155 // Fixed title and bar widths, so the bars line up from row to row.
156 const titleW = Math.max(4, Math.floor(cols * 0.4))
157 const title = cut(r.title, titleW).padEnd(titleW)
158 const barW = Math.max(6, Math.min(20, cols - titleW - 34))
159 const filled = Math.round(barW * clamp01(r.progress))
160 const bar = '█'.repeat(filled) + '░'.repeat(Math.max(0, barW - filled))
161 return (
162 <Text wrap="truncate-end">
163 <Text dimColor>{branch === 'mid' ? '├─ ' : '└─ '}</Text>
164 <Text color={glyphColor}>{GLYPH[r.status]}</Text> {title}{' '}
165 {isGreyBar(r) ? <Text dimColor>{bar}</Text> : <Text color={color}>{bar}</Text>} <Text dimColor>{right}</Text>
166 </Text>
167 )
168}
169hooks/ram.ts 195 lines1// System RAM in use: the probe to run per platform and the parser of its
2// output. Pure: register.tsx runs the probe ($.process.run / $.fs.read) and
3// hands the text here. No `$` here.
4
5export type RamInfo = { usedBytes: number; totalBytes: number }
6/**
7 * Which parser reads a probe's output. 'typeperf' gives only the available
8 * memory: it needs the total of an earlier reading.
9 */
10export type RamFormat = 'win32' | 'typeperf' | 'darwin' | 'linux'
11export type RamProbe =
12 | { kind: 'run'; argv: string[]; format: RamFormat; timeoutMs: number }
13 | { kind: 'file'; path: string; format: RamFormat }
14
15/** How often register.tsx re-reads the RAM figures. */
16export const RAM_POLL_MS = 10000
17/** A failed poll stays silent while the last good reading is younger than this. */
18export const RAM_GRACE_MS = 60000
19
20const WIN_SCRIPT =
21 '$o=Get-CimInstance Win32_OperatingSystem; "$($o.TotalVisibleMemorySize) $($o.FreePhysicalMemory)"'
22
23// typeperf's counter path is localized: English first, then Italian.
24const TYPEPERF_COUNTERS = ['\\Memory\\Available MBytes', '\\Memoria\\MByte disponibili']
25
26/**
27 * What to run or read on `platform` ('win32' | 'darwin' | 'linux'), in the
28 * order to try them: register.tsx moves on to the next when one rejects,
29 * exits non-zero or does not parse. Empty elsewhere.
30 */
31export function ramProbes(platform: string): RamProbe[] {
32 switch (platform) {
33 case 'win32':
34 return [
35 {
36 kind: 'run',
37 argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', WIN_SCRIPT],
38 format: 'win32',
39 // A cold PowerShell start can take several seconds.
40 timeoutMs: 15000,
41 },
42 ...TYPEPERF_COUNTERS.map(
43 (counter): RamProbe => ({
44 kind: 'run',
45 argv: ['typeperf.exe', counter, '-sc', '1'],
46 format: 'typeperf',
47 timeoutMs: 10000,
48 }),
49 ),
50 ]
51 case 'darwin':
52 return [{ kind: 'run', argv: ['sh', '-c', 'sysctl -n hw.memsize; vm_stat'], format: 'darwin', timeoutMs: 4000 }]
53 case 'linux':
54 return [{ kind: 'file', path: '/proc/meminfo', format: 'linux' }]
55 default:
56 return []
57 }
58}
59
60const lines = (s: string): string[] =>
61 s
62 .split(/\r?\n|\r/)
63 .map(l => l.trim())
64 .filter(Boolean)
65
66const info = (total: number, used: number): RamInfo | null =>
67 Number.isFinite(total) && Number.isFinite(used) && total > 0
68 ? { totalBytes: total, usedBytes: Math.min(total, Math.max(0, used)) }
69 : null
70
71function parseWin(out: string): RamInfo | null {
72 // "<TotalVisibleMemorySize KB> <FreePhysicalMemory KB>"
73 for (const l of lines(out)) {
74 const m = /^(\d+)\s+(\d+)$/.exec(l)
75 if (!m) continue
76 const total = Number(m[1]) * 1024
77 const free = Number(m[2]) * 1024
78 if (free > total) return null
79 return info(total, total - free)
80 }
81 return null
82}
83
84/**
85 * typeperf's CSV (`-sc 1`): a header line, then `"<timestamp>","<value>"`,
86 * among blank and localized status lines. The last CSV line's last quoted
87 * value is the available memory in MB; null when it is not a number (no
88 * sample, header only, an error message).
89 */
90export function parseTypeperfAvailableMB(out: string): number | null {
91 if (typeof out !== 'string') return null
92 const csv = lines(out).filter(l => l.startsWith('"'))
93 const last = csv[csv.length - 1]
94 if (!last) return null
95 const values = [...last.matchAll(/"([^"]*)"/g)].map(m => m[1] ?? '')
96 const v = (values[values.length - 1] ?? '').trim().replace(/^(\d+),(\d+)$/, '$1.$2')
97 return /^\d+(\.\d+)?$/.test(v) ? Number(v) : null
98}
99
100function parseTypeperf(out: string, totalBytes: number | null): RamInfo | null {
101 const mb = parseTypeperfAvailableMB(out)
102 if (mb === null || totalBytes === null || !(totalBytes > 0)) return null
103 const avail = mb * 1024 * 1024
104 if (avail > totalBytes) return null
105 return info(totalBytes, totalBytes - avail)
106}
107
108function parseMac(out: string): RamInfo | null {
109 const ls = lines(out)
110 const memLine = ls.find(l => /^\d+$/.test(l))
111 const pageM = /page size of (\d+) bytes/i.exec(out)
112 if (!memLine || !pageM) return null
113 const pages = (label: string): number | null => {
114 const re = new RegExp(`^Pages ${label}:\\s*(\\d+)\\.?$`, 'i')
115 for (const l of ls) {
116 const m = re.exec(l)
117 if (m) return Number(m[1])
118 }
119 return null
120 }
121 const active = pages('active')
122 const wired = pages('wired down')
123 const compressed = pages('occupied by compressor')
124 if (active === null || wired === null || compressed === null) return null
125 return info(Number(memLine), (active + wired + compressed) * Number(pageM[1]))
126}
127
128function parseLinux(out: string): RamInfo | null {
129 const kb = (key: string): number | null => {
130 const m = new RegExp(`^${key}:\\s*(\\d+)\\s*kB$`, 'im').exec(out.replace(/\r/g, ''))
131 return m ? Number(m[1]) * 1024 : null
132 }
133 const total = kb('MemTotal')
134 const avail = kb('MemAvailable')
135 if (total === null || avail === null || avail > total) return null
136 return info(total, total - avail)
137}
138
139/**
140 * The probe's output (stdout, or the file's text) as used/total bytes; null
141 * when unreadable. `format` is the probe's (a platform name, or 'typeperf',
142 * which reads only the available memory and takes the total as
143 * `knownTotalBytes`, from an earlier reading: null without one).
144 */
145export function parseRam(format: string, output: string, knownTotalBytes: number | null = null): RamInfo | null {
146 if (typeof output !== 'string' || !output.trim()) return null
147 switch (format) {
148 case 'win32':
149 return parseWin(output)
150 case 'typeperf':
151 return parseTypeperf(output, knownTotalBytes)
152 case 'darwin':
153 return parseMac(output)
154 case 'linux':
155 return parseLinux(output)
156 default:
157 return null
158 }
159}
160
161const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
162
163function unitOf(n: number): number {
164 let i = 0
165 while (i < UNITS.length - 1 && n >= 1024 ** (i + 1)) i++
166 return i
167}
168
169const dec1 = (v: number): string => v.toFixed(1).replace(/\.0$/, '')
170
171/** Base 1024: 15.2 GB, 16 GB, 812 MB, 512 B. GB and up keep one decimal (dropped when .0). */
172export function fmtBytes(n: number): string {
173 const v = Number.isFinite(n) ? Math.max(0, n) : 0
174 const i = unitOf(v)
175 const x = v / 1024 ** i
176 return `${i >= 3 ? dec1(x) : String(Math.round(x))} ${UNITS[i]}`
177}
178
179/** "9.8/16 GB": used and total in the total's unit; the total rounded past 10. */
180export function fmtRamPair(r: RamInfo): string {
181 const i = unitOf(r.totalBytes)
182 const t = r.totalBytes / 1024 ** i
183 const u = r.usedBytes / 1024 ** i
184 const total = t >= 10 ? String(Math.round(t)) : dec1(t)
185 const used = i >= 3 ? dec1(u) : String(Math.round(u))
186 return `${used}/${total} ${UNITS[i]}`
187}
188
189/** used/total in [0, 1]. */
190export const ramRatio = (r: RamInfo): number =>
191 r.totalBytes > 0 ? Math.min(1, Math.max(0, r.usedBytes / r.totalBytes)) : 0
192
193/** Load color: green under 60%, amber under 85%, red past. */
194export const ramColor = (ratio: number): string => (ratio < 0.6 ? '#1D9E75' : ratio < 0.85 ? '#F5B731' : '#D0453F')
195hooks/ram-card.tsx 166 lines1// The RAM card beside the main bar: one small SVG on the desktop, one short
2// line on the terminal. savvy-progress look (track card, muted label, load
3// colored mini bar, tabular figures). No `$` here.
4import { fmtRamPair, ramColor, ramRatio, type RamInfo } from './ram'
5import { fitText, pxSvgOpen, textWidth, xml, type TerminalUi } from './shared'
6
7export const RAM_CARD_W = 128
8export const RAM_CARD_H = 30
9
10const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
11const PAD = 8
12const GLYPH_W = 11
13const GLYPH_H = 7
14const MIN_BAR = 12
15// Below these widths the card sheds the chip icon, then everything but the percent.
16const ICON_MIN_W = 110
17const PAIR_MIN_W = 80
18
19const STYLE =
20 '<style>' +
21 '.k{fill:#ecebe8}.s{fill:#6b6b68}.ss{stroke:#6b6b68}.t{fill:#1f1f1f}.bt{fill:#dad8d3}' +
22 '@media (prefers-color-scheme: dark){.k{fill:#2c2c2b}.s{fill:#a8a8a4}.ss{stroke:#a8a8a4}.t{fill:#ececec}.bt{fill:#3d3d3b}}' +
23 `text{font-family:${FONT};font-size:11px;font-variant-numeric:tabular-nums}` +
24 '</style>'
25
26// A memory chip: a small outline with three pins above and below, its body centred on `cy`.
27const glyph = (cy: number): string => {
28 const x = PAD
29 const y = Math.round((cy - GLYPH_H / 2) * 2) / 2
30 const pins = [2.5, 5.5, 8.5]
31 .map(dx => `M${x + dx} ${y - 2}v2M${x + dx} ${y + GLYPH_H + 1}v2`)
32 .join('')
33 return (
34 `<rect class="ss" x="${x + 0.5}" y="${y + 0.5}" width="${GLYPH_W - 1}" height="${GLYPH_H}" rx="1.5" fill="none" stroke-width="1"/>` +
35 `<path class="ss" d="${pins}" stroke-width="1" fill="none"/>`
36 )
37}
38
39/**
40 * What a reader is told of the RAM figures (an Svg's alt, an aria-label):
41 * always short, never the reason a reading failed. `RAM 9.8/16 GB`, `RAM ?`
42 * after a failure, `RAM` before any reading.
43 */
44export function ramAltText(r: RamInfo | null, error: string | null = null): string {
45 return r ? `RAM ${fmtRamPair(r)}` : error ? 'RAM ?' : 'RAM'
46}
47
48// The card's elements, without an <svg> wrapper or its <style>: drawn at the
49// origin, `w` × `h`. Shared by the standalone card and the right zone.
50function ramCardBody(r: RamInfo | null, w: number, h: number, isPercentOnly: boolean, error: string | null): string {
51 const cy = h / 2
52 const baseline = (cy + 4).toFixed(1)
53 const hasIcon = w >= ICON_MIN_W && !isPercentOnly
54 const barX = hasIcon ? PAD + GLYPH_W + 6 : PAD
55 const textR = w - PAD
56 const head =
57 `<rect id="pbram-card" class="k" x="0" y="0" width="${w}" height="${h}" rx="6"/>` + (hasIcon ? glyph(cy) : '')
58 if (!r) return `${head}<text id="pbram-label" class="s" x="${barX}" y="${baseline}">${error ? 'RAM ?' : 'RAM –'}</text>`
59
60 const ratio = ramRatio(r)
61 if (w < PAIR_MIN_W || isPercentOnly) {
62 // Only the percent, centred and colored by load.
63 const pct = `${Math.round(ratio * 100)}%`
64 return (
65 head +
66 `<text id="pbram-text" x="${(w / 2).toFixed(1)}" y="${baseline}" text-anchor="middle" fill="${ramColor(ratio)}">${xml(pct)}</text>`
67 )
68 }
69 const maxText = textR - barX - MIN_BAR - 6
70 const label = fitText(fmtRamPair(r), 11, maxText)
71 const barW = Math.max(MIN_BAR, textR - textWidth(label, 11) - 6 - barX)
72 const fillW = Math.round(barW * ratio * 10) / 10
73 const barY = (cy - 2).toFixed(1)
74 return (
75 head +
76 `<rect id="pbram-track" class="bt" x="${barX}" y="${barY}" width="${barW.toFixed(1)}" height="4" rx="2"/>` +
77 (fillW > 0
78 ? `<rect id="pbram-fill" x="${barX}" y="${barY}" width="${Math.max(2, fillW).toFixed(1)}" height="4" rx="2" fill="${ramColor(ratio)}"/>`
79 : '') +
80 `<text id="pbram-text" class="t" x="${textR}" y="${baseline}" text-anchor="end">${xml(label)}</text>`
81 )
82}
83
84/**
85 * The desktop card, `width` × `height` (RAM_CARD_W × RAM_CARD_H by default):
86 * chip icon, a load bar taking the free space, `9.8/16 GB`. Under ~110px it
87 * drops the icon, under ~80px (or with `isPercentOnly`) it shows only the
88 * percent; "RAM –" with no reading yet, "RAM ?" when `error` is set and there
89 * is no reading (its aria-label stays short: ramAltText).
90 */
91export function ramCardSvg(
92 r: RamInfo | null,
93 width = RAM_CARD_W,
94 height = RAM_CARD_H,
95 isPercentOnly = false,
96 error: string | null = null,
97): string {
98 const w = Math.max(24, Math.round(width))
99 const h = Math.max(14, Math.round(height))
100 return (
101 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" ` +
102 `viewBox="0 0 ${w} ${h}" role="img" aria-label="${xml(ramAltText(r, error))}">` +
103 STYLE +
104 ramCardBody(r, w, h, isPercentOnly, error) +
105 '</svg>'
106 )
107}
108
109const TERM_BAR = 5
110
111/** The terminal line: `RAM ▕███░░▏ 9.8/16 GB`, the bar colored by load; `RAM ?` after a failed probe, `RAM –` before any. */
112export function ramCardTerminal(ui: TerminalUi, r: RamInfo | null, error: string | null = null): JSX.Element {
113 const { Text } = ui
114 if (!r) return <Text dimColor>{error ? 'RAM ?' : 'RAM –'}</Text>
115 const ratio = ramRatio(r)
116 const n = Math.min(TERM_BAR, Math.max(0, Math.round(ratio * TERM_BAR)))
117 const color = ramColor(ratio)
118 return (
119 <Text>
120 <Text dimColor>RAM ▕</Text>
121 {n ? <Text color={color}>{'█'.repeat(n)}</Text> : null}
122 {n < TERM_BAR ? <Text dimColor>{'░'.repeat(TERM_BAR - n)}</Text> : null}
123 <Text dimColor>▏ </Text>
124 <Text color={color}>{fmtRamPair(r)}</Text>
125 </Text>
126 )
127}
128
129// The right zone's divider: light/dark colors that stay visible on either band background.
130const ZONE_STYLE =
131 '<style>.pbz-l{stroke:#c8c7c2}@media (prefers-color-scheme: dark){.pbz-l{stroke:#5c5b57}}</style>'
132// Where the card's area starts, right of the divider.
133const ZONE_LEFT = 8
134const ZONE_CARD_MAX = 160
135
136/**
137 * The desktop band's right zone, `height` px tall and as wide as its slot
138 * (pxSvgOpen): a 1px divider down its left edge (the whole band tall) and
139 * the RAM card, top-aligned with the main bar (RAM_CARD_H tall) and centered
140 * in the area right of the divider at the zone's real width. The card keeps
141 * its px: its width is designed at `width` (the estimated zone), its center
142 * sits at 50% of the real width plus half the divider's area (a zero-width
143 * <svg> at 50%, the card drawn around it).
144 *
145 * Styles at the root; the card's elements in a translated <g>.
146 */
147export function rightZoneSvg(r: RamInfo | null, error: string | null, width: number, height: number): string {
148 const w = Math.max(ZONE_LEFT + 24, Math.round(width))
149 const h = Math.max(RAM_CARD_H, Math.round(height))
150 const area = w - ZONE_LEFT
151 const cardW = Math.max(24, Math.min(ZONE_CARD_MAX, area - 8))
152 // The area's middle is ZONE_LEFT / 2 right of the zone's: the card's left edge off 50%.
153 const cardDx = Math.round(ZONE_LEFT / 2 - cardW / 2)
154 return (
155 pxSvgOpen(h, ` role="img" aria-label="${xml(ramAltText(r, error))}"`) +
156 ZONE_STYLE +
157 STYLE +
158 `<line id="pbz-line" class="pbz-l" x1="0.5" y1="2" x2="0.5" y2="${h - 2}" stroke-width="1"/>` +
159 `<svg x="50%" y="0" width="1" height="${RAM_CARD_H}" overflow="visible">` +
160 `<g id="pbz-card" transform="translate(${cardDx},0)">` +
161 ramCardBody(r, cardW, RAM_CARD_H, false, error) +
162 '</g></svg>' +
163 '</svg>'
164 )
165}
166types/index.d.ts 97 lines1/** A moment in ms on `$.clock`, or null before the first one. */
2export type ProgressBarMoment = number | null
3
4/** The phase of a planned multi-step task (a /savvy-flow run). */
5export type ProgressBarPhase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
6
7/** One planned worker task, numbered from 1 in the plan's order. */
8export type ProgressBarPlannedTask = {
9 title: string
10 tier: string
11 /** Numbers of the tasks this one waits for. */
12 after: number[]
13}
14
15/** The planned multi-step task, as reported through the `progress` tool. */
16export type ProgressBarFlow = {
17 title: string
18 total: number
19 done: number
20 /** Workers launched and not yet returned. */
21 running: number
22 phase: ProgressBarPhase
23 isFinished: boolean
24 tasks: ProgressBarPlannedTask[]
25}
26
27export type ProgressBarAgentStatus = 'running' | 'done' | 'failed'
28
29/** One subagent run of this session. */
30export type ProgressBarAgentRun = {
31 id: string
32 agentId?: string
33 type: string
34 description: string
35 model: string
36 effort?: string
37 status: ProgressBarAgentStatus
38 startedAt: number
39 endedAt?: number
40 contextTokens: number
41 contextMax: number
42 tokens: number
43 costUsd: number
44 steps: number
45 round: number
46 /** Self-reported by the worker through the `step` tool. */
47 stepDone?: number
48 stepTotal?: number
49 stepNote?: string
50}
51
52/** System RAM in use, in bytes, as the RAM card reads it. */
53export type ProgressBarRam = { usedBytes: number; totalBytes: number }
54
55export type ProgressBarPlanStatus = 'pending' | 'in_progress' | 'completed'
56
57/** One item of the main loop's task list. */
58export type ProgressBarPlanItem = { title: string; status: ProgressBarPlanStatus }
59
60/**
61 * The main loop's task list, as seen in its tool calls (the desktop's
62 * `mcp__ccd_turn__update_plan`, the CLI's `TodoWrite`), in order.
63 */
64export type ProgressBarPlan = {
65 items: ProgressBarPlanItem[]
66 /** When it last changed ($.clock time). */
67 at: number
68}
69
70
71declare module 'claude-code' {
72 interface PluginState {
73 'progress-bar': {
74 /** The planned multi-step task, or null before the first report. */
75 flow: ProgressBarFlow | null
76 /** The subagent runs of this session, oldest first. */
77 agents: ProgressBarAgentRun[]
78 /** The main loop's task list; kept until replaced, cleared when a new turn starts after it was completed. */
79 plan: ProgressBarPlan | null
80 /** When the last main-loop prompt was submitted. */
81 startedAt: ProgressBarMoment
82 /** When the last main-loop turn completed. */
83 endedAt: ProgressBarMoment
84 /** Main-loop tool calls since the last prompt. */
85 tools: number
86 /** Clock for elapsed times, ticked once a second only while something runs. */
87 now: number
88 /** Whether the per-agent rows are shown under the main row. */
89 isExpanded: boolean
90 /** System RAM in use, polled every few seconds; null until the first reading or where none can be taken. */
91 ram: ProgressBarRam | null
92 /** Why the last RAM reading failed (every probe tried, e.g. "process.run refused: …", "parse failed"), or null once one succeeds. */
93 ramError: string | null
94 }
95 }
96}
97