A quiet band above the prompt: model, effort, context %, cost, current task and live subagents

A minimal band above the Claude Code prompt:
opus 5.5 · high ctx ▰▰▰▰▱▱▱▱▱▱ 36% $1.42 2 agents ▸ Running tests
● Explore haiku 4.5 ▰▱▱▱▱ 42k $0.04 0:37 Map the auth flow — Grep session
✓ Plan opus 5.5 ▰▰▱▱▱ 61k $0.31 2:03 Draft migration plan
/cockpit toggles showing every subagent of the session.claude plugin marketplace add 11Fred11/fredj-hammami-plugins
claude plugin install cockpit@fredj-hammami
Then restart Claude Code, or run /reload-plugins in a running session.
Set where the context bar turns amber and red with /plugin configure cockpit@fredj-hammami in Claude Code, or from a shell:
echo '{"warnAt": "40%", "dangerAt": "120k"}' | claude plugin configure cockpit@fredj-hammami --values-stdin
| Setting | Default | Accepts |
|---|---|---|
warnAt: context amber from | 30% | a share of the window (40%), or a token count (80k, 1.2m) |
dangerAt: context red from | 50% | the same |
A percentage moves with the model's window. A token count stays put when you switch to a model with a larger window.
claude plugin validate plugins/cockpit
claude plugin test plugins/cockpit
Built on Claude Code's mods API, whose events and methods can change between releases. Develop against the folder with claude --plugin-dir plugins/cockpit.
hooks/register.tsx 429 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CockpitAgent, CockpitLoop, CockpitUsage } from '../types'
5
6const main = atom({ plugin: 'cockpit', key: 'main' } as const, {})
7const task = atom({ plugin: 'cockpit', key: 'task' } as const, null)
8const agents = atom({ plugin: 'cockpit', key: 'agents' } as const, [])
9const usage = atom({ plugin: 'cockpit', key: 'usage' } as const, {})
10const now = atom({ plugin: 'cockpit', key: 'now' } as const, 0)
11const isExpanded = atom({ plugin: 'cockpit', key: 'isExpanded' } as const, false)
12
13// Finished agents stay on the band this long before they drop off.
14const LINGER_MS = 20_000
15const MAX_AGENT_ROWS = 6
16const CTX_CELLS = 10
17const AGENT_CTX_CELLS = 5
18// Claude Code reports the main model's window alone; a subagent on another
19// model is measured against the window most models have.
20const ASSUMED_WINDOW = 200_000
21const DEFAULT_WARN_AT = '30%'
22const DEFAULT_DANGER_AT = '50%'
23
24// Where the context bar turns amber and red, as percentages of the window.
25export type CtxLimits = { warn: number; danger: number }
26
27// A limit as the person wrote it: "30%" (or a bare 30) is a share of the
28// window; "120k", "1.2m" or a bare 120000 a token count, which holds still
29// when a model with a larger window comes in.
30export const parseLimit = (text: string, window: number | undefined): number | undefined => {
31 const match = /^\s*(\d+(?:\.\d+)?)\s*(%|k|m)?\s*$/i.exec(text)
32 if (!match) return undefined
33 const n = Number(match[1])
34 const unit = match[2]?.toLowerCase() ?? (n <= 100 ? '%' : '')
35 if (unit === '%') return n
36 if (!window) return undefined
37 const tokens = unit === 'k' ? n * 1_000 : unit === 'm' ? n * 1_000_000 : n
38
39 return (tokens / window) * 100
40}
41
42export const ctxLimits = (warnAt: string, dangerAt: string, window: number | undefined): CtxLimits => {
43 const danger = parseLimit(dangerAt, window) ?? parseLimit(DEFAULT_DANGER_AT, window)!
44 const warn = parseLimit(warnAt, window) ?? parseLimit(DEFAULT_WARN_AT, window)!
45
46 return { warn: Math.min(warn, danger), danger }
47}
48
49export const ctxColor = (pct: number, limits: CtxLimits): string =>
50 pct >= limits.danger ? 'error' : pct >= limits.warn ? 'warning' : 'success'
51
52// The filled cells in runs of one color, each cell colored by the share at its
53// middle; any context at all fills one cell.
54export const ctxRuns = (pct: number, limits: CtxLimits, cells = CTX_CELLS): { color: string; cells: string }[] => {
55 const step = 100 / cells
56 const filled = pct > 0 ? Math.max(1, Math.min(cells, Math.round(pct / step))) : 0
57 const runs: { color: string; cells: string }[] = []
58 for (let i = 0; i < filled; i++) {
59 const color = ctxColor((i + 0.5) * step, limits)
60 const last = runs[runs.length - 1]
61 if (last?.color === color) last.cells += '▰'
62 else runs.push({ color, cells: '▰' })
63 }
64
65 return runs
66}
67
68// List prices in $ per million tokens, from the Claude API model table; cache
69// writes at the 5-minute rate. Claude Code reports dollars for the whole
70// session only, so a subagent's cost is estimated from its own token counts.
71type Price = { input: number; output: number; read: number; write: number }
72const PRICES: [family: string, price: Price][] = [
73 ['haiku', { input: 1, output: 5, read: 0.1, write: 1.25 }],
74 ['sonnet', { input: 2, output: 10, read: 0.2, write: 2.5 }],
75 ['opus', { input: 4, output: 20, read: 0.2, write: 5 }],
76 ['fable', { input: 10, output: 50, read: 0.25, write: 12.5 }],
77 ['mythos', { input: 10, output: 50, read: 0.25, write: 12.5 }],
78]
79
80export type CallUsage = {
81 input_tokens: number
82 output_tokens: number
83 cache_read_input_tokens: number
84 cache_creation_input_tokens: number
85}
86
87// One request's cost in dollars; undefined for a model with no known price.
88export const usageUsd = (model: string | undefined, u: CallUsage): number | undefined => {
89 const price = PRICES.find(([family]) => model?.includes(family))?.[1]
90 if (!price) return undefined
91
92 return (
93 (u.input_tokens * price.input +
94 u.output_tokens * price.output +
95 u.cache_read_input_tokens * price.read +
96 u.cache_creation_input_tokens * price.write) /
97 1_000_000
98 )
99}
100
101export const fmtUsd = (usd: number): string => (usd < 0.01 ? '<$0.01' : `$${usd.toFixed(2)}`)
102
103// The window a subagent's context is measured against.
104export const agentWindow = (agentModel: string | undefined, mainModel: string | undefined, mainWindow: number | undefined): number =>
105 mainWindow && agentModel && shortModel(agentModel) === shortModel(mainModel) ? mainWindow : ASSUMED_WINDOW
106
107export const shortModel = (id: string | undefined): string => {
108 if (!id) return ''
109
110 return id
111 .replace(/^claude-/, '')
112 .replace(/-\d{8}$/, '')
113 .replace(/-(\d+)-(\d+)$/, ' $1.$2')
114}
115
116export const shortEffort = (effort: string | number | undefined): string | undefined => {
117 if (effort === undefined) return undefined
118 if (typeof effort === 'number') return `${effort}`
119
120 return effort === 'medium' ? 'med' : effort
121}
122
123export const fmtTokens = (n: number): string =>
124 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
125
126export const fmtElapsed = (ms: number): string => {
127 const s = Math.max(0, Math.floor(ms / 1000))
128
129 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
130}
131
132const basename = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
133
134const clip = (text: string, max: number): string =>
135 text.length > max ? `${text.slice(0, max - 1)}…` : text
136
137// One short line for what a tool call is doing: "Bash npm test", "Edit app.ts".
138export const describeCall = (tool: string, args: Record<string, unknown>): string => {
139 const str = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : undefined)
140 const path = str('file_path') ?? str('notebook_path') ?? str('path')
141 const detail =
142 str('description') ??
143 str('command') ??
144 (path ? basename(path) : undefined) ??
145 str('pattern') ??
146 str('query') ??
147 str('url') ??
148 str('skill') ??
149 str('subject')
150 const name = tool.startsWith('mcp__') ? tool.split('__').slice(1).join(':') : tool
151
152 return clip(detail ? `${name} ${detail.split('\n')[0]}` : name, 60)
153}
154
155const loopOf = (agentId: string | undefined) => agentId ?? 'main'
156
157const patchLoop = async ($: EngineInterface, agentId: string | undefined, patch: CockpitLoop) => {
158 if (agentId === undefined) {
159 await update($, main, loop => ({ ...loop, ...patch }))
160 return
161 }
162 await update($, agents, list => list.map(a => (a.id === agentId ? { ...a, ...patch } : a)))
163}
164
165const refreshUsage = async ($: EngineInterface) => {
166 const u = await $.session.usage()
167 const next: CockpitUsage = { percent: u.context.percent, window: u.context.window, usd: u.cost?.usd }
168 await update($, usage, () => next)
169}
170
171// Picks up agents the spawn hook missed (a reload mid-run) and their ends.
172const syncAgents = async ($: EngineInterface) => {
173 const listed = await $.agent.list()
174 const at = await $.clock.now()
175 await update($, agents, list => {
176 const known = new Map(list.map(a => [a.id, a]))
177 for (const info of listed) {
178 if (info.teammateId) continue
179 const ended = info.status === 'completed' || info.status === 'failed' || info.status === 'killed'
180 const mine = known.get(info.id)
181 if (!mine) {
182 if (ended) continue
183 known.set(info.id, {
184 id: info.id,
185 type: info.type,
186 description: info.description,
187 status: 'running',
188 tokens: 0,
189 startedAt: at,
190 })
191 } else if (ended && mine.status === 'running') {
192 known.set(info.id, { ...mine, status: info.status === 'completed' ? 'done' : 'failed', endedAt: at })
193 }
194 }
195
196 return [...known.values()].slice(-50)
197 })
198}
199
200export const register: Register = (on, options) => {
201 const warnAt = String(options.warnAt ?? DEFAULT_WARN_AT)
202 const dangerAt = String(options.dangerAt ?? DEFAULT_DANGER_AT)
203
204 const taskTitles = new Map<string, string>()
205
206 on('session.start', async ($, e, next) => {
207 await $.command.register({
208 name: 'cockpit',
209 description: 'Toggle cockpit between compact and showing every subagent of this session',
210 immediate: true,
211 })
212 // Figures are read from the engine's own ledger, refreshed as each model
213 // response lands; nothing here polls or calls the API.
214 await refreshUsage($)
215 await syncAgents($)
216 // The model is known before the first response; effort arrives with it.
217 const model = await $.session.model()
218 await update($, main, loop => (loop.model ? loop : { ...loop, model }))
219
220 // Only moves the elapsed-time column while a subagent row is on screen.
221 $.clock.every(1000, () => {
222 void (async () => {
223 const list = await read($, agents)
224 const at = await $.clock.now()
225 const isTicking = list.some(a => a.status === 'running' || (a.endedAt ?? 0) > at - LINGER_MS - 1000)
226 if (isTicking) await update($, now, () => at)
227 })()
228 })
229
230 return next(e)
231 })
232
233 on('command.run', { command: 'cockpit' }, async $ => {
234 const was = await read($, isExpanded)
235 await update($, isExpanded, () => !was)
236
237 return { text: was ? 'cockpit: compact' : 'cockpit: showing every subagent' }
238 })
239
240 on('turn.step', async function* ($, e, next) {
241 await patchLoop($, e.agentId, { model: e.model, effort: shortEffort(e.effort) })
242 const result = yield* next(e)
243 const u = result.usage
244 if (u && e.agentId !== undefined) {
245 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
246 const usd = usageUsd(u.model ?? e.model, u)
247 await update($, agents, list =>
248 list.map(a =>
249 a.id === e.agentId ? { ...a, tokens: total, usd: usd === undefined ? a.usd : (a.usd ?? 0) + usd } : a,
250 ),
251 )
252 }
253 await refreshUsage($)
254
255 return result
256 })
257
258 on('agent.spawn', async ($, e, next) => {
259 const spawned = await next(e)
260 if (spawned.agentId && !e.isTeammate) {
261 const agent: CockpitAgent = {
262 id: spawned.agentId,
263 type: e.subagentType,
264 description: e.description,
265 model: spawned.model,
266 status: 'running',
267 tokens: 0,
268 startedAt: await $.clock.now(),
269 }
270 await update($, agents, list => [...list.filter(a => a.id !== agent.id), agent].slice(-50))
271 await update($, now, () => agent.startedAt)
272 }
273
274 return spawned
275 })
276
277 on('tool.call', async ($, e, next) => {
278 const args = e as unknown as Record<string, unknown>
279 await patchLoop($, e.agentId, { activity: describeCall(String(e.tool), args) })
280 const ran = await next(e)
281
282 // The main loop's task list names what it is working on.
283 if (e.agentId === undefined && !('deny' in ran && ran.deny)) {
284 if (e.tool === 'TodoWrite') {
285 const doing = e.todos.find(t => t.status === 'in_progress')
286 await update($, task, () => doing?.activeForm ?? null)
287 } else if (e.tool === 'TaskCreate') {
288 const created = (ran.result as { task?: { id: string } } | undefined)?.task
289 if (created) taskTitles.set(created.id, e.activeForm ?? e.subject)
290 } else if (e.tool === 'TaskUpdate') {
291 const title = e.activeForm ?? e.subject ?? taskTitles.get(e.taskId)
292 if (e.activeForm ?? e.subject) taskTitles.set(e.taskId, (e.activeForm ?? e.subject)!)
293 if (e.status === 'in_progress' && title) await update($, task, () => title)
294 if (e.status === 'completed' || e.status === 'deleted') {
295 await update($, task, current => (current === title ? null : current))
296 }
297 }
298 }
299
300 return ran
301 })
302
303 // /model, the picker or a fallback: show the new model before its first response.
304 on('classic.PostModelSwitch', async ($, e, next) => {
305 await update($, main, loop => ({ ...loop, model: e.to_model, effort: undefined }))
306 await refreshUsage($)
307
308 return next(e)
309 })
310
311 on('turn.complete', async ($, e, next) => {
312 const done = await next(e)
313 if (e.agentId === undefined) {
314 await patchLoop($, undefined, { activity: undefined })
315 await refreshUsage($)
316 } else {
317 const at = await $.clock.now()
318 const status = e.reason === 'answer' ? 'done' : 'failed'
319 await update($, agents, list =>
320 list.map(a => (a.id === e.agentId ? { ...a, status, endedAt: at, activity: undefined } : a)),
321 )
322 }
323
324 return done
325 })
326
327 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
328 if (e.props.hasSurvey) return next(e)
329
330 const { Box, Text } = $.ui.resolve(e)
331 const loop = await read($, main)
332 const current = await read($, task)
333 const list = await read($, agents)
334 const u = await read($, usage)
335 const at = await read($, now)
336 const isAll = await read($, isExpanded)
337 const width = e.props.bodyColumns
338
339 const shown = list
340 .filter(a => isAll || a.status === 'running' || (a.endedAt ?? 0) > at - LINGER_MS)
341 .slice(-MAX_AGENT_ROWS)
342 const running = list.filter(a => a.status === 'running').length
343
344 const pct = u.percent
345 const limits = ctxLimits(warnAt, dangerAt, u.window)
346 const runs = pct === undefined ? [] : ctxRuns(pct, limits)
347 const filled = runs.reduce((n, run) => n + run.cells.length, 0)
348 const doing = current ?? (e.props.isWorking ? loop.activity : undefined)
349
350 const typeWidth = Math.min(16, Math.max(...shown.map(a => a.type.length), 4))
351 const modelWidth = Math.max(...shown.map(a => shortModel(a.model).length + (a.effort ? a.effort.length + 3 : 0)), 4)
352 // Name, model, bar and figures sit together on the left, as on the main line; the task takes the rest.
353 const hasCost = shown.some(a => a.usd !== undefined)
354 const costWidth = hasCost ? 7 : 0
355
356 return (
357 <Box flexDirection="column" width={width}>
358 <Box gap={2}>
359 {loop.model && (
360 <Text>
361 <Text bold>{shortModel(loop.model)}</Text>
362 {loop.effort && <Text dimColor> · {loop.effort}</Text>}
363 </Text>
364 )}
365 <Text>
366 <Text dimColor>ctx </Text>
367 {runs.map(run => (
368 <Text color={run.color}>{run.cells}</Text>
369 ))}
370 <Text dimColor>{'▱'.repeat(CTX_CELLS - filled)}</Text>
371 <Text color={pct === undefined ? undefined : ctxColor(pct, limits)}> {pct === undefined ? '–' : `${pct}%`}</Text>
372 </Text>
373 {u.usd !== undefined && <Text dimColor>${u.usd.toFixed(2)}</Text>}
374 {running > 0 && <Text color="claude">{running} agent{running === 1 ? '' : 's'}</Text>}
375 {doing && (
376 <Box flexShrink={1}>
377 <Text wrap="truncate-end" dimColor={!current}>▸ {doing}</Text>
378 </Box>
379 )}
380 </Box>
381 {shown.map(a => {
382 const isRunning = a.status === 'running'
383 const mark = isRunning ? '●' : a.status === 'done' ? '✓' : '✗'
384 const markColor = isRunning ? 'claude' : a.status === 'done' ? 'success' : 'error'
385 const label = `${shortModel(a.model)}${a.effort ? ` · ${a.effort}` : ''}`
386 const what = isRunning && a.activity ? `${a.description} — ${a.activity}` : a.description
387 const agentLimits = ctxLimits(warnAt, dangerAt, agentWindow(a.model, loop.model, u.window))
388 const agentPct = (a.tokens / agentWindow(a.model, loop.model, u.window)) * 100
389 // Too few cells to color by position: the whole bar takes the current level's color.
390 const agentFilled = ctxRuns(agentPct, agentLimits, AGENT_CTX_CELLS).reduce((n, run) => n + run.cells.length, 0)
391 const agentColor = ctxColor(agentPct, agentLimits)
392
393 return (
394 <Box key={a.id}>
395 <Text color={markColor}>{mark} </Text>
396 <Box width={typeWidth + 1} flexShrink={0}>
397 <Text wrap="truncate-end">{a.type}</Text>
398 </Box>
399 <Box width={modelWidth + 1} flexShrink={0}>
400 <Text dimColor>{label}</Text>
401 </Box>
402 <Box width={AGENT_CTX_CELLS + 1} flexShrink={0}>
403 <Text>
404 <Text color={agentColor} dimColor={!isRunning}>{'▰'.repeat(agentFilled)}</Text>
405 <Text dimColor>{'▱'.repeat(AGENT_CTX_CELLS - agentFilled)}</Text>
406 </Text>
407 </Box>
408 <Box width={6} flexShrink={0} justifyContent="flex-end">
409 <Text dimColor>{a.tokens ? fmtTokens(a.tokens) : ''}</Text>
410 </Box>
411 {hasCost && (
412 <Box width={costWidth} flexShrink={0} justifyContent="flex-end">
413 <Text dimColor>{a.usd === undefined ? '' : fmtUsd(a.usd)}</Text>
414 </Box>
415 )}
416 <Box width={6} flexShrink={0} justifyContent="flex-end">
417 <Text dimColor>{fmtElapsed((a.endedAt ?? at) - a.startedAt)}</Text>
418 </Box>
419 <Box flexShrink={1} marginLeft={2}>
420 <Text wrap="truncate-end" dimColor={!isRunning}>{what}</Text>
421 </Box>
422 </Box>
423 )
424 })}
425 </Box>
426 )
427 })
428}
429types/index.d.ts 36 lines1export type CockpitLoop = {
2 model?: string
3 effort?: string
4 activity?: string
5}
6
7export type CockpitAgent = CockpitLoop & {
8 id: string
9 type: string
10 description: string
11 status: 'running' | 'done' | 'failed'
12 tokens: number
13 usd?: number
14 startedAt: number
15 endedAt?: number
16}
17
18export type CockpitUsage = {
19 percent?: number
20 window?: number
21 usd?: number
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'cockpit': {
27 main: CockpitLoop
28 task: string | null
29 agents: CockpitAgent[]
30 usage: CockpitUsage
31 now: number
32 isExpanded: boolean
33 }
34 }
35}
36