SLOPSHOPPER

progress-bar

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

bandguardprompttoolprocesstimer
v0.4.3no licenseupdated 2026-10-21DamianoSrb/Mods/progress-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · progress-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ● Ready ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ last … │ RAM ? ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● Ready ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ last … │ RAM ?
README

progress-bar

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.

Cosa fa

  • Barra colorata e animata, in tre modi:
  • flow: un'attività pianificata in più passi. Titolo, barra a sfumatura viola → blu → verde acqua con una tacca per attività, fase (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%.
  • In lavoro: un turno del modello è in corso. Barra animata viola → rosa → ambra, tempo trascorso e numero di tool chiamati.
  • Pronto: nessun turno in corso. Durata e tool dell'ultimo turno e, se c'era, il flow appena chiuso (✓ <titolo>).
  • Scheda RAM, sempre visibile all'estremità destra della riga (il 20% della riga; su una riga stretta solo la percentuale): memoria usata e totale (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: …).
  • Vista agenti: quando partono dei subagenti compare, tra la barra e la scheda RAM, il pulsante agenti compatto (●▾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.

Tool per il modello

  • 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.

Opzioni

  • language: auto (predefinito: segue la lingua di Claude Code, poi il locale del sistema), it oppure en.

Installazione

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>
Source 8 files
hooks/register.tsx 816 lines
1import { 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}
816
hooks/model.ts 474 lines
1// 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}
474
hooks/shared.ts 285 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
285
hooks/main-row.tsx 583 lines
1// 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}
583
hooks/agent-rows.tsx 169 lines
1// 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}
169
hooks/ram.ts 195 lines
1// 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')
195
hooks/ram-card.tsx 166 lines
1// 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}
166
types/index.d.ts 97 lines
1/** 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