A live pane: context ring, session cost, a colored timeline of every tool call, files edited, and a sports-commentator recap of each turn

A live pane beside the transcript that shows the whole session at a glance.
/mission opens the pane. It also opens by itself when a session starts, where there is room for it (144 columns or more).
claude plugin install mission-control@bar-mods
From claude plugin validate:
session.start, command.run{command=mission}, prompt.submit, tool.call (observes only, never changes a call), turn.complete, ui.render{component=Pane}$.clock.after, $.clock.now, $.command.register, $.model.complete, $.session.usage, $.state.get, $.state.set, $.ui.open, $.ui.resolve, $.ui.toastNo files, processes, network or environment. Cost: the commentator makes one Haiku call per turn on your plan or API key (low effort, at most 120 output tokens, 15 s timeout). Mute it and it makes none.
The desktop app draws the ring and timeline as animated SVG. The terminal draws a text version: a bar for the context, the cost, and a row of colored squares. In claude -p, the SDK and the VS Code chat panel its hooks run and nothing draws.
Tested on Claude Code 2.1.288.
hooks/register.tsx 317 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Call, Gauge } from '../types'
5
6const PANE = 'mission-control'
7
8const calls = atom({ plugin: 'mission-control', key: 'calls' } as const, [])
9const files = atom({ plugin: 'mission-control', key: 'files' } as const, [])
10const gauge = atom({ plugin: 'mission-control', key: 'gauge' } as const, null)
11const lines = atom({ plugin: 'mission-control', key: 'lines' } as const, [])
12const turns = atom({ plugin: 'mission-control', key: 'turns' } as const, 0)
13const working = atom({ plugin: 'mission-control', key: 'isWorking' } as const, false)
14const narrating = atom({ plugin: 'mission-control', key: 'isNarrating' } as const, false)
15const commentary = atom({ plugin: 'mission-control', key: 'isCommentaryOn' } as const, true)
16
17// Tool families, each with one color that reads on light and dark.
18type Family = [name: string, color: string, match: RegExp]
19const OTHER: Family = ['other', '#8a94a6', /./]
20const FAMILIES: Family[] = [
21 ['read', '#4c8dff', /^(Read|Grep|Glob|LS|NotebookRead)$/],
22 ['edit', '#2fbf71', /^(Edit|Write|MultiEdit|NotebookEdit)$/],
23 ['shell', '#ff8a3d', /^(Bash|BashOutput|KillShell|Monitor)$/],
24 ['web', '#a66bff', /^(WebFetch|WebSearch)$|Browser|chrome/],
25 ['agent', '#ff5fa2', /^(Agent|Task|SendMessage|Workflow)$/],
26 ['mcp', '#19b8c7', /^mcp__/],
27 OTHER,
28]
29
30const familyOf = (tool: string) => FAMILIES.find(([, , re]) => re.test(tool)) ?? OTHER
31
32const basename = (path: string) => path.split('/').filter(Boolean).pop() ?? path
33
34const labelOf = (input: Record<string, unknown>) => {
35 const pick = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
36 const path = pick('file_path') || pick('notebook_path') || pick('path')
37 if (path) return basename(path)
38 const text = pick('command') || pick('pattern') || pick('url') || pick('query') || pick('description')
39 return text.replace(/\s+/g, ' ').slice(0, 48)
40}
41
42const ring = (percent: number, usd: number, isBusy: boolean) => {
43 const c = 2 * Math.PI * 34
44 const p = Math.max(0, Math.min(100, percent))
45 const color = p < 50 ? '#2fbf71' : p < 80 ? '#ffb020' : '#ff4d4f'
46 const spinner = isBusy
47 ? `<circle cx="50" cy="50" r="44" fill="none" stroke="${color}" stroke-width="2" stroke-dasharray="3 9" opacity="0.8">
48 <animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="2.4s" repeatCount="indefinite"/>
49 </circle>`
50 : ''
51 return `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="100" viewBox="0 0 300 100">
52 ${spinner}
53 <circle cx="50" cy="50" r="34" fill="none" stroke="#8a94a6" stroke-opacity="0.25" stroke-width="9"/>
54 <circle cx="50" cy="50" r="34" fill="none" stroke="${color}" stroke-width="9" stroke-linecap="round"
55 stroke-dasharray="${((c * p) / 100).toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 50 50)">
56 <animate attributeName="stroke-dasharray" from="0 ${c.toFixed(1)}" to="${((c * p) / 100).toFixed(1)} ${c.toFixed(1)}" dur="0.9s" fill="freeze"/>
57 </circle>
58 <text x="50" y="49" text-anchor="middle" font-family="ui-sans-serif,system-ui" font-size="19" font-weight="700" fill="${color}">${Math.round(p)}%</text>
59 <text x="50" y="66" text-anchor="middle" font-family="ui-sans-serif,system-ui" font-size="9" fill="#8a94a6">context</text>
60 <text x="118" y="44" font-family="ui-sans-serif,system-ui" font-size="30" font-weight="800" fill="${color}">$${usd.toFixed(2)}</text>
61 <text x="120" y="64" font-family="ui-sans-serif,system-ui" font-size="11" fill="#8a94a6">spent this session</text>
62 </svg>`
63}
64
65const timeline = (list: Call[]) => {
66 const shown = list.slice(-80)
67 const perRow = 20
68 const cell = 15
69 const rows = Math.max(1, Math.ceil(shown.length / perRow))
70 const cells = shown
71 .map((call, i) => {
72 const [, color] = familyOf(call.tool)
73 const x = (i % perRow) * cell + 2
74 const y = Math.floor(i / perRow) * cell + 2
75 const stroke = call.state === 'err' ? ` stroke="#ff4d4f" stroke-width="2.5"` : ''
76 const pulse =
77 call.state === 'run'
78 ? `<animate attributeName="opacity" values="1;0.25;1" dur="0.9s" repeatCount="indefinite"/>`
79 : `<animate attributeName="opacity" from="0" to="1" dur="0.4s" fill="freeze"/>`
80 const tip = `<title>${call.tool}${call.label ? `: ${call.label.replace(/[<&>"]/g, '')}` : ''}</title>`
81 return call.isSub
82 ? `<circle cx="${x + 5.5}" cy="${y + 5.5}" r="5.5" fill="${color}"${stroke}>${pulse}${tip}</circle>`
83 : `<rect x="${x}" y="${y}" width="11" height="11" rx="2.5" fill="${color}"${stroke}>${pulse}${tip}</rect>`
84 })
85 .join('')
86 return `<svg xmlns="http://www.w3.org/2000/svg" width="${perRow * cell + 4}" height="${rows * cell + 4}" viewBox="0 0 ${perRow * cell + 4} ${rows * cell + 4}">${cells}</svg>`
87}
88
89const bar = (percent: number, width: number) => {
90 const filled = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
91 return '█'.repeat(filled) + '░'.repeat(width - filled)
92}
93
94async function refresh($: EngineInterface) {
95 const usage = await $.session.usage()
96 const { tokens, window, percent } = usage.context
97 const value: Gauge = {
98 percent: percent ?? (tokens && window ? (tokens / window) * 100 : 0),
99 usd: usage.cost?.usd ?? 0,
100 }
101 await update($, gauge, () => value)
102}
103
104async function narrate(
105 $: EngineInterface,
106 n: number,
107 seconds: number,
108 moves: string,
109 answer: string,
110) {
111 await update($, narrating, () => true)
112
113 try {
114 const reply = await $.model.complete({
115 model: 'haiku',
116 effort: 'low',
117 maxTokens: 120,
118 timeoutMs: 15000,
119 system:
120 "You are an over-the-top live sports commentator narrating an AI coding agent's last turn as if it were a championship match. " +
121 'Exactly one sentence, at most 28 words. Name the real moves (tools, files). Never use em dashes. No quotes, no emoji.',
122 prompt: `Turn ${n}, ${seconds}s on the clock. Moves in order: ${moves}. The agent's final words began: ${answer}`,
123 })
124
125 if (reply.isAnswered) {
126 const line = `T${n} ${reply.text.trim().replace(/\s*—\s*/g, ', ')}`
127 await update($, lines, list => [line, ...list].slice(0, 4))
128 }
129 } finally {
130 await update($, narrating, () => false)
131 }
132}
133
134export const register: Register = on => {
135 let turnMoves: string[] = []
136 let turnStartedAt = 0
137 let seq = 0
138
139 on('session.start', async ($, e, next) => {
140 await $.command.register({
141 name: 'mission',
142 description: 'Open the Mission Control pane',
143 })
144 // Opened unasked, so it seats only where there is room (144+ columns).
145 $.ui.open({ id: PANE, title: 'Mission Control' }).catch(() => undefined)
146
147 return next(e)
148 })
149
150 on('command.run', { command: 'mission' }, async $ => {
151 await $.ui.open({ id: PANE, title: 'Mission Control' })
152
153 return { text: 'Mission Control is open.' }
154 })
155
156 on('prompt.submit', async ($, e, next) => {
157 turnMoves = []
158 turnStartedAt = await $.clock.now()
159 await update($, working, () => true)
160
161 return next(e)
162 })
163
164 on('tool.call', async ($, e, next) => {
165 const isSub = e.agentId !== undefined
166 const label = labelOf(e as unknown as Record<string, unknown>)
167 const call: Call = { id: e.tool_use_id ?? `${e.tool}-${(seq += 1)}`, tool: e.tool, label, state: 'run', isSub }
168 if (!isSub) turnMoves.push(label ? `${e.tool}(${label})` : e.tool)
169 const size = (await update($, calls, list => [...list, call].slice(-300))).length
170
171 if (size % 50 === 0) {
172 $.ui.toast(`🏆 Tool call #${size} this session`)
173 }
174
175 const ran = await next(e)
176 const failed = ran.deny !== undefined || ran.isError === true
177
178 await update($, calls, list =>
179 list.map(one => (one.id === call.id ? { ...one, state: failed ? 'err' : 'ok' } : one)),
180 )
181
182 if (!failed && /^(Edit|Write|MultiEdit|NotebookEdit)$/.test(e.tool) && label) {
183 await update($, files, list => [label, ...list.filter(f => f !== label)].slice(0, 10))
184 }
185
186 await refresh($)
187
188 return ran
189 })
190
191 on('turn.complete', async ($, e, next) => {
192 const ran = await next(e)
193
194 if (e.agentId !== undefined) {
195 return ran
196 }
197
198 const n = await update($, turns, t => t + 1)
199 await update($, working, () => false)
200 await refresh($)
201
202 if (await read($, commentary)) {
203 const seconds = Math.round(((await $.clock.now()) - turnStartedAt) / 1000)
204 const moves = turnMoves.slice(0, 30).join(', ') || 'no tools, pure talk'
205 const answer = e.answer.slice(0, 400)
206 // Narrate after this dispatch, so the turn ends without waiting on a model call.
207 $.clock.after(10, () => {
208 narrate($, n, seconds, moves, answer).catch(() => undefined)
209 })
210 }
211
212 return ran
213 })
214
215 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
216 const list = await read($, calls)
217 const touched = await read($, files)
218 const g = (await read($, gauge)) ?? { percent: 0, usd: 0 }
219 const said = await read($, lines)
220 const n = await read($, turns)
221 const isBusy = await read($, working)
222 const isTalking = await read($, narrating)
223 const isOn = await read($, commentary)
224 const errors = list.filter(c => c.state === 'err').length
225 const counts = FAMILIES.map(([name, color, re]) => ({
226 name,
227 color,
228 count: list.filter(c => familyOf(c.tool)[0] === name).length,
229 })).filter(f => f.count > 0)
230 const stats =
231 `${list.length} tool calls · ${n} turns · ${touched.length} files edited` +
232 (errors > 0 ? ` · ${errors} errors` : '')
233 const toggle = () => update($, commentary, was => !was)
234 const reset = async () => {
235 await update($, calls, () => [])
236 await update($, files, () => [])
237 await update($, lines, () => [])
238 }
239
240 if (e.surface === 'terminal') {
241 const { Box, Text, Button } = $.ui.resolve(e)
242 const width = Math.max(10, Math.min(30, (e.props.bodyColumns ?? 40) - 14))
243 return (
244 <Box flexDirection="column" gap={1}>
245 <Text>{`context ${bar(g.percent, width)} ${Math.round(g.percent)}%`}</Text>
246 <Text bold>{`$${g.usd.toFixed(2)} spent`}</Text>
247 <Text dimColor>{stats}</Text>
248 <Text wrap="wrap">
249 {list.slice(-120).map(c => (
250 <Text color={c.state === 'err' ? '#ff4d4f' : familyOf(c.tool)[1]}>{c.isSub ? '●' : '■'}</Text>
251 ))}
252 </Text>
253 {said.map(line => (
254 <Text italic wrap="wrap">{`🎙 ${line}`}</Text>
255 ))}
256 <Box gap={1}>
257 <Button key="toggle" label={isOn ? 'Mute commentator' : 'Unmute commentator'} onPress={toggle} />
258 <Button key="reset" label="Reset" onPress={reset} />
259 </Box>
260 </Box>
261 )
262 }
263
264 const { Box, Text, Button, Svg } = $.ui.resolve(e)
265 return (
266 <Box flexDirection="column" gap={1} paddingX={1}>
267 <Svg
268 source={ring(g.percent, g.usd, isBusy)}
269 alt={`Context ${Math.round(g.percent)}% full, $${g.usd.toFixed(2)} spent`}
270 isInteractive
271 />
272 <Text dimColor>{stats}</Text>
273 <Box flexDirection="column">
274 <Text bold>Timeline</Text>
275 {list.length === 0 ? (
276 <Text dimColor>Waiting for the first move…</Text>
277 ) : (
278 <Svg source={timeline(list)} alt={`${list.length} tool calls, colored by kind`} isInteractive />
279 )}
280 <Box flexWrap="wrap" columnGap={2}>
281 {counts.map(f => (
282 <Text>
283 <Text color={f.color}>■</Text>
284 {` ${f.name} ${f.count}`}
285 </Text>
286 ))}
287 </Box>
288 </Box>
289 <Box flexDirection="column">
290 <Text bold>{`🎙 Commentary${isTalking ? ' …' : ''}`}</Text>
291 {said.length === 0 ? (
292 <Text dimColor>{isOn ? 'The commentator calls the next turn.' : 'Muted.'}</Text>
293 ) : (
294 said.map((line, i) => (
295 <Text italic dimColor={i > 0} wrap="wrap">
296 {line}
297 </Text>
298 ))
299 )}
300 </Box>
301 {touched.length > 0 && (
302 <Box flexDirection="column">
303 <Text bold>Files edited</Text>
304 {touched.map(f => (
305 <Text color="#2fbf71">{`✎ ${f}`}</Text>
306 ))}
307 </Box>
308 )}
309 <Box gap={1}>
310 <Button key="toggle" label={isOn ? 'Mute commentator' : 'Unmute commentator'} onPress={toggle} />
311 <Button key="reset" label="Reset" onPress={reset} />
312 </Box>
313 </Box>
314 )
315 })
316}
317types/index.d.ts 24 lines1export type Call = {
2 id: string
3 tool: string
4 label: string
5 state: 'run' | 'ok' | 'err'
6 isSub: boolean
7}
8export type Gauge = { percent: number; usd: number }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'mission-control': {
13 calls: Call[]
14 files: string[]
15 gauge: Gauge | null
16 lines: string[]
17 turns: number
18 isWorking: boolean
19 isNarrating: boolean
20 isCommentaryOn: boolean
21 }
22 }
23}
24