SLOPSHOPPER

mission-control

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

newpaneguardcommandtoastprompt
v0.2.0MITupdated 2026-10-02barmoshe/bar-mods/mission-control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ Mission Control ✕ › fix the failing auth test and add an audit log call │ ┃ context ███████████████░░░░░░░░░░░░░░░ 49% │ ┃ ⏺ Read(src/auth.ts) │ ┃ $0.42 spent ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 9 tool calls · 1 turns · 3 files edited · 1 ⎿ Added 2 lines, removed 1 line │ ┃ errors ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ■■■■■■■■■ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Mute commentator ] [ Reset ] │ ✻ Worked for 42s · done 4:20 PM │ │ › /mission │ ⎿ mission-control: Mission Control is open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mission Control
context ███████████████░░░░░░░░░░░░░░░ 49% $0.42 spent 9 tool calls · 1 turns · 3 files edited · 1 errors ■■■■■■■■■ [ Mute commentator ] [ Reset ]
README

mission-control

A live pane beside the transcript that shows the whole session at a glance.

  • Context ring: how full the context window is, green under 50%, amber to 80%, red above. A dashed ring spins around it while Claude works.
  • Cost: what the session has spent so far.
  • Timeline: one square per tool call, colored by kind (blue reads, green edits, orange shell, purple web, pink agents, teal MCP). The running call pulses, a failed one gets a red outline, a subagent's call is a circle. Hover a square to see the file or command.
  • Commentator: after each turn, Haiku calls it in one sentence like a sports commentator, naming the real tools and files.
  • Files edited: the last ten files Claude changed.
  • Milestones: a toast every 50 tool calls.
  • Buttons: Mute commentator, Reset.

/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

Footprint

From claude plugin validate:

  • hooks: session.start, command.run{command=mission}, prompt.submit, tool.call (observes only, never changes a call), turn.complete, ui.render{component=Pane}
  • calls: $.clock.after, $.clock.now, $.command.register, $.model.complete, $.session.usage, $.state.get, $.state.set, $.ui.open, $.ui.resolve, $.ui.toast

No 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.

Surfaces

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.

Source 2 files
hooks/register.tsx 317 lines
1import { 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}
317
types/index.d.ts 24 lines
1export 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