Session row above the prompt in every session (model, cost, tokens, context, 5-hour and 7-day limits, time) and a session panel (Details or /agents-info): cost…

sgs-kit fork (1.2.0-sgs.7) of savvy-progress from johnnyvizz/claude-kit. It adds an always-on session row above the prompt (model, cost, tokens, context fill, the 5-hour and 7-day limits, session time) whose Details button opens the session panel: cost with the main session / subagent split, usage limits with reset times, the context window by category, tokens by kind with the cache hit rate and cost by model, above the subagents. Cost is Claude Code's own ledger (/cost) when the host keeps one, else an API-equivalent estimate from token counts; never a bill. Planned tasks may name their
model/effort, and the panel speaks Turkish (language: tr).
A Claude Code mod: a session row above the prompt, a progress bar and a live panel of the session and its subagents. Made for the savvy-flow skill, and useful with any subagents.
<img src="../../docs/savvy-progress/band.png" alt="The session row above a savvy-flow progress bar at 3 of 6 tasks, with 7 agents" width="1010">
<img src="../../docs/savvy-progress/panel.png" alt="The session panel: cost split, usage limits, context window, tokens, cost by model and the subagents card" width="542">
Drawn by the mod's own rendering code with sample data; more in the repository README.
savvy-* worker starts./agents-info or the row's Details button toggles it): the session cards above, then a subagents card of running, finished and planned subagents with model, effort, task progress, context, estimated cost and time, with Collapse and Hide finished under it. Working crabs walk, and each savvy tier animates its prop: the astronaut floats, the detective sweeps the magnifier, the engineer turns the wrench, the chef tosses the omelette, the racer runs with a fluttering flag. prefers-reduced-motion stops them.mcp__savvy-progress__progress: the orchestrator reports the plan, phase and accepted tasks (each task may name its model and effort).mcp__savvy-progress__step: a worker reports its own steps (done, total, note). A worker that does not report shows its context fill in grey instead.Cost is a rough estimate from token counts and a built-in per-model price table (PRICES in hooks/register.tsx), not a bill.
language: auto (default), en, ru or tr. auto follows Claude Code's language setting, then the system locale, and falls back to English. Set it in /config, or, for a mod loaded by hand, in ~/.claude/settings.json:
{ "pluginConfigs": { "savvy-progress": { "options": { "language": "ru" } } } }
Install instructions are in the repository README.
hooks/register.tsx 1934 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Flow, MainUsage, Meter, Panel, Phase, PlannedTask, RateWindow, TokenSplit } from '../types'
5
6const flow = atom({ plugin: 'savvy-progress', key: 'flow' } as const, null)
7const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [])
8const panel = atom({ plugin: 'savvy-progress', key: 'panel' } as const, {
9 isCompact: false,
10 isDoneCollapsed: false,
11 autoOpenedFor: '',
12})
13const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
14const main = atom({ plugin: 'savvy-progress', key: 'main' } as const, {
15 model: '',
16 tokens: 0,
17 costUsd: 0,
18 steps: 0,
19} as MainUsage)
20const meter = atom({ plugin: 'savvy-progress', key: 'meter' } as const, {
21 startedAt: 0,
22 contextWindow: 0,
23 rateLimits: [],
24} as Meter)
25
26const TOOL = 'mcp__savvy-progress__progress'
27const STEP_TOOL = 'mcp__savvy-progress__step'
28const PANE = 'savvy-agents'
29const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
30const ACCENT = '#8f8cf4'
31const DONE = '#5fbf8f'
32const WARN = '#D9A23A'
33const ALERT = '#D0453F'
34
35type ProgressInput = {
36 title?: string
37 total?: number
38 done?: number
39 phase?: Phase
40 finished?: boolean
41 tasks?: { title?: string; tier?: string; after?: number[]; model?: string; effort?: string }[]
42}
43
44// ---------------------------------------------------------------------------
45// Language: the `language` option, else Claude Code's `language` setting, else the
46// process locale; English when nothing says Russian or Turkish.
47
48type Lang = 'en' | 'ru' | 'tr'
49
50const STRINGS = {
51 en: {
52 pane: 'Session',
53 details: 'Details',
54 context: 'Context',
55 sessionCost: 'Session cost',
56 estimated: 'estimated',
57 elapsed: 'Elapsed',
58 active: 'Active',
59 limits: 'Usage limits',
60 fiveHour: '5-hour window',
61 fiveShort: '5h',
62 sevenDay: '7-day window',
63 sevenShort: '7d',
64 spend: 'Spend limit',
65 spendShort: 'limit',
66 ctxWindow: 'Context window',
67 noReading: 'No reading yet',
68 tokenSplit: 'Tokens',
69 input: 'Input',
70 output: 'Output',
71 cacheRead: 'Cache read',
72 cacheWrite: 'Cache write',
73 cacheHit: 'Cache hit',
74 requests: 'model requests',
75 byModel: 'By model',
76 emptyHint: 'A subagent shows up here with its model, context and cost once it starts.',
77 d: 'd',
78 h: 'h',
79 min: 'min',
80 lessMin: '<1 min',
81 cost: 'Cost',
82 tokens: 'Tokens',
83 time: 'Time',
84 collapse: 'Collapse',
85 expand: 'Expand',
86 running: 'Running',
87 finished: 'Finished',
88 planned: 'Planned',
89 empty: 'No subagents yet.',
90 round: 'round',
91 failed: 'error',
92 after: 'after',
93 tokensWord: 'tokens',
94 agentsCount: 'agents',
95 isRunning: 'running',
96 isFinished: 'finished',
97 isPlanned: 'planned',
98 opened: 'Agents panel opened.',
99 closed: 'Agents panel closed.',
100 done: 'Done',
101 plan: 'Plan',
102 design: 'Design',
103 tasks: 'Tasks',
104 review: 'Review',
105 busy: 'running',
106 mainSession: 'Main session',
107 subagents: 'Subagents',
108 estimate: '≈ API-equivalent estimate from token counts; not a bill',
109 hideDone: 'Hide finished',
110 showDone: 'Show finished',
111 },
112 ru: {
113 pane: 'Сессия',
114 details: 'Подробнее',
115 context: 'Контекст',
116 sessionCost: 'Стоимость сессии',
117 estimated: 'оценка',
118 elapsed: 'Прошло',
119 active: 'Активно',
120 limits: 'Лимиты',
121 fiveHour: '5-часовое окно',
122 fiveShort: '5ч',
123 sevenDay: '7-дневное окно',
124 sevenShort: '7д',
125 spend: 'Лимит расходов',
126 spendShort: 'лимит',
127 ctxWindow: 'Контекстное окно',
128 noReading: 'Пока нет данных',
129 tokenSplit: 'Токены',
130 input: 'Ввод',
131 output: 'Вывод',
132 cacheRead: 'Чтение кэша',
133 cacheWrite: 'Запись кэша',
134 cacheHit: 'Попадание в кэш',
135 requests: 'запросов к модели',
136 byModel: 'По моделям',
137 emptyHint: 'Субагент появится здесь с моделью, контекстом и стоимостью, как только начнёт работу.',
138 d: 'д',
139 h: 'ч',
140 min: 'мин',
141 lessMin: '<1 мин',
142 cost: 'Стоимость',
143 tokens: 'Токены',
144 time: 'Время',
145 collapse: 'Свернуть',
146 expand: 'Развернуть',
147 running: 'Работают',
148 finished: 'Завершены',
149 planned: 'Запланированы',
150 empty: 'Субагентов пока нет.',
151 round: 'раунд',
152 failed: 'ошибка',
153 after: 'после',
154 tokensWord: 'токенов',
155 agentsCount: 'агентов',
156 isRunning: 'работает',
157 isFinished: 'завершён',
158 isPlanned: 'запланирована',
159 opened: 'Панель агентов открыта.',
160 closed: 'Панель агентов закрыта.',
161 done: 'Готово',
162 plan: 'План',
163 design: 'Дизайн',
164 tasks: 'Задачи',
165 review: 'Ревью',
166 busy: 'в работе',
167 mainSession: 'Основная сессия',
168 subagents: 'Субагенты',
169 estimate: '≈ оценка по ценам API из числа токенов; не счёт',
170 hideDone: 'Скрыть завершённые',
171 showDone: 'Показать завершённые',
172 },
173 tr: {
174 pane: 'Oturum',
175 details: 'Ayrıntılar',
176 context: 'Bağlam',
177 sessionCost: 'Oturum maliyeti',
178 estimated: 'tahmini',
179 elapsed: 'Süre',
180 active: 'Aktif',
181 limits: 'Kullanım limitleri',
182 fiveHour: '5 saatlik pencere',
183 fiveShort: '5 sa',
184 sevenDay: '7 günlük pencere',
185 sevenShort: '7 gün',
186 spend: 'Harcama limiti',
187 spendShort: 'limit',
188 ctxWindow: 'Bağlam penceresi',
189 noReading: 'Henüz ölçüm yok',
190 tokenSplit: 'Token dağılımı',
191 input: 'Girdi',
192 output: 'Çıktı',
193 cacheRead: 'Önbellek okuma',
194 cacheWrite: 'Önbellek yazma',
195 cacheHit: 'Önbellek isabeti',
196 requests: 'model isteği',
197 byModel: 'Modellere göre',
198 emptyHint: 'Bir alt ajan başladığında modeli, bağlamı ve maliyetiyle burada görünür.',
199 d: 'gün',
200 h: 'sa',
201 min: 'dk',
202 lessMin: '<1 dk',
203 cost: 'Maliyet',
204 tokens: 'Token',
205 time: 'Süre',
206 collapse: 'Daralt',
207 expand: 'Genişlet',
208 running: 'Çalışıyor',
209 finished: 'Bitti',
210 planned: 'Planlandı',
211 empty: 'Henüz alt ajan yok.',
212 round: 'tur',
213 failed: 'hata',
214 after: 'sonra',
215 tokensWord: 'token',
216 agentsCount: 'ajan',
217 isRunning: 'çalışıyor',
218 isFinished: 'bitti',
219 isPlanned: 'planlandı',
220 opened: 'Ajan paneli açıldı.',
221 closed: 'Ajan paneli kapandı.',
222 done: 'Bitti',
223 plan: 'Plan',
224 design: 'Tasarım',
225 tasks: 'Görevler',
226 review: 'İnceleme',
227 busy: 'çalışıyor',
228 mainSession: 'Ana oturum',
229 subagents: 'Alt ajanlar',
230 estimate: '≈ token sayısından API fiyatıyla tahmin; fatura değil',
231 hideDone: 'Bitenleri gizle',
232 showDone: 'Bitenleri göster',
233 },
234} as const
235
236// Module scope is fine here: session.start sets it again on every (re)load.
237let lang: Lang = 'en'
238const tr = () => STRINGS[lang]
239
240const langOf = (v: unknown): Lang =>
241 typeof v !== 'string' ? 'en' : /^(ru|russian|рус)/i.test(v.trim()) ? 'ru' : /^(tr|turkish|türk)/i.test(v.trim()) ? 'tr' : 'en'
242
243async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
244 if (option === 'en' || option === 'ru' || option === 'tr') return option
245 try {
246 const settings = (await $.settings.read()) as Record<string, unknown>
247 if (typeof settings.language === 'string' && settings.language.trim()) return langOf(settings.language)
248 } catch {
249 // No settings: fall through to the locale.
250 }
251 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
252 return langOf(locale)
253}
254
255const blank = (): Flow => ({
256 title: 'savvy-flow',
257 total: 0,
258 done: 0,
259 running: 0,
260 phase: 'plan',
261 isFinished: false,
262 tasks: [],
263})
264
265const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
266 !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
267
268const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
269 tasks
270 ?.filter(t => t.title?.trim())
271 .map(t => ({
272 title: (t.title ?? '').trim(),
273 tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
274 after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
275 ...(t.model?.trim() ? { model: t.model.trim().toLowerCase() } : {}),
276 ...(t.effort?.trim() ? { effort: t.effort.trim().toLowerCase() } : {}),
277 }))
278
279const merge = (prev: Flow | null, input: ProgressInput, at: number): Flow => {
280 // A new title means a new flow: never carry counters over from an earlier one.
281 const base = isNewFlow(prev, input) || !prev ? { ...blank(), startedAt: at } : { ...blank(), ...prev }
282 const tasks = cleanTasks(input.tasks) ?? base.tasks
283 const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
284 const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
285 const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
286 return {
287 ...base,
288 title: input.title?.trim() || base.title,
289 total,
290 done,
291 phase: input.finished ? 'close' : phase,
292 isFinished: input.finished === true,
293 tasks,
294 }
295}
296
297const label = (f: Flow): string => {
298 const s = tr()
299 if (f.isFinished) return s.done
300 if (f.phase === 'plan') return s.plan
301 if (f.phase === 'design') return s.design
302 const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
303 return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
304}
305
306const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
307
308// Deterministic noise so the dither does not shimmer between redraws.
309const noise = (x: number, y: number): number => {
310 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
311 return s - Math.floor(s)
312}
313
314// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
315// so title, bar, percent, the crab and the crew count live in one drawing.
316const H = 22
317const BAR_H = 16
318const CELL = 3
319const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
320
321const xml = (s: string): string =>
322 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
323
324const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
325
326// Rough advance of system UI text, in em; good enough to size the title's slot.
327const charEm = (ch: string): number =>
328 /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
329
330const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
331
332// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
333const fitText = (s: string, size: number, maxW: number): string => {
334 if (textWidth(s, size) <= maxW) return s
335 let out = ''
336 for (const ch of s) {
337 if (textWidth(out + ch + '…', size) > maxW) break
338 out += ch
339 }
340 return out + '…'
341}
342
343// The right end reads "29% 🦀 2 agents": the crew is a figure, not a control.
344// On a narrow band the row gives way in order: the word "agents", then the percent
345// (the pill already says 2/7), then the title, so the bar always holds its pill.
346const rowSvg = (f: Flow, W: number, isWorking: boolean, crew: number): string => {
347 const percent = fmtPct(ratio(f) * 100)
348 const crewWord = ` ${tr().agentsCount}`
349 const text = label(f)
350 const pillW = Math.round(18 + text.length * 6.6)
351 const minBar = pillW + 24
352 // At 0.8 the crab's body spans x 2.4 to 21.6 of its box and y 8 to 21: lifted 3 px
353 // so it centres on the row's middle line, as the text and the bar do.
354 const place = (hasWord: boolean, hasPercent: boolean) => {
355 const crewW = crew ? Math.ceil(textWidth(String(crew), 12.5) + (hasWord ? textWidth(crewWord, 11.5) : 0)) : 0
356 const crabX = Math.round(W - (crew ? crewW + 6 : 0) - 22)
357 const barEnd = hasPercent ? crabX - 4 - textWidth(percent, 12.5) - 12 : crabX - 6
358 return { hasWord, hasPercent, crabX, barEnd }
359 }
360 // The title takes what it needs, up to 40% of the row; the bar takes the rest.
361 const titleW = (end: number) => Math.min(W * 0.4, end - minBar - 28)
362 const fits = (l: ReturnType<typeof place>) => titleW(l.barEnd) >= Math.min(textWidth(f.title, 13), 60)
363 const layout = [place(true, true), place(false, true), place(false, false)].find(fits) ?? place(false, false)
364 const title = fitText(f.title, 13, Math.max(24, titleW(layout.barEnd)))
365 const { crabX, barEnd } = layout
366 const percentX = crabX - 4
367 const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
368 const BAR_W = Math.max(minBar, Math.round(barEnd - BAR_X))
369 const color = f.isFinished ? DONE : ACCENT
370 const y0 = (H - BAR_H) / 2
371 const fillW = Math.round(BAR_W * ratio(f))
372 const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
373 const dots: string[] = []
374
375 // Dithered fill: sparse at the start, dense toward the head.
376 const cols = Math.floor(fillW / CELL)
377 const rows = Math.floor(BAR_H / CELL)
378 for (let c = 0; c < cols; c++) {
379 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
380 for (let r = 0; r < rows; r++) {
381 if (noise(c, r) < density) dots.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
382 }
383 }
384 // Handed to workers, not yet accepted: a faint second layer.
385 const faint: string[] = []
386 for (let c = cols; c < Math.floor(runW / CELL); c++) {
387 for (let r = 0; r < rows; r++) {
388 if (noise(c + 7, r + 3) < 0.2) faint.push(`<rect class="t${Math.floor(noise(r + 5, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="1.7" height="1.7"/>`)
389 }
390 }
391
392 const ticks: string[] = []
393 for (let i = 1; i < f.total; i++) {
394 const x = Math.round((BAR_W * i) / f.total)
395 if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
396 }
397
398 const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
399
400 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
401<style>
402.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
403@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
404/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
405.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
406.t1{animation-duration:${f.isFinished ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${f.isFinished ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${f.isFinished ? 3.5 : 3.3}s;animation-delay:-.4s}
407@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
408@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
409</style>
410<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
411<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
412<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
413<g transform="translate(${BAR_X},${y0})">
414<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
415<g clip-path="url(#c)">
416<g fill="${color}">${dots.join('')}</g>
417<g fill="${color}" opacity="0.45">${faint.join('')}</g>
418<g class="tk">${ticks.join('')}</g>
419</g>
420<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
421<text x="${pillX + pillW / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(text)}</text>
422</g>
423${layout.hasPercent ? `<text class="m" x="${percentX}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>` : ''}
424${CRAB_CSS}${crab(crabX, -3, 'other', false, isWorking, 0.8)}
425${crew ? `<text x="${W}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-variant-numeric="tabular-nums"><tspan class="t" font-size="12.5" font-weight="600">${crew}</tspan>${layout.hasWord ? `<tspan class="m" font-size="11.5">${xml(crewWord)}</tspan>` : ''}</text>` : ''}
426</svg>`
427}
428
429const barText = (f: Flow, width: number): string => {
430 const filled = Math.round(width * ratio(f))
431 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
432}
433
434// ---------------------------------------------------------------------------
435// Agents panel: every subagent of the session, plus the tasks the flow planned.
436
437const TIER_COLOR: Record<string, string> = {
438 fable: '#7F77DD',
439 heavy: '#D85A30',
440 careful: '#BA7517',
441 medium: '#378ADD',
442 light: '#1D9E75',
443 other: '#888780',
444}
445
446const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
447
448// What each savvy tier runs on, for planned tasks that have no run yet and name no model.
449
450const TIER_MODEL: Record<string, string> = {
451 fable: 'Fable · high',
452 heavy: 'Opus · xhigh',
453 careful: 'Opus · high',
454 medium: 'Opus · medium',
455 light: 'Opus · low',
456}
457
458// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
459// The engine reports tokens, not money, so the panel's cost is an estimate.
460const PRICES: [RegExp, [number, number, number, number]][] = [
461 [/fable|mythos/, [10, 50, 0.25, 12.5]],
462 [/opus-5-5/, [4, 20, 0.2, 5]],
463 [/opus/, [5, 25, 0.5, 6.25]],
464 [/sonnet/, [2, 10, 0.2, 2.5]],
465 [/haiku/, [1, 5, 0.1, 1.25]],
466]
467
468type Usage = {
469 input_tokens: number
470 output_tokens: number
471 cache_read_input_tokens: number
472 cache_creation_input_tokens: number
473}
474
475const priceOf = (model: string): [number, number, number, number] =>
476 PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
477
478const costOf = (model: string, u: Usage): number => {
479 const [i, o, r, w] = priceOf(model)
480 return (
481 ((u.input_tokens || 0) * i +
482 (u.output_tokens || 0) * o +
483 (u.cache_read_input_tokens || 0) * r +
484 (u.cache_creation_input_tokens || 0) * w) /
485 1e6
486 )
487}
488
489// A planned task's model: the one the orchestrator named, else its tier's default.
490const plannedModel = (p: PlannedTask, tier: string): string =>
491 p.model ? `${modelName(p.model)}${p.effort ? ' · ' + p.effort : ''}` : (TIER_MODEL[tier] ?? '')
492
493const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
494
495// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
496const tierOf = (type: string): string => {
497 const bare = type.replace(/^[^:]*:/, '')
498 const t = bare.replace(/^savvy-/, '').toLowerCase()
499 return t in TIER_COLOR && bare.startsWith('savvy-') ? t : 'other'
500}
501
502const modelName = (id: string): string => {
503 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
504 const [, family = '', major = '', minor] = m ?? []
505 if (!family) {
506 const bare = /^(fable|mythos|opus|sonnet|haiku)$/i.exec(id.trim())?.[1]
507 if (bare) return bare.charAt(0).toUpperCase() + bare.slice(1).toLowerCase()
508 return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
509 }
510 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
511}
512
513const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
514
515const fmtTokens = (n: number): string =>
516 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
517
518const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
519
520const fmtTime = (ms: number): string => {
521 const s = Math.max(0, Math.round(ms / 1000))
522 const h = Math.floor(s / 3600)
523 const m = Math.floor((s % 3600) / 60)
524 const ss = String(s % 60).padStart(2, '0')
525 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
526}
527
528const fmtPct = (n: number): string => (lang === 'tr' ? `%${Math.round(n)}` : `${Math.round(n)}%`)
529
530// A share that is neither none nor all never rounds to 0 or 100.
531const fmtShare = (n: number): string =>
532 n > 0 && n < 1 ? (lang === 'tr' ? '<%1' : '<1%') : n < 100 && n > 99 ? (lang === 'tr' ? '>%99' : '>99%') : fmtPct(n)
533
534// A coarse span for the band and reset times: "2 d 21 h", "1 h 12 min", "12 min", "<1 min".
535const fmtSpan = (ms: number): string => {
536 const s = tr()
537 const min = Math.floor(Math.max(0, ms) / 60000)
538 if (min < 1) return s.lessMin
539 const h = Math.floor(min / 60)
540 if (h >= 24) return `${Math.floor(h / 24)} ${s.d} ${h % 24} ${s.h}`
541 return h ? `${h} ${s.h} ${min % 60} ${s.min}` : `${min} ${s.min}`
542}
543
544const resetsIn = (span: string): string =>
545 lang === 'tr' ? `${span} sonra sıfırlanır` : lang === 'ru' ? `сброс через ${span}` : `resets in ${span}`
546
547// Gauges turn amber, then red, as a window fills; the figure is always printed beside them.
548const levelColor = (pct: number): string => (pct >= 90 ? ALERT : pct >= 70 ? WARN : ACCENT)
549
550const rateLabel = (kind: string, isShort: boolean): string => {
551 const s = tr()
552 if (kind === 'five_hour') return isShort ? s.fiveShort : s.fiveHour
553 if (kind === 'seven_day') return isShort ? s.sevenShort : s.sevenDay
554 if (kind === 'spend_limit') return isShort ? s.spendShort : s.spend
555 return kind.replace(/_/g, ' ')
556}
557
558// /context's category names, in the panel's language where it is not English.
559const CATEGORY_TR: Record<string, string> = {
560 'system prompt': 'Sistem istemi',
561 'system tools': 'Sistem araçları',
562 'mcp tools': 'MCP araçları',
563 'custom agents': 'Özel ajanlar',
564 'memory files': 'Bellek dosyaları',
565 skills: 'Beceriler',
566 'slash commands': 'Komutlar',
567 messages: 'Mesajlar',
568 'free space': 'Boş alan',
569 'autocompact buffer': 'Otomatik sıkıştırma payı',
570}
571
572const categoryName = (name: string): string => (lang === 'tr' ? (CATEGORY_TR[name.trim().toLowerCase()] ?? name) : name)
573
574const ZERO: TokenSplit = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
575
576const splitOf = (u: Usage): TokenSplit => ({
577 input: u.input_tokens || 0,
578 output: u.output_tokens || 0,
579 cacheRead: u.cache_read_input_tokens || 0,
580 cacheWrite: u.cache_creation_input_tokens || 0,
581})
582
583const addSplit = (a: TokenSplit | undefined, b: TokenSplit | undefined): TokenSplit => ({
584 input: (a?.input ?? 0) + (b?.input ?? 0),
585 output: (a?.output ?? 0) + (b?.output ?? 0),
586 cacheRead: (a?.cacheRead ?? 0) + (b?.cacheRead ?? 0),
587 cacheWrite: (a?.cacheWrite ?? 0) + (b?.cacheWrite ?? 0),
588})
589
590const sumSplit = (t: TokenSplit): number => t.input + t.output + t.cacheRead + t.cacheWrite
591
592const rateWindows = (list: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): RateWindow[] =>
593 list.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, ...(r.resetsAt ? { resetsAt: r.resetsAt } : {}) }))
594
595const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
596
597type Planned = PlannedTask & { n: number }
598
599// The list holds every subagent of the session; a flow's own are those started
600// since it began, plus any still running from before.
601const flowRuns = (f: Flow | null, list: AgentRun[]): AgentRun[] =>
602 f ? list.filter(a => a.status === 'running' || a.startedAt >= (f.startedAt ?? 0)) : []
603
604const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
605 if (!f || f.isFinished) return []
606 const started = new Set(flowRuns(f, list).map(a => norm(a.description)))
607 return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
608}
609
610// Subagents and the main loop, both priced from their token counts; the headline
611// cost is the engine's own ledger (/cost) when the host keeps one.
612const totals = (list: AgentRun[], at: number, m?: MainUsage, mt?: Meter) => {
613 const agentsCost = list.reduce((s, a) => s + a.costUsd, 0)
614 const mainCost = m?.costUsd ?? 0
615 const tokens = list.reduce((s, a) => s + a.tokens, 0) + (m?.tokens ?? 0)
616 const start = Math.min(...list.map(a => a.startedAt))
617 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
618 const split = list.reduce((s, a) => addSplit(s, a.split), addSplit(ZERO, m?.split))
619 const isLedger = mt?.costUsd !== undefined && mt.costUsd > 0
620 const estimate = agentsCost + mainCost
621 // Splits are priced from token counts; with a ledger they are scaled to it, so
622 // the parts always add up to the headline.
623 const k = isLedger && estimate > 0 ? (mt?.costUsd ?? 0) / estimate : 1
624 return {
625 cost: isLedger ? (mt?.costUsd ?? 0) : estimate,
626 isLedger,
627 k,
628 agentsCost: agentsCost * k,
629 mainCost: mainCost * k,
630 tokens,
631 split,
632 steps: (m?.steps ?? 0) + list.reduce((s, a) => s + a.steps, 0),
633 time: list.length ? end - start : 0,
634 sessionMs: mt?.startedAt ? Math.max(0, at - mt.startedAt) : 0,
635 activeMs: (m?.activeMs ?? 0) + (m?.turnStartedAt ? Math.max(0, at - m.turnStartedAt) : 0),
636 }
637}
638
639type Totals = ReturnType<typeof totals>
640
641// --- desktop drawings: each row is one SVG, as the band above the prompt is.
642
643const PANE_CSS = `<style>
644.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
645@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
646.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
647@media (prefers-reduced-motion: reduce){.live{animation:none}}
648</style>`
649
650// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
651// The body keeps the brand clay; the tier's color lives in the costume's accent.
652const CLAY = '#D97757'
653const INK = '#1F1E1D'
654
655// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
656// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
657type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
658
659const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
660 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
661
662// `armCls` lets a raised claw travel with the prop it holds.
663const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
664 f(7, 10, 16, 12, CLAY)
665 f(3, 14, 4, 4, CLAY)
666 f(23, 14 + armFront, 4, 4, CLAY, armCls)
667 f(9, 12, 2, 2, INK)
668 f(19, 12, 2, 2, INK)
669 f(7, 22, 2, 4, CLAY, 'la')
670 f(17, 22, 2, 4, CLAY, 'la')
671 f(11, 22, 2, 4, CLAY, 'lb')
672 f(21, 22, 2, 4, CLAY, 'lb')
673}
674
675// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
676// once-a-second redraw of a running row restarts them in phase. Every crab walks;
677// each costume adds its prop's own motion on top.
678const CRAB_CSS = `<style>
679.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
680.run .bd{animation:bob .5s steps(1) infinite -.125s}
681.run g{transform-box:fill-box}
682@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
683.c-fable.run{animation:float 1s ease-in-out infinite}
684.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
685.c-fable.run .ant{animation:blink 1s steps(1) infinite}
686.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
687@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
688.c-heavy.run .it{animation:scan 1s steps(1) infinite}
689.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
690@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
691.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
692@keyframes twist{50%{transform:rotate(-35deg)}}
693.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
694.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
695@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
696.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
697.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
698@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
699.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
700@keyframes fence{50%{transform:rotate(25deg)}}
701@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
702</style>`
703
704const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
705 // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
706 fable: (f, t) => {
707 crabBody(f)
708 f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
709 f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
710 f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
711 f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
712 },
713 // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
714 heavy: (f, t) => {
715 crabBody(f, -4, 'it')
716 stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
717 f(6, 9, 18, 1, t)
718 stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
719 f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
720 },
721 // Careful: engineer in a hard hat; the wrench turns a bolt.
722 careful: (f, t) => {
723 crabBody(f)
724 stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
725 f(13, 5, 4, 2, t)
726 stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
727 },
728 // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
729 medium: (f, t) => {
730 crabBody(f, -4, 'pan')
731 stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
732 f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
733 },
734 // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
735 light: (f, t) => {
736 crabBody(f, -4)
737 stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
738 f(25, 1, 1, 9, '#8E929A')
739 stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
740 },
741 // Explore: pirate scouting the code; the cutlass fences.
742 explore: f => {
743 crabBody(f)
744 stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
745 f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
746 f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
747 },
748 other: f => crabBody(f),
749}
750
751const costumeOf = (type: string): string => (type === 'Explore' ? 'explore' : tierOf(type))
752
753const CRAB_SCALE = 1.1
754
755// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
756// legs stay outside it and step on their own.
757const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
758 const groups = new Map<string, string[]>([['bd', []]])
759 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
760 if (!groups.has(cls)) groups.set(cls, [])
761 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
762 }
763 const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
764 draw(f, colorOf(costume))
765 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
766 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
767 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
768 return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
769}
770
771const statusMark = (x: number, y: number, status: string, color: string): string => {
772 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
773 if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
774 if (status === 'failed') return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
775 return `<circle cx="${x}" cy="${y}" r="5" fill="none" stroke="#9a9a96" stroke-width="1.4"/><path d="M${x} ${y - 2.5}v2.8l1.8 1.2" fill="none" stroke="#9a9a96" stroke-width="1.4" stroke-linecap="round"/>`
776}
777
778const svg = (W: number, H: number, body: string): string =>
779 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
780
781type TextOpts = {
782 size?: number
783 weight?: number
784 cls?: string
785 anchor?: 'middle' | 'end'
786 fill?: string
787 num?: boolean
788}
789
790const txt = (x: number, y: number, s: string, o: TextOpts = {}): string =>
791 `<text${o.fill ? ` fill="${o.fill}"` : ` class="${o.cls ?? 't'}"`} x="${x}" y="${y}"${o.anchor ? ` text-anchor="${o.anchor}"` : ''} font-family="${FONT}" font-size="${o.size ?? 12}"${o.weight ? ` font-weight="${o.weight}"` : ''}${o.num ? ' font-variant-numeric="tabular-nums"' : ''}>${xml(s)}</text>`
792
793// A rounded track with a fill from the left; `pct` is 0 to 100.
794const meterBar = (x: number, y: number, w: number, h: number, pct: number, color: string): string => {
795 const fill = Math.round((w * Math.max(0, Math.min(100, pct))) / 100)
796 return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>${fill > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fill)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : ''}`
797}
798
799// Segments laid end to end inside one rounded track, a hairline between them.
800const stackBar = (x: number, y: number, w: number, h: number, parts: { w: number; color: string }[]): string => {
801 let cx = x
802 const segs = parts
803 .filter(p => p.w >= 1)
804 .map(p => {
805 const r = `<rect x="${cx}" y="${y}" width="${Math.max(1, p.w - 1)}" height="${h}" fill="${p.color}"/>`
806 cx += p.w
807 return r
808 })
809 return `<defs><clipPath id="sb"><rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/></clipPath></defs>
810<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/><g clip-path="url(#sb)">${segs.join('')}</g>`
811}
812
813const PAD = 14
814
815const card = (W: number, H: number, title: string, right: string, body: string): string =>
816 svg(
817 W,
818 H,
819 `<rect class="tile" width="${W}" height="${H}" rx="10"/>
820${txt(PAD, 22, fitText(title, 11.5, right ? W * 0.55 : W - PAD * 2), { size: 11.5, weight: 600, cls: 's' })}
821${right ? txt(W - PAD, 22, fitText(right, 11.5, W * 0.4), { size: 11.5, cls: 's', anchor: 'end', num: true }) : ''}
822${body}`,
823 )
824
825const swatch = (x: number, y: number, color: string): string =>
826 `<rect x="${x}" y="${y - 8}" width="8" height="8" rx="2" fill="${color}"/>`
827
828// Two-column legend rows: swatch, name, and a value right-aligned in its column.
829const legend = (W: number, top: number, rows: { name: string; value: string; color: string }[], rowH = 20): string => {
830 const colW = (W - PAD * 2 - 16) / 2
831 return rows
832 .map((r, i) => {
833 const x = PAD + (i % 2) * (colW + 16)
834 const y = top + Math.floor(i / 2) * rowH
835 const vw = textWidth(r.value, 11.5)
836 return `${swatch(x, y, r.color)}${txt(x + 13, y, fitText(r.name, 11.5, colW - vw - 22), { size: 11.5, cls: 's' })}${txt(x + colW, y, r.value, { size: 11.5, anchor: 'end', num: true })}`
837 })
838 .join('')
839}
840
841// Cost first: the engine's ledger when there is one, else the estimate; the split
842// bar is always the estimate, labelled so.
843const HERO_H = 118
844
845const heroSvg = (W: number, t: Totals): string => {
846 const s = tr()
847 const inner = W - PAD * 2
848 const sum = t.mainCost + t.agentsCost
849 const mainW = sum ? Math.round((inner * t.mainCost) / sum) : 0
850 const cost = (t.isLedger ? '' : '≈') + fmtCost(t.cost)
851 const stat = (x: number, label: string, value: string) =>
852 `${txt(x, 40, label, { size: 11, cls: 'm', anchor: 'end' })}${txt(x, 59, value, { size: 15, weight: 600, anchor: 'end', num: true })}`
853 const hasActive = W >= 340
854 const mainLegend = `${s.mainSession} ≈${fmtCost(t.mainCost)}`
855 const agentsX = PAD + 13 + textWidth(mainLegend, 11.5) + 18
856 return card(
857 W,
858 HERO_H,
859 s.sessionCost,
860 t.isLedger ? '/cost' : s.estimated,
861 `${txt(PAD, 60, cost, { size: 28, weight: 650, num: true })}
862${stat(W - PAD, hasActive ? s.active : s.elapsed, fmtTime(hasActive ? t.activeMs : t.sessionMs))}
863${hasActive ? stat(W - PAD - 86, s.elapsed, fmtTime(t.sessionMs)) : ''}
864${sum ? stackBar(PAD, 74, inner, 8, [{ w: mainW, color: ACCENT }, { w: inner - mainW, color: CLAY }]) : meterBar(PAD, 74, inner, 8, 0, ACCENT)}
865${swatch(PAD, 104, ACCENT)}${txt(PAD + 13, 104, mainLegend, { size: 11.5, cls: 's', num: true })}
866${swatch(agentsX, 104, CLAY)}${txt(agentsX + 13, 104, `${s.subagents} ≈${fmtCost(t.agentsCost)}`, { size: 11.5, cls: 's', num: true })}`,
867 )
868}
869
870const limitsHeight = (n: number): number => 34 + n * 46
871
872const limitsSvg = (W: number, rl: RateWindow[], at: number): string =>
873 card(
874 W,
875 limitsHeight(rl.length),
876 tr().limits,
877 '',
878 rl
879 .map((r, i) => {
880 const y0 = 34 + i * 46
881 const pct = r.percentUsed
882 const color = levelColor(pct)
883 const left = r.resetsAt ? Date.parse(r.resetsAt) - at : NaN
884 return `${txt(PAD, y0 + 10, rateLabel(r.kind, false), { size: 12.5, weight: 500 })}
885${txt(W - PAD, y0 + 10, fmtPct(pct), { size: 12.5, weight: 600, anchor: 'end', num: true, ...(pct >= 70 ? { fill: color } : {}) })}
886${meterBar(PAD, y0 + 17, W - PAD * 2, 6, pct, color)}
887${Number.isFinite(left) && left > 0 ? txt(PAD, y0 + 37, resetsIn(fmtSpan(left)), { size: 11, cls: 'm' }) : ''}`
888 })
889 .join(''),
890 )
891
892const PALETTE = ['#8f8cf4', '#378ADD', '#1D9E75', '#BA7517', '#D97757', '#7F77DD', '#D85A30', '#888780']
893
894const contextSlices = (mt: Meter) => (mt.categories ?? []).filter(c => c.kind === 'used' && c.tokens > 0).slice(0, 8)
895
896const contextHeight = (mt: Meter): number => {
897 if (mt.contextTokens === undefined) return 70
898 const rows = Math.ceil(contextSlices(mt).length / 2)
899 return rows ? 58 + rows * 20 : 54
900}
901
902const contextSvg = (W: number, mt: Meter): string => {
903 const s = tr()
904 const inner = W - PAD * 2
905 if (mt.contextTokens === undefined) {
906 return card(W, contextHeight(mt), s.ctxWindow, '', `${meterBar(PAD, 32, inner, 10, 0, ACCENT)}${txt(PAD, 60, s.noReading, { size: 11.5, cls: 'm' })}`)
907 }
908 const pct = mt.contextPercent ?? (mt.contextWindow ? (mt.contextTokens / mt.contextWindow) * 100 : 0)
909 const right = `${fmtPct(pct)} · ${fmtTokens(mt.contextTokens)} / ${fmtTokens(mt.contextWindow)}`
910 const slices = contextSlices(mt)
911 const used = slices.reduce((a, c) => a + c.tokens, 0)
912 // The categories are estimates against the compaction window: scale them to the measured fill.
913 const bar = slices.length
914 ? stackBar(
915 PAD,
916 32,
917 inner,
918 10,
919 slices.map((c, i) => ({ w: Math.round((inner * (pct / 100) * c.tokens) / used), color: PALETTE[i % PALETTE.length] ?? ACCENT })),
920 )
921 : meterBar(PAD, 32, inner, 10, pct, levelColor(pct))
922 const rows = slices.map((c, i) => ({ name: categoryName(c.name), value: fmtTokens(c.tokens), color: PALETTE[i % PALETTE.length] ?? ACCENT }))
923 return card(W, contextHeight(mt), s.ctxWindow, right, `${bar}${legend(W, 66, rows)}`)
924}
925
926const TOKEN_COLORS = { input: '#378ADD', output: ACCENT, cacheRead: '#1D9E75', cacheWrite: '#BA7517' }
927
928const tokensHeight = (t: Totals): number => (sumSplit(t.split) ? 120 : 70)
929
930const tokensSvg = (W: number, t: Totals): string => {
931 const s = tr()
932 const inner = W - PAD * 2
933 const total = sumSplit(t.split)
934 if (!total) return card(W, tokensHeight(t), s.tokenSplit, '', `${meterBar(PAD, 32, inner, 10, 0, ACCENT)}${txt(PAD, 60, s.noReading, { size: 11.5, cls: 'm' })}`)
935 const kinds = [
936 { name: s.input, v: t.split.input, color: TOKEN_COLORS.input },
937 { name: s.output, v: t.split.output, color: TOKEN_COLORS.output },
938 { name: s.cacheRead, v: t.split.cacheRead, color: TOKEN_COLORS.cacheRead },
939 { name: s.cacheWrite, v: t.split.cacheWrite, color: TOKEN_COLORS.cacheWrite },
940 ]
941 const prompt = t.split.input + t.split.cacheRead + t.split.cacheWrite
942 const hit = prompt ? (t.split.cacheRead / prompt) * 100 : 0
943 return card(
944 W,
945 tokensHeight(t),
946 s.tokenSplit,
947 fmtTokens(total),
948 `${stackBar(PAD, 32, inner, 10, kinds.map(k => ({ w: Math.round((inner * k.v) / total), color: k.color })))}
949${legend(W, 66, kinds.map(k => ({ name: k.name, value: `${fmtTokens(k.v)} · ${fmtShare((k.v / total) * 100)}`, color: k.color })))}
950${txt(PAD, 108, fitText(`${s.cacheHit} ${fmtShare(hit)} · ${t.steps} ${s.requests}`, 11, inner), { size: 11, cls: 'm', num: true })}`,
951 )
952}
953
954type ModelRow = { name: string; costUsd: number; tokens: number }
955
956const modelRows = (m: MainUsage, list: AgentRun[], k: number): ModelRow[] => {
957 const rows = new Map<string, ModelRow>()
958 const add = (name: string, costUsd: number, tokens: number) => {
959 if (!name || (!costUsd && !tokens)) return
960 const r = rows.get(name) ?? { name, costUsd: 0, tokens: 0 }
961 rows.set(name, { name, costUsd: r.costUsd + costUsd * k, tokens: r.tokens + tokens })
962 }
963 for (const [name, v] of Object.entries(m.byModel ?? {})) add(name, v.costUsd, v.tokens)
964 for (const a of list) add(modelName(a.model), a.costUsd, a.tokens)
965 return [...rows.values()].sort((a, b) => b.costUsd - a.costUsd).slice(0, 6)
966}
967
968const modelsHeight = (n: number): number => 36 + n * 24
969
970const modelsSvg = (W: number, rows: ModelRow[]): string => {
971 const top = Math.max(...rows.map(r => r.costUsd), 0.000001)
972 const nameW = 96
973 const valueW = 108
974 const barW = Math.max(30, W - PAD * 2 - nameW - valueW)
975 return card(
976 W,
977 modelsHeight(rows.length),
978 tr().byModel,
979 '',
980 rows
981 .map((r, i) => {
982 const y = 44 + i * 24
983 return `${txt(PAD, y, fitText(r.name, 12, nameW - 8), { size: 12, weight: 500 })}
984${meterBar(PAD + nameW, y - 7, barW, 6, (r.costUsd / top) * 100, PALETTE[i % PALETTE.length] ?? ACCENT)}
985${txt(W - PAD, y, `≈${fmtCost(r.costUsd)} · ${fmtTokens(r.tokens)}`, { size: 11.5, cls: 's', anchor: 'end', num: true })}`
986 })
987 .join(''),
988 )
989}
990
991// --- the band's session row: one SVG of labelled figures, dropped by priority as it narrows.
992
993type Seg = { prio: number; w: number; draw: (x: number) => string }
994
995const BAND_Y = 15.5
996
997const figure = (label: string, value: string, prio: number, color?: string): Seg => {
998 const lw = label ? textWidth(label, 11.5) + 5 : 0
999 return {
1000 prio,
1001 w: lw + textWidth(value, 12.5),
1002 draw: x =>
1003 (label ? txt(x, BAND_Y, label, { size: 11.5, cls: 'm' }) : '') +
1004 txt(x + lw, BAND_Y, value, { size: 12.5, weight: 600, num: true, ...(color ? { fill: color } : {}) }),
1005 }
1006}
1007
1008const gauge = (label: string, pct: number, prio: number): Seg => {
1009 const lw = textWidth(label, 11.5) + 6
1010 const bw = 30
1011 const v = fmtPct(pct)
1012 const color = levelColor(pct)
1013 return {
1014 prio,
1015 w: lw + bw + 6 + textWidth(v, 12.5),
1016 draw: x =>
1017 txt(x, BAND_Y, label, { size: 11.5, cls: 'm' }) +
1018 meterBar(x + lw, 8, bw, 6, pct, color) +
1019 txt(x + lw + bw + 6, BAND_Y, v, { size: 12.5, weight: 600, num: true, ...(pct >= 70 ? { fill: color } : {}) }),
1020 }
1021}
1022
1023const RATE_PRIO: Record<string, number> = { five_hour: 3, spend_limit: 3, seven_day: 6 }
1024
1025const bandSegs = (t: Totals, m: MainUsage, mt: Meter): Seg[] => {
1026 const s = tr()
1027 const segs: Seg[] = []
1028 if (m.model) segs.push(figure('', modelName(m.model), 7))
1029 segs.push(figure(s.cost, (t.isLedger ? '' : '≈') + fmtCost(t.cost), 1))
1030 segs.push(figure(s.tokens, fmtTokens(t.tokens), 4))
1031 if (mt.contextTokens !== undefined && mt.contextPercent !== undefined) segs.push(gauge(s.context, mt.contextPercent, 2))
1032 for (const r of mt.rateLimits) segs.push(gauge(rateLabel(r.kind, true), r.percentUsed, RATE_PRIO[r.kind] ?? 8))
1033 if (t.sessionMs) segs.push(figure(s.elapsed, fmtSpan(t.sessionMs), 5))
1034 return segs
1035}
1036
1037const bandText = (t: Totals, m: MainUsage, mt: Meter): string => {
1038 const s = tr()
1039 const parts: string[] = []
1040 if (m.model) parts.push(modelName(m.model))
1041 parts.push(`${s.cost} ${t.isLedger ? '' : '≈'}${fmtCost(t.cost)}`)
1042 if (mt.contextPercent !== undefined) parts.push(`${s.context} ${fmtPct(mt.contextPercent)}`)
1043 for (const r of mt.rateLimits) parts.push(`${rateLabel(r.kind, true)} ${fmtPct(r.percentUsed)}`)
1044 parts.push(`${s.tokens} ${fmtTokens(t.tokens)}`)
1045 if (t.sessionMs) parts.push(`${s.elapsed} ${fmtSpan(t.sessionMs)}`)
1046 return parts.join(' · ')
1047}
1048
1049const BAND_GAP = 19
1050
1051// Returns the drawing and its width: the figures that fit, and no more. `textWidth`
1052// runs a few px short on figures, so the end keeps room for the last glyph.
1053const statsSvg = (W: number, segs: Seg[], isWorking: boolean): { source: string; width: number } => {
1054 // Keep the most important figures that fit, then draw them in their own order.
1055 let used = 16
1056 const kept = new Set<Seg>()
1057 for (const seg of [...segs].sort((a, b) => a.prio - b.prio)) {
1058 const w = seg.w + (kept.size ? BAND_GAP : 0)
1059 if (used + w > W) continue
1060 kept.add(seg)
1061 used += w
1062 }
1063 let x = 16
1064 const body = segs
1065 .filter(seg => kept.has(seg))
1066 .map((seg, i) => {
1067 const sep = i ? `<rect class="k" x="${x + Math.floor(BAND_GAP / 2) - 0.5}" y="5" width="1" height="12"/>` : ''
1068 if (i) x += BAND_GAP
1069 const out = sep + seg.draw(x)
1070 x += seg.w
1071 return out
1072 })
1073 .join('')
1074 const width = Math.ceil(x + 10)
1075 return { source: svg(width, H, `<circle${isWorking ? ' class="live"' : ''} cx="5" cy="${H / 2}" r="4" fill="${isWorking ? ACCENT : DONE}"/>${body}`), width }
1076}
1077
1078// The task's own progress when the worker reports steps; a finished run is full.
1079const progressOf = (a: AgentRun): number | null => {
1080 if (a.status === 'done') return 1
1081 if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
1082 return null
1083}
1084
1085const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
1086
1087// --- the subagents card: one card stacked from several drawings (its header, group
1088// labels and rows), each painting its own slice of the tile so they read as one, as
1089// the cards above do. Rows sit flush; the first slice rounds the top corners and
1090// the last the bottom ones, with a little room under it.
1091
1092type Slice = { source: string; height: number }
1093type Part = { key: string; alt: string; draw: (isFirst: boolean, isLast: boolean) => Slice }
1094
1095const CARD_R = 10
1096const CARD_FOOT = 8
1097const ROW_X = PAD + 40
1098
1099const tileSlice = (W: number, H: number, isFirst: boolean, isLast: boolean): string => {
1100 const t = isFirst ? CARD_R : 0
1101 const b = isLast ? CARD_R : 0
1102 return `<path class="tile" d="M0 ${t}${t ? `A${t} ${t} 0 0 1 ${t} 0` : ''}H${W - t}${t ? `A${t} ${t} 0 0 1 ${W} ${t}` : ''}V${H - b}${b ? `A${b} ${b} 0 0 1 ${W - b} ${H}` : ''}H${b}${b ? `A${b} ${b} 0 0 1 0 ${H - b}` : ''}Z"/>`
1103}
1104
1105// `height` is the slice's own; the last one grows by the card's foot.
1106const slice = (W: number, height: number, body: string) => (isFirst: boolean, isLast: boolean): Slice => {
1107 const h = height + (isLast ? CARD_FOOT : 0)
1108 return { source: svg(W, h, `${tileSlice(W, h, isFirst, isLast)}${body}`), height: h }
1109}
1110
1111// A hairline between two rows of one group, inset as the card's text is.
1112const rule = (W: number, isRuled: boolean): string => (isRuled ? `<line class="ln" x1="${PAD}" y1="0.5" x2="${W - PAD}" y2="0.5"/>` : '')
1113
1114const headPart = (W: number, right: string): Part => ({
1115 key: 'agents-head',
1116 alt: `${tr().subagents} ${right}`,
1117 draw: slice(
1118 W,
1119 34,
1120 `${txt(PAD, 22, tr().subagents, { size: 11.5, weight: 600, cls: 's' })}${right ? txt(W - PAD, 22, fitText(right, 11.5, W * 0.55), { size: 11.5, cls: 's', anchor: 'end', num: true }) : ''}`,
1121 ),
1122})
1123
1124const groupPart = (W: number, key: string, text: string): Part => ({
1125 key,
1126 alt: text,
1127 draw: slice(W, 26, txt(PAD, 18, text, { size: 11, weight: 600, cls: 'm', num: true })),
1128})
1129
1130const emptyPart = (W: number): Part => {
1131 const s = tr()
1132 return {
1133 key: 'agents-empty',
1134 alt: `${s.empty} ${s.emptyHint}`,
1135 draw: slice(
1136 W,
1137 48,
1138 `${crab(PAD - 4, 4, 'other', true)}
1139${txt(ROW_X, 20, s.empty, { size: 12.5, weight: 500, cls: 's' })}
1140${txt(ROW_X, 37, fitText(s.emptyHint, 11, W - ROW_X - PAD), { size: 11, cls: 'm' })}`,
1141 ),
1142 }
1143}
1144
1145const agentPart = (W: number, a: AgentRun, at: number, k: number, isRuled: boolean): Part => {
1146 const s = tr()
1147 const tier = tierOf(a.type)
1148 const color = colorOf(tier)
1149 const ctx = ctxOf(a)
1150 const right = W - PAD
1151 const barW = right - ROW_X
1152 const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
1153 if (a.round > 1) meta.push(`${s.round} ${a.round}`)
1154 if (a.status === 'failed') meta.push(s.failed)
1155 const progress = progressOf(a)
1156 const stats = `ctx ${fmtPct(ctx)} · ${fmtTokens(a.contextTokens)} ≈${fmtCost(a.costUsd * k)} ${fmtTime(elapsed(a, at))}`
1157 const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
1158 const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
1159 // Without reported steps the bar falls back to the context, drawn grey.
1160 const fillW = Math.round(barW * (progress ?? ctx / 100))
1161 return {
1162 key: a.id,
1163 alt: `${a.description}: ${modelName(a.model)}, ${a.status === 'running' ? s.isRunning : s.isFinished}`,
1164 draw: slice(
1165 W,
1166 66,
1167 `${rule(W, isRuled)}${crab(PAD - 4, 15, costumeOf(a.type), false, a.status === 'running')}
1168${txt(ROW_X, 21, fitText(a.description || a.type, 13, barW - 22), { size: 13, weight: 600 })}
1169<text x="${ROW_X}" y="37" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier === 'other' ? a.type : tier)}</tspan><tspan class="s"> ${xml(meta.join(' · '))}</tspan></text>
1170${steps && stepsW > 30 ? txt(ROW_X, 51, fitText(steps, 11, stepsW), { size: 11, num: true }) : ''}
1171${txt(right, 51, stats, { size: 11, cls: 's', anchor: 'end', num: true })}
1172<rect class="k" x="${ROW_X}" y="57" width="${barW}" height="4" rx="2"/><rect${progress === null ? ' class="m"' : ''} x="${ROW_X}" y="57" width="${fillW}" height="4" rx="2"${progress === null ? '' : ` fill="${color}"`}/>
1173${statusMark(right - 6, 17, a.status, color)}`,
1174 ),
1175 }
1176}
1177
1178const plannedPart = (W: number, p: Planned, isRuled: boolean): Part => {
1179 const tier = p.tier in TIER_COLOR ? p.tier : 'other'
1180 const color = colorOf(tier)
1181 const right = W - PAD
1182 const meta = [plannedModel(p, tier)]
1183 if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
1184 return {
1185 key: `plan-${p.n}`,
1186 alt: `${p.n}. ${p.title}: ${tr().isPlanned}`,
1187 draw: slice(
1188 W,
1189 48,
1190 `${rule(W, isRuled)}${crab(PAD - 4, 8, tier, true)}
1191${txt(ROW_X, 21, fitText(`${p.n}. ${p.title}`, 13, right - ROW_X - 22), { size: 13, weight: 600, cls: 's' })}
1192<text x="${ROW_X}" y="37" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier)}</tspan><tspan class="m"> ${xml(meta.filter(Boolean).join(' · '))}</tspan></text>
1193${statusMark(right - 6, 17, 'planned', color)}`,
1194 ),
1195 }
1196}
1197
1198const compactPart = (W: number, list: AgentRun[], planned: Planned[], t: Totals): Part => {
1199 const icons = [
1200 ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: 'running', dim: false })),types/index.d.ts 117 lines1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4 title: string
5 tier: string
6 after: number[]
7 /** The model the task will run on, when the orchestrator names it; the tier's default otherwise. */
8 model?: string
9 effort?: string
10}
11
12/** Token counts by kind, summed over model requests. */
13export type TokenSplit = {
14 input: number
15 output: number
16 cacheRead: number
17 cacheWrite: number
18}
19
20/** The main loop's own model requests: an API-equivalent estimate, never a bill. */
21export type MainUsage = {
22 model: string
23 tokens: number
24 costUsd: number
25 steps: number
26 split?: TokenSplit
27 /** Estimated cost and tokens of the main loop per model display name. */
28 byModel?: Record<string, { costUsd: number; tokens: number }>
29 /** Wall-clock time of finished main-loop turns. */
30 activeMs?: number
31 turns?: number
32 /** Set while a main-loop turn runs. */
33 turnStartedAt?: number
34}
35
36export type RateWindow = {
37 kind: string
38 percentUsed: number
39 resetsAt?: string
40}
41
42export type ContextSlice = {
43 name: string
44 tokens: number
45 kind: string
46}
47
48/** The engine's own session figures (`$.session.usage()` and `session.measure`). */
49export type Meter = {
50 startedAt: number
51 contextTokens?: number
52 contextWindow: number
53 contextPercent?: number
54 rateLimits: RateWindow[]
55 /** The engine's ledger, as /cost totals it; absent where the host keeps none. */
56 costUsd?: number
57 categories?: ContextSlice[]
58 categoriesMax?: number
59 breakdownAt?: number
60}
61
62export type Flow = {
63 title: string
64 total: number
65 done: number
66 running: number
67 phase: Phase
68 isFinished: boolean
69 tasks: PlannedTask[]
70 /** When the flow began: runs that started earlier belong to an earlier flow. */
71 startedAt?: number
72}
73
74export type AgentStatus = 'running' | 'done' | 'failed'
75
76export type AgentRun = {
77 id: string
78 agentId?: string
79 type: string
80 description: string
81 model: string
82 effort?: string
83 status: AgentStatus
84 startedAt: number
85 endedAt?: number
86 contextTokens: number
87 contextMax: number
88 tokens: number
89 costUsd: number
90 steps: number
91 round: number
92 split?: TokenSplit
93 /** Self-reported by the worker through the `step` tool. */
94 stepDone?: number
95 stepTotal?: number
96 stepNote?: string
97}
98
99export type Panel = {
100 isCompact: boolean
101 isDoneCollapsed: boolean
102 autoOpenedFor: string
103}
104
105declare module 'claude-code' {
106 interface PluginState {
107 'savvy-progress': {
108 flow: Flow | null
109 agents: AgentRun[]
110 panel: Panel
111 now: number
112 main: MainUsage
113 meter: Meter
114 }
115 }
116}
117