SLOPSHOPPER

subagents-tab-view

A panel of your subagents with model, context, cost and time, a per-agent Steps accordion, and a crab whose costume follows the model. Fork of the agents panel…

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

Draws

Pane · Agents
Collapse ≈$0.00 · 0 tokens · 0:00 No subagents yet.
README

subagents-tab-view

A Claude Code mod that lists your subagents in a panel: running, finished and planned, each with its model, context fill, estimated cost and time.

  • Steps accordion: every agent row has a "Steps" button that opens the steps the agent reported, with ✓ for the finished ones and ● for the current one. By default each subagent's brief gets a request to report through the step tool, so it works without asking for it in the prompt.
  • Costumes: a pixel crab walks while the agent runs, and its costume follows the model. Haiku is a racer with a flag, Sonnet a chef tossing an omelette, Opus a detective with a magnifier, Fable an astronaut. Explore agents scout with a cutlass and Plan agents turn a wrench. prefers-reduced-motion stops the animation.
  • Progress bar (optional): a multi-agent orchestrator can call progress to show a title, phase and accepted tasks above the prompt. Without a caller the bar does not appear.
  • /agents-info toggles the panel; /agents-info open opens it or brings it forward.

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

Settings

  • language: auto (default), en, ru or he.
  • autoOpen: plan (default) opens the panel when a plan is reported; any also opens it, once per session, when any subagent starts.
  • stepsFor: all (default) or off. With all, every subagent is asked to report its steps.

Credits

Fork of the agents panel from claude-kit by JohnnyVizz, MIT. The panel, the crab sprites and their animations come from there; this fork adds the Steps accordion, model-based costumes and the step request for every subagent.

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