Agent cockpit for Claude Code: live context, limits, cost, tools, files, tests and a guard for risky commands

A live instrument panel for Claude Code, built as a mod: context fill, rate limits, cost, subagents, tool calls, changed files and test runs, each section opening to its details, plus a guard that asks before risky commands and edits to secrets files.
Works in the Claude Code terminal and the Claude desktop app (Code tab). Requires Claude Code v2.1.287 or later.
/cockpit opens the panel/cockpit-demo fills it with sample data (/cockpit-demo off restores yours)/cockpit-report, /cockpit-export, /cockpit-budget, /cockpit-guard, /cockpit-cacheCockpit is one hooks module, hooks/register.tsx, readable TypeScript with no dependencies.
Sends: nothing. Cockpit makes no network requests and does not send the conversation, files or any other data anywhere. Everything it reads is shown in your own Claude Code window and stays on your machine.
Programs it starts, and why. Only git, only in your project, only to show changed files:
git diff --numstat HEAD (or git diff --numstat) and git ls-files --others --exclude-standard: the list of changed and new files with +/- line counts, refreshed after an edit and after each turngit rev-parse --show-toplevel and git diff --unified=1 --no-color [HEAD] -- <file>: the diff of one file, only when you click that file in the Files section. <file> is the path git itself listedWhat it reads. Through the Claude Code mods API: the session's usage figures (context, rate limits, cost), the tool calls Claude makes and their results (to count calls, time them, size their output and spot loops), the subagent list, and the model's turns. From disk: the contents of a new file only when you click it in Files. It never reads secrets files (.env*, keys, credentials) and shows "contents not read" for them instead.
What it writes. One file, .cockpit/cockpit-report.md in the session's working directory, only when you run /cockpit-export or press Export; each export replaces it. It never writes build, settings, start-up or instruction files. All-time totals (session, turn and tool-call counts, spend) are kept in the plugin's own store on your machine; session data lives in memory and ends with the session.
Slash commands it runs itself. One: /compact, and only when you press the Compact now button (shown when the context is over 70% and Claude is idle).
Commands it adds. /cockpit, /cockpit-report, /cockpit-export, /cockpit-budget, /cockpit-guard, /cockpit-cache, /cockpit-demo.
Events it hooks. tool.call: counts and times each call; for a risky Bash command or an edit to a secrets file it asks you first in Claude Code's own question dialog and refuses the call only if you block it. It never changes a call's input or result. session.compact: reads the result of a compaction to count it and show tokens before and after; it never changes the compaction. It also listens to session.start, session.measure, turn.start, turn.step and turn.complete to read figures, and draws its panel and band with ui.render.
About "download-and-run" patterns in the source. The text curl … | sh appears in register.tsx only as one of the guard's detection rules: Cockpit stops such a command and asks you. Cockpit itself never downloads or runs anything.
Full documentation, screenshots and the changelog: https://github.com/drkokorev/cockpit-for-claude
hooks/register.tsx 1937 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderChildren, Timer } from 'claude-code'
3
4import type { CockpitAgent, CockpitCall, CockpitDiffFile, CockpitPane, CockpitPrefs, CockpitStats, CockpitTest, CockpitTurnRecord } from '../types'
5
6// Agent Cockpit: a live instrument panel for Claude Code sessions.
7// Context fill, rate limits, cost, the running turn, tool usage, edited files,
8// test runs, turn history, all-time totals, and a guard for risky commands.
9
10const PANE = 'cockpit'
11const STORE_LIFETIME = 'lifetime'
12const STORE_PLAN = 'plan'
13
14const EMPTY_STATS: CockpitStats = {
15 startedAt: 0,
16 model: '',
17 cwd: '',
18 ctxWindow: 0,
19 limits: [],
20 costUsd: 0,
21 turnActive: false,
22 turnStartedAt: 0,
23 turnSteps: 0,
24 turnTools: 0,
25 turnCostAtStart: 0,
26 tools: {},
27 toolTotal: 0,
28 toolErrors: 0,
29 edits: {},
30 diff: [],
31 diffAdd: 0,
32 diffDel: 0,
33 lastTest: null,
34 testRuns: 0,
35 testFails: 0,
36 turns: [],
37 guardBlocked: 0,
38 guardAllowed: 0,
39 guardLog: [],
40 warned: [],
41 lifetimeTurns: 0,
42 lifetimeTools: 0,
43 lifetimeUsd: 0,
44 lifetimeSessions: 0,
45 ctxParts: [],
46 cacheRead: 0,
47 inputTotal: 0,
48 toolMs: {},
49 slowest: null,
50 recentSigs: [],
51 loopSigs: [],
52 loopAlerts: 0,
53 lastLoop: '',
54 agentsActive: [],
55 agentsSpawned: 0,
56 compactions: 0,
57 lastCompact: null,
58 limitStart: {},
59 secretsBlocked: 0,
60 agents: {},
61 turnLog: [],
62 testHistory: [],
63 loopLog: [],
64 compactLog: [],
65 ctxFree: 0,
66 ctxMemory: [],
67 ctxMcp: [],
68 callLog: [],
69 topOutputs: [],
70 lastResponseAt: 0,
71 models: {},
72 fileView: null,
73}
74
75const stats = atom({ plugin: 'cockpit', key: 'stats' } as const, EMPTY_STATS)
76const DEFAULT_PREFS: CockpitPrefs = {
77 isCompact: false,
78 isGuardOn: true,
79 isStatusOn: true,
80 isBandOn: false,
81 budgetUsd: 0,
82 budgetMode: 'auto',
83 isCostHidden: false,
84 isAgentsOpen: true,
85 agentOpen: [],
86 openSections: [],
87 toolOpen: [],
88 cacheTtlMin: 5,
89}
90const prefs = atom({ plugin: 'cockpit', key: 'prefs' } as const, DEFAULT_PREFS)
91const pane = atom({ plugin: 'cockpit', key: 'pane' } as const, { isPlaced: false, reason: '' } as CockpitPane)
92
93// ---------- risky command patterns (the guard) ----------
94
95const RISKS: { pattern: RegExp; reason: string }[] = [
96 { pattern: /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r|-[a-zA-Z]*R[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*R)\b/, reason: 'recursive force delete' },
97 { pattern: /\brm\s+.*--recursive\b.*--force\b|\brm\s+.*--force\b.*--recursive\b/, reason: 'recursive force delete' },
98 { pattern: /\bgit\s+push\b.*(\s--force(\s|$)|\s-f(\s|$))/, reason: 'force push rewrites remote history' },
99 { pattern: /\bgit\s+reset\s+--hard\b/, reason: 'discards uncommitted work' },
100 { pattern: /\bgit\s+clean\s+-[a-zA-Z]*f/, reason: 'deletes untracked files' },
101 { pattern: /\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)/, reason: 'discards all local changes' },
102 { pattern: /\bgit\s+branch\s+-D\b/, reason: 'force-deletes a branch' },
103 { pattern: /\b(drop\s+(table|database|schema)|truncate\s+table)\b/i, reason: 'destroys database data' },
104 { pattern: /\bchmod\s+-R\s+777\b/, reason: 'opens permissions on a whole tree' },
105 { pattern: /\b(mkfs(\.\w+)?|fdisk|diskutil\s+erase\w*)\b/, reason: 'formats a disk' },
106 { pattern: /\bdd\s+.*\bof=\/dev\//, reason: 'writes raw bytes to a device' },
107 { pattern: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b/, reason: 'pipes a download straight into a shell' },
108 { pattern: /(^|[;&|]\s*)sudo\s/, reason: 'runs as root' },
109 { pattern: /\bkubectl\s+delete\b/, reason: 'deletes cluster resources' },
110 { pattern: /\bterraform\s+(destroy|apply\b.*-auto-approve)/, reason: 'changes infrastructure without review' },
111 { pattern: /\b(npm|pnpm|yarn)\s+publish\b/, reason: 'publishes a package' },
112 { pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, reason: 'fork bomb' },
113]
114
115const TEST_COMMAND =
116 /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bnpx\s+(jest|vitest|playwright\s+test|mocha)\b|\b(jest|vitest|pytest|rspec|phpunit|mocha)\b|\bgo\s+test\b|\bcargo\s+test\b|\bmvn\s+test\b|\bgradlew?\s+test\b|\bmix\s+test\b|\bdotnet\s+test\b|\bdeno\s+test\b|\bmake\s+test\b|\bpython3?\s+-m\s+(pytest|unittest)\b/
117
118const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
119
120const SECRET_FILE =
121 /(^|\/)(\.env(\.[\w.-]+)?|\.npmrc|\.pypirc|\.netrc|id_(rsa|ed25519|ecdsa)(\.pub)?|credentials(\.json)?|secrets?\.(json|ya?ml|toml)|[\w.-]+\.(pem|key|p12|pfx|keystore))$/i
122
123// What makes two tool calls "the same" for the loop detector.
124const signatureOf = (tool: string, args: Record<string, unknown>) => {
125 const key = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'path']
126 .map(name => args[name])
127 .find(value => typeof value === 'string')
128 return `${tool}:${typeof key === 'string' ? key.slice(0, 200) : ''}`
129}
130
131// A subscription reports rate-limit windows; API-key billing does not. The
132// windows arrive with the first reply, so a plan seen once is remembered.
133const isSubscription = (s: CockpitStats) => s.limits.length > 0 || s.isPlan === true
134
135// On a subscription the dollars are only what the API would charge: the
136// budget and the dollars in the status line and band stay out of the way
137// unless the budget is turned on.
138const isBudgetControlShown = (p: CockpitPrefs, s: CockpitStats) => !isSubscription(s) || p.budgetMode === 'on' || p.budgetUsd > 0
139
140const isMoneyInLine = (p: CockpitPrefs, s: CockpitStats) => !p.isCostHidden && (!isSubscription(s) || p.budgetMode === 'on')
141
142const isBudgetVisible = (p: CockpitPrefs, s: CockpitStats) =>
143 !p.isCostHidden && p.budgetUsd > 0 && (p.budgetMode === 'on' || (p.budgetMode === 'auto' && !isSubscription(s)))
144
145const newAgent = (id: string, now: number): CockpitAgent => ({
146 id,
147 type: '',
148 description: '',
149 status: 'running',
150 startedAt: now,
151 steps: 0,
152 tools: 0,
153 errors: 0,
154 toolCounts: {},
155 lastTool: '',
156 tokensIn: 0,
157 tokensOut: 0,
158})
159
160// Agents the list never names (compaction and memory forks) and that called no tool stay hidden.
161const visibleAgents = (s: CockpitStats) =>
162 Object.values(s.agents ?? {})
163 .filter(a => a.type !== '' || a.tools > 0)
164 .sort((a, b) => b.startedAt - a.startedAt)
165
166const LOOP_WINDOW = 8
167const LOOP_REPEATS = 3
168
169// ---------- formatting ----------
170
171const fmtTokens = (n?: number) =>
172 n === undefined ? '—' : n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
173
174const fmtUsd = (n: number) => (n > 0 && n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`)
175
176const fmtDur = (ms: number) => {
177 if (ms > 0 && ms < 100) return '<0.1s'
178 if (ms > 0 && ms < 10_000) return `${(ms / 1000).toFixed(1).replace(/\.0$/, '')}s`
179 const sec = Math.max(0, Math.round(ms / 1000))
180 if (sec < 60) return `${sec}s`
181 const min = Math.floor(sec / 60)
182 if (min < 60) return `${min}:${String(sec % 60).padStart(2, '0')}`
183 return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}m`
184}
185
186const fmtAgo = (ms: number) => (ms < 60_000 ? 'just now' : `${fmtDur(ms)} ago`)
187
188const bar = (pct: number, width: number) => {
189 const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
190 return '█'.repeat(filled) + '░'.repeat(width - filled)
191}
192
193const SPARKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
194const sparkline = (values: number[]) => {
195 const max = Math.max(1, ...values)
196 return values.map(v => SPARKS.at(Math.min(7, Math.floor((v / max) * 7))) ?? '▁').join('')
197}
198
199const levelColor = (pct: number) => (pct >= 85 ? 'red' : pct >= 60 ? 'yellow' : 'green')
200
201const limitLabel = (kind: string) =>
202 kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind === 'spend_limit' ? 'spend' : kind
203
204const resetIn = (iso: string | undefined, now: number) => {
205 if (!iso) return ''
206 const at = Date.parse(iso)
207 return Number.isNaN(at) ? '' : `resets in ${fmtDur(Math.max(0, at - now))}`
208}
209
210const relPath = (path: string, cwd: string) =>
211 cwd && path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
212
213const shorten = (text: string, max: number) => (text.length > max ? text.slice(0, max - 1) + '…' : text)
214
215// ---------- state helpers ----------
216
217type Dollar = EngineInterface
218type CockpitElements = Pick<Elements['terminal'] | Elements['desktop'], 'Box' | 'Text' | 'Button'>
219
220const pushStatus = async ($: Dollar) => {
221 const p = await read($, prefs)
222 if (!p.isStatusOn) {
223 $.ui.status(undefined)
224 return
225 }
226 const s = await read($, stats)
227 const parts = [`ctx ${s.ctxPercent === undefined ? '—' : `${s.ctxPercent}%`}`]
228 const fiveHour = s.limits.find(l => l.kind === 'five_hour')
229 if (fiveHour) parts.push(`5h ${Math.round(fiveHour.percentUsed)}%`)
230 if (isMoneyInLine(p, s)) parts.push(isBudgetVisible(p, s) ? `${fmtUsd(s.costUsd)}/${fmtUsd(p.budgetUsd)}` : fmtUsd(s.costUsd))
231 const running = visibleAgents(s).filter(a => a.status === 'running').length
232 if (running) parts.push(`${running} agent${running > 1 ? 's' : ''}`)
233 parts.push(`${s.toolTotal} tools`)
234 if (s.turnActive) parts.push('working')
235 $.ui.status(`◆ ${parts.join(' · ')}`)
236}
237
238const warnOnce = async ($: Dollar) => {
239 const s = await read($, stats)
240 const due: { id: string; text: string }[] = []
241 const pct = s.ctxPercent ?? 0
242 if (pct >= 95) due.push({ id: 'ctx95', text: `Context ${pct}% full: compaction is imminent` })
243 else if (pct >= 80) due.push({ id: 'ctx80', text: `Context ${pct}% full: consider /compact soon` })
244 for (const limit of s.limits) {
245 if (limit.percentUsed >= 90) due.push({ id: `lim90:${limit.kind}`, text: `${limitLabel(limit.kind)} limit ${Math.round(limit.percentUsed)}% used` })
246 else if (limit.percentUsed >= 75) due.push({ id: `lim75:${limit.kind}`, text: `${limitLabel(limit.kind)} limit ${Math.round(limit.percentUsed)}% used` })
247 }
248 const p = await read($, prefs)
249 if (isBudgetVisible(p, s)) {
250 const share = (s.costUsd / p.budgetUsd) * 100
251 if (share >= 100) due.push({ id: `budget100:${p.budgetUsd}`, text: `budget ${fmtUsd(p.budgetUsd)} reached (${fmtUsd(s.costUsd)} spent)` })
252 else if (share >= 80) due.push({ id: `budget80:${p.budgetUsd}`, text: `${Math.round(share)}% of the ${fmtUsd(p.budgetUsd)} budget spent` })
253 }
254 const fresh = due.filter(d => !s.warned.includes(d.id))
255 if (fresh.length === 0) return
256 await update($, stats, cur => ({ ...cur, warned: [...cur.warned, ...fresh.map(d => d.id)] }))
257 for (const d of fresh) $.ui.toast(`Cockpit: ${d.text}`, { timeoutMs: 6000 })
258}
259
260const applyUsage = async (
261 $: Dollar,
262 usage: { context: { tokens?: number; window: number; percent?: number }; rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]; cost?: { usd: number } },
263) => {
264 await update($, stats, s => ({
265 ...s,
266 ctxTokens: usage.context.tokens,
267 ctxWindow: usage.context.window,
268 ctxPercent: usage.context.percent,
269 limits: usage.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
270 costUsd: usage.cost?.usd ?? s.costUsd,
271 isPlan: s.isPlan || usage.rateLimits.length > 0,
272 }))
273 if (usage.rateLimits.length > 0 && !isPlanStored) {
274 isPlanStored = true
275 await $.store.set(STORE_PLAN, true)
276 }
277 const now = await $.clock.now()
278 await update($, stats, s => {
279 const limitStart = { ...(s.limitStart ?? {}) }
280 for (const l of usage.rateLimits) {
281 const first = limitStart[l.kind]
282 // a window that reset (percent fell) starts its pace sample over
283 if (!first || l.percentUsed < first.pct) limitStart[l.kind] = { pct: l.percentUsed, at: now }
284 }
285 return { ...s, limitStart }
286 })
287 await warnOnce($)
288 await pushStatus($)
289}
290
291const refreshUsage = async ($: Dollar) => {
292 try {
293 const usage = await $.session.usage()
294 const model = await $.session.model()
295 await update($, stats, s => ({ ...s, model }))
296 await applyUsage($, usage)
297 } catch (error) {
298 $.ui.log(`cockpit: usage unavailable (${String(error)})`, { to: 'debug' })
299 }
300}
301
302const refreshBreakdown = async ($: Dollar) => {
303 try {
304 const usage = await $.session.usage({ breakdown: 'summary' })
305 const breakdown = usage.context.breakdown
306 const categories = breakdown?.categories ?? []
307 const parts = categories
308 .filter(c => c.kind === 'used' && c.tokens > 0)
309 .sort((a, b) => b.tokens - a.tokens)
310 .slice(0, 10)
311 .map(c => ({ name: c.name, tokens: c.tokens }))
312 const free = categories.filter(c => c.kind === 'free').reduce((sum, c) => sum + c.tokens, 0)
313 const memory = (breakdown?.memoryFiles ?? [])
314 .slice()
315 .sort((a, b) => b.tokens - a.tokens)
316 .slice(0, 6)
317 .map(m => ({ path: m.path, tokens: m.tokens }))
318 const servers: Record<string, { tokens: number; tools: number }> = {}
319 for (const tool of breakdown?.mcpTools ?? []) {
320 const cur = servers[tool.serverName] ?? { tokens: 0, tools: 0 }
321 servers[tool.serverName] = { tokens: cur.tokens + tool.tokens, tools: cur.tools + 1 }
322 }
323 const mcp = Object.entries(servers)
324 .map(([server, v]) => ({ server, tokens: v.tokens, tools: v.tools }))
325 .sort((a, b) => b.tokens - a.tokens)
326 .slice(0, 6)
327 await update($, stats, s => ({ ...s, ctxParts: parts, ctxFree: free, ctxMemory: memory, ctxMcp: mcp }))
328 } catch {
329 // breakdown unavailable on this host
330 }
331}
332
333// Minutes until a rate-limit window fills at the pace seen this session.
334const paceEta = (s: CockpitStats, kind: string, now: number) => {
335 const limit = s.limits.find(l => l.kind === kind)
336 const first = s.limitStart?.[kind]
337 if (!limit || !first || now - first.at < 5 * 60_000) return undefined
338 const perMs = (limit.percentUsed - first.pct) / (now - first.at)
339 if (perMs <= 0) return undefined
340 return (100 - limit.percentUsed) / perMs
341}
342
343const burnPerHour = (s: CockpitStats, now: number) => {
344 const hours = (now - s.startedAt) / 3_600_000
345 return s.startedAt && hours > 0.05 ? s.costUsd / hours : undefined
346}
347
348const cacheShare = (s: CockpitStats) =>
349 s.inputTotal > 0 ? Math.round((s.cacheRead / s.inputTotal) * 100) : undefined
350
351const refreshAgents = async ($: Dollar) => {
352 try {
353 const list = await $.agent.list()
354 const now = await $.clock.now()
355 await update($, stats, s => {
356 const agents = { ...(s.agents ?? {}) }
357 for (const info of list) {
358 const cur = agents[info.id] ?? newAgent(info.id, now)
359 agents[info.id] = {
360 ...cur,
361 type: info.type,
362 description: info.description,
363 name: info.name,
364 status: info.status,
365 parentId: info.parentId,
366 endedAt: info.status !== 'running' && !cur.endedAt ? now : cur.endedAt,
367 }
368 }
369 // keep the 20 most recent
370 const kept = Object.values(agents).sort((a, b) => b.startedAt - a.startedAt).slice(0, 20)
371 return { ...s, agents: Object.fromEntries(kept.map(a => [a.id, a])) }
372 })
373 } catch {
374 // agent list unavailable
375 }
376}
377
378const parseNumstat = (text: string): CockpitDiffFile[] =>
379 text
380 .split('\n')
381 .map(line => line.split('\t'))
382 .filter(cols => cols.length >= 3)
383 .map(cols => ({
384 path: cols.slice(2).join('\t'),
385 add: Number(cols.at(0)) || 0,
386 del: Number(cols.at(1)) || 0,
387 }))
388
389const refreshDiff = async ($: Dollar) => {
390 try {
391 let run = await $.process.run(['git', 'diff', '--numstat', 'HEAD'], { timeoutMs: 5000 })
392 if (run.exitCode !== 0) run = await $.process.run(['git', 'diff', '--numstat'], { timeoutMs: 5000 })
393 if (run.exitCode !== 0) return
394 const files = parseNumstat(run.stdout)
395 const untracked = await $.process.run(['git', 'ls-files', '--others', '--exclude-standard'], { timeoutMs: 5000 })
396 if (untracked.exitCode === 0) {
397 for (const path of untracked.stdout.split('\n').filter(Boolean).slice(0, 50)) {
398 files.push({ path, add: -1, del: 0 })
399 }
400 }
401 await update($, stats, s => ({
402 ...s,
403 diff: files.slice(0, 100),
404 diffAdd: files.reduce((sum, f) => sum + Math.max(0, f.add), 0),
405 diffDel: files.reduce((sum, f) => sum + f.del, 0),
406 }))
407 } catch {
408 // not a git repository, or git is missing: the files section falls back to edits
409 }
410}
411
412const checkRisk = (command: string) => RISKS.find(r => r.pattern.test(command))?.reason
413
414const parseTestOutput = (text: string) => {
415 const passed = text.match(/(\d+)\s+(passed|passing)\b/i)
416 const failed = text.match(/(\d+)\s+(failed|failing)\b/i)
417 return {
418 passed: passed ? Number(passed[1]) : undefined,
419 failed: failed ? Number(failed[1]) : undefined,
420 }
421}
422
423const buildReport = (s: CockpitStats, now: number) => {
424 const lines = [`Cockpit report · session ${fmtDur(now - s.startedAt)} · ${s.model || 'model ?'}`]
425 lines.push(`Context: ${s.ctxPercent ?? '—'}% (${fmtTokens(s.ctxTokens)} / ${fmtTokens(s.ctxWindow)})`)
426 for (const l of s.limits) lines.push(`Limit ${limitLabel(l.kind)}: ${l.percentUsed}% ${resetIn(l.resetsAt, now)}`)
427 lines.push(`Cost: ${fmtUsd(s.costUsd)} · turns: ${s.turns.length} · tool calls: ${s.toolTotal} (${s.toolErrors} errors)`)
428 const top = Object.entries(s.tools).sort((a, b) => b[1].calls - a[1].calls).slice(0, 8)
429 if (top.length) lines.push(`Tools: ${top.map(([name, t]) => `${name} ${t.calls}`).join(', ')}`)
430 const files = s.diff.length ? s.diff.map(f => `${f.path} ${f.add < 0 ? 'new' : `+${f.add} -${f.del}`}`) : Object.entries(s.edits).map(([p, n]) => `${relPath(p, s.cwd)} (${n} edits)`)
431 if (files.length) lines.push(`Files: ${files.slice(0, 12).join(', ')}`)
432 if (s.lastTest) lines.push(`Tests: ${s.testRuns} runs, ${s.testFails} failed; last ${s.lastTest.isOk ? 'passed' : 'failed'} (${s.lastTest.command})`)
433 const cache = cacheShare(s)
434 const burn = burnPerHour(s, now)
435 lines.push(`Pace: ${burn === undefined ? '—' : `${fmtUsd(burn)}/h`} · prompt cache hits ${cache === undefined ? '—' : `${cache}%`}`)
436 if (s.ctxParts?.length) lines.push(`Context parts: ${s.ctxParts.map(c => `${c.name} ${fmtTokens(c.tokens)}`).join(', ')}`)
437 if (s.slowest) lines.push(`Slowest tool call: ${s.slowest.tool} ${fmtDur(s.slowest.ms)} (${s.slowest.label})`)
438 lines.push(`Subagents: ${s.agentsSpawned} spawned · compactions: ${s.compactions} · loop alerts: ${s.loopAlerts}`)
439 for (const a of visibleAgents(s).slice(0, 10)) {
440 lines.push(` ${a.status === 'running' ? '●' : a.status === 'completed' ? '✓' : '✗'} ${a.type || 'agent'} "${a.description}" · ${fmtDur((a.endedAt ?? now) - a.startedAt)} · ${a.tools} tools · ${fmtTokens(a.tokensIn)} in`)
441 }
442 lines.push(`Guard: ${s.guardBlocked} blocked, ${s.guardAllowed} allowed, ${s.secretsBlocked} secret-file edits stopped`)
443 lines.push(`All-time: ${s.lifetimeSessions} sessions · ${s.lifetimeTurns} turns · ${s.lifetimeTools} tools · ${fmtUsd(s.lifetimeUsd)}`)
444 return lines.join('\n')
445}
446
447const buildMarkdown = (s: CockpitStats, now: number) => {
448 const turns = s.turns
449 .map((t, i) => `| ${i + 1} | ${fmtDur(t.durationMs)} | ${t.steps} | ${t.tools} | ${fmtTokens(t.tokensIn)} | ${fmtTokens(t.tokensOut)} | ${fmtUsd(t.costUsd)} | ${t.reason} |`)
450 .join('\n')
451 const tools = Object.entries(s.tools)
452 .sort((a, b) => b[1].calls - a[1].calls)
453 .map(([name, t]) => `| ${name} | ${t.calls} | ${t.errors} | ${fmtDur(s.toolMs?.[name]?.totalMs ?? 0)} |`)
454 .join('\n')
455 return [
456 `# Cockpit report`,
457 '',
458 '```',
459 buildReport(s, now),
460 '```',
461 '',
462 '## Turns',
463 '',
464 '| # | Duration | Steps | Tools | In | Out | Cost | End |',
465 '| --- | --- | --- | --- | --- | --- | --- | --- |',
466 turns || '| – | | | | | | | |',
467 '',
468 '## Tools',
469 '',
470 '| Tool | Calls | Errors | Time |',
471 '| --- | --- | --- | --- |',
472 tools || '| – | | | |',
473 '',
474 `_Generated by Cockpit for Claude Code on ${new Date(now).toISOString()}_`,
475 '',
476 ].join('\n')
477}
478
479const exportReport = async ($: Dollar) => {
480 const s = await read($, stats)
481 const now = await $.clock.now()
482 const root = s.cwd || (await $.session.cwd())
483 try {
484 // one fixed file, relative to the session's working directory; each export replaces it
485 await $.fs.write('.cockpit/cockpit-report.md', buildMarkdown(s, now))
486 return `Cockpit report saved to ${root}/.cockpit/cockpit-report.md`
487 } catch (error) {
488 return `Cockpit could not save the report: ${String(error)}`
489 }
490}
491
492// The full cockpit tree, drawn in the side panel or in the expanded band.
493const drawCockpit = async ($: Dollar, els: CockpitElements, bodyColumns: number) => {
494 const { Box, Text, Button } = els
495 const s = await read($, stats)
496 const p = await read($, prefs)
497 const now = await $.clock.now()
498 const cols = Math.max(30, bodyColumns || 60)
499 const barWidth = Math.max(8, Math.min(24, cols - 26))
500 const labelWidth = 12
501 const mini = (share: number, width = 10) => bar(Math.max(0, Math.min(100, share)), width)
502 const pad = (text: string, width: number) => (text.length >= width ? text.slice(0, width - 1) + ' ' : text.padEnd(width))
503 const nameWidth = Math.max(10, Math.min(22, cols - labelWidth - 30))
504
505 const isOpen = (label: string) => p.openSections.includes(label)
506 const toggleSection = (label: string) =>
507 update($, prefs, cur => ({
508 ...cur,
509 openSections: cur.openSections.includes(label) ? cur.openSections.filter(x => x !== label) : [...cur.openSections, label],
510 }))
511
512 // A section: its label opens the details, which show under the summary.
513 const row = (label: string, body: RenderChildren, details?: () => RenderChildren, onToggle?: () => unknown, isToggled?: boolean) => (
514 <Box flexDirection="row">
515 <Box width={labelWidth} flexShrink={0}>
516 {details || onToggle ? (
517 <Button
518 key={`sec-${label}`}
519 plain
520 dimColor={!(onToggle ? isToggled : isOpen(label))}
521 label={`${(onToggle ? isToggled : isOpen(label)) ? '▾' : '▸'} ${label}`}
522 onPress={() => (onToggle ? onToggle() : toggleSection(label))}
523 />
524 ) : (
525 <Text dimColor bold>{` ${label}`}</Text>
526 )}
527 </Box>
528 <Box flexDirection="column" flexGrow={1}>
529 {body}
530 {details && isOpen(label) ? (
531 <Box flexDirection="column" paddingLeft={1} marginBottom={1} borderStyle="single" borderColor="gray" borderDimColor>
532 {details()}
533 </Box>
534 ) : null}
535 </Box>
536 </Box>
537 )
538
539 const line = (...parts: { text: string; color?: string; dim?: boolean; bold?: boolean }[]) => (
540 <Box flexDirection="row">
541 {parts.map(part => (
542 <Text color={part.color} dimColor={part.dim} bold={part.bold} wrap="truncate-end">
543 {part.text}
544 </Text>
545 ))}
546 </Box>
547 )
548
549 // context
550 const pct = s.ctxPercent
551 const contextBody =
552 pct === undefined
553 ? line({ text: 'waiting for the first response', dim: true })
554 : [
555 line(
556 { text: bar(pct, barWidth), color: levelColor(pct) },
557 { text: ` ${pct}%`, bold: true, color: levelColor(pct) },
558 { text: ` ${fmtTokens(s.ctxTokens)} / ${fmtTokens(s.ctxWindow)}`, dim: true },
559 ),
560 s.ctxParts.length > 0
561 ? line({ text: s.ctxParts.slice(0, 4).map(c => `${c.name.toLowerCase()} ${fmtTokens(c.tokens)}`).join(' · '), dim: true })
562 : null,
563 pct >= 70 && !s.turnActive ? (
564 <Box flexDirection="row" gap={1}>
565 <Button key="compact-now" variant="primary" label="Compact now" onPress={() => $.command.run({ command: 'compact' })} />
566 <Text dimColor>summarize the history before it happens mid-task</Text>
567 </Box>
568 ) : null,
569 ]
570
571 // limits
572 const limitsBody =
573 s.limits.length === 0
574 ? line({ text: 'no rate-limit data (API key or no response yet)', dim: true })
575 : s.limits.map(l =>
576 line(
577 { text: `${limitLabel(l.kind).padEnd(5)} ` },
578 { text: bar(l.percentUsed, Math.max(6, barWidth - 6)), color: levelColor(l.percentUsed) },
579 { text: ` ${Math.round(l.percentUsed)}%`, bold: true },
580 { text: ` ${resetIn(l.resetsAt, now)}`, dim: true },
581 (() => {
582 const eta = paceEta(s, l.kind, now)
583 const resetsAt = l.resetsAt ? Date.parse(l.resetsAt) : NaN
584 if (eta === undefined) return { text: '' }
585 const isBeforeReset = Number.isNaN(resetsAt) || now + eta < resetsAt
586 return isBeforeReset ? { text: ` · full in ${fmtDur(eta)} at this pace`, color: 'yellow' } : { text: ' · on pace', dim: true }
587 })(),
588 ),
589 )
590
591 // cost
592 const lastTurn = s.turns.at(-1)
593 const burn = burnPerHour(s, now)
594 const cache = cacheShare(s)
595 const budgetShare = isBudgetVisible(p, s) ? (s.costUsd / p.budgetUsd) * 100 : undefined
596 const costBody = [
597 line(
598 { text: fmtUsd(s.costUsd), bold: true },
599 { text: isSubscription(s) ? ' API value (covered by your plan)' : ' this session', dim: true },
600 { text: lastTurn ? ` · ${fmtUsd(lastTurn.costUsd)} last turn` : '', dim: true },
601 ),
602 (() => {
603 const left = cacheLeftMs(s, p, now)
604 if (left === undefined) return null
605 if (s.turnActive) return line({ text: '◉ cache in use', color: 'green' })
606 return left > 0
607 ? line({ text: `◉ cache warm ${fmtDur(left)}`, color: left > 60_000 ? 'green' : 'yellow' }, { text: ' · reply before it expires to keep requests cheap', dim: true })
608 : line({ text: '○ cache expired', color: 'yellow' }, { text: ' · the next request re-reads the whole context at full price', dim: true })
609 })(),
610 line(
611 { text: burn === undefined ? 'pace —' : `pace ${fmtUsd(burn)}/h`, dim: true },
612 { text: cache === undefined ? '' : ` · cache hits ${cache}%`, color: cache === undefined ? undefined : cache >= 70 ? 'green' : cache >= 40 ? 'yellow' : 'red' },
613 ),
614 budgetShare === undefined
615 ? null
616 : line(
617 { text: `budget ${bar(Math.min(100, budgetShare), Math.max(6, barWidth - 7))}`, color: levelColor(budgetShare) },
618 { text: ` ${Math.round(budgetShare)}% of ${fmtUsd(p.budgetUsd)}`, bold: budgetShare >= 80 },
619 ),
620 ]
621
622 // the running turn
623 const turnBody = s.turnActive
624 ? line(
625 { text: '● working ', color: 'cyan', bold: true },
626 { text: fmtDur(now - s.turnStartedAt) },
627 { text: ` · ${s.turnSteps} steps · ${s.turnTools} tools`, dim: true },
628 { text: s.agentsActive.length ? ` · ${s.agentsActive.length} subagent${s.agentsActive.length > 1 ? 's' : ''}` : '', color: 'magenta' },
629 )
630 : line(
631 { text: '○ idle', dim: true },
632 { text: lastTurn ? ` · last ${fmtDur(lastTurn.durationMs)}, ${lastTurn.tools} tools, ${fmtTokens(lastTurn.tokensIn)} in / ${fmtTokens(lastTurn.tokensOut)} out` : '', dim: true },
633 )
634
635 const header = line(
636 { text: '◆ COCKPIT', bold: true, color: 'cyan' },
637 { text: ` ${s.model || ''}`, dim: true },
638 { text: s.startedAt ? ` · ${fmtDur(now - s.startedAt)}` : '', dim: true },
639 )
640
641 const controls = (
642 <Box flexDirection="row" gap={1} marginTop={1} flexWrap="wrap">
643 <Button
644 key="sections"
645 hotkey="x"
646 label={p.openSections.length ? 'Collapse all' : 'Expand all'}
647 onPress={() =>
648 update($, prefs, cur => ({
649 ...cur,
650 openSections: cur.openSections.length ? [] : ['CONTEXT', 'LIMITS', 'COST', 'TURN', 'TOOLS', 'FILES', 'TESTS', 'GUARD', 'ALERTS', 'HISTORY', 'ALL-TIME'],
651 }))
652 }
653 />
654 <Button key="compact" hotkey="c" label={p.isCompact ? 'Full view' : 'Compact'} onPress={() => update($, prefs, cur => ({ ...cur, isCompact: !cur.isCompact }))} />
655 <Button
656 key="guard"
657 hotkey="g"
658 variant={p.isGuardOn ? 'primary' : 'secondary'}
659 label={p.isGuardOn ? 'Guard: on' : 'Guard: off'}
660 onPress={() => update($, prefs, cur => ({ ...cur, isGuardOn: !cur.isGuardOn }))}
661 />
662 <Button
663 key="status"
664 hotkey="s"
665 label={p.isStatusOn ? 'Status line: on' : 'Status line: off'}
666 onPress={async () => {
667 await update($, prefs, cur => ({ ...cur, isStatusOn: !cur.isStatusOn }))
668 await pushStatus($)
669 }}
670 />
671 <Button
672 key="money"
673 hotkey="m"
674 label={p.isCostHidden ? 'Cost: hidden' : 'Cost: shown'}
675 onPress={async () => {
676 await update($, prefs, cur => ({ ...cur, isCostHidden: !cur.isCostHidden }))
677 await pushStatus($)
678 }}
679 />
680 {isBudgetControlShown(p, s) ? (
681 <Button
682 key="budget"
683 hotkey="b"
684 label={`Budget: ${p.budgetMode}${p.budgetUsd > 0 ? ` ${fmtUsd(p.budgetUsd)}` : ''}`}
685 onPress={async () => {
686 await update($, prefs, cur => ({ ...cur, budgetMode: (cur.budgetMode === 'auto' ? 'on' : cur.budgetMode === 'on' ? 'off' : 'auto') as CockpitPrefs['budgetMode'] }))
687 await pushStatus($)
688 }}
689 />
690 ) : null}
691 <Button
692 key="report"
693 hotkey="p"
694 label="Report"
695 onPress={async () => {
696 const cur = await read($, stats)
697 $.ui.log(buildReport(cur, await $.clock.now()))
698 }}
699 />
700 <Button key="export" hotkey="e" label="Export .md" onPress={async () => $.ui.toast(await exportReport($), { timeoutMs: 8000 })} />
701 <Button
702 key="reset"
703 hotkey="r"
704 dimColor
705 label="Reset"
706 onPress={async () => {
707 await update($, stats, cur => ({
708 ...EMPTY_STATS,
709 startedAt: cur.startedAt,
710 model: cur.model,
711 cwd: cur.cwd,
712 ctxTokens: cur.ctxTokens,
713 ctxWindow: cur.ctxWindow,
714 ctxPercent: cur.ctxPercent,
715 limits: cur.limits,
716 costUsd: cur.costUsd,
717 turnActive: cur.turnActive,
718 turnStartedAt: cur.turnStartedAt,
719 turnCostAtStart: cur.turnCostAtStart,
720 diff: cur.diff,
721 diffAdd: cur.diffAdd,
722 diffDel: cur.diffDel,
723 lifetimeTurns: cur.lifetimeTurns,
724 lifetimeTools: cur.lifetimeTools,
725 lifetimeUsd: cur.lifetimeUsd,
726 lifetimeSessions: cur.lifetimeSessions,
727 }))
728 await pushStatus($)
729 }}
730 />
731 </Box>
732 )
733
734 if (p.isCompact) {
735 return (
736 <Box flexDirection="column">
737 {header}
738 {row('CONTEXT', contextBody)}
739 {row('LIMITS', limitsBody)}
740 {p.isCostHidden ? null : row('COST', costBody)}
741 {row('TURN', turnBody)}
742 {controls}
743 </Box>
744 )
745 }
746
747 // tools
748 const topTools = Object.entries(s.tools)
749 .sort((a, b) => b[1].calls - a[1].calls)
750 .slice(0, 6)
751 const toolsBody =
752 topTools.length === 0
753 ? line({ text: 'no tool calls yet', dim: true })
754 : [
755 line({ text: topTools.map(([name, t]) => `${name.replace(/^mcp__[^_]+__/, '')} ${t.calls}`).join(' · ') }),
756 line(
757 { text: `${s.toolTotal} calls`, dim: true },
758 { text: s.toolErrors ? ` · ${s.toolErrors} errors` : '', color: s.toolErrors ? 'red' : undefined },
759 { text: s.slowest && s.slowest.ms >= 1000 ? ` · slowest ${s.slowest.tool} ${fmtDur(s.slowest.ms)}` : '', dim: true },
760 ),
761 ]
762
763 // files
764 const fileRows: RenderChildren[] = []
765 if (s.diff.length > 0) {
766 for (const f of s.diff.slice(0, 6)) {
767 fileRows.push(
768 line(
769 { text: shorten(f.path, Math.max(12, cols - labelWidth - 14)) },
770 f.add < 0 ? { text: ' new', color: 'cyan' } : { text: ` +${f.add}`, color: 'green' },
771 f.add < 0 ? { text: '' } : { text: ` −${f.del}`, color: 'red' },
772 ),
773 )
774 }
775 fileRows.push(
776 line({ text: `${s.diff.length} files · +${s.diffAdd} −${s.diffDel} vs HEAD`, dim: true }),
777 )
778 } else {
779 const edited = Object.entries(s.edits).sort((a, b) => b[1] - a[1])
780 if (edited.length === 0) fileRows.push(line({ text: 'no edits yet', dim: true }))
781 for (const [path, count] of edited.slice(0, 6)) {
782 fileRows.push(line({ text: shorten(relPath(path, s.cwd), Math.max(12, cols - labelWidth - 12)) }, { text: ` ${count} edits`, dim: true }))
783 }
784 }
785
786 // tests
787 const testBody = s.lastTest
788 ? [
789 line(
790 { text: s.lastTest.isOk ? '✓ passed' : '✗ failed', color: s.lastTest.isOk ? 'green' : 'red', bold: true },
791 { text: s.lastTest.passed !== undefined ? ` · ${s.lastTest.passed} passed` : '' },
792 { text: s.lastTest.failed ? ` · ${s.lastTest.failed} failed` : '', color: 'red' },
793 { text: ` · ${fmtAgo(now - s.lastTest.at)}`, dim: true },
794 ),
795 line({ text: `${s.lastTest.command} · ${s.testRuns} runs, ${s.testFails} failed`, dim: true }),
796 ]
797 : line({ text: 'no test runs seen yet', dim: true })
798
799 // guard
800 const lastGuard = s.guardLog.at(-1)
801 const guardBody = [
802 line(
803 { text: p.isGuardOn ? 'on' : 'off', color: p.isGuardOn ? 'green' : 'yellow', bold: true },
804 { text: ` · ${s.guardBlocked} blocked · ${s.guardAllowed} allowed`, dim: true },
805 { text: s.secretsBlocked ? ` · ${s.secretsBlocked} secret edits stopped` : '', color: 'yellow' },
806 ),
807 lastGuard
808 ? line(
809 { text: lastGuard.verdict === 'blocked' ? '✗ ' : '✓ ', color: lastGuard.verdict === 'blocked' ? 'red' : 'yellow' },
810 { text: shorten(lastGuard.command, Math.max(12, cols - labelWidth - 4)), dim: true },
811 )
812 : line({ text: 'watching rm -rf, force push, reset --hard, sudo, .env and key files…', dim: true }),
813 ]
814
815 // agents: one line each, details on demand
816 const agents = visibleAgents(s)
817 const runningCount = agents.filter(a => a.status === 'running').length
818 const agentCard = (a: CockpitAgent) => {
819 const isOpen = p.agentOpen.includes(a.id)
820 const isRunning = a.status === 'running'
821 const took = (a.endedAt ?? now) - a.startedAt
822 const mark = isRunning ? '●' : a.status === 'completed' ? '✓' : '✗'
823 const markColor = isRunning ? 'magenta' : a.status === 'completed' ? 'green' : 'red'
824 const title = a.description || a.name || a.id.slice(0, 8)
825 const topTools = Object.entries(a.toolCounts).sort((x, y) => y[1] - x[1]).slice(0, 5)
826 return (
827 <Box flexDirection="column">
828 <Box flexDirection="row" gap={1}>
829 <Button
830 key={`agent-${a.id}`}
831 plain
832 dimColor={!isOpen}
833 label={isOpen ? '▾' : '▸'}
834 onPress={() =>
835 update($, prefs, cur => ({
836 ...cur,
837 agentOpen: cur.agentOpen.includes(a.id) ? cur.agentOpen.filter(id => id !== a.id) : [...cur.agentOpen, a.id].slice(-10),
838 }))
839 }
840 />
841 <Text color={markColor}>{mark}</Text>
842 <Text bold>{a.type || 'agent'}</Text>
843 <Text wrap="truncate-end">{shorten(title, Math.max(10, cols - labelWidth - 34))}</Text>
844 <Text dimColor>{`${fmtDur(took)} · ${a.tools} tools`}</Text>
845 </Box>
846 {isOpen ? (
847 <Box flexDirection="column" paddingLeft={4}>
848 {line({ text: `${a.status} · ${a.steps} steps · ${fmtTokens(a.tokensIn)} in / ${fmtTokens(a.tokensOut)} out`, dim: true }, { text: a.errors ? ` · ${a.errors} errors` : '', color: 'red' })}
849 {topTools.length ? line({ text: `tools: ${topTools.map(([name, n]) => `${name.replace(/^mcp__[^_]+__/, '')} ${n}`).join(' · ')}`, dim: true }) : null}
850 {a.lastTool ? line({ text: `last: ${shorten(a.lastTool, Math.max(12, cols - labelWidth - 12))}`, dim: true }) : null}
851 {a.name || a.parentId ? line({ text: [a.name ? `name ${a.name}` : '', a.parentId ? `spawned by ${a.parentId.slice(0, 8)}` : 'spawned by main'].filter(Boolean).join(' · '), dim: true }) : null}
852 {(() => {
853 const calls = (s.callLog ?? []).filter(c => c.agentId === a.id).slice(-8)
854 return calls.length
855 ? [
856 line({ text: 'timeline', bold: true }),
857 ...calls.map(c =>
858 line(
859 { text: c.isError ? '✗ ' : '✓ ', color: c.isError ? 'red' : 'green' },
860 { text: pad(c.tool.replace(/^mcp__[^_]+__/, ''), 10), bold: true },
861 { text: pad(fmtDur(c.ms), 6), dim: true },
862 { text: shorten(c.label, Math.max(10, cols - labelWidth - 26)), dim: true },
863 ),
864 ),
865 ]
866 : null
867 })()}
868 {a.result ? line({ text: 'result: ', bold: true }, { text: shorten(a.result, Math.max(20, (cols - labelWidth - 14) * 2)), dim: true }) : null}
869 </Box>
870 ) : null}
871 </Box>
872 )
873 }
874 const agentsBody =
875 agents.length === 0
876 ? line({ text: 'no subagents yet', dim: true })
877 : [
878 <Box flexDirection="row" gap={1}>
879 <Text>{`${agents.length} total`}</Text>
880 <Text color={runningCount ? 'magenta' : undefined} dimColor={!runningCount}>{`${runningCount} running`}</Text>
881 <Button
882 key="agents-toggle"
883 plain
884 dimColor
885 hotkey="a"
886 label={p.isAgentsOpen ? 'hide list' : 'show list'}
887 onPress={() => update($, prefs, cur => ({ ...cur, isAgentsOpen: !cur.isAgentsOpen }))}
888 />
889 </Box>,
890 p.isAgentsOpen ? agents.slice(0, 6).map(agentCard) : null,
891 ]
892
893 // alerts: loops, compactions, subagents
894 const alertLines = [
895 s.loopAlerts
896 ? line({ text: `⟳ ${s.loopAlerts} loop alert${s.loopAlerts > 1 ? 's' : ''}`, color: 'yellow', bold: true }, { text: ` · ${shorten(s.lastLoop, Math.max(10, cols - labelWidth - 18))}`, dim: true })
897 : null,
898 s.compactions
899 ? line({ text: `${s.compactions} compaction${s.compactions > 1 ? 's' : ''}`, color: 'cyan' }, { text: s.lastCompact ? ` · last ${fmtTokens(s.lastCompact.before)} → ${fmtTokens(s.lastCompact.after)} (${s.lastCompact.trigger})` : '', dim: true })
900 : null,
901 ].filter(Boolean)
902 const alertsBody = alertLines.length ? alertLines : line({ text: 'no loops or compactions yet', dim: true })
903
904 // history
905 const recent = s.turns.slice(-10)
906 const historyBody =
907 recent.length === 0
908 ? line({ text: 'no finished turns yet', dim: true })
909 : [
910 line({ text: sparkline(recent.map(t => t.tokensIn)), color: 'cyan' }, { text: ' input tokens per turn', dim: true }),
911 line({
912 text: recent
913 .slice(-3)
914 .map(t => `${fmtDur(t.durationMs)} ${t.tools}t ${fmtUsd(t.costUsd)}`)
915 .join(' · '),
916 dim: true,
917 }),
918 ]
919
920
921 // ---------- details, drawn only for an open section ----------
922
923 const hogLines = (limit: number) =>
924 (s.topOutputs ?? []).slice(0, limit).map(o =>
925 line(
926 { text: pad(`~${fmtTokens(o.tokens)}`, 8), color: o.tokens >= 10_000 ? 'red' : o.tokens >= 3000 ? 'yellow' : undefined, bold: true },
927 { text: pad(o.tool.replace(/^mcp__[^_]+__/, ''), 9) },
928 { text: o.agentId ? '↳ ' : '', color: 'magenta' },
929 { text: shorten(o.label || '(no input)', Math.max(10, cols - labelWidth - 24)), dim: true },
930 ),
931 )
932
933 const contextDetails = () => [
934 line({ text: `${s.model || 'model'} · window ${fmtTokens(s.ctxWindow)} · free ${fmtTokens(s.ctxFree || (s.ctxWindow - (s.ctxTokens ?? 0)))}`, dim: true }),
935 ...s.ctxParts.map(c =>
936 line(
937 { text: pad(c.name, nameWidth) },
938 { text: mini(s.ctxWindow ? (c.tokens / s.ctxWindow) * 100 : 0), color: 'cyan' },
939 { text: ` ${fmtTokens(c.tokens)}`, dim: true },
940 ),
941 ),
942 (s.topOutputs ?? []).length ? line({ text: 'biggest tool outputs (context hogs)', bold: true }) : null,
943 ...hogLines(3),
944 s.ctxMemory.length ? line({ text: 'memory files', bold: true }) : null,
945 ...s.ctxMemory.map(m => line({ text: pad(shorten(relPath(m.path, s.cwd), nameWidth), nameWidth) }, { text: fmtTokens(m.tokens), dim: true })),
946 s.ctxMcp.length ? line({ text: 'MCP servers (tool schemas)', bold: true }) : null,
947 ...s.ctxMcp.map(m => line({ text: pad(m.server, nameWidth) }, { text: `${fmtTokens(m.tokens)} · ${m.tools} tools`, dim: true })),
948 line({
949 text: (s.ctxPercent ?? 0) >= 80 ? 'tip: run /compact now, before it runs on its own mid-task' : 'tip: /context draws the full grid',
950 color: (s.ctxPercent ?? 0) >= 80 ? 'yellow' : undefined,
951 dim: (s.ctxPercent ?? 0) < 80,
952 }),
953 ]
954
955 const limitsDetails = () =>
956 s.limits.length === 0
957 ? [line({ text: 'Rate-limit windows are reported on a Pro or Max subscription after the first response.', dim: true })]
958 : s.limits.map(l => {
959 const first = s.limitStart?.[l.kind]
960 const hours = first ? (now - first.at) / 3_600_000 : 0
961 const perHour = first && hours > 0.08 ? (l.percentUsed - first.pct) / hours : undefined
962 const eta = paceEta(s, l.kind, now)
963 return [
964 line({ text: `${limitLabel(l.kind)} window`, bold: true }, { text: ` · ${l.percentUsed}% used · ${resetIn(l.resetsAt, now) || 'reset time unknown'}`, dim: true }),
965 line({
966 text:
967 perHour === undefined
968 ? ' pace: measuring (needs a few minutes)'
969 : ` pace: +${perHour.toFixed(1)}% per hour this session${eta !== undefined ? ` · full in ${fmtDur(eta)}` : ''}`,
970 dim: true,
971 }),
972 ]
973 })
974
975 const costDetails = () => [
976 line({ text: `session ${fmtUsd(s.costUsd)} · pace ${burn === undefined ? '—' : `${fmtUsd(burn)}/h`} · cache hits ${cache === undefined ? '—' : `${cache}%`}`, dim: true }),
977 line({ text: `cache served ${fmtTokens(s.cacheRead)} of ${fmtTokens(s.inputTotal)} input tokens`, dim: true }),
978 s.turns.length ? line({ text: 'last turns', bold: true }) : null,
979 ...s.turns
980 .slice(-6)
981 .reverse()
982 .map(t =>
983 line(
984 { text: pad(fmtUsd(t.costUsd), 8) },
985 { text: `${pad(fmtDur(t.durationMs), 7)}${pad(`${fmtTokens(t.tokensIn)} in`, 10)}cache ${t.tokensIn ? Math.round((t.cacheRead / t.tokensIn) * 100) : 0}%`, dim: true },
986 ),
987 ),
988 !isBudgetControlShown(p, s) ? line({ text: 'budget: hidden on a subscription · /cockpit-budget on shows it', dim: true }) : line({
989 text: `budget: ${p.budgetMode}${p.budgetUsd > 0 ? ` · ${fmtUsd(p.budgetUsd)}` : ' · not set'}${isSubscription(s) ? ' · subscription detected' : ' · API billing'}`,
990 dim: true,
991 }),
992 Object.keys(s.models ?? {}).length ? line({ text: 'by model', bold: true }) : null,
993 ...Object.entries(s.models ?? {})
994 .sort((a, b) => b[1].tokensIn - a[1].tokensIn)
995 .map(([model, m]) =>
996 line(
997 { text: pad(shortModel(model), nameWidth) },
998 { text: `${pad(`${fmtTokens(m.tokensIn)} in`, 10)}${pad(`${fmtTokens(m.tokensOut)} out`, 10)}${m.turns} runs · cache ${m.tokensIn ? Math.round((m.cacheRead / m.tokensIn) * 100) : 0}%`, dim: true },
999 ),
1000 ),
1001 <Box flexDirection="row" gap={1}>
1002 <Text dimColor>{`cache lifetime assumed: ${p.cacheTtlMin} min`}</Text>
1003 <Button
1004 key="cache-ttl"
1005 plain
1006 dimColor
1007 label={p.cacheTtlMin === 60 ? 'use 5 min' : 'use 1 hour'}
1008 onPress={() => update($, prefs, cur => ({ ...cur, cacheTtlMin: cur.cacheTtlMin === 60 ? 5 : 60 }))}
1009 />
1010 </Box>,
1011 line({ text: `all-time spend ${fmtUsd(s.lifetimeUsd)}`, dim: true }),
1012 ]
1013
1014 const turnDetails = () => {
1015 const log = (s.turnLog ?? []).slice(-10)
1016 return log.length === 0
1017 ? [line({ text: 'no tool calls in this turn yet', dim: true })]
1018 : [
1019 line({ text: s.turnActive ? 'this turn, latest last' : 'last turn, latest last', dim: true }),
1020 ...log.map(entry =>
1021 line(
1022 { text: entry.isError ? '✗ ' : '✓ ', color: entry.isError ? 'red' : 'green' },
1023 { text: entry.agentId ? '↳ ' : '', color: 'magenta' },
1024 { text: pad(entry.tool.replace(/^mcp__[^_]+__/, ''), 12), bold: true },
1025 { text: pad(fmtDur(entry.ms), 6), dim: true },
1026 { text: shorten(entry.label, Math.max(10, cols - labelWidth - 26)), dim: true },
1027 ),
1028 ),
1029 ]
1030 }
1031
1032 const toolsDetails = () => [
1033 line({ text: `${pad(' tool', 16)}${pad('calls', 7)}${pad('errors', 8)}${pad('avg', 7)}max`, dim: true, bold: true }),
1034 ...Object.entries(s.tools)
1035 .sort((a, b) => b[1].calls - a[1].calls)
1036 .slice(0, 12)
1037 .map(([name, t]) => {
1038 const timing = s.toolMs?.[name]
1039 const isToolOpen = p.toolOpen.includes(name)
1040 const calls = (s.callLog ?? []).filter(c => c.tool === name).slice(-8).reverse()
1041 const okShare = t.calls ? Math.round(((t.calls - t.errors) / t.calls) * 100) : 100
1042 return (
1043 <Box flexDirection="column">
1044 <Box flexDirection="row">
1045 <Button
1046 key={`tool-${name}`}
1047 plain
1048 dimColor={!isToolOpen}
1049 label={pad(`${isToolOpen ? '▾' : '▸'} ${name.replace(/^mcp__[^_]+__/, '')}`, 16)}
1050 onPress={() =>
1051 update($, prefs, cur => ({
1052 ...cur,
1053 toolOpen: cur.toolOpen.includes(name) ? cur.toolOpen.filter(x => x !== name) : [...cur.toolOpen, name].slice(-8),
1054 }))
1055 }
1056 />
1057 {line(
1058 { text: pad(String(t.calls), 7) },
1059 { text: pad(String(t.errors), 8), color: t.errors ? 'red' : undefined, dim: !t.errors },
1060 { text: `${pad(timing ? fmtDur(timing.totalMs / Math.max(1, t.calls)) : '—', 7)}${timing ? fmtDur(timing.maxMs) : '—'}`, dim: true },
1061 )}
1062 </Box>
1063 {isToolOpen ? (
1064 <Box flexDirection="column" paddingLeft={2}>
1065 {line(
1066 { text: `${okShare}% ok`, color: okShare >= 90 ? 'green' : okShare >= 70 ? 'yellow' : 'red' },
1067 { text: ` · total ${fmtDur(timing?.totalMs ?? 0)} · last ${calls.length} calls, newest first`, dim: true },
1068 )}
1069 {calls.map(c => [
1070 line(
1071 { text: c.isError ? '✗ ' : '✓ ', color: c.isError ? 'red' : 'green' },
1072 { text: pad(fmtAgo(now - c.at), 10), dim: true },
1073 { text: pad(fmtDur(c.ms), 6) },
1074 { text: c.agentId ? '↳ ' : '', color: 'magenta' },
1075 { text: shorten(c.label || '(no input)', Math.max(10, cols - labelWidth - 30)), dim: true },
1076 ),
1077 c.isError && c.errorText ? line({ text: ` ${shorten(c.errorText, Math.max(10, cols - labelWidth - 8))}`, color: 'red' }) : null,
1078 ])}
1079 </Box>
1080 ) : null}
1081 </Box>
1082 )
1083 }),
1084 ]
1085
1086 const toolsDetailsWithHogs = () => [
1087 ...toolsDetails(),
1088 line({ text: 'biggest outputs (context hogs)', bold: true }),
1089 ...((s.topOutputs ?? []).length ? hogLines(8) : [line({ text: 'no tool output over ~500 tokens yet', dim: true })]),
1090 ]
1091
1092 const filesDetails = () => {
1093 const edited = Object.entries(s.edits).sort((a, b) => b[1] - a[1])
1094 return [
1095 ...(s.diff.length
1096 ? s.diff.slice(0, 20).map(f => {
1097 const isViewed = s.fileView?.path === f.path
1098 const width = Math.max(12, cols - labelWidth - 20)
1099 return (
1100 <Box flexDirection="column">
1101 <Box flexDirection="row">
1102 <Button
1103 key={`file-${f.path}`}
1104 plain
1105 dimColor={!isViewed}
1106 label={pad(`${isViewed ? '▾' : '▸'} ${shorten(f.path, width - 3)}`, width)}
1107 onPress={() => (isViewed ? update($, stats, cur => ({ ...cur, fileView: null })) : loadFileView($, f.path, f.add < 0))}
1108 />
1109 {f.add < 0 ? <Text color="cyan">new</Text> : <Text color="green">{`+${f.add}`}</Text>}
1110 {f.add < 0 ? null : <Text color="red">{` −${f.del}`}</Text>}
1111 </Box>
1112 {isViewed && s.fileView ? (
1113 <Box flexDirection="column" paddingLeft={2}>
1114 {s.fileView.lines.map(l =>
1115 line({
1116 text: shorten(l || ' ', Math.max(10, cols - labelWidth - 8)),
1117 color: l.startsWith('@@') ? 'cyan' : l.startsWith('+') ? 'green' : l.startsWith('-') ? 'red' : undefined,
1118 dim: !(l.startsWith('+') || l.startsWith('-') || l.startsWith('@@')),
1119 }),
1120 )}
1121 </Box>
1122 ) : null}
1123 </Box>
1124 )
1125 })
1126 : [line({ text: 'no uncommitted changes vs HEAD (or not a git repository)', dim: true })]),
1127 edited.length ? line({ text: 'edited by Claude this session', bold: true }) : null,
1128 ...edited.slice(0, 10).map(([path, n]) => line({ text: shorten(relPath(path, s.cwd), Math.max(12, cols - labelWidth - 14)) }, { text: ` ${n}×`, dim: true })),
1129 ]
1130 }
1131
1132 const testsDetails = () =>
1133 (s.testHistory ?? []).length === 0
1134 ? [line({ text: 'Runs of npm/pnpm/yarn test, jest, vitest, pytest, go test, cargo test… show up here.', dim: true })]
1135 : (s.testHistory ?? [])
1136 .slice()
1137 .reverse()
1138 .map(t =>
1139 line(
1140 { text: t.isOk ? '✓ ' : '✗ ', color: t.isOk ? 'green' : 'red' },
1141 { text: pad(fmtAgo(now - t.at), 10), dim: true },
1142 { text: `${t.passed !== undefined ? `${t.passed} passed ` : ''}${t.failed ? `${t.failed} failed ` : ''}`, color: t.failed ? 'red' : undefined },
1143 { text: t.command, dim: true },
1144 ),
1145 )
1146
1147 const guardDetails = () => [
1148 ...(s.guardLog.length === 0
1149 ? [line({ text: 'nothing stopped yet', dim: true })]
1150 : s.guardLog
1151 .slice()
1152 .reverse()
1153 .map(g =>
1154 line(
1155 { text: g.verdict === 'blocked' ? '✗ blocked ' : '✓ allowed ', color: g.verdict === 'blocked' ? 'red' : 'yellow' },
1156 { text: pad(fmtAgo(now - g.at), 10), dim: true },
1157 { text: `${g.reason}: `, dim: true },
1158 { text: shorten(g.command, Math.max(10, cols - labelWidth - 40)) },
1159 ),
1160 )),
1161 line({ text: 'watches: rm -rf · force push · reset --hard · clean -f · checkout -- . · sudo · curl|sh · DROP TABLE · kubectl delete · terraform destroy · npm publish · .env and key files', dim: true }),
1162 ]
1163
1164 const alertsDetails = () => [
1165 ...(s.loopLog ?? []).slice().reverse().map(l => line({ text: '⟳ ', color: 'yellow' }, { text: pad(fmtAgo(now - l.at), 10), dim: true }, { text: shorten(l.text, Math.max(10, cols - labelWidth - 16)) })),
1166 ...(s.compactLog ?? []).slice().reverse().map(c => line({ text: '⇣ ', color: 'cyan' }, { text: pad(fmtAgo(now - c.at), 10), dim: true }, { text: `compacted ${fmtTokens(c.before)} → ${fmtTokens(c.after)} (${c.trigger})` })),
1167 (s.loopLog ?? []).length + (s.compactLog ?? []).length === 0 ? line({ text: 'A loop alert fires when the same call repeats 3 times within 8 calls.', dim: true }) : null,
1168 ]
1169
1170 const historyDetails = () => [
1171 line({ text: `${pad('#', 4)}${pad('time', 7)}${pad('steps', 7)}${pad('tools', 7)}${pad('in', 7)}${pad('out', 7)}${p.isCostHidden ? '' : pad('cost', 8)}end`, dim: true, bold: true }),
1172 ...s.turns
1173 .slice(-10)
1174 .map((t, i, list) =>
1175 line({
1176 text: `${pad(String(s.turns.length - list.length + i + 1), 4)}${pad(fmtDur(t.durationMs), 7)}${pad(String(t.steps), 7)}${pad(String(t.tools), 7)}${pad(fmtTokens(t.tokensIn), 7)}${pad(fmtTokens(t.tokensOut), 7)}${p.isCostHidden ? '' : pad(fmtUsd(t.costUsd), 8)}${t.reason}`,
1177 }),
1178 ),
1179 ]
1180
1181 const lifetimeDetails = () => [
1182 line({ text: `per session: ${s.lifetimeSessions ? (s.lifetimeTurns / s.lifetimeSessions).toFixed(1) : '—'} turns · ${p.isCostHidden ? '' : `${fmtUsd(s.lifetimeSessions ? s.lifetimeUsd / s.lifetimeSessions : 0)} · `}${s.lifetimeTurns ? (s.lifetimeTools / s.lifetimeTurns).toFixed(1) : '—'} tools per turn`, dim: true }),
1183 line({ text: 'kept on this machine in the plugin store; Reset clears this session only', dim: true }),
1184 ]
1185
1186 const lifetimeBody = line({
1187 text: `${s.lifetimeSessions} sessions · ${s.lifetimeTurns} turns · ${s.lifetimeTools} tools · ${fmtUsd(s.lifetimeUsd)}`,
1188 dim: true,
1189 })
1190
1191 return (
1192 <Box flexDirection="column">
1193 {header}
1194 {row('CONTEXT', contextBody, contextDetails)}
1195 {row('LIMITS', limitsBody, limitsDetails)}
1196 {p.isCostHidden ? null : row('COST', costBody, costDetails)}
1197 {row('TURN', turnBody, turnDetails)}
1198 {row('AGENTS', agentsBody, undefined, () => update($, prefs, cur => ({ ...cur, isAgentsOpen: !cur.isAgentsOpen })), p.isAgentsOpen)}
1199 {row('TOOLS', toolsBody, toolsDetailsWithHogs)}
1200 {row('FILES', fileRows, filesDetails)}types/index.d.ts 142 lines1export type CockpitLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type CockpitTurnRecord = {
4 durationMs: number
5 tools: number
6 steps: number
7 tokensIn: number
8 tokensOut: number
9 costUsd: number
10 cacheRead: number
11 reason: string
12}
13
14export type CockpitTest = {
15 command: string
16 isOk: boolean
17 passed?: number
18 failed?: number
19 at: number
20}
21
22export type CockpitDiffFile = { path: string; add: number; del: number }
23
24export type CockpitGuardEvent = { command: string; reason: string; verdict: 'allowed' | 'blocked'; at: number }
25
26export type CockpitAgent = {
27 id: string
28 type: string
29 description: string
30 name?: string
31 status: string
32 parentId?: string
33 startedAt: number
34 endedAt?: number
35 steps: number
36 tools: number
37 errors: number
38 toolCounts: Record<string, number>
39 lastTool: string
40 tokensIn: number
41 tokensOut: number
42 result?: string
43}
44
45export type CockpitCall = {
46 tool: string
47 label: string
48 ms: number
49 isError: boolean
50 errorText?: string
51 agentId?: string
52 at: number
53}
54
55export type CockpitStats = {
56 startedAt: number
57 model: string
58 cwd: string
59 ctxTokens?: number
60 ctxWindow: number
61 ctxPercent?: number
62 limits: CockpitLimit[]
63 /** a subscription was seen (now or in an earlier session) */
64 isPlan?: boolean
65 costUsd: number
66 turnActive: boolean
67 turnStartedAt: number
68 turnSteps: number
69 turnTools: number
70 turnCostAtStart: number
71 tools: Record<string, { calls: number; errors: number }>
72 toolTotal: number
73 toolErrors: number
74 edits: Record<string, number>
75 diff: CockpitDiffFile[]
76 diffAdd: number
77 diffDel: number
78 lastTest: CockpitTest | null
79 testRuns: number
80 testFails: number
81 turns: CockpitTurnRecord[]
82 guardBlocked: number
83 guardAllowed: number
84 guardLog: CockpitGuardEvent[]
85 warned: string[]
86 lifetimeTurns: number
87 lifetimeTools: number
88 lifetimeUsd: number
89 lifetimeSessions: number
90 ctxParts: { name: string; tokens: number }[]
91 cacheRead: number
92 inputTotal: number
93 toolMs: Record<string, { totalMs: number; maxMs: number }>
94 slowest: { tool: string; ms: number; label: string } | null
95 recentSigs: string[]
96 loopSigs: string[]
97 loopAlerts: number
98 lastLoop: string
99 agentsActive: string[]
100 agentsSpawned: number
101 compactions: number
102 lastCompact: { before?: number; after?: number; trigger: string } | null
103 limitStart: Record<string, { pct: number; at: number }>
104 secretsBlocked: number
105 agents: Record<string, CockpitAgent>
106 turnLog: { tool: string; label: string; ms: number; isError: boolean; agentId?: string; at: number }[]
107 testHistory: CockpitTest[]
108 loopLog: { text: string; at: number }[]
109 compactLog: { before?: number; after?: number; trigger: string; at: number }[]
110 ctxFree: number
111 ctxMemory: { path: string; tokens: number }[]
112 ctxMcp: { server: string; tokens: number; tools: number }[]
113 callLog: CockpitCall[]
114 topOutputs: { tool: string; label: string; tokens: number; agentId?: string; at: number }[]
115 lastResponseAt: number
116 models: Record<string, { tokensIn: number; tokensOut: number; cacheRead: number; turns: number }>
117 fileView: { path: string; lines: string[]; at: number } | null
118}
119
120export type CockpitPrefs = {
121 isCompact: boolean
122 isGuardOn: boolean
123 isStatusOn: boolean
124 isBandOn: boolean
125 budgetUsd: number
126 budgetMode: 'auto' | 'on' | 'off'
127 isCostHidden: boolean
128 isAgentsOpen: boolean
129 agentOpen: string[]
130 openSections: string[]
131 toolOpen: string[]
132 cacheTtlMin: number
133}
134
135export type CockpitPane = { isPlaced: boolean; reason: string }
136
137declare module 'claude-code' {
138 interface PluginState {
139 cockpit: { stats: CockpitStats; prefs: CockpitPrefs; pane: CockpitPane }
140 }
141}
142