A header with the workspace (folder, git branch), the main thread (model, context, cost, tokens, limits) and the subagents' totals, and a live panel of every…

A Claude Code mod that shows your session and its subagents in a live side panel.
/mission-control opens and closes it./plugin marketplace add davidbalivo/mission-control
/plugin install mission-control@mission-control
Then run /reload-plugins.
Tokens from Claude Code × a price table. Price sources, first valid one wins:
https://platform.claude.com/docs/en/about-claude/pricing.md.PRICES in hooks/register.tsx, copied from that page on 2026-10-17.A fetch with any doubtful value: discarded whole. No credentials and no user data in the request.
Not priced: fast mode, 1-hour cache writes, US-only inference. No signal from Claude Code for them.
Subagents report progress with the step tool, sending done and total. The panel draws it on the row as 3/5 and small blocks.
mission-control started as a fork of savvy-progress from johnnyvizz/claude-kit (MIT).
MIT. See LICENSE.
hooks/register.tsx 1559 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, SessionUsage } from 'claude-code'
3
4import type { AgentRun, AgentStatus, Env, Limit, Main, Panel } from '../types'
5
6const agents = atom({ plugin: 'mission-control', key: 'agents' } as const, [])
7const panel = atom({ plugin: 'mission-control', key: 'panel' } as const, {
8 isCompact: false,
9 isDoneCollapsed: false,
10 expanded: [],
11})
12const now = atom({ plugin: 'mission-control', key: 'now' } as const, 0)
13// Where the session works (folder, its parent, git branch), for the header.
14const env = atom({ plugin: 'mission-control', key: 'env' } as const, { folder: '', path: '' } as Env)
15// The main thread's model, context and own usage, for its section.
16const main = atom({ plugin: 'mission-control', key: 'main' } as const, {
17 tokens: 0,
18 inputTokens: 0,
19 outputTokens: 0,
20 costUsd: 0,
21 limits: [],
22})
23
24const STEP_TOOL = 'mcp__mission-control__step'
25const PANE = 'mission-control'
26
27// ---------------------------------------------------------------------------
28// The panel's words.
29
30const S = {
31 pane: 'Mission Control',
32 repo: 'Repo',
33 mainThread: 'Main thread',
34 subagents: 'Subagents',
35 context: 'Context',
36 cost: 'Cost',
37 tokens: 'Tokens',
38 time: 'Time',
39 collapse: 'Collapse',
40 expand: 'Expand',
41 running: 'Running',
42 finished: 'Finished',
43 empty: 'No subagents yet',
44 round: 'round',
45 failed: 'error',
46 tokensWord: 'tokens',
47 isRunning: 'running',
48 isFinished: 'finished',
49 isFailed: 'failed',
50 isStopped: 'stopped',
51 stopping: 'stopping',
52 agent: 'agent',
53 agents: 'agents',
54 opened: 'opened',
55 closed: 'closed',
56 sub: 'sub',
57} as const
58
59const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
60
61const xml = (s: string): string =>
62 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
63
64// Rough advance of system UI text, in em; good enough to size the title's slot.
65const charEm = (ch: string): number =>
66 /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56
67
68const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
69
70// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
71const fitText = (s: string, size: number, maxW: number): string => {
72 if (textWidth(s, size) <= maxW) return s
73 let out = ''
74 for (const ch of s) {
75 if (textWidth(out + ch + '…', size) > maxW) break
76 out += ch
77 }
78 return out + '…'
79}
80
81// ---------------------------------------------------------------------------
82// Agents panel: every subagent of the session.
83
84// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
85// The engine reports tokens, not money, so the panel's cost is an estimate.
86// Three sources, first valid one wins: the official pricing page, fetched at
87// session start; the last valid fetch, kept in `$.store`; the built-in PRICES.
88// PRICES: list prices from platform.claude.com/docs/en/about-claude/pricing,
89// checked 2026-10-17. First match wins, so specific versions go first.
90type Price = [number, number, number, number]
91const PRICES: [RegExp, Price][] = [
92 [/(fable|mythos)-5-1/, [10, 50, 0.25, 12.5]],
93 [/fable|mythos/, [10, 50, 1, 12.5]],
94 [/opus-5-5/, [4, 20, 0.2, 5]],
95 [/opus/, [5, 25, 0.5, 6.25]],
96 [/sonnet-5-5/, [2, 10, 0.1, 2.5]],
97 [/sonnet-5/, [2, 10, 0.2, 2.5]],
98 [/sonnet/, [3, 15, 0.3, 3.75]],
99 [/haiku-5-5/, [0.1, 0.5, 0.01, 0.125]],
100 [/haiku/, [1, 5, 0.1, 1.25]],
101]
102
103// Haiku 5.5 bills a request whose prompt (input + cache) passes 100k
104// tokens at these prices instead.
105const HAIKU_5_5_LONG: Price = [0.5, 2.5, 0.05, 0.625]
106const HAIKU_5_5_LIMIT = 100_000
107
108// --- prices from the official page. Its text goes through the parser and no
109// further: only numbers come out, never to the model, never into an SVG.
110
111const PRICE_URL = 'https://platform.claude.com/docs/en/about-claude/pricing.md'
112const PRICE_STORE_KEY = 'prices'
113// The page runs near 50 KB; anything far past that is not it.
114const PRICE_MAX_CHARS = 512_000
115// The page's table has far fewer rows; more is not it.
116const PRICE_MAX_ROWS = 200
117// Families the page must list for its table to count.
118const PRICE_FAMILIES = ['opus', 'sonnet', 'haiku'] as const
119// Models the page must list with their long-prompt tier, as PRICES knows them.
120const PRICE_TIERED = ['haiku-5-5'] as const
121// When PRICES was checked against the page (2026-10-17): a stored table
122// older than that loses to PRICES.
123const PRICES_CHECKED_AT = 1_792_195_200_000
124
125/** A prompt past `over` tokens bills at `price` instead (Haiku 5.5). */
126type LongPrice = { readonly over: number; readonly price: Price }
127/** One model's prices, keyed as `modelKey` names it: `opus-5-5`. */
128type PriceRow = { readonly key: string; readonly price: Price; readonly long?: LongPrice }
129type PriceTable = { readonly checkedAt: number; readonly rows: readonly PriceRow[] }
130
131// The table in use while the page or the store has given a valid one.
132let fetchedPrices: PriceTable | undefined
133
134const MODEL_ID = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i
135const KEY = /^(fable|mythos|opus|sonnet|haiku)-\d{1,2}(-\d{1,2})?$/
136
137// `claude-opus-5-5-20261001` and `claude-opus-5-5[1m]` → `opus-5-5`; the
138// alias `claude-opus-4-0` → `opus-4`, as the page names it.
139const modelKey = (model: string): string | undefined => {
140 const [, family, major, minor] = MODEL_ID.exec(model) ?? []
141 if (!family || !major) return undefined
142 return `${family.toLowerCase()}-${major}${minor && minor !== '0' ? `-${minor}` : ''}`
143}
144
145const isList = (v: unknown): v is readonly unknown[] => Array.isArray(v)
146const isFigure = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0.001 && v <= 1000
147
148// Each figure in range, and in the order every Claude price keeps:
149// cache read < input < output, cache write ≥ input.
150const isPrice = (p: unknown): p is Price => {
151 if (!isList(p) || p.length !== 4) return false
152 const [input, output, read, write] = p
153 return isFigure(input) && isFigure(output) && isFigure(read) && isFigure(write) && read < input && input < output && write >= input
154}
155
156// The whole table holds, or none of it is used.
157const isValidTable = (rows: readonly PriceRow[]): boolean => {
158 const keys = rows.map(r => r.key)
159 return (
160 new Set(keys).size === keys.length &&
161 PRICE_FAMILIES.every(f => keys.some(k => k.startsWith(`${f}-`))) &&
162 PRICE_TIERED.every(k => rows.some(r => r.key === k && r.long)) &&
163 rows.every(
164 r =>
165 KEY.test(r.key) &&
166 isPrice(r.price) &&
167 (r.long === undefined ||
168 (Number.isInteger(r.long.over) && r.long.over > 0 && isPrice(r.long.price) && r.long.price.every((v, i) => v >= (r.price[i] ?? 0)))),
169 )
170 )
171}
172
173// `$12.50 / MTok`, a footnote mark allowed: the number, or undefined.
174const moneyOf = (cell: string): number | undefined => {
175 const m = /^\$(\d{1,4}(?:\.\d{1,4})?) \/ MTok$/.exec(cell.replace(/<sup>\d{1,2}<\/sup>/g, '').trim())
176 return m?.[1] === undefined ? undefined : Number(m[1])
177}
178
179type PageRow = { readonly key: string; readonly price: Price; readonly tier?: { readonly side: 'up to' | 'over'; readonly at: number } }
180
181// `Claude Haiku 5.5 (for prompts over 100,000 tokens)` → its key and tier.
182// Rows of other shapes are skipped.
183const modelRowOf = (cell: string): Omit<PageRow, 'price'> | undefined => {
184 const text = cell.replace(/<sup>\d{1,2}<\/sup>/g, '').trim()
185 const m = /^Claude (Fable|Mythos|Opus|Sonnet|Haiku) (\d{1,2})(?:\.(\d{1,2}))?(?![\d.])(.*)$/.exec(text)
186 const [, family, major, minor, rest = ''] = m ?? []
187 if (!family || !major) return undefined
188 const key = `${family.toLowerCase()}-${major}${minor ? `-${minor}` : ''}`
189 const t = /\(for prompts (up to|over) (\d{1,3}(?:,\d{3})*) tokens\)/.exec(rest)
190 const side = t?.[1]
191 const at = Number((t?.[2] ?? '').replace(/,/g, ''))
192 if (side === 'up to' || side === 'over') return { key, tier: { side, at } }
193 return { key }
194}
195
196// The "Model pricing" table of the page as rows, by its header's names, not
197// its column order. Undefined when the table is missing or a price unreadable.
198const parsePricePage = (md: string): PriceRow[] | undefined => {
199 const lines = md.split('\n')
200 const start = lines.findIndex(l => l.trim() === '## Model pricing')
201 if (start < 0) return undefined
202 const after = lines.slice(start + 1)
203 const end = after.findIndex(l => l.startsWith('## '))
204 const section = end < 0 ? after : after.slice(0, end)
205 const first = section.findIndex(l => l.trim().startsWith('|'))
206 if (first < 0) return undefined
207 const rest = section.slice(first)
208 const stop = rest.findIndex(l => !l.trim().startsWith('|'))
209 const table = stop < 0 ? rest : rest.slice(0, stop)
210 const cells = (line: string): string[] => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
211
212 const [headLine, sepLine, ...body] = table
213 if (headLine === undefined || sepLine === undefined || !/^[\s|:-]+$/.test(sepLine)) return undefined
214 const head = cells(headLine)
215 const col = (re: RegExp): number => head.findIndex(h => re.test(h))
216 const cols = {
217 model: col(/^Model$/i),
218 input: col(/^Base input tokens$/i),
219 write: col(/^5m cache writes$/i),
220 read: col(/^Cache hits/i),
221 output: col(/^Output tokens$/i),
222 }
223 if (Object.values(cols).some(i => i < 0) || body.length > PRICE_MAX_ROWS) return undefined
224
225 const pageRows: PageRow[] = []
226 for (const line of body) {
227 const c = cells(line)
228 if (c.length !== head.length) return undefined
229 const model = modelRowOf(c[cols.model] ?? '')
230 if (!model) continue
231 const [input, output, read, write] = [cols.input, cols.output, cols.read, cols.write].map(i => moneyOf(c[i] ?? ''))
232 if (input === undefined || output === undefined || read === undefined || write === undefined) return undefined
233 pageRows.push({ ...model, price: [input, output, read, write] })
234 }
235
236 // A model in two tiers: the lower one is its price, the upper one its `long`.
237 const rows: PriceRow[] = []
238 for (const r of pageRows) {
239 if (!r.tier) {
240 rows.push({ key: r.key, price: r.price })
241 continue
242 }
243 if (r.tier.side === 'over') continue
244 const at = r.tier.at
245 const over = pageRows.find(o => o.key === r.key && o.tier?.side === 'over' && o.tier.at === at)
246 if (!over) return undefined
247 rows.push({ key: r.key, price: r.price, long: { over: at, price: over.price } })
248 }
249 // An upper tier with no lower one is a table it cannot read.
250 if (pageRows.some(o => o.tier?.side === 'over' && !rows.some(r => r.key === o.key && r.long))) return undefined
251 return rows
252}
253
254// The page's text, checked, as a table; undefined when anything is off.
255const tableOfPage = (text: string, checkedAt: number): PriceTable | undefined => {
256 if (text.length > PRICE_MAX_CHARS) return undefined
257 const rows = parsePricePage(text)
258 return rows && isValidTable(rows) ? { checkedAt, rows } : undefined
259}
260
261// What `$.store` gave back, checked again as the page's table is.
262const storedTable = (v: unknown): PriceTable | undefined => {
263 if (typeof v !== 'object' || v === null || !('checkedAt' in v) || !('rows' in v)) return undefined
264 const { checkedAt, rows } = v
265 if (typeof checkedAt !== 'number' || !Array.isArray(rows)) return undefined
266 const out: PriceRow[] = []
267 for (const r of rows) {
268 if (typeof r !== 'object' || r === null || !('key' in r) || !('price' in r)) return undefined
269 const { key, price } = r
270 if (typeof key !== 'string' || !isPrice(price)) return undefined
271 const long = 'long' in r ? r.long : undefined
272 if (long === undefined) {
273 out.push({ key, price })
274 continue
275 }
276 if (typeof long !== 'object' || long === null || !('over' in long) || !('price' in long)) return undefined
277 const { over, price: longPrice } = long
278 if (typeof over !== 'number' || !isPrice(longPrice)) return undefined
279 out.push({ key, price, long: { over, price: longPrice } })
280 }
281 return checkedAt >= PRICES_CHECKED_AT && isValidTable(out) ? { checkedAt, rows: out } : undefined
282}
283
284// The last valid fetch from the store, then the page itself. A store or a
285// fetch that fails, or does not check out, changes nothing. Never awaited by the panel.
286async function loadPrices($: EngineInterface): Promise<void> {
287 const stored = storedTable(await $.store.get(PRICE_STORE_KEY).catch(() => undefined))
288 if (stored) fetchedPrices = stored
289 const r = await $.http.fetch(PRICE_URL, { headers: { accept: 'text/markdown' } })
290 if (!r.ok || !(r.headers['content-type'] ?? '').startsWith('text/')) return
291 const table = tableOfPage(r.text, await $.clock.now())
292 if (!table) return
293 fetchedPrices = table
294 await $.store.set(PRICE_STORE_KEY, table)
295}
296
297type Usage = {
298 input_tokens: number
299 output_tokens: number
300 cache_read_input_tokens: number
301 cache_creation_input_tokens: number
302}
303
304// Everything the request sent, cached or not.
305const inputOf = (u: Usage): number =>
306 (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
307
308// The fetched table first; a model it does not list takes PRICES.
309const priceOf = (model: string, prompt = 0): Price => {
310 const key = modelKey(model)
311 const row = key === undefined ? undefined : fetchedPrices?.rows.find(r => r.key === key)
312 if (row) return row.long && prompt > row.long.over ? row.long.price : row.price
313 const id = model.toLowerCase()
314 if (/haiku-5-5/.test(id) && prompt > HAIKU_5_5_LIMIT) return HAIKU_5_5_LONG
315 return PRICES.find(([re]) => re.test(id))?.[1] ?? [4, 20, 0.2, 5]
316}
317
318const costOf = (model: string, u: Usage): number => {
319 const [i, o, r, w] = priceOf(model, inputOf(u))
320 return (
321 ((u.input_tokens || 0) * i +
322 (u.output_tokens || 0) * o +
323 (u.cache_read_input_tokens || 0) * r +
324 (u.cache_creation_input_tokens || 0) * w) /
325 1e6
326 )
327}
328
329// Haiku 5.5 has the 1M window; older Haikus keep 200k.
330const windowOf = (model: string): number => (/haiku/i.test(model) && !/haiku-5-5/i.test(model) ? 200_000 : 1_000_000)
331
332const modelName = (id: string): string => {
333 const m = MODEL_ID.exec(id)
334 const [, family = '', major = '', minor] = m ?? []
335 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
336 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
337}
338
339const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
340
341const fmtTokens = (n: number): string =>
342 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
343
344const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
345
346const fmtTime = (ms: number): string => {
347 const s = Math.max(0, Math.round(ms / 1000))
348 const h = Math.floor(s / 3600)
349 const m = Math.floor((s % 3600) / 60)
350 const ss = String(s % 60).padStart(2, '0')
351 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
352}
353
354const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
355
356const totals = (list: AgentRun[], at: number) => {
357 const cost = list.reduce((s, a) => s + a.costUsd, 0)
358 const tokens = list.reduce((s, a) => s + a.tokens, 0)
359 const start = Math.min(...list.map(a => a.startedAt))
360 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
361 return { cost, tokens, time: list.length ? end - start : 0 }
362}
363
364// ---------------------------------------------------------------------------
365// The tree of spawns. `agent.spawn` records the loop each run was
366// spawned from; a run whose parent is not in the list (main thread, trimmed)
367// is a root. A family is a root and all its descendants.
368
369const MAX_DEPTH = 3
370const INDENT = 18
371const RAIL_OFFSET = 10
372
373type TreeRow = {
374 a: AgentRun
375 depth: number
376 /** rails[level]: the ancestor at that depth has later siblings, so its line runs on. */
377 rails: boolean[]
378 isLast: boolean
379 isGroupEnd: boolean
380 /** Every descendant, not just direct children. */
381 kids: number
382}
383
384type Family = { rows: TreeRow[]; isRunning: boolean }
385
386const familiesOf = (list: AgentRun[]): Family[] => {
387 const byAgentId = new Map(list.filter(a => a.agentId).map(a => [a.agentId as string, a]))
388 const parentOf = (a: AgentRun): AgentRun | undefined => {
389 const p = a.parentAgentId ? byAgentId.get(a.parentAgentId) : undefined
390 return p && p.id !== a.id ? p : undefined
391 }
392 const children = new Map<string, AgentRun[]>()
393 const roots: AgentRun[] = []
394 for (const a of list) {
395 const p = parentOf(a)
396 if (!p) roots.push(a)
397 else children.set(p.id, [...(children.get(p.id) ?? []), a])
398 }
399 const seen = new Set<string>()
400 const walk = (a: AgentRun, depth: number, rails: boolean[], isLast: boolean, out: TreeRow[]): void => {
401 if (seen.has(a.id)) return
402 seen.add(a.id)
403 const at = out.length
404 out.push({ a, depth, rails, isLast, isGroupEnd: false, kids: 0 })
405 const kids = children.get(a.id) ?? []
406 // Below this run, its own rail runs on while it has later siblings.
407 const next = rails.slice()
408 next[depth] = depth > 0 && !isLast
409 kids.forEach((c, i) => walk(c, depth + 1, next, i === kids.length - 1, out))
410 const row = out[at]
411 if (row) row.kids = out.length - at - 1
412 }
413 // A parent loop that never reaches a root (it should not happen) still shows: its runs become roots.
414 const families = (rs: AgentRun[]): Family[] =>
415 rs.flatMap(r => {
416 if (seen.has(r.id)) return []
417 const rows: TreeRow[] = []
418 walk(r, 0, [], true, rows)
419 const last = rows[rows.length - 1]
420 if (last) last.isGroupEnd = true
421 return [{ rows, isRunning: rows.some(x => x.a.status === 'running') }]
422 })
423 const out = families(roots)
424 return [...out, ...families(list.filter(a => !seen.has(a.id)))]
425}
426
427// --- desktop drawings: each row is one SVG.
428
429// Palette from the redesign research (Few, Carbon, Okabe-Ito, WCAG):
430// grey text in three steps, one hue per state, every color with a dark variant.
431// Text passes 4.5:1 and marks 3:1 on #FFFFFF / #1F1F1E. `.cR` running, `.cF`
432// failed, `.cG` `.cW` `.cO` `.cF` the header's context by level (green, amber,
433// orange, red), `.bf` an agent's body (`--b`), `.card` the usage card's soft fill.
434// Metrics: `.mvC` cost in a muted teal, the only colored figure on a row; time,
435// context and tokens in the grey `.ml`; the words `ctx`, `in`, `out` and the
436// agent type lighter still, `.mk`
437// (about 2.6:1, below 4.5:1 on purpose: they name columns the values explain).
438const BODY_COLORS: [string, string][] = [
439 ['#C96442', '#D97757'],
440 ['#0072B2', '#56B4E9'],
441 ['#009E73', '#2BBE8E'],
442 ['#B8609A', '#CC79A7'],
443 ['#B97A00', '#E69F00'],
444 ['#2A86BD', '#7CC4EE'],
445 ['#7F7A00', '#C9C24A'],
446 ['#66717D', '#9AA4AF'],
447]
448const bodyCss = (i: 0 | 1): string => BODY_COLORS.map((c, n) => `.b${n}{--b:${c[i]}}`).join('')
449const PANE_CSS = `<style>
450.t{fill:#1F1F1E}.s{fill:#55544F}.m{fill:#73726D}.k{fill:#E6E5E1}.ln{stroke:#ECEBE7}.tile{fill:#F4F3F0}.card{fill:#F5F4F1}.tr{stroke:#D3D2CD;stroke-width:1.2;fill:none;stroke-linecap:round}
451.mvC{fill:#2B6A62}.ml{fill:#73726D}.mk{fill:#A3A29C}
452.cR{fill:#B5532F}.cF{fill:#C42B2B}.cW{fill:#9A5B00}.cG{fill:#2E7D52}.cO{fill:#B34E00}.sF{stroke:#C42B2B}.sM{stroke:#73726D}.bf{fill:var(--b)}${bodyCss(0)}
453@media (prefers-color-scheme: dark){.t{fill:#ECEBE8}.s{fill:#BDBCB6}.m{fill:#97968F}.k{fill:#3A3A37}.ln{stroke:#2E2E2C}.tile{fill:#262625}.card{fill:#2A2A28}.tr{stroke:#4A4A47}
454.mvC{fill:#8FC8BF}.ml{fill:#97968F}.mk{fill:#6C6B66}
455.cR{fill:#E08A6B}.cF{fill:#F2726B}.cW{fill:#E8A33D}.cG{fill:#5DBE8A}.cO{fill:#F08A4B}.sF{stroke:#F2726B}.sM{stroke:#97968F}${bodyCss(1)}}
456</style>`
457
458// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid. Explore
459// runs wear a pirate costume; every other run is the bare crab.
460const CLAY = '#D97757'
461// Each agent run gets its own body color, in launch order, cycling.
462// The terminal takes the light variant; an SVG takes `BODY` and the row's `.bN` class.
463const bodyIndexOf = (list: AgentRun[], a: AgentRun): number => {
464 const i = list.findIndex(x => x.id === a.id)
465 return ((i % BODY_COLORS.length) + BODY_COLORS.length) % BODY_COLORS.length
466}
467const bodyOf = (list: AgentRun[], a: AgentRun): string => BODY_COLORS[bodyIndexOf(list, a)]?.[0] ?? CLAY
468const BODY = 'var(--b)'
469const INK = '#1F1E1D'
470
471// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
472// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
473type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
474type Costume = 'explore' | 'other'
475
476const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
477 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
478
479// `armCls` lets a raised claw travel with the prop it holds.
480const crabBody = (f: Fill, body: string, armFront = 0, armCls?: string): void => {
481 f(7, 10, 16, 12, body)
482 f(3, 14, 4, 4, body)
483 f(23, 14 + armFront, 4, 4, body, armCls)
484 f(9, 12, 2, 2, INK)
485 f(19, 12, 2, 2, INK)
486 f(7, 22, 2, 4, body, 'la')
487 f(17, 22, 2, 4, body, 'la')
488 f(11, 22, 2, 4, body, 'lb')
489 f(21, 22, 2, 4, body, 'lb')
490}
491
492// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
493// once-a-second redraw of a running row restarts them in phase. Every crab walks;
494// a costume adds its prop's own motion on top.
495const CRAB_CSS = `<style>
496.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
497.run .bd{animation:bob .5s steps(1) infinite -.125s}
498.run g{transform-box:fill-box}
499@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
500.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
501@keyframes fence{50%{transform:rotate(25deg)}}
502@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
503</style>`
504
505const COSTUMES: Record<Costume, (f: Fill) => void> = {
506 // Explore: pirate scouting the code; the cutlass fences.
507 explore: f => {
508 crabBody(f, BODY)
509 stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
510 f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
511 f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
512 },
513 other: f => crabBody(f, BODY),
514}
515
516const costumeOf = (type: string): Costume => (type === 'Explore' ? 'explore' : 'other')
517
518// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
519// legs stay outside it and step on their own.
520const crab = (x: number, y: number, costume: Costume, isWalking: boolean, scale: number): string => {
521 const groups = new Map<string, string[]>([['bd', []]])
522 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
523 if (!groups.has(cls)) groups.set(cls, [])
524 const paint = c === BODY ? 'class="bf"' : `fill="${c}"`
525 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" ${paint}/>`)
526 }
527 COSTUMES[costume](f)
528 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
529 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
530 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
531 return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
532}
533
534// A run still working shows its stop button in the slot, a finished or
535// stopped one its fold arrow; a failed one draws a red cross there.
536const failedMark = (x: number, y: number): string =>
537 `<path class="sF" d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke-width="1.8" stroke-linecap="round"/>`
538
539// Runs that fold to one line: those that ended well, and those stopped from the panel.
540const isFoldable = (a: AgentRun): boolean => a.status === 'done' || a.status === 'stopped'
541
542// Runs whose slot holds a Button beside the drawing: the stop, or the fold arrow.
543const hasSlotButton = (a: AgentRun): boolean => a.status === 'running' || isFoldable(a)
544
545// What a run says it is, for a screen reader.
546const stateOf = (a: AgentRun): string =>
547 a.status === 'running' ? (a.isStopRequested ? S.stopping : S.isRunning) : a.status === 'stopped' ? S.isStopped : a.status === 'failed' ? S.isFailed : S.isFinished
548
549const svg = (W: number, H: number, body: string): string =>
550 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
551
552// The pane's own title says "Mission Control". The header has two bands. Above, the
553// folder, its parent path and the git branch in a pill; below, context, tokens,
554// cost and time, each value under its label, the last three on the agent rows'
555// metric columns. Only the cost and the context carry color and weight.
556const MONO = "ui-monospace,'SF Mono',SFMono-Regular,Menlo,monospace"
557const monoWidth = (s: string, size: number): number => [...s].length * 0.6 * size
558
559// Cuts the middle out of `s` to fit `maxW`, keeping its start and its end.
560const fitMiddle = (s: string, size: number, maxW: number): string => {
561 if (textWidth(s, size) <= maxW) return s
562 const chars = [...s]
563 let head = Math.ceil(chars.length / 2)
564 let tail = Math.floor(chars.length / 2)
565 while (head + tail > 1 && textWidth(`${chars.slice(0, head).join('')}…${chars.slice(chars.length - tail).join('')}`, size) > maxW) {
566 if (head > tail) head--
567 else tail--
568 }
569 return `${chars.slice(0, head).join('')}…${chars.slice(chars.length - tail).join('')}`
570}
571
572const BRANCH_ICON = (x: number, y: number): string =>
573 `<g transform="translate(${x},${y})" class="sM" fill="none" stroke-width="1.2" stroke-linecap="round"><circle cx="2" cy="2" r="1.5"/><circle cx="2" cy="10" r="1.5"/><circle cx="8" cy="4" r="1.5"/><path d="M2 3.5v5M8 5.5c0 2.5-6 2-6 3"/></g>`
574
575// The main thread's context figure and bar change color as it fills up:
576// green below 40%, amber below 50%, orange below 60%, red from there.
577const ctxLevel = (pct: number): string => (pct < 40 ? 'cG' : pct < 50 ? 'cW' : pct < 60 ? 'cO' : 'cF')
578
579// A rate-limit window grey until 80%, orange until 95%, red from there.
580const limitLevel = (pct: number): string => (pct < 80 ? 'ml' : pct < 95 ? 'cO' : 'cF')
581
582const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
583
584// The windows the section shows, in a fixed order, at most two.
585const limitsOf = (list: readonly Limit[]): { label: string; pct: number }[] =>
586 Object.entries(LIMIT_LABEL)
587 .flatMap(([kind, label]) => {
588 const l = list.find(x => x.kind === kind)
589 return l ? [{ label, pct: Math.round(l.pct) }] : []
590 })
591 .slice(0, 2)
592
593// A window size reads `1M`, not `1.0M`.
594const fmtWindow = (n: number): string => (n >= 1e6 && n % 1e6 === 0 ? `${n / 1e6}M` : fmtTokens(n))
595
596const svgText = (x: number, y: number, size: number, cls: string, body: string, extra = ''): string =>
597 `<text class="${cls}" x="${x}" y="${y}" font-family="${FONT}" font-size="${size}" font-variant-numeric="tabular-nums"${extra}>${body}</text>`
598const END = ' text-anchor="end"'
599const BOLD = ' font-weight="600"'
600// Bold runs ~6% wider than the estimate.
601const boldWidth = (s: string, size: number): number => textWidth(s, size) * HEADER_BOLD
602
603// A section's title: 10 px capitals, tracked, bold, in the `.m` grey, so it
604// reads apart from the 11 px column labels.
605const TITLE_TRACKING = 0.8
606const sectionTitle = (x: number, y: number, title: string): string =>
607 svgText(x, y, 10, 'm', xml(title.toUpperCase()), `${BOLD} letter-spacing="${TITLE_TRACKING}"`)
608const titleWidth = (title: string): number => boldWidth(title.toUpperCase(), 10) + TITLE_TRACKING * title.length
609
610// One figure of a row: its width and its markup, drawn at a given x.
611type Figure = { w: number; body: string }
612
613// A grey figure with its word in the lighter `ctx` grey, before or after it.
614const labelled = (value: string, word: string, side: 'before' | 'after' = 'before', size = SIZE): Figure => ({
615 w: textWidth(`${word} ${value}`, size),
616 body:
617 side === 'before'
618 ? `<tspan class="mk">${xml(word)}</tspan> <tspan class="ml">${xml(value)}</tspan>`
619 : `<tspan class="ml">${xml(value)}</tspan> <tspan class="mk">${xml(word)}</tspan>`,
620})
621
622// Figures in a row from x = 0, FIGURE_GAP apart. A group that does not fit in
623// `maxW` is left out whole, and so are the ones after it.
624const FIGURE_GAP = 20
625const spaced = (groups: Figure[][], maxW: number, y: number, size = SIZE): string => {
626 const out: string[] = []
627 let x = 0
628 for (const group of groups) {
629 const gaps = out.length ? group.length : group.length - 1
630 const w = group.reduce((sum, f) => sum + f.w, 0) + FIGURE_GAP * gaps
631 if (x + w > maxW) break
632 for (const f of group) {
633 if (out.length) x += FIGURE_GAP
634 out.push(svgText(Math.round(x), y, size, 'ml', f.body))
635 x += f.w
636 }
637 }
638 return out.join('')
639}
640
641// The header: the repo on top, the session's usage in one card below.
642// Top line: the folder, its parent path and the git branch in a pill, with no
643// title. Below it a soft card holds the usage in two sections, Main thread and
644// Subagents, each under its title; the card starts in this SVG and ends in the
645// subagents' one, stacked right under it. Right of the time column the rows
646// keep room for their status mark and fold arrow (ARROW_W + ROW_GAP); the card
647// takes that room as its right margin outside plus PAD inside, and mirrors both
648// on the left, so the right side keeps the agent rows' columns.
649// Main thread: two rows mirrored around the context bar. On the left the model
650// over the cost and tokens; on the right two columns that share the agent rows'
651// cost and time edges: the context percentage over one limit, the context
652// tokens over the other. Everything ends on the time column.
653const PAD = 12
654// A function, not a constant: ARROW_W and ROW_GAP are declared further down.
655const cardInset = (): number => ARROW_W + ROW_GAP - PAD
656const CARD_R = 8
657const TITLE_GAP = 25
658const SECTION_GAP = 44
659const Y_ID = 22
660const CARD_TOP = 40
661// Air inside the card above its first title and below its last figures,
662// and below the card before the agent list.
663const CARD_PAD_TOP = 32
664const CARD_PAD_BOTTOM = 24
665const CARD_GAP_BELOW = 16
666const Y_TITLE = CARD_TOP + CARD_PAD_TOP
667const Y_ROW1 = Y_TITLE + TITLE_GAP
668const BAR_Y = Y_ROW1 + 9
669const BAR_H = 4
670const Y_ROW2 = BAR_Y + BAR_H + 9 + 9
671const HEADER_H = Y_ROW2 + 12
672const SIZE = 12
673const SIZE_KEY = 13
674
675const headerSvg = (W: number, where: Env, m: Main): string => {
676 const [, c2, c3] = rowEdges(W)
677 const cx = cardInset()
678 // The card's right edge, cx from the panel's, as on the left.
679 const cardW = W - cx
680 const x0 = cx + PAD
681
682 // Repo: folder, then its parent path, then the branch pill ending on the time column.
683 // The folder starts at x0, on the card's titles.
684 let pill = ''
685 let pillX = c3
686 if (where.branch) {
687 const maxText = Math.max(30, W * 0.4 - 26)
688 let branch = where.branch
689 while (branch.length > 2 && monoWidth(branch, 11) > maxText) branch = branch.slice(0, -2) + '…'
690 const pw = Math.round(26 + monoWidth(branch, 11))
691 pillX = c3 - pw
692 pill =
693 `<rect class="tile" x="${pillX}" y="${Y_ID - 11}" width="${pw}" height="18" rx="6"/>${BRANCH_ICON(pillX + 6, Y_ID - 8)}` +
694 `<text class="s" x="${pillX + 20}" y="${Y_ID + 2}" font-family="${MONO}" font-size="11">${xml(branch)}</text>`
695 }
696 const folder = fitText(where.folder || '—', 14, Math.max(40, pillX - x0 - 12 - 60))
697 const pathX = Math.round(x0 + textWidth(folder, 14) * 1.04 + 6)
698 const pathW = pillX - 12 - pathX
699 const path = where.path && pathW > 30 ? svgText(pathX, Y_ID, 11, 'm', xml(fitMiddle(where.path, 11, pathW))) : ''
700
701 // Main thread. Both left lines stop at the same edge, clear of the cost column's figures.
702 const leftW = Math.max(0, c2 - x0 - Math.max(boldWidth('100%', SIZE_KEY), textWidth('spend 100%', SIZE)) - 16)
703 // The model in the row's grey, its effort in the lighter one; the effort goes first when room runs out.
704 const name = m.model ? modelName(m.model) : '—'
705 const effort = m.model && m.effort ? ` · ${m.effort}` : ''
706 const model =
707 effort && textWidth(name + effort, SIZE) <= leftW
708 ? `${xml(name)}<tspan class="mk">${xml(effort)}</tspan>`
709 : xml(fitText(name, SIZE, leftW))
710 const pct = m.ctxPct ?? 0
711 const pctClass = ctxLevel(pct)
712 const ctxTokens = m.ctxWindow
713 ? `<tspan class="ml">${xml(fmtTokens(m.ctxTokens ?? 0))}</tspan><tspan class="mk"> / ${xml(fmtWindow(m.ctxWindow))}</tspan>`
714 : ''
715 const barW = c3 - x0
716 const fill = Math.round((barW * Math.min(100, pct)) / 100)
717
718 // Row 2, left: the cost, then the tokens, then sent and generated; a group
719 // that does not fit is left out whole, the last first.
720 const cost = fmtCost(m.costUsd)
721 const figures = spaced(
722 [
723 [{ w: boldWidth(cost, SIZE_KEY), body: `<tspan class="mvC" font-size="${SIZE_KEY}" font-weight="600">${xml(cost)}</tspan>` }],
724 [labelled(fmtTokens(m.tokens), S.tokensWord, 'after')],
725 [labelled(fmtTokens(m.inputTokens), 'in'), labelled(fmtTokens(m.outputTokens), 'out')],
726 ],
727 leftW,
728 Y_ROW2,
729 )
730
731 // Row 2, right: the limits on the time column, then the cost column.
732 const limits = limitsOf(m.limits)
733 .reverse()
734 .map((l, i) =>
735 svgText(i === 0 ? c3 : c2, Y_ROW2, SIZE, 'ml', `<tspan class="mk">${l.label}</tspan> <tspan class="${limitLevel(l.pct)}">${l.pct}%</tspan>`, END),
736 )
737 .join('')
738
739 return svg(
740 W,
741 HEADER_H,
742 `${svgText(x0, Y_ID, 14, 'ml', xml(folder), BOLD)}${path}${pill}
743<path class="card" d="M${cx} ${CARD_TOP + CARD_R}a${CARD_R} ${CARD_R} 0 0 1 ${CARD_R} -${CARD_R}H${cardW - CARD_R}a${CARD_R} ${CARD_R} 0 0 1 ${CARD_R} ${CARD_R}V${HEADER_H}H${cx}Z"/>
744${sectionTitle(x0, Y_TITLE, S.mainThread)}
745${svgText(x0, Y_ROW1, SIZE, 's', model)}
746${svgText(c2, Y_ROW1, SIZE_KEY, pctClass, `${pct}%`, END + BOLD)}${ctxTokens ? svgText(c3, Y_ROW1, SIZE, 'ml', ctxTokens, END) : ''}
747<rect class="k" x="${x0}" y="${BAR_Y}" width="${barW}" height="${BAR_H}" rx="${BAR_H / 2}"/><rect class="${pctClass}" x="${x0}" y="${BAR_Y}" width="${fill}" height="${BAR_H}" rx="${BAR_H / 2}"/>
748<g transform="translate(${x0},0)">${figures}</g>
749${limits}`,
750 )
751}
752
753// The subagents' sums, the card's second section: the title SECTION_GAP below
754// the main thread's last row, with the column labels on its line while 24 px
755// stay between them, else LABELS_DROP lower; the values TITLE_GAP below the
756// labels, on the agent rows' columns; then the card's rounded bottom. Left of
757// the values, how many runs there are, then how many still work and how many
758// failed. Figures take the main thread's sizes: SIZE, and SIZE_KEY for the cost.
759const SUB_TITLE = Y_ROW2 + SECTION_GAP - HEADER_H
760const LABELS_DROP = 19
761const labelsDrop = (W: number): number => {
762 const [c1] = rowEdges(W)
763 return cardInset() + PAD + titleWidth(S.subagents) + 24 <= c1 - textWidth(S.tokens, 11) ? 0 : LABELS_DROP
764}
765const cardBottom = (W: number): number => SUB_TITLE + labelsDrop(W) + TITLE_GAP + CARD_PAD_BOTTOM
766const subagentsHeight = (W: number): number => cardBottom(W) + CARD_GAP_BELOW
767const subagentsSvg = (W: number, t: ReturnType<typeof totals>, list: readonly AgentRun[]): string => {
768 const [c1, c2, c3] = rowEdges(W)
769 const H = subagentsHeight(W)
770 const bottom = cardBottom(W)
771 const yLab = SUB_TITLE + labelsDrop(W)
772 const yVal = yLab + TITLE_GAP
773 const cx = cardInset()
774 // The card's right edge, cx from the panel's, as on the left.
775 const cardW = W - cx
776 const x0 = cx + PAD
777 const running = list.filter(a => a.status === 'running').length
778 const failed = list.filter(a => a.status === 'failed').length
779 const tokens = fmtTokens(t.tokens)
780 // A failed count keeps the red of the failed mark.
781 const failedFigure: Figure = {
782 w: textWidth(`${failed} ${S.isFailed}`, SIZE),
783 body: `<tspan class="cF">${failed}</tspan> <tspan class="mk">${S.isFailed}</tspan>`,
784 }
785 const counts = spaced(
786 [
787 [labelled(String(list.length), list.length === 1 ? S.agent : S.agents, 'after')],
788 running ? [labelled(String(running), S.isRunning, 'after')] : [],
789 failed ? [failedFigure] : [],
790 ].filter(g => g.length),
791 c1 - x0 - textWidth(tokens, SIZE) - 16,
792 yVal,
793 )
794 return svg(
795 W,
796 H,
797 `<path class="card" d="M${cx} 0H${cardW}V${bottom - CARD_R}a${CARD_R} ${CARD_R} 0 0 1 -${CARD_R} ${CARD_R}H${cx + CARD_R}a${CARD_R} ${CARD_R} 0 0 1 -${CARD_R} -${CARD_R}Z"/>
798${sectionTitle(x0, SUB_TITLE, S.subagents)}
799${svgText(c1, yLab, 11, 'mk', S.tokens, END)}${svgText(c2, yLab, 11, 'mk', S.cost, END)}${svgText(c3, yLab, 11, 'mk', S.time, END)}
800<g transform="translate(${x0},0)">${counts}</g>${svgText(c1, yVal, SIZE, 'ml', xml(tokens), END)}${svgText(c2, yVal, SIZE_KEY, 'mvC', xml(fmtCost(t.cost)), END + BOLD)}${svgText(c3, yVal, SIZE, 'ml', xml(fmtTime(t.time)), END)}`,
801 )
802}
803
804// A line of the list in 11 px and the `.mk` grey of `ctx`, `in` and `out`:
805// "No subagents yet", and the "Running · n" and "Finished · n" labels. Drawn,
806// not a dim Text, so the grey is ours and not the surface's.
807const NOTE_H = 18
808const noteSvg = (W: number, text: string): string =>
809 svg(W, NOTE_H, `<text class="mk" x="0" y="13" font-family="${FONT}" font-size="11">${xml(text)}</text>`)
810
811// The task's own progress when the worker reports steps; a finished run is full.
812const progressOf = (a: AgentRun): number | null => {
813 if (a.status === 'done') return 1
814 if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
815 return null
816}
817
818const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
819
820// Rows are taller and airier. A run that ended well or was stopped folds to
821// one line on its own; its arrow unfolds it. Failed and running runs always
822// show in full.
823const isFolded = (a: AgentRun, p: Panel): boolean => isFoldable(a) && !(p.expanded ?? []).includes(a.id)
824
825// Height, gap between text lines, crab scale and where the text starts. Text
826// blocks and the crab sit centred in the row.
827type RowGeom = { H: number; gap: number; scale: number; textX: number }
828const FULL_ROOT: RowGeom = { H: 76, gap: 19, scale: 0.9, textX: 38 }
829const FULL_SUB: RowGeom = { H: 64, gap: 17, scale: 0.7, textX: 30 }
830const FOLD_ROOT: RowGeom = { H: 38, gap: 0, scale: 0.6, textX: 26 }
831const FOLD_SUB: RowGeom = { H: 32, gap: 0, scale: 0.5, textX: 22 }
832const geomOf = (r: TreeRow, isFold: boolean): RowGeom =>
833 r.depth > 0 ? (isFold ? FOLD_SUB : FULL_SUB) : isFold ? FOLD_ROOT : FULL_ROOT
834
835const rowHeight = (r: TreeRow, isFold = false): number => geomOf(r, isFold).H
836
837// Baselines of `n` lines `gap` apart, centred on the row (+4: half a cap height).
838const baselines = (H: number, n: number, gap: number): number[] =>
839 Array.from({ length: n }, (_, k) => Math.round(H / 2 + 4 - ((n - 1) * gap) / 2 + k * gap))
840
841// The crab's body spans rows 10 to 26 of its 28-row grid: centre that.
842const crabTop = (H: number, sc: number): number => Math.round(H / 2 - 18 * sc)
843
844// The metrics form a borderless table of three right-aligned columns, the same
845// widths on every row, so cost, time and tokens line up down the whole list.
846const COL_GAP = 20
847// The slot right of each desktop row's time column: a finished run's fold
848// arrow, or the status mark of a run still working or failed. ROW_GAP keeps it
849// clear of the time column.
850const ARROW_W = 16
851const ROW_GAP = 4
852// The metric columns of the rows, and of the header above them, from the pane's width.
853const rowEdges = (W: number): [number, number, number] => metricEdges(W - ARROW_W - ROW_GAP)
854// A foldable row lights while hovered. A translucent grey reads on
855// both themes; no theme key paints a background.
856const ROW_HOVER = 'rgba(128,128,128,0.08)'
857// The slot Buttons keep their resting look under the pointer: dim, no
858// background. The desktop builds the pane's page nodes anew on every drawing
859// (once a second while runs work), so a Button under the pointer lost its
860// hover look and faded back in: a flash. With the same look either way, a new
861// drawing has nothing to fade. Needs the row's keyed Box.
862const SLOT_HOVER = { dimColor: true, backgroundColor: 'rgba(0,0,0,0)' } as const
863// The stop's slot lights while hovered, on the keyed Box around its Button. A
864// Box draws as a page node with no fade, so its hover comes back at once on a
865// new drawing; the Button's eases in, hence SLOT_HOVER. Darker than ROW_HOVER.
866const STOP_HOVER = 'rgba(128,128,128,0.2)'
867// Widths also fit the header's 14 px bold totals (bold runs ~6% wider).
868const HEADER_BOLD = 1.06
869const METRIC_COLS: [number, number, number] = [
870 Math.max(textWidth('888k', 11), textWidth('8.8M', 14) * HEADER_BOLD),
871 Math.max(textWidth('in 8.8M', 11), textWidth('$88.88', 14) * HEADER_BOLD),
872 Math.max(textWidth('out 888k', 11), textWidth('8:88:88', 14) * HEADER_BOLD),
873]
874// Right edge of each column, from the row's right edge.
875const metricEdges = (RX: number): [number, number, number] => {
876 const [, w2, w3] = METRIC_COLS
877 const c3 = RX
878 const c2 = c3 - w3 - COL_GAP
879 return [c2 - w2 - COL_GAP, c2, c3]
880}
881
882const altOf = (r: TreeRow, state: string): string =>
883 `${r.depth > 0 ? '↳ ' : ''}${r.a.description || r.a.type}: ${modelName(r.a.model)}, ${state}${r.kids > 0 ? `, ${r.kids} ${S.sub}` : ''}` +
884 // A folded row still says its cost and tokens to a screen reader.
885 `, ${fmtCost(r.a.costUsd)}, ${fmtTokens(r.a.tokens)} ${S.tokensWord}`
886
887// Steps as small blocks after "3/5" (a thin bar past 8 steps). Returns the markup
888// and the width it took.
889const MAX_STEP_BLOCKS = 8
890const stepBlocks = (x: number, base: number, done: number, total: number): [string, number] => {
891 if (total > MAX_STEP_BLOCKS) {
892 const w = 48
893 const fill = Math.round((w * Math.min(done, total)) / total)
894 return [`<rect class="k" x="${x}" y="${base - 6}" width="${w}" height="3" rx="1.5"/><rect class="cR" x="${x}" y="${base - 6}" width="${fill}" height="3" rx="1.5"/>`, w]
895 }
896 const blocks = Array.from({ length: total }, (_, k) => `<rect class="${k < done ? 'cR' : 'k'}" x="${x + k * 8}" y="${base - 7}" width="6" height="4" rx="1"/>`)
897 return [blocks.join(''), total * 8 - 2]
898}
899
900// A row knows its place in the tree of spawns. Subagents draw smaller,
901// indented under the run that spawned them, joined to it by tree lines.
902// The "two densities" layout. In full, the left block (title; type,
903// model and effort; steps) faces the metrics table (cost, time; context;
904// total, sent and generated tokens). Folded, the title faces cost and time.
905const agentSvg = (W: number, row: TreeRow, at: number, bodyIdx: number, isFold = false): string => {
906 const { a } = row
907 const d = Math.min(row.depth, MAX_DEPTH)
908 const isSub = d > 0
909 const ind = d * INDENT
910 const g = geomOf(row, isFold)
911 const { H, scale: sc } = g
912 const crabY = crabTop(H, sc)
913 const x0 = ind + g.textX
914 const [c1, c2, c3] = rowEdges(W)
915 // A run with a Button in the slot leaves it to the Button, beside the drawing.
916 const svgW = hasSlotButton(a) ? W - ARROW_W : W
917 const crabBottom = crabY + Math.round(28 * sc)
918
919 // Tree lines: ancestors with later siblings keep their rail; this row's own
920 // elbow; a stub down from the crab when children follow.
921 const railX = (level: number): number => (level - 1) * INDENT + RAIL_OFFSET
922 const midY = crabY + Math.round(14 * sc)
923 const lines: string[] = []
924 for (let level = 1; level < d; level++) {
925 if (row.rails[level]) lines.push(`<line class="tr" x1="${railX(level)}" y1="0" x2="${railX(level)}" y2="${H}"/>`)
926 }
927 if (isSub) {
928 const x = railX(d)
929 lines.push(`<path class="tr" d="M${x} 0V${row.isLast ? midY - 4 : H}"/>`)
930 lines.push(`<path class="tr" d="M${x} ${midY - 4}q0 4 4 4H${ind - 2}"/>`)
931 }
932 if (row.kids > 0 && row.depth < MAX_DEPTH) {
933 const x = railX(d + 1)
934 lines.push(`<line class="tr" x1="${x}" y1="${crabBottom + 2}" x2="${x}" y2="${H}"/>`)
935 }
936
937 const text = (x: number, y: number, size: number, cls: string, body: string, extra = ''): string =>
938 `<text class="${cls}" x="${x}" y="${y}" font-family="${FONT}" font-size="${size}" font-variant-numeric="tabular-nums"${extra}>${body}</text>`
939 const end = ' text-anchor="end"'
940 const cell = (x: number, y: number, size: number, cls: string, body: string): string => (body ? text(x, y, size, cls, body, end) : '')
941 const cost = fmtCost(a.costUsd)
942 const time = fmtTime(elapsed(a, at))
943 const crabSvg = `<g class="b${bodyIdx}">${crab(ind, crabY, costumeOf(a.type), a.status === 'running', sc)}</g>`
944 const groupEnd = row.isGroupEnd ? `<line class="ln" x1="0" y1="${H - 0.5}" x2="${W - ARROW_W}" y2="${H - 0.5}"/>` : ''
945 const mark = a.status === 'failed' ? failedMark(W - ARROW_W / 2, Math.round(H / 2)) : ''
946
947 if (isFold) {
948 const [y] = baselines(H, 1, 0)
949 const size = isSub ? 11 : 12
950 // A stopped run keeps saying so after its title, bold in the light `.mk` grey.
951 const stopped = a.status === 'stopped' ? ` · ${S.isStopped}` : ''
952 const titleW = Math.max(0, c2 - METRIC_COLS[1] - x0 - 12 - textWidth(stopped, 11))
953 const title = xml(fitText(a.description || a.type, size, titleW)) + (stopped ? `<tspan class="mk" font-size="11" font-weight="600">${xml(stopped)}</tspan>` : '')
954 return svg(
955 svgW,
956 H,
957 `${lines.join('')}${crabSvg}
958${text(x0, y ?? 0, size, 's', title, ' font-weight="500"')}
959${text(c2, y ?? 0, 11, 'mvC', xml(cost), `${end} font-weight="600"`)}${cell(c3, y ?? 0, 11, 'ml', xml(time))}
960${mark}${groupEnd}`,
961 )
962 }
963
964 const ctx = ctxOf(a)
965 const fmtSplit = (n: number | undefined): string => (n === undefined ? '—' : fmtTokens(n))
966 const [titleSize, s1] = isSub ? [11, 11] : [12, 12]
967 const [r1 = 0, r2 = 0, r3 = 0] = baselines(H, 3, g.gap)
968 // Tokens: the total bare, then sent and generated, each after its label. Only
969 // cost, context percentage and time stand out; the rest share the labels' grey.
970 const labelled = (label: string, v: string): string => `<tspan class="mk">${label}</tspan> ${xml(v)}`
971 const metrics = [
972 text(c2, r1, s1, 'mvC', xml(cost), `${end} font-weight="600"`),
973 cell(c3, r1, s1, 'ml', xml(time)),
974 cell(c1, r2, 11, 'mk', 'ctx'),
975 text(c2, r2, 11, 'ml', `${ctx}%`, `${end} font-weight="600"`),
976 cell(c3, r2, 11, 'ml', xml(fmtTokens(a.contextTokens))),
977 cell(c1, r3, 11, 'ml', xml(fmtTokens(a.tokens))),
978 cell(c2, r3, 11, 'ml', labelled('in', fmtSplit(a.inputTokens))),
979 cell(c3, r3, 11, 'ml', labelled('out', fmtSplit(a.outputTokens))),
980 ].join('')
981
982 // How far each left line may run before it meets the metrics beside it: row 1
983 // leaves column 1 empty, row 2 holds only "ctx" there, row 3 is full width.
984 const roomTo = (edge: number): number => Math.max(0, edge - x0 - 12)
985 const titleW = roomTo(c2 - METRIC_COLS[1])
986 const metaW = roomTo(c1 - textWidth('ctx', 11))
987 const leftW = roomTo(c1 - METRIC_COLS[0])
988
989 // Three lines, centred. Line 2 is the model and its effort (plus
990 // round, subagents and failure); line 3 the agent type, then the steps.
991 const [l1 = 0, l2 = 0, l3 = 0] = baselines(H, 3, g.gap)
992 // The model in the row's grey; its effort and the round in the lighter `.mk`
993 // grey, as on the main thread's line.
994 const meta: [string, string][] = [[modelName(a.model), 's']]
995 if (a.effort) meta.push([` · ${a.effort}`, 'mk'])
996 if (a.round > 1) meta.push([` · ${S.round} ${a.round}`, 'mk'])
997 if (row.kids > 0) meta.push([` · ↳ ${row.kids} ${S.sub}`, 's'])
998 // The run's end, when it is not a plain finish, bold: a failure in red, a stop
999 // in the light `.mk` grey.
1000 const [tail, tailCls] =
1001 a.status === 'failed'
1002 ? [` · ${S.failed}`, 'cF']
1003 : a.status === 'stopped'
1004 ? [` · ${S.isStopped}`, 'mk']
1005 : a.isStopRequested
1006 ? [` · ${S.stopping}…`, 'mk']
1007 : ['', '']
1008 // Each part in its grey while the line fits; past that, the whole cut in the row's grey.
1009 const metaPlain = meta.map(([t]) => t).join('')
1010 const metaRoom = Math.max(0, metaW - textWidth(tail, 11))
1011 const metaRich =
1012 (textWidth(metaPlain, 11) <= metaRoom
1013 ? meta.map(([t, cls]) => `<tspan class="${cls}">${xml(t)}</tspan>`).join('')
1014 : xml(fitText(metaPlain, 11, metaRoom))) + (tail ? `<tspan class="${tailCls}" font-weight="600">${xml(tail)}</tspan>` : '')
1015
1016 let line3 = ''
1017 if (a.stepTotal) {
1018 const total = a.stepTotal
1019 const done = a.stepDone ?? 0
1020 const count = `${done}/${total}`
1021 const [, bw] = stepBlocks(0, 0, done, total)
1022 // The steps come first in priority: the type gets what they leave.
1023 const stepsW = textWidth(count, 11) + 6 + bw
1024 const typeRoom = leftW - stepsW - 10
1025 const typeText = typeRoom >= 30 ? fitText(a.type, 11, typeRoom) : ''
1026 let x = x0 + (typeText ? textWidth(typeText, 11) + 10 : 0)
1027 if (typeText) line3 += text(x0, l3, 11, 'mk', xml(typeText))
1028 line3 += text(x, l3, 11, 't', count, ' font-weight="600"')
1029 x += textWidth(count, 11) + 6
1030 // The count and its blocks alone: the step's note made the line too long.
1031 if (x + bw <= x0 + leftW) line3 += stepBlocks(x, l3, done, total)[0]
1032 } else {
1033 line3 = text(x0, l3, 11, 'mk', xml(fitText(a.type, 11, leftW)))
1034 }
1035
1036 return svg(
1037 svgW,
1038 H,
1039 `${lines.join('')}${crabSvg}
1040${text(x0, l1, titleSize, 't', xml(fitText(a.description || a.type, titleSize, titleW)), ' font-weight="600"')}
1041${text(x0, l2, 11, 's', metaRich)}
1042${line3}
1043${metrics}
1044${mark}${groupEnd}`,
1045 )
1046}
1047
1048// --- terminal drawing: the same rows in text.
1049
1050const ctxBar = (pct: number, width: number): string => {
1051 const filled = Math.round((width * pct) / 100)
1052 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
1053}
1054
1055const STATUS_GLYPH: Record<AgentStatus, string> = { running: '●', done: '✓', failed: '✗', stopped: '■' }
1056
1057// Opens the agents pane, or closes it when it is up; true when it ends up open.
1058async function togglePane($: EngineInterface): Promise<boolean> {
1059 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
1060 if (isOpen) {
1061 await $.ui.close({ id: PANE })
1062 return false
1063 }
1064 const at = await $.clock.now()
1065 await update($, now, () => at)
1066 await $.ui.open({ id: PANE, title: S.pane })
1067 return true
1068}
1069
1070// Folder, parent path and branch, read from the session and git. Runs
1071// at start, after each Bash call (a checkout moves the branch) and when the main
1072// thread ends a turn; never while drawing, which happens once a second.
1073async function refreshEnv($: EngineInterface): Promise<void> {
1074 const root = (await $.session.root()).replace(/\/+$/, '')
1075 const home = ((await $.env.get('HOME')) ?? '').replace(/\/+$/, '')
1076 const cut = root.lastIndexOf('/')
1077 const folder = root.slice(cut + 1) || root
1078 const parent = cut > 0 ? root.slice(0, cut) : '/'
1079 const path = home && (parent === home || parent.startsWith(`${home}/`)) ? `~${parent.slice(home.length)}` : parent
1080 let branch: string | undefined
1081 try {
1082 const git = (args: string[]) => $.process.run(['git', ...args], { cwd: root, timeoutMs: 2000 })
1083 const r = await git(['branch', '--show-current'])
1084 if (r.exitCode === 0) branch = r.stdout.trim()
1085 if (r.exitCode === 0 && !branch) {
1086 const h = await git(['rev-parse', '--short', 'HEAD'])
1087 branch = h.exitCode === 0 ? `@${h.stdout.trim()}` : undefined
1088 }
1089 } catch {
1090 branch = undefined
1091 }
1092 await update($, env, prev => (prev.folder === folder && prev.path === path && prev.branch === branch ? prev : { ...prev, folder, path, branch }))
1093}
1094
1095const mainModel = (m: Main): string => (m.model ? (m.effort ? `${modelName(m.model)} · ${m.effort}` : modelName(m.model)) : '—')
1096
1097// The main thread's section in words: for a screen reader and the terminal.
1098const mainSummary = (m: Main): string[] => [
1099 `${S.context} ${m.ctxPct ?? 0}%${m.ctxWindow ? ` ${fmtTokens(m.ctxTokens ?? 0)} / ${fmtWindow(m.ctxWindow)}` : ''}`,
1100 `${fmtCost(m.costUsd)} · ${fmtTokens(m.tokens)} ${S.tokensWord} · in ${fmtTokens(m.inputTokens)} out ${fmtTokens(m.outputTokens)}`,
1101 ...limitsOf(m.limits).map(l => `${l.label} ${l.pct}%`),
1102]
1103
1104// The main thread's context and the account's limits, as the engine measured them.
1105const measured = (prev: Main, u: Pick<SessionUsage, 'context' | 'rateLimits'>): Main => ({
1106 ...prev,
1107 ctxTokens: u.context.tokens,
1108 ctxWindow: u.context.window,
1109 ctxPct: u.context.percent,
1110 limits: u.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
1111})
1112
1113// What a run stopped from the panel answers to the agent that spawned it.
1114const STOP_ANSWER = 'Stopped by the person from the Mission Control panel before finishing.'
1115
1116const isStopRequested = async ($: EngineInterface, agentId: string): Promise<boolean> =>
1117 (await read($, agents)).some(a => a.agentId === agentId && a.isStopRequested === true)
1118
1119// A run's stop Button is keyed STOP_KEY + the run's id, its fold arrow FOLD_KEY + the id,
1120// and the Box that holds either SLOT_KEY + the id.
1121const STOP_KEY = 'stop-'
1122const FOLD_KEY = 'fold-'
1123const SLOT_KEY = 'slot-'
1124
1125// Marks a running run to stop; a run that already ended is left alone.
1126const requestStop = ($: EngineInterface, id: string) =>
1127 update($, agents, rows => rows.map(x => (x.id === id && x.status === 'running' ? { ...x, isStopRequested: true } : x)))
1128
1129// Unfolds a finished run, or folds it back.
1130const toggleRun = ($: EngineInterface, id: string) =>
1131 update($, panel, prev => {
1132 const open = prev.expanded ?? []
1133 return { ...prev, expanded: open.includes(id) ? open.filter(x => x !== id) : [...open, id].slice(-200) }
1134 })
1135
1136// The desktop's row Buttons, kept from one drawing to the next. The host
1137// gives a Button a new handle for every new element, and drops the old handle
1138// as soon as the next drawing is made. The page sends a click with the handle
1139// it drew, so a click that lands after a redraw names a handle the host no
1140// longer holds: the engine drops it before any `ui.press` hook runs. While
1141// runs work the pane redraws several times a second, so most clicks on the
1142// stop were lost. The same element, drawn again, keeps its handle.
1143// Keyed by the Button's key; a new label (the fold arrow's ▸ / ▾) makes a new one.
1144const keptButtons = new Map<string, { label: string; element: RenderElement }>()
1145
1146export const register: Register = on => {
1147 on('session.start', async ($, e, next) => {
1148 const started = await next(e)
1149 // Prices load beside the rest; until they do, the built-in PRICES count.
1150 void loadPrices($).catch(() => undefined)
1151 await refreshEnv($).catch(() => undefined)
1152 const usage = await $.session.usage()
1153 // The model before the first response; each request names it from then on.
1154 const model = await $.session.model()
1155 await update($, main, prev => ({ ...measured(prev, usage), model }))
1156 await $.tool.register({
1157 name: 'step',
1158 description:
1159 'For subagents: report progress on your own task to the Mission Control panel. ' +
1160 'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
1161 'call it again as each step finishes. Cheap and silent: it only draws a bar.',
1162 inputSchema: {
1163 type: 'object',
1164 properties: {
1165 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
1166 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
1167 note: { type: 'string', description: 'The step in progress, a few words.' },
1168 },
1169 required: ['done'],
1170 },
1171 })
1172 await $.command.register({
1173 name: 'mission-control',
1174 description: 'Show or hide Mission Control: the session and its subagents, with model, context, cost and time',
1175 })
1176
1177 // Ticks the running agents' clocks; quiet when nothing runs.
1178 $.clock.every(1000, () => {
1179 void (async () => {
1180 const list = await read($, agents)
1181 if (!list.some(a => a.status === 'running')) return
1182 const at = await $.clock.now()
1183 await update($, now, () => at)
1184 })()
1185 })
1186
1187 // Open the agents panel by default on every session, right away.
1188 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
1189 if (!isOpen) void $.ui.open({ id: PANE, title: S.pane })
1190 return started
1191 })
1192
1193 on('command.run', { command: 'mission-control' }, async $ => {
1194 const isOpen = await togglePane($)
1195 return { text: isOpen ? S.opened : S.closed }
1196 })
1197
1198 // A press on a run's stop or fold arrow, taken here by its key: a kept
1199 // Button's own handler holds the `$` of the drawing that first made it.
1200 on('ui.press', { plugin: 'mission-control' }, async ($, e, next) => {types/index.d.ts 90 lines1/** `stopped`: the person stopped it from the panel. */
2export type AgentStatus = 'running' | 'done' | 'failed' | 'stopped'
3
4export type AgentRun = {
5 id: string
6 agentId?: string
7 /** AgentId of the loop that spawned this run; absent when the main thread did. */
8 parentAgentId?: string
9 type: string
10 description: string
11 model: string
12 effort?: string
13 status: AgentStatus
14 startedAt: number
15 endedAt?: number
16 contextTokens: number
17 contextMax: number
18 tokens: number
19 /** Tokens sent, cache reads and writes included. Absent on runs recorded before this field existed. */
20 inputTokens?: number
21 /** Tokens generated. Absent on runs recorded before this field existed. */
22 outputTokens?: number
23 costUsd: number
24 steps: number
25 round: number
26 /** Self-reported by the worker through the `step` tool. */
27 stepDone?: number
28 stepTotal?: number
29 stepNote?: string
30 /** Set by the panel's stop: the run asks for nothing more and ends at its next model request. */
31 isStopRequested?: boolean
32}
33
34export type Panel = {
35 isCompact: boolean
36 isDoneCollapsed: boolean
37 /** Ids of runs that ended well, unfolded by their arrow. Absent in state saved before this field existed. */
38 expanded?: string[]
39}
40
41/** Where the session works, for the header. */
42export type Env = {
43 /** Last segment of the session's root. */
44 folder: string
45 /** The root's parent, with the home directory as `~`. */
46 path: string
47 /** Current git branch, `@abc1234` on a detached HEAD; absent outside git. */
48 branch?: string
49}
50
51/** One rate-limit window of the account, as the engine reports it. */
52export type Limit = {
53 /** `five_hour`, `seven_day` or `spend_limit`. */
54 kind: string
55 /** How much of the window is used, 0 to 100 (past 100 on an exceeded spend limit). */
56 pct: number
57}
58
59/** The main thread: its model, its context and what its own requests used. */
60export type Main = {
61 model?: string
62 effort?: string
63 /** Input tokens of the last response; absent before the first response. */
64 ctxTokens?: number
65 /** The model's context window, in tokens. */
66 ctxWindow?: number
67 /** `ctxTokens` over `ctxWindow`, 0 to 100; absent before the first response. */
68 ctxPct?: number
69 /** Tokens sent (cache reads and writes included) plus tokens generated. */
70 tokens: number
71 inputTokens: number
72 outputTokens: number
73 /** Estimated from `PRICES`, as for the subagents. */
74 costUsd: number
75 /** Empty off a subscription or before the first reading. */
76 limits: Limit[]
77}
78
79declare module 'claude-code' {
80 interface PluginState {
81 'mission-control': {
82 agents: AgentRun[]
83 panel: Panel
84 now: number
85 env: Env
86 main: Main
87 }
88 }
89}
90