SLOPSHOPPER

mission-control

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…

newpanebandspinnerrowscommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ Context ██████████░░░░░░░░░░ 49% · 97k of 20 │ ┃ ⏺ Read(src/auth.ts) │ ┃ Tool output in context (8 calls, ~171 tokens ⎿ Read 6 lines │ ┃ Bash ████████████████████ 76 3 calls ☑ Edit app/src/auth.ts │ ┃ Read █████████████░░░░░░░ 49 1 call ⏺ Bash(bun test) │ ┃ Write ███████░░░░░░░░░░░░░ 26 2 calls ⎿ 3 pass, 1 fail │ ┃ Edit ███░░░░░░░░░░░░░░░░░ 12 1 call │ ┃ Grep ██░░░░░░░░░░░░░░░░░░ 8 1 call ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Biggest outputs ✻ Worked for 42s · done 4:20 PM │ ┃ 49 Read │ ┃ 40 Bash › /clean │ ┃ 28 Bash ⎿ mission-control: Clean view on: tool rows show as a checklist. │ ┃ 13 Write │ ┃ 13 Write │ ┃ 12 Edit │ ┃ 8 Grep │ ┃ 8 Bash │ ┃ │ ┃ [ Refresh ] [ Hide band row ] │ ✻ [SYS] Thinking… [SYS] repo:— ⎇ no git ◆ opus-5.5 [HW-GUARD OFF] [CLEAN] [CTL] model: opus [ opus ] [ sonnet ] [ haiku ] [ fable ] │ effort: ? [ low ] [ med ] [ high ] [ xhigh ] [ max ] [CTX] █████░░░░░ 49% 97k/200k · tool output: Bash 76 · Read 49 · Write 26 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[SYS] repo:— ⎇ no git ◆ opus-5.5 [HW-GUARD OFF] [CLEAN] [CTL] model: opus [ opus ] [ sonnet ] [ haiku ] [ fable ] │ effort: ? [ low ] [ med ] [ high ] [ xhi [CTX] █████░░░░░ 49% 97k/200k · tool output: Bash 76 · Read 49 · Write 26 ⟨Claude Code's own drawing⟩
Pane · Context
Context ██████████░░░░░░░░░░ 49% · 97k of 200k Tool output in context (8 calls, ~171 tokens, estimated) Bash ████████████████████ 76 3 calls Read █████████████░░░░░░░ 49 1 call Write ███████░░░░░░░░░░░░░ 26 2 calls Edit ███░░░░░░░░░░░░░░░░░ 12 1 call Grep ██░░░░░░░░░░░░░░░░░░ 8 1 call Biggest outputs 49 Read 40 Bash 28 Bash 13 Write 13 Write 12 Edit 8 Grep 8 Bash [ Refresh ] [ Hide band row ]
Tool row
☑ Edit app/src/auth.ts
README

claude-mods

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.

Source 4 files
hooks/register.tsx 598 lines
1import { 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}
598
hooks/look.ts 59 lines
1// 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}
59
hooks/meter.ts 91 lines
1// 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}
91
types/index.d.ts 32 lines
1export 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