Mission-control skin: [SYS]/[TOOL]/[EDIT] tool rows with per-tool icons and one status block above the prompt (under flightdeck's session log while its pane is…

Personal Claude Code mods, built or vendored at a reviewed commit. Each mod lives in its own folder and is listed in .claude-plugin/marketplace.json.
hooks/register.tsx 598 lines1import { atom, read, update } from 'claude-code'
2import type { Args, EngineInterface, Register, RenderInput, SessionMessage } from 'claude-code'
3
4import type { Call, Category, Fill, GitInfo, Output } from '../types'
5import { C, look, shortModel } from './look'
6import { bar, estimate, fmt, isCompactionDrop, labelOf, oneLine, tone, toolName, totals } from './meter'
7
8// A skin: restyles the spinner and tool rows, and draws one status block in
9// the band above the prompt (stacked over other mods' rows, never replacing
10// them), or under flightdeck's session log while that pane is open:
11// [SYS] repo, branch, model and hw-write-guard's mode (ASK, NOTIFY, OFF; ?
12// while it has published none; OFF when it is not loaded)
13// [CTL] model and effort buttons; a press runs /model or /effort as if typed
14// (where this build has no /effort, the pick is applied to each
15// main-loop request instead)
16// [CTX] how full the context window is and which tool outputs filled it,
17// with a /ctx pane by tool (sizes estimated locally, ~4 chars a token)
18// /clean collapses tool rows into a checklist. Reads with $.fs and $.session:
19// no network, no processes, no model calls.
20
21const clean = atom({ plugin: 'mission-control', key: 'clean' } as const, false)
22const git = atom({ plugin: 'mission-control', key: 'git' } as const, null)
23const model = atom({ plugin: 'mission-control', key: 'model' } as const, '')
24const guard = atom({ plugin: 'mission-control', key: 'guard' } as const, false)
25const effort = atom({ plugin: 'mission-control', key: 'effort' } as const, '')
26const hasEffortCommand = atom({ plugin: 'mission-control', key: 'hasEffortCommand' } as const, true)
27const override = atom({ plugin: 'mission-control', key: 'override' } as const, '')
28const outputs = atom({ plugin: 'mission-control', key: 'outputs' } as const, [])
29const pending = atom({ plugin: 'mission-control', key: 'pending' } as const, {})
30const fill = atom({ plugin: 'mission-control', key: 'fill' } as const, null)
31const categories = atom({ plugin: 'mission-control', key: 'categories' } as const, [])
32const ctxBand = atom({ plugin: 'mission-control', key: 'ctxBand' } as const, true)
33
34const STORE_CLEAN = 'clean'
35const STORE_CTX_BAND = 'ctx-band'
36const CTX_PANE = 'ctx'
37const KEEP_OUTPUTS = 1000
38
39export const MODELS = ['opus', 'sonnet', 'haiku', 'fable'] as const
40export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
41const EFFORT_LABEL: Record<string, string> = { low: 'low', medium: 'med', high: 'high', xhigh: 'xhigh', max: 'max' }
42
43/** `claude-sonnet-5-5` → `sonnet`, matched against the buttons. */
44export const family = (id: string) => MODELS.find(f => id.toLowerCase().includes(f)) ?? ''
45
46async function findGit($: EngineInterface): Promise<GitInfo | null> {
47 let dir = (await $.session.cwd()).replace(/\\/g, '/').replace(/\/+$/, '')
48 for (let depth = 0; depth < 40 && dir; depth++) {
49 const dotGit = `${dir}/.git`
50 if (await $.fs.exists(dotGit)) {
51 let gitDir = dotGit
52 const stat = await $.fs.stat(dotGit)
53 if (stat.kind === 'file') {
54 // a worktree or submodule: ".git" names the real git dir
55 const m = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dotGit)))
56 if (!m) return null
57 const target = m[1].trim()
58 gitDir = /^([A-Za-z]:|\/)/.test(target) ? target : `${dir}/${target}`
59 }
60 const head = String(await $.fs.read(`${gitDir}/HEAD`)).trim()
61 const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head)
62 return { repo: dir.split('/').pop() || dir, branch: ref ? ref[1] : `detached ${head.slice(0, 7)}` }
63 }
64 const up = dir.replace(/\/[^/]*$/, '')
65 if (up === dir || up === '' || /^[A-Za-z]:$/.test(dir)) break
66 dir = up
67 }
68 return null
69}
70
71async function refresh($: EngineInterface) {
72 const [g, m, commands] = await Promise.all([
73 findGit($).catch(() => null),
74 $.session.model().catch(() => ''),
75 $.command.list().catch(() => undefined),
76 ])
77 await update($, git, () => g)
78 await update($, model, () => m)
79 if (commands) {
80 await update($, guard, () => commands.some(c => c.name === 'hw-guard'))
81 await update($, hasEffortCommand, () => commands.some(c => c.name === 'effort'))
82 }
83}
84
85const isCtxPaneOpen = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === CTX_PANE)
86
87async function refreshContext($: EngineInterface) {
88 const withBreakdown = await isCtxPaneOpen($).catch(() => false)
89 const usage = await $.session.usage(withBreakdown ? { breakdown: 'summary' } : undefined)
90 const c = usage.context
91 const now: Fill = { window: c.window }
92 if (c.tokens !== undefined) now.tokens = c.tokens
93 if (c.percent !== undefined) now.percent = c.percent
94 await update($, fill, () => now)
95
96 if (c.breakdown) {
97 const used: Category[] = c.breakdown.categories.filter(k => k.kind === 'used' && k.tokens > 0).map(k => ({ name: k.name, tokens: k.tokens }))
98 await update($, categories, () => used)
99 }
100}
101
102// The whole-transcript walk: only at session start, after a compaction and on
103// the pane's Refresh. Between those, `outputs` follows the rows as they are
104// stored (noteCalls, noteResults).
105async function rebuildOutputs($: EngineInterface, messages?: readonly SessionMessage[]) {
106 const list: Output[] = []
107 const waiting: Record<string, Call> = {}
108 for (const m of messages ?? (await $.session.messages())) {
109 for (const use of m.toolUses ?? []) {
110 const label = labelOf(use.tool, use.input ?? {})
111 if (use.text) list.push({ id: use.tool_use_id, tool: use.tool, label, tokens: estimate(use.text) })
112 else if (use.text === undefined) waiting[use.tool_use_id] = { tool: use.tool, label }
113 }
114 }
115 await update($, outputs, () => list.slice(-KEEP_OUTPUTS))
116 await update($, pending, () => waiting)
117}
118
119type Block = Args<'session.append'>['message']['content'][number]
120
121/** A response row's tool_use blocks: remember each call until its result is stored. */
122async function noteCalls($: EngineInterface, blocks: readonly Block[]) {
123 const uses = blocks.filter(b => b.type === 'tool_use' && typeof b.id === 'string')
124 if (uses.length === 0) return
125 await update($, pending, p => {
126 const next = { ...p }
127 for (const b of uses) {
128 const tool = String(b.name ?? 'unknown')
129 next[b.id as string] = { tool, label: labelOf(tool, (b.input ?? {}) as Record<string, unknown>) }
130 }
131 return next
132 })
133}
134
135/** Text blocks of a tool_result's content joined, as the model read them. */
136const resultText = (content: unknown): string =>
137 typeof content === 'string'
138 ? content
139 : Array.isArray(content)
140 ? content.map(b => (b && b.type === 'text' && typeof b.text === 'string' ? b.text : '')).join('')
141 : ''
142
143/** A tool-result row: each tool_result answering a remembered call becomes one output. */
144async function noteResults($: EngineInterface, blocks: readonly Block[], fallbackTool: string) {
145 const results = blocks.filter(b => b.type === 'tool_result' && typeof b.tool_use_id === 'string')
146 if (results.length === 0) return
147 const calls = await read($, pending)
148 const fresh: Output[] = []
149 for (const r of results) {
150 const id = r.tool_use_id as string
151 const text = resultText(r.content)
152 const call = calls[id] ?? { tool: fallbackTool, label: '' }
153 if (text) fresh.push({ id, tool: call.tool, label: call.label, tokens: estimate(text) })
154 }
155 await update($, pending, p => {
156 const next = { ...p }
157 for (const r of results) delete next[r.tool_use_id as string]
158 return next
159 })
160 if (fresh.length) await update($, outputs, list => [...list, ...fresh].slice(-KEEP_OUTPUTS))
161}
162
163// flightdeck's `dock`: true while its pane is open and drawn. A mod not
164// installed reads as never written, so the block stays in the band.
165const FLIGHTDECK = 'flightdeck'
166const FLIGHTDECK_DOCK = { plugin: 'flightdeck', key: 'dock' } as const
167async function isDocked($: EngineInterface) {
168 const held = await $.state.get(FLIGHTDECK_DOCK as never).catch(() => undefined)
169 return (held as { value?: unknown } | undefined)?.value === true
170}
171
172// hw-write-guard's mode, which it publishes and keeps per machine. Read while
173// drawing, so a change draws the block again. Undefined while it has published
174// none (an older build, or not loaded) or anything but its three modes.
175const HW_GUARD_MODE = { plugin: 'hw-write-guard', key: 'mode' } as const
176async function guardMode($: EngineInterface): Promise<'ask' | 'notify' | 'off' | undefined> {
177 const held = await $.state.get(HW_GUARD_MODE as never).catch(() => undefined)
178 const value = (held as { value?: unknown } | undefined)?.value
179 return value === 'ask' || value === 'notify' || value === 'off' ? value : undefined
180}
181
182/**
183 * [SYS], [CTL] and [CTX], `width` cells across: the band's, or flightdeck's
184 * pane's. `hasCtx` false leaves [CTX] out (flightdeck's card shows ctx).
185 */
186async function statusBlock($: EngineInterface, e: RenderInput<'AbovePrompt' | 'Pane'>, width: number, hasCtx = true) {
187 const { Box, Button, Text } = $.ui.resolve(e)
188 const [g, m, armed, mode, isClean, level, viaCommand, forced, f, isCtx, outs] = await Promise.all([
189 read($, git),
190 read($, model),
191 read($, guard),
192 guardMode($),
193 read($, clean),
194 read($, effort),
195 read($, hasEffortCommand),
196 read($, override),
197 read($, fill),
198 read($, ctxBand),
199 read($, outputs),
200 ])
201 // No /hw-guard command: not loaded. Loaded: its mode, or ? until it says.
202 const hw = !armed
203 ? { word: 'OFF', color: C.warn }
204 : mode === 'ask'
205 ? { word: 'ASK', color: C.ok }
206 : mode === 'notify'
207 ? { word: 'NOTIFY', color: C.warn }
208 : mode === 'off'
209 ? { word: 'OFF', color: C.bad }
210 : { word: '?', color: C.dim }
211
212 const sys = (
213 <Box backgroundColor={C.bg} width={width}>
214 <Text color={C.primary} bold>
215 [SYS]{' '}
216 </Text>
217 <Text wrap="truncate-end">
218 <Text color={C.primary}>repo:</Text>
219 <Text color="white">{g ? g.repo : '—'} </Text>
220 <Text color={C.primary}>⎇ </Text>
221 <Text color="white">{g ? g.branch : 'no git'} </Text>
222 <Text color={C.primary}>◆ </Text>
223 <Text color="white">{m ? shortModel(m) : '…'} </Text>
224 <Text color={hw.color} bold>
225 [HW-GUARD {hw.word}]
226 </Text>
227 {isClean ? <Text color={C.dim}> [CLEAN]</Text> : ''}
228 </Text>
229 </Box>
230 )
231
232 const fam = family(m)
233 const shown = !viaCommand && forced ? forced : level
234 const pickModel = (name: string) => () => {
235 void $.command
236 .run({ command: 'model', args: name })
237 .then(() => refresh($))
238 .catch(err => $.ui.toast(`mission-control: /model ${name} failed: ${err instanceof Error ? err.message : String(err)}`))
239 }
240 const pickEffort = (lvl: string) => async () => {
241 await update($, effort, () => lvl)
242 if (!viaCommand) {
243 await update($, override, () => lvl)
244 $.ui.toast(`effort ${lvl}: applied to each request by mission-control (this build has no /effort)`)
245 return
246 }
247 void $.command.run({ command: 'effort', args: lvl }).catch(err => $.ui.toast(`mission-control: /effort ${lvl} failed: ${err instanceof Error ? err.message : String(err)}`))
248 }
249 // The model and effort halves wrap as wholes, so a narrow pane puts effort on a row of its own.
250 const ctl = (
251 <Box flexDirection="row" flexWrap="wrap" columnGap={1} width={width}>
252 <Box flexDirection="row" columnGap={1}>
253 <Text color={C.primary} bold>
254 [CTL]
255 </Text>
256 <Text color={C.dim}>model:</Text>
257 <Text color="white" bold>
258 {fam || m || '…'}
259 </Text>
260 {MODELS.map(name => (
261 <Button key={`model-${name}`} label={name} variant={name === fam ? 'primary' : 'secondary'} onPress={pickModel(name)} />
262 ))}
263 </Box>
264 <Box flexDirection="row" columnGap={1}>
265 <Text color={C.dim}>│ effort:</Text>
266 <Text color={shown && shown !== 'n/a' ? 'white' : C.dim} bold>
267 {shown || '?'}
268 {!viaCommand && forced ? '*' : ''}
269 </Text>
270 {EFFORTS.map(l => (
271 <Button key={`effort-${l}`} label={EFFORT_LABEL[l]} variant={l === shown ? 'primary' : 'secondary'} onPress={pickEffort(l)} />
272 ))}
273 </Box>
274 </Box>
275 )
276
277 const top = totals(outs)
278 .slice(0, 3)
279 .map(t => `${toolName(t.tool)} ${fmt(t.tokens)}`)
280 .join(' · ')
281 const ctx =
282 hasCtx && isCtx && f && f.percent !== undefined && f.tokens !== undefined ? (
283 <Box flexDirection="row" width={width}>
284 <Text color={C.primary} bold>
285 [CTX]{' '}
286 </Text>
287 <Text color={tone(f.percent)}>{bar(f.percent / 100, 10)}</Text>
288 <Text wrap="truncate-end">
289 {' '}
290 {f.percent}% {fmt(f.tokens)}/{fmt(f.window)}
291 {top ? <Text color={C.dim}> · tool output: {top}</Text> : ''}
292 </Text>
293 </Box>
294 ) : null
295
296 return (
297 <Box flexDirection="column">
298 {sys}
299 {ctl}
300 {ctx}
301 </Box>
302 )
303}
304
305export const register: Register = on => {
306 // session.compact rebuilt the outputs for the compaction whose drop the next reading shows.
307 let isCompactHeard = false
308
309 on('session.start', async ($, e, next) => {
310 await $.command.register({ name: 'clean', description: 'Toggle mission-control clean view (tool rows as a checklist)' })
311 await $.command.register({ name: 'ctx', description: 'Context pane: what fills the window, by tool; "/ctx band" hides or shows the [CTX] row' })
312 const saved = await $.store.get(STORE_CLEAN).catch(() => undefined)
313 if (typeof saved === 'boolean') await update($, clean, () => saved)
314 const savedBand = await $.store.get(STORE_CTX_BAND).catch(() => undefined)
315 if (typeof savedBand === 'boolean') await update($, ctxBand, () => savedBand)
316 const ran = await next(e)
317 await refresh($)
318 // awaited: the session's first rows must not land before the rebuild that would overwrite them
319 await rebuildOutputs($).catch(() => undefined)
320 void refreshContext($).catch(() => undefined)
321 // other mods register their commands at session.start too: look again once they have
322 $.clock.after(1500, () => void refresh($).catch(() => undefined))
323 return ran
324 })
325
326 on('command.run', { command: 'clean' }, async $ => {
327 let now = false
328 await update($, clean, v => (now = !v))
329 await $.store.set(STORE_CLEAN, now)
330 return { text: now ? 'Clean view on: tool rows show as a checklist.' : 'Clean view off: full tool rows.' }
331 })
332
333 on('command.run', { command: 'ctx' }, async ($, e) => {
334 if (e.args.trim() === 'band') {
335 let now = false
336 await update($, ctxBand, v => (now = !v))
337 await $.store.set(STORE_CTX_BAND, now)
338 return { text: now ? 'Context row on.' : 'Context row off (the /ctx pane still works).' }
339 }
340 if (await isCtxPaneOpen($)) {
341 await $.ui.close({ id: CTX_PANE })
342 return { text: 'Context pane closed.' }
343 }
344 await $.ui.open({ id: CTX_PANE, title: 'Context' })
345 await refreshContext($)
346 return { text: 'Context pane opened.' }
347 })
348
349 on('turn.start', async ($, e, next) => {
350 void refresh($).catch(() => undefined)
351 return next(e)
352 })
353
354 on('turn.complete', async ($, e, next) => {
355 const done = await next(e)
356 void refresh($).catch(() => undefined)
357 return done
358 })
359
360 // Read the effort each main-loop request goes out with; apply the override only without /effort.
361 on('turn.step', async function* ($, e, next) {
362 let step = e
363 if (e.agentId === undefined) {
364 const forced = (await read($, hasEffortCommand)) ? '' : await read($, override)
365 if (forced && e.effort !== undefined) step = { ...e, effort: forced as (typeof EFFORTS)[number] }
366 const sent = step.effort === undefined ? 'n/a' : String(step.effort)
367 void update($, effort, () => sent).catch(() => undefined)
368 }
369 return yield* next(step)
370 })
371
372 // Tool outputs follow the main conversation's rows as they are stored: a
373 // response's tool_use blocks name the calls, a tool-result row answers them.
374 on('session.append', { door: 'response' }, async ($, e, next) => {
375 const stored = await next(e)
376 if (e.agentId === undefined && stored.message) await noteCalls($, stored.message.content).catch(() => undefined)
377 return stored
378 }).catch(($, e, next) => next(e))
379
380 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
381 const stored = await next(e)
382 if (e.agentId === undefined && stored.message) {
383 const tool = e.origin.kind === 'tool' ? e.origin.tool : 'unknown'
384 await noteResults($, stored.message.content, tool).catch(() => undefined)
385 }
386 return stored
387 }).catch(($, e, next) => next(e))
388
389 // A compaction replaces the transcript: rebuild from what it reads as afterwards.
390 on('session.compact', async ($, e, next) => {
391 const done = await next(e)
392 if (e.agentId === undefined && e.trigger !== 'precompute' && done.messages) {
393 isCompactHeard = true
394 await rebuildOutputs($, done.messages).catch(() => undefined)
395 }
396 return done
397 }).catch(($, e, next) => next(e))
398
399 // The engine measures after each response: the moment the context fill moves.
400 // Cheap: the usage reading (and the breakdown while the /ctx pane is open).
401 // Where an organization manages Claude Code, session.compact passes over the
402 // plugins a person installs: a compaction shows here instead, as the fill
403 // falling to under half, and the outputs are rebuilt from the transcript.
404 on('session.measure', async ($, e, next) => {
405 const ran = await next(e)
406 if (e.changed.includes('context')) {
407 const before = (await read($, fill))?.tokens
408 await refreshContext($).catch(() => undefined)
409 if (!isCompactHeard && isCompactionDrop(before, e.context.tokens)) await rebuildOutputs($).catch(() => undefined)
410 isCompactHeard = false
411 }
412 return ran
413 }).catch(($, e, next) => next(e))
414
415 // [SYS] goes into the spinner's own text, so it shares the line with the
416 // animated word; a row wrapped around the engine's tree put it on a line of
417 // its own above the spinner.
418 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
419 const p = e.props
420 return next({ ...e, props: { ...p, word: `[SYS] ${p.word}`, message: p.message === null ? null : `[SYS] ${p.message}` } })
421 })
422
423 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
424 const { Box, Text } = $.ui.resolve(e)
425 const p = e.props
426 const l = look(p.tool, p.input)
427 const width = Math.max(20, (e.viewport?.columns ?? 100) - 4)
428 const state = p.isInterrupted
429 ? { mark: '⏸', color: C.warn, word: 'interrupted' }
430 : p.isErrored
431 ? { mark: '✖', color: C.warn, word: 'error' }
432 : p.isRunning
433 ? { mark: '◌', color: C.primary, word: 'running' }
434 : { mark: '✔', color: C.ok, word: '' }
435
436 if (await read($, clean)) {
437 const box = p.isInterrupted || p.isErrored ? '☒' : p.isRunning ? '☐' : '☑'
438 return (
439 <Box width={width}>
440 <Text color={state.color}>{box} </Text>
441 <Text wrap="truncate-end" dimColor={!p.isRunning}>
442 {l.verb} {l.target}
443 </Text>
444 </Box>
445 )
446 }
447
448 const header = (
449 <Box width={width} backgroundColor={C.bg}>
450 <Text color={l.color} bold>
451 [{l.label}]
452 </Text>
453 <Text color={l.color}> {l.icon} </Text>
454 <Text color={l.color === C.edit ? C.edit : 'white'} bold>
455 {p.tool.startsWith('mcp__') ? l.verb : p.tool}{' '}
456 </Text>
457 <Text wrap="truncate-end" color={C.dim}>
458 {l.target}
459 </Text>
460 <Text color={state.color}>
461 {' '}
462 {state.mark}
463 {state.word ? ` ${state.word}` : ''}
464 </Text>
465 </Box>
466 )
467 // While the call runs the engine's row is more than its header line (the
468 // live progress beneath it: a command's output, an agent's steps, the
469 // background hint), so its drawing stays below ours. A finished row is ours
470 // alone: its props can't tell a standalone row (whose result draws as its
471 // own ToolResult row) from an expanded group's, and wrapping every finished
472 // row would print two headers. The cost: an expanded group's inline results.
473 if (!p.isRunning) return header
474 return (
475 <Box flexDirection="column">
476 {header}
477 {await next(e)}
478 </Box>
479 )
480 })
481
482 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
483 if (!(await read($, clean))) return next(e)
484 const { Box, Text } = $.ui.resolve(e)
485 return e.props.isErrored ? (
486 <Box>
487 <Text color={C.warn}> ⎿ failed</Text>
488 </Box>
489 ) : (
490 <Box />
491 )
492 })
493
494 // While flightdeck's pane is open and drawn the block sits under its session
495 // log; otherwise it stays in the band.
496 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
497 const below = await next(e)
498 if (e.props.hasSurvey || (await isDocked($))) return below
499 const { Box } = $.ui.resolve(e)
500 return (
501 <Box flexDirection="column">
502 {await statusBlock($, e, e.props.bodyColumns)}
503 {below}
504 </Box>
505 )
506 })
507
508 on('ui.render', { component: 'Pane', requestId: FLIGHTDECK }, async ($, e, next) => {
509 const above = await next(e)
510 if (!(await isDocked($))) return above
511 const { Box } = $.ui.resolve(e)
512 const width = Math.max(40, e.props.bodyColumns)
513 return (
514 <Box flexDirection="column" width={width}>
515 {/* beneath flightdeck's hook, `above` is the engine's own empty drawing: leave it out */}
516 {(above as { type?: string }).type === 'engine' ? null : above}
517 <Box flexDirection="column" borderStyle="round" borderColor={C.dim} paddingX={1} width={width}>
518 {await statusBlock($, e, width - 4, false)}
519 </Box>
520 </Box>
521 )
522 })
523
524 on('ui.render', { component: 'Pane', requestId: CTX_PANE }, async ($, e) => {
525 const { Box, Button, Text } = $.ui.resolve(e)
526 const [f, list, cats, isBand] = await Promise.all([read($, fill), read($, outputs), read($, categories), read($, ctxBand)])
527 const width = e.props.bodyColumns
528 const barWidth = Math.max(6, Math.min(24, width - 36))
529 const byTool = totals(list).slice(0, 12)
530 const nameWidth = Math.min(20, Math.max(4, ...byTool.map(t => toolName(t.tool).length), ...cats.map(c => c.name.length)))
531 const most = Math.max(1, byTool[0]?.tokens ?? 1)
532 const allTokens = list.reduce((n, o) => n + o.tokens, 0)
533 const biggest = [...list].sort((a, b) => b.tokens - a.tokens).slice(0, 10)
534 const window = f?.window ?? 1
535
536 return (
537 <Box flexDirection="column" width={width}>
538 {f && f.percent !== undefined && f.tokens !== undefined ? (
539 <Text>
540 <Text bold>Context </Text>
541 <Text color={tone(f.percent)}>{bar(f.percent / 100, barWidth)}</Text> {f.percent}% · {fmt(f.tokens)} of {fmt(f.window)}
542 </Text>
543 ) : (
544 <Text dimColor>No reading yet: it shows after Claude's next reply.</Text>
545 )}
546 <Text> </Text>
547 <Text bold>
548 Tool output in context <Text dimColor>({list.length} calls, ~{fmt(allTokens)} tokens, estimated)</Text>
549 </Text>
550 {byTool.length === 0 && <Text dimColor> None yet.</Text>}
551 {byTool.map(t => (
552 <Text wrap="truncate-end">
553 {' '}
554 {toolName(t.tool).padEnd(nameWidth)} <Text color={C.primary}>{bar(t.tokens / most, barWidth)}</Text> {fmt(t.tokens).padStart(5)}
555 <Text dimColor>
556 {' '}
557 {t.calls} call{t.calls === 1 ? '' : 's'}
558 </Text>
559 </Text>
560 ))}
561 {biggest.length > 0 && <Text> </Text>}
562 {biggest.length > 0 && <Text bold>Biggest outputs</Text>}
563 {biggest.map(o => (
564 <Text wrap="truncate-end">
565 {' '}
566 {fmt(o.tokens).padStart(5)} <Text color={C.primary}>{oneLine(toolName(o.tool), 18)}</Text> <Text dimColor>{oneLine(o.label, Math.max(10, width - 30))}</Text>
567 </Text>
568 ))}
569 {cats.length > 0 && <Text> </Text>}
570 {cats.length > 0 && (
571 <Text bold>
572 Whole window by category <Text dimColor>(estimated)</Text>
573 </Text>
574 )}
575 {cats.map(c => (
576 <Text wrap="truncate-end">
577 {' '}
578 {oneLine(c.name, nameWidth).padEnd(nameWidth)} <Text color="#a78bfa">{bar(c.tokens / window, barWidth)}</Text> {fmt(c.tokens).padStart(5)}
579 </Text>
580 ))}
581 <Text> </Text>
582 <Box flexDirection="row" columnGap={1}>
583 <Button key="refresh" label="Refresh" onPress={() => void Promise.all([refreshContext($), rebuildOutputs($)]).catch(() => undefined)} />
584 <Button
585 key="band"
586 label={isBand ? 'Hide band row' : 'Show band row'}
587 onPress={() =>
588 void update($, ctxBand, v => !v)
589 .then(v => $.store.set(STORE_CTX_BAND, v))
590 .catch(() => undefined)
591 }
592 />
593 </Box>
594 </Box>
595 )
596 })
597}
598hooks/look.ts 59 lines1// What each tool row says: its label, icon, color and one-line target.
2
3export const C = {
4 bg: 'black',
5 primary: 'cyan',
6 ok: 'green',
7 warn: '#FFB000',
8 bad: '#f87171',
9 edit: 'magenta',
10 dim: 'gray',
11} as const
12
13export type Look = { label: string; icon: string; color: string; target: string; verb: string }
14
15const str = (v: unknown) => (typeof v === 'string' ? v : '')
16const firstLine = (s: string) => s.split(/\r?\n/)[0]
17const tail = (p: string) => p.replace(/\\/g, '/').split('/').slice(-3).join('/')
18
19export function look(tool: string, input: unknown): Look {
20 const i = (input ?? {}) as Record<string, unknown>
21 switch (tool) {
22 case 'Bash':
23 return { label: 'TOOL', icon: '❯', color: C.primary, target: firstLine(str(i.description) || str(i.command)), verb: 'Run' }
24 case 'PowerShell':
25 return { label: 'TOOL', icon: '❯', color: C.primary, target: firstLine(str(i.description) || str(i.command)), verb: 'Run' }
26 case 'Read':
27 return { label: 'READ', icon: '◇', color: C.primary, target: tail(str(i.file_path)), verb: 'Read' }
28 case 'Write':
29 return { label: 'EDIT', icon: '✚', color: C.edit, target: tail(str(i.file_path)), verb: 'Write' }
30 case 'Edit':
31 return { label: 'EDIT', icon: '✎', color: C.edit, target: tail(str(i.file_path)), verb: 'Edit' }
32 case 'NotebookEdit':
33 return { label: 'EDIT', icon: '✎', color: C.edit, target: tail(str(i.notebook_path)), verb: 'Edit' }
34 case 'Grep':
35 case 'Glob':
36 return { label: 'FIND', icon: '⌕', color: C.primary, target: str(i.pattern), verb: 'Search' }
37 case 'WebFetch':
38 case 'WebSearch':
39 return { label: 'NET', icon: '⇅', color: C.primary, target: str(i.url) || str(i.query), verb: 'Fetch' }
40 case 'Agent':
41 return { label: 'AGENT', icon: '◈', color: C.primary, target: str(i.description), verb: 'Agent' }
42 case 'AskUserQuestion':
43 return { label: 'ASK', icon: '?', color: C.warn, target: 'question for you', verb: 'Ask' }
44 case 'Skill':
45 return { label: 'SKILL', icon: '✦', color: C.primary, target: str(i.skill), verb: 'Skill' }
46 }
47 if (tool.startsWith('mcp__')) {
48 const [, server = '', name = ''] = tool.split('__')
49 return { label: 'MCP', icon: '⬡', color: C.primary, target: `${server}:${name}`, verb: name || tool }
50 }
51 return { label: 'TOOL', icon: '⚙', color: C.primary, target: '', verb: tool }
52}
53
54/** `claude-opus-5-5[1m]` → `opus-5.5 [1m]`. */
55export function shortModel(id: string): string {
56 const m = /^(?:claude-)?([a-z]+)-(\d+)-(\d+)(.*)$/.exec(id)
57 return m ? `${m[1]}-${m[2]}.${m[3]}${m[4] ? ' ' + m[4] : ''}` : id
58}
59hooks/meter.ts 91 lines1// Pure helpers: token estimates, labels, bars and per-tool totals.
2
3import type { Output } from '../types'
4
5/** Rough tokens for text as the model reads it: about 4 characters a token. */
6export const estimate = (text: string): number => Math.ceil(text.length / 4)
7
8export function fmt(n: number): string {
9 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
10 if (n >= 10_000) return `${Math.round(n / 1000)}k`
11 if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
12 return String(Math.round(n))
13}
14
15export function bar(fraction: number, width: number): string {
16 const w = Math.max(1, Math.floor(width))
17 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * w)
18 return '█'.repeat(filled) + '░'.repeat(w - filled)
19}
20
21/**
22 * Two context readings in a row where the fill fell to under half of at least 20k tokens: a
23 * compaction (a rewind looks the same). Nothing else empties the window; /clear starts a new one.
24 */
25export const isCompactionDrop = (before: number | undefined, after: number | undefined): boolean =>
26 before !== undefined && after !== undefined && before >= 20_000 && after < before / 2
27
28/** Green under 60%, yellow under 80%, red from there. */
29export const tone = (percent: number): string => (percent >= 80 ? '#f87171' : percent >= 60 ? '#facc15' : '#4ade80')
30
31/** `mcp__server__tool` reads as `server:tool`. */
32export function toolName(tool: string): string {
33 const m = /^mcp__(.+?)__(.+)$/.exec(tool)
34 return m ? `${m[1]}:${m[2]}` : tool
35}
36
37export function oneLine(text: string, max: number): string {
38 const t = text.replace(/\s+/g, ' ').trim()
39 return t.length > max ? t.slice(0, Math.max(1, max - 1)) + '…' : t
40}
41
42/** Longest label kept per output: a heredoc command is not stored whole. */
43export const LABEL_MAX = 160
44
45/** What the call was about, from its arguments (the command, the file, the pattern), on one line of at most LABEL_MAX. */
46export function labelOf(tool: string, input: Record<string, unknown>): string {
47 const full = whole(tool, input)
48 return oneLine(full.length > LABEL_MAX * 4 ? full.slice(0, LABEL_MAX * 4) : full, LABEL_MAX)
49}
50
51function whole(tool: string, input: Record<string, unknown>): string {
52 const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
53 const base = (p: string) => p.replace(/\\/g, '/').split('/').pop() || p
54 switch (tool) {
55 case 'Bash':
56 case 'PowerShell':
57 return s('command')
58 case 'Read':
59 case 'Write':
60 case 'Edit':
61 return base(s('file_path'))
62 case 'NotebookEdit':
63 return base(s('notebook_path'))
64 case 'Grep':
65 case 'Glob':
66 return s('pattern')
67 case 'WebFetch':
68 return s('url')
69 case 'WebSearch':
70 return s('query')
71 case 'Agent':
72 return s('description')
73 default:
74 return ''
75 }
76}
77
78export type ToolTotal = { tool: string; calls: number; tokens: number }
79
80/** Tokens and calls per tool, biggest first. */
81export function totals(outputs: readonly Output[]): ToolTotal[] {
82 const by = new Map<string, ToolTotal>()
83 for (const o of outputs) {
84 const t = by.get(o.tool) ?? { tool: o.tool, calls: 0, tokens: 0 }
85 t.calls++
86 t.tokens += o.tokens
87 by.set(o.tool, t)
88 }
89 return [...by.values()].sort((a, b) => b.tokens - a.tokens)
90}
91types/index.d.ts 32 lines1export type GitInfo = { repo: string; branch: string }
2export type Output = { id: string; tool: string; label: string; tokens: number }
3export type Fill = { tokens?: number; window: number; percent?: number }
4export type Category = { name: string; tokens: number }
5export type Call = { tool: string; label: string }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'mission-control': {
10 clean: boolean
11 git: GitInfo | null
12 /** the main loop's model id, as /model shows it */
13 model: string
14 guard: boolean
15 /** the effort the last main-loop request was sent with, or the level just picked */
16 effort: string
17 /** whether this build has an /effort command; without it the pick is applied per request */
18 hasEffortCommand: boolean
19 /** the level applied per request when there is no /effort command */
20 override: string
21 /** tool outputs in the live window, estimated */
22 outputs: Output[]
23 /** main-loop tool calls the model made whose result has not been stored yet, by tool_use_id */
24 pending: Record<string, Call>
25 fill: Fill | null
26 categories: Category[]
27 /** whether the [CTX] row shows */
28 ctxBand: boolean
29 }
30 }
31}
32