Progress bar above the prompt for /serhan, plus an agents panel (/agents-info): running, finished and planned subagents with model, task progress, context…

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