Mission control for Claude Code: live agent and subagent status, a session log, usage charts, a git graph, a mini terminal, notifications and a better effort…

Mission control for Claude Code. Live subagent fleet · token & cost telemetry · one-key effort switching · done-notifications.

Once Claude Code fans work out to subagents, the terminal stops telling you much. Which agent is running what? Where did the last 40k tokens go? Is the main turn finished, or still waiting on a subagent?
Cockpit answers those questions in real time, without leaving the session:
| Problem | Cockpit's answer |
|---|---|
| No visibility into parallel subagents | A live fleet tree showing the exact tool each agent is running right now |
| Token and cost spend is opaque | Per-turn token charts, cost KPIs, context-window and rate-limit bars |
| Changing effort means typing commands | A clickable gauge plus ⌥E / ⌥⇧E chords |
| You have to babysit long turns | Pop-up and/or system notifications when work finishes, with a minimum-duration threshold |
| Subagent behaviour is fixed | Global model, effort and background overrides, plus preset agent types |
A single live line under the prompt:
⠹ main working 14s · Bash npm test │ ⑂ subagents 2 running · 3/5 done │ ⚡ High │ Σ 48.2k tok · $0.412 · ctx 31%
Main-agent status, subagent counts, tokens, context usage and an interactive effort gauge:
✈ Cockpit │ main ✓ done 42s │ ⑂ 0 running 3 done │ Σ 48.2k ctx 31% ⚡ ◀ ▰▰▰▱▱ ▶ High ⌥E up · ⌥⇧E down
Click a gauge segment to jump to that level, or step with ◀ / ▶.
flowchart TD
A["User prompt"] --> B["turn.start<br/>mark main agent running"]
B --> C["turn.step<br/>apply selected effort level"]
C --> D{"Agent action"}
D -- "tool call" --> E["tool.call<br/>record current tool, counts, errors"]
D -- "spawn subagent" --> F["agent.offer / agent.spawn<br/>apply model, effort, background overrides"]
F --> G["Subagent turn<br/>same hook cycle, tracked in fleet tree"]
G --> E
E --> H["session.measure<br/>tokens, cost, context, rate limits"]
H --> I[("$.state<br/>live session state")]
I --> J["ui.render"]
J --> J1["Status line"]
J --> J2["Band above prompt<br/>effort gauge"]
J --> J3["/cockpit pane<br/>7 tabs"]
E --> K{"Turn finished?"}
K -- "no" --> C
K -- "yes" --> L["turn.complete<br/>record per-turn usage"]
L --> M{"Longer than threshold<br/>or error?"}
M -- "yes" --> N["Notify<br/>pop-up / system / both"]
M -- "no" --> O["Done"]
N --> O
S["Settings tab"] -.-> P[("$.store<br/>persisted settings")]
P -.-> C
P -.-> F
P -.-> M
/cockpit — the seven-tab paneWith the pane focused, press 1–7 to switch tabs.
| # | Tab | What you get |
|---|---|---|
| 1 | Overview | Cards for the main agent, subagents, tokens and context; effort control; live activity feed |
| 2 | Agents | Fleet tree (main + nested subagents) with each agent's current tool, swimlane timeline, per-subagent bar charts (tool calls, tokens, duration), and a detail drawer with each agent's tool mix |
| 3 | Usage | KPI tiles (input, output, cache, cost), context-window bar, plan rate-limit bars, tokens-per-turn column chart (main vs. subagents), split by model |
| 4 | Git | Branch, ahead/behind, changed files, and a coloured git log --graph --all — press r to refresh |
| 5 | Logs | Session log of prompts, tools, agents, turns, errors and alerts, with filter chips, paging, row inspection and Export to JSONL |
| 6 | Terminal | A zsh prompt in the session folder, independent of Claude's turn, with streaming output, Stop, Clear and quick commands |
| 7 | Settings | Notifications, effort, subagent customisation and display toggles |
Configured independently for the main agent and for subagents:
Where system notifications are unavailable, Cockpit falls back to the in-app pop-up.
cockpit:reviewer, cockpit:tester, cockpit:scout (Haiku), each toggleableIn a Claude Code terminal session:
/plugin install cockpit --marketplace Shashwat-19/claude-cockpit
y to add the marketplace.Use this if you want to hack on Cockpit or pin a specific commit:
git clone https://github.com/Shashwat-19/claude-cockpit.git ~/claude-mods/cockpit
claude plugin marketplace add ~/claude-mods/cockpit
claude plugin install cockpit@cockpit --scope user
Claude Code reads the plugin directly from that folder. After editing, run /reload-plugins.
⌥E / ⌥⇧E)Merge examples/keybindings.json into ~/.claude/keybindings.json.
On macOS, your terminal must send Option as Meta:
| Terminal | Setting |
|---|---|
| iTerm2 | Profiles → Keys → Left Option key = Esc+ |
| Terminal.app | Settings → Profiles → Keyboard → Use Option as Meta key |
/cockpit # open the pane on Overview
/cockpit agents # open straight to a tab
Then kick off any task that spawns subagents and watch the Agents tab fill in.
| Command | Description |
|---|---|
/cockpit [tab] | Open the pane, optionally on a specific tab |
/fleet | Open the Agents tab |
/usage-chart | Render a tokens-per-turn chart inline in the conversation |
/session-log | Open the Logs tab |
/git-graph | Open the Git tab |
/effort-up, /effort-down | Change effort level — works everywhere, including the desktop app |
| Key | Context | Action |
|---|---|---|
1–7 | Pane focused | Switch tab |
r | Git tab | Refresh |
⌥E / ⌥⇧E | Terminal sessions | Effort up / down |
All settings live in the Settings tab and persist across sessions via $.store.
| Setting | Options |
|---|---|
| Main-agent notifications | Off · Pop-up · System · Both |
| Subagent notifications | Off · Pop-up · System · Both |
| Minimum turn length to notify | 10s · 30s · 1m · 2m |
| Notify on errors | On · Off |
| Effort | Model default, or a fixed level |
| Subagent model | Inherit · Haiku · Sonnet · Opus |
| Subagent effort | Inherit, or a fixed level |
| Always background subagents | On · Off |
| Preset agent types | Toggle reviewer / tester / scout individually |
Cockpit is a Claude Code mod: a hooks module the engine loads into a sandbox with no DOM and no Node runtime. All interaction with the engine goes through the $ interface.
flowchart LR
E[Claude Code engine] -- lifecycle hooks --> R[hooks/register.tsx]
R -- read / write --> S[($.state<br/>hot-reload safe)]
R -- persist --> P[($.store<br/>cross-session)]
R -- uses --> L[hooks/lib.ts<br/>pure helpers]
R -- ui.render --> U[Pane · AbovePrompt band · CommandOutput]
| Hook | Used for |
|---|---|
turn.start / turn.complete | Main/subagent run status, per-turn token usage, notifications |
tool.call | Live "current tool" per agent, tool counts and errors, timeline entries |
agent.spawn / agent.offer | Subagent tracking; model / effort / background overrides; preset types |
turn.step | Applies the selected effort level to each model request |
session.measure | Context window, cost and plan rate limits |
ui.render (Pane, AbovePrompt, CommandOutput) | The pane, the band and the inline usage chart |
$.state (typed in types/index.d.ts), so a hot reload doesn't wipe it.$.store and survive across sessions.hooks/lib.ts, which keeps it unit-testable and free of $ dependencies..
├── .claude-plugin/
│ ├── plugin.json # plugin manifest
│ └── marketplace.json # makes the repo installable via /plugin install
├── .github/ISSUE_TEMPLATE/ # bug report / feature request templates
├── assets/ # banner and media
├── docs/ # additional documentation
├── examples/
│ └── keybindings.json # optional ⌥E / ⌥⇧E bindings
├── hooks/
│ ├── hooks.json # declares the hooks module
│ ├── register.tsx # every hook, view and action (all $-dependent code lives here)
│ └── lib.ts # pure helpers: formatting, text charts
├── tests/
│ └── cockpit.test.ts # run with `claude plugin test .`
├── types/
│ └── index.d.ts # the $.state contract
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
└── tsconfig.json
claude plugin validate . # validate the manifest and module exactly as the engine will
claude plugin test . # run tests/*.test.ts against the engine (terminal + desktop surfaces)
claude --plugin-dir . # start a session with this folder loaded; saving a file hot-reloads it
On first load, Claude Code writes its API types into .claude-plugin/types/ (gitignored). After that:
npx tsc -p . # type-check the mod
Conventions
$ goes in hooks/register.tsx; everything else belongs in hooks/lib.ts.types/index.d.ts first — that file is the contract.validate, test and tsc before opening a PR.~/.claude/cockpit-logs/<session>.jsonl and nothing is sent anywhere else.⌥E / ⌥⇧E work in terminal sessions only. In the desktop app, use the gauge or /effort-up / /effort-down.Bug reports, feature requests and PRs are welcome. Use the issue templates and read CONTRIBUTING.md before submitting. Release notes are tracked in CHANGELOG.md.
This project is distributed under the MIT License. For detailed licensing information, please refer to the LICENSE file included in this repository.
Machine Learning Engineer | Scalable AI Systems
🔹 ML systems: (CV, NLP) + data pipelines<br> 🔹 End-to-end: training → deployment<br> 🔹 Backend & Cloud: Python, Flask, Node.js, Docker, AWS<br> 🔹 Projects: Traffic AI, Video Summarizer, AI Assistants<br>
Building robust applications and leveraging cloud technologies for high-performance solutions.
<img src="https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white" /> <img src="https://img.shields.io/badge/LinkedIn-0A66C2?style=for-the-badge&logo=linkedin&logoColor=white" /> <img src="https://img.shields.io/badge/Email-D14836?style=for-the-badge&logo=gmail&logoColor=white" /> <img src="https://img.shields.io/badge/Hashnode-2962FF?style=for-the-badge&logo=hashnode&logoColor=white" /> <img src="https://img.shields.io/badge/HackerRank-15%2B-2EC866?style=for-the-badge&logo=HackerRank&logoColor=white" />
Feel free to connect for tech collaborations, open-source contributions, or brainstorming innovative solutions!
hooks/register.tsx 2039 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type {
5 AgentRec,
6 AgentStatusKind,
7 Effort,
8 EffortState,
9 GitSnap,
10 LogEntry,
11 LogFilter,
12 LogKind,
13 MainStatus,
14 NotifyMode,
15 CockpitSettings,
16 Tab,
17 TermLine,
18 TermState,
19 TurnRec,
20 UsageSnap,
21 ViewState,
22} from '../types'
23import {
24 KIND_COLOR,
25 KIND_LABEL,
26 LEVELS,
27 LEVEL_COLOR,
28 LEVEL_LABEL,
29 SPIN,
30 bar,
31 clip,
32 colorFor,
33 columns,
34 detailOf,
35 fmtClock,
36 fmtDur,
37 fmtTok,
38 fmtUsd,
39 isEffort,
40 pad,
41 spark,
42 stepEffort,
43 stripAnsi,
44} from './lib'
45
46type $ = EngineInterface
47
48// ══ state ══
49
50const PANE = 'cockpit'
51
52const DEFAULT_SETTINGS: CockpitSettings = {
53 notifyMain: 'both',
54 notifySub: 'toast',
55 notifyErrors: true,
56 minSeconds: 10,
57 subModel: 'inherit',
58 subEffort: 'inherit',
59 subBackground: false,
60 presets: { reviewer: true, tester: true, scout: true },
61 showBand: true,
62 showStatus: true,
63}
64
65const MAIN: MainStatus = {
66 phase: 'idle',
67 startedAt: 0,
68 endedAt: 0,
69 tools: 0,
70 errors: 0,
71 turns: 0,
72 activity: null,
73 model: '',
74 hits: [],
75}
76
77const USAGE: UsageSnap = {
78 startedAt: 0,
79 contextPct: null,
80 contextTokens: null,
81 window: 0,
82 costUsd: null,
83 limits: [],
84}
85
86const EFFORT: EffortState = { override: null, base: null, configKey: null }
87
88const GIT: GitSnap = { at: 0, branch: '', ahead: 0, behind: 0, changed: 0, graph: [], error: null }
89
90const TERM: TermState = { cwd: '', running: false, lines: [] }
91
92const VIEW: ViewState = { tab: 'overview', logFilter: 'all', expanded: null, logPage: 0, agentSel: null }
93
94const mainA = atom({ plugin: 'cockpit', key: 'main' } as const, MAIN)
95const agentsA = atom({ plugin: 'cockpit', key: 'agents' } as const, [])
96const turnsA = atom({ plugin: 'cockpit', key: 'turns' } as const, [])
97const usageA = atom({ plugin: 'cockpit', key: 'usage' } as const, USAGE)
98const logsA = atom({ plugin: 'cockpit', key: 'logs' } as const, [])
99const effortA = atom({ plugin: 'cockpit', key: 'effort' } as const, EFFORT)
100const settingsA = atom({ plugin: 'cockpit', key: 'settings' } as const, DEFAULT_SETTINGS)
101const gitA = atom({ plugin: 'cockpit', key: 'git' } as const, GIT)
102const termA = atom({ plugin: 'cockpit', key: 'term' } as const, TERM)
103const viewA = atom({ plugin: 'cockpit', key: 'view' } as const, VIEW)
104const nowA = atom({ plugin: 'cockpit', key: 'now' } as const, 0)
105
106// ══ actions ══
107
108// ── session log ──────────────────────────────────────────────────────────────
109
110async function log(
111 $: $,
112 kind: LogKind,
113 agent: string | null,
114 title: string,
115 detail = '',
116): Promise<void> {
117 const at = await $.clock.now()
118 let who = 'main'
119 if (agent !== null) {
120 const rec = (await read($, agentsA)).find(one => one.id === agent)
121 who = rec?.type ?? 'agent'
122 }
123 await update($, logsA, list => {
124 const id = (list.at(-1)?.id ?? 0) + 1
125 const entry = { id, at, kind, agent, who, title: clip(title, 160), detail: clip(detail, 800) }
126 return [...list, entry].slice(-600)
127 })
128}
129
130// ── notifications ────────────────────────────────────────────────────────────
131
132async function notify($: $, mode: NotifyMode, title: string, body: string): Promise<void> {
133 if (mode === 'off') return
134 let isShown = false
135 if (mode === 'native' || mode === 'both') {
136 try {
137 isShown = (await $.ui.notify(body, { title })).isSent
138 } catch {
139 isShown = false
140 }
141 }
142 if (mode === 'toast' || mode === 'both' || !isShown) {
143 $.ui.toast(`${title} · ${body}`, { timeoutMs: 6000 })
144 }
145 await log($, 'notify', null, title, body)
146}
147
148// ── settings ─────────────────────────────────────────────────────────────────
149
150async function patchSettings($: $, patch: Partial<CockpitSettings>): Promise<void> {
151 await update($, settingsA, cur => ({ ...cur, ...patch }))
152 await $.store.set('settings', await read($, settingsA))
153}
154
155async function loadSettings($: $): Promise<void> {
156 const saved = await $.store.get('settings')
157 if (typeof saved === 'object' && saved !== null) {
158 await update($, settingsA, cur => ({ ...cur, ...(saved as Partial<CockpitSettings>) }) as CockpitSettings)
159 }
160}
161
162// ── pane ─────────────────────────────────────────────────────────────────────
163
164async function openPane($: $, tab?: Tab): Promise<boolean> {
165 if (tab !== undefined) await setTab($, tab)
166 const opened = await $.ui.open({ id: PANE, title: '✈ Cockpit' })
167 return opened.isPlaced
168}
169
170async function setTab($: $, tab: Tab): Promise<void> {
171 await update($, viewA, v => ({ ...v, tab }))
172 if (tab === 'git') void refreshGit($)
173}
174
175// ── effort ───────────────────────────────────────────────────────────────────
176
177/** The level in force now: the mod's choice, else what the engine last asked for. */
178async function currentEffort($: $): Promise<Effort | null> {
179 const eff = await read($, effortA)
180 return eff.override ?? eff.base
181}
182
183/** Finds a /config row for effort, so changes go through the engine's own setting. */
184async function findEffortRow($: $): Promise<void> {
185 try {
186 const row = (await $.config.list()).find(r => /effort/i.test(r.key) && !r.isLocked)
187 if (row === undefined) return
188 await update($, effortA, cur => ({
189 ...cur,
190 configKey: row.key,
191 base: isEffort(row.value) ? row.value : cur.base,
192 }))
193 } catch {
194 // no config rows on this host: the mod applies the level per request instead
195 }
196}
197
198async function setEffort($: $, level: Effort | null, source: string): Promise<Effort | null> {
199 const eff = await read($, effortA)
200 let isNative = false
201 if (eff.configKey !== null && level !== null) {
202 try {
203 const done = await $.config.set({ key: eff.configKey, value: level })
204 isNative = done.deny === undefined
205 } catch {
206 isNative = false
207 }
208 }
209 await update($, effortA, cur =>
210 isNative && level !== null ? { ...cur, base: level, override: null } : { ...cur, override: level },
211 )
212 const now = await currentEffort($)
213 const label = now === null ? 'model default' : LEVEL_LABEL[now]
214 $.ui.toast(`⚡ Effort → ${label}`, { timeoutMs: 1800 })
215 await log($, 'system', null, `Effort set to ${label}`, source)
216 return now
217}
218
219async function nudgeEffort($: $, dir: 1 | -1, source: string): Promise<Effort | null> {
220 const now = (await currentEffort($)) ?? 'high'
221 return setEffort($, stepEffort(now, dir), source)
222}
223
224// ── git ──────────────────────────────────────────────────────────────────────
225
226async function refreshGit($: $): Promise<void> {
227 const at = await $.clock.now()
228 try {
229 const status = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 8000 })
230 if (status.exitCode !== 0) {
231 await update($, gitA, g => ({ ...g, at, error: clip(status.stderr.trim() || 'Not a git repository', 200), graph: [] }))
232 return
233 }
234 let branch = ''
235 let ahead = 0
236 let behind = 0
237 let changed = 0
238 for (const line of status.stdout.split('\n')) {
239 if (line.startsWith('# branch.head ')) branch = line.slice(14)
240 else if (line.startsWith('# branch.ab ')) {
241 const m = /\+(\d+) -(\d+)/.exec(line)
242 ahead = Number(m?.[1] ?? 0)
243 behind = Number(m?.[2] ?? 0)
244 } else if (line !== '' && !line.startsWith('#')) changed += 1
245 }
246 const graph = await $.process.run(
247 [
248 'git',
249 'log',
250 '--graph',
251 '--all',
252 '--decorate=short',
253 '--color=never',
254 '--format=%h%x1f%D%x1f%s%x1f%an%x1f%ar',
255 '-n',
256 '80',
257 ],
258 { timeoutMs: 8000 },
259 )
260 await update($, gitA, () => ({
261 at,
262 branch,
263 ahead,
264 behind,
265 changed,
266 graph: graph.stdout.split('\n').filter(l => l !== ''),
267 error: graph.exitCode === 0 ? null : clip(graph.stderr.trim(), 200),
268 }))
269 } catch (err) {
270 await update($, gitA, g => ({ ...g, at, error: clip(String(err), 200) }))
271 }
272}
273
274// ── terminal ─────────────────────────────────────────────────────────────────
275
276let stopRunning: (() => void) | null = null
277
278function pushLines(lines: TermLine[], add: TermLine[]): TermLine[] {
279 return [...lines, ...add].slice(-500)
280}
281
282async function stopTerminal($: $): Promise<void> {
283 stopRunning?.()
284 stopRunning = null
285 await update($, termA, t => ({ ...t, running: false, lines: pushLines(t.lines, [{ kind: 'info', text: '■ stopped' }]) }))
286}
287
288async function runTerminal($: $, raw: string): Promise<void> {
289 const cmd = raw.trim()
290 if (cmd === '') return
291 const term = await read($, termA)
292 if (term.running) {
293 $.ui.toast('A command is still running: stop it first', { timeoutMs: 2500 })
294 return
295 }
296 if (cmd === 'clear' || cmd === 'cls') {
297 await update($, termA, t => ({ ...t, lines: [] }))
298 return
299 }
300 const cwd = term.cwd || (await $.session.cwd())
301 await update($, termA, t => ({
302 ...t,
303 cwd,
304 running: true,
305 lines: pushLines(t.lines, [{ kind: 'cmd', text: cmd }]),
306 }))
307 await log($, 'system', null, 'Terminal', cmd)
308
309 const script = `${cmd}\n__cockpit_rc=$?\nprintf '\\n\\037%s' "$PWD"\nexit $__cockpit_rc`
310 const stream = $.process.spawn({ argv: ['/bin/zsh', '-c', script], cwd })
311 const it = stream[Symbol.asyncIterator]()
312 stopRunning = () => void it.return?.(undefined as never)
313
314 void (async () => {
315 const carry = { stdout: '', stderr: '' }
316 let nextCwd = cwd
317 let code: number | null = null
318 const flush = async (kind: 'stdout' | 'stderr', isEnd: boolean) => {
319 const parts = carry[kind].split('\n')
320 carry[kind] = isEnd ? '' : (parts.pop() ?? '')
321 const add: TermLine[] = []
322 for (const part of parts) {
323 const at = part.indexOf('\u001f')
324 if (at >= 0) {
325 nextCwd = part.slice(at + 1) || nextCwd
326 const before = part.slice(0, at)
327 if (before !== '') add.push({ kind: kind === 'stdout' ? 'out' : 'err', text: stripAnsi(before) })
328 continue
329 }
330 add.push({ kind: kind === 'stdout' ? 'out' : 'err', text: stripAnsi(part) })
331 }
332 if (add.length > 0) await update($, termA, t => ({ ...t, lines: pushLines(t.lines, add) }))
333 }
334 try {
335 for (;;) {
336 const step = await it.next()
337 if (step.done === true) {
338 code = step.value.code
339 break
340 }
341 carry[step.value.stream] += step.value.text
342 await flush(step.value.stream, false)
343 }
344 } catch (err) {
345 await update($, termA, t => ({ ...t, lines: pushLines(t.lines, [{ kind: 'err', text: String(err) }]) }))
346 }
347 // Drop the trailing newline printf put before the cwd marker.
348 if (carry.stdout.startsWith('\u001f')) {
349 nextCwd = carry.stdout.slice(1) || nextCwd
350 carry.stdout = ''
351 }
352 await flush('stdout', true)
353 await flush('stderr', true)
354 stopRunning = null
355 await update($, termA, t => {
356 const lines = t.lines.at(-1)?.kind === 'out' && t.lines.at(-1)?.text === '' ? t.lines.slice(0, -1) : t.lines
357 const tail: TermLine[] = code !== null && code !== 0 ? [{ kind: 'info', text: `exit ${code}` }] : []
358 return { cwd: nextCwd, running: false, lines: pushLines(lines, tail) }
359 })
360 })()
361}
362
363// ── export ───────────────────────────────────────────────────────────────────
364
365async function exportLogs($: $): Promise<string> {
366 const home = (await $.env.get('HOME')) ?? '.'
367 const id = await $.session.id()
368 const path = `${home}/.claude/cockpit-logs/${id}.jsonl`
369 const list = await read($, logsA)
370 await $.fs.write(path, list.map(entry => JSON.stringify(entry)).join('\n') + '\n')
371 return path
372}
373
374// ══ views ══
375
376type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & {
377 Input?: Elements['terminal']['Input']
378}
379
380type Data = {
381 main: MainStatus
382 agents: AgentRec[]
383 turns: TurnRec[]
384 usage: UsageSnap
385 logs: LogEntry[]
386 effort: EffortState
387 settings: CockpitSettings
388 git: GitSnap
389 term: TermState
390 view: ViewState
391 now: number
392 width: number
393 rows: number
394}
395
396const TABS: readonly { tab: Tab; label: string; hotkey: string }[] = [
397 { tab: 'overview', label: 'Overview', hotkey: '1' },
398 { tab: 'agents', label: 'Agents', hotkey: '2' },
399 { tab: 'usage', label: 'Usage', hotkey: '3' },
400 { tab: 'git', label: 'Git', hotkey: '4' },
401 { tab: 'logs', label: 'Logs', hotkey: '5' },
402 { tab: 'terminal', label: 'Terminal', hotkey: '6' },
403 { tab: 'settings', label: 'Settings', hotkey: '7' },
404]
405
406// ── shared bits ──────────────────────────────────────────────────────────────
407
408function sel(on: boolean): { variant: 'primary' } | { dimColor: true } {
409 return on ? { variant: 'primary' } : { dimColor: true }
410}
411
412function effortOf(d: Pick<Data, 'effort'>): Effort | null {
413 return d.effort.override ?? d.effort.base
414}
415
416function tokensOf(turns: readonly TurnRec[]) {
417 let input = 0
418 let output = 0
419 let cacheRead = 0
420 let cacheWrite = 0
421 for (const t of turns) {
422 input += t.input
423 output += t.output
424 cacheRead += t.cacheRead
425 cacheWrite += t.cacheWrite
426 }
427 return { input, output, cacheRead, cacheWrite, total: input + output + cacheRead + cacheWrite }
428}
429
430function turnTotal(t: TurnRec): number {
431 return t.input + t.output + t.cacheRead + t.cacheWrite
432}
433
434function meter(K: Kit, frac: number, width: number, color: string): RenderChildren {
435 const { Text } = K
436 const b = bar(frac, width)
437 return [
438 <Text color={color}>{b.fill}</Text>,
439 <Text dimColor>{b.track}</Text>,
440 ]
441}
442
443function mainChip(K: Kit, main: MainStatus, now: number): RenderChildren {
444 const { Text } = K
445 if (main.phase === 'working') {
446 const spin = SPIN[Math.floor(now / 100) % SPIN.length] ?? '•'
447 return (
448 <Text color="warning" bold>
449 {spin} working {fmtDur(now - main.startedAt)}
450 </Text>
451 )
452 }
453 if (main.phase === 'done') return <Text color="success" bold>✓ done {fmtDur(main.endedAt - main.startedAt)}</Text>
454 if (main.phase === 'aborted') return <Text color="warning" bold>■ interrupted</Text>
455 if (main.phase === 'error') return <Text color="error" bold>✗ error</Text>
456 return <Text dimColor>◌ idle</Text>
457}
458
459function agentGlyph(K: Kit, a: AgentRec, now: number): RenderChildren {
460 const { Text } = K
461 if (a.status === 'running') return <Text color="warning">{SPIN[Math.floor(now / 100) % SPIN.length] ?? '•'}</Text>
462 if (a.status === 'done') return <Text color="success">✓</Text>
463 if (a.status === 'aborted') return <Text color="warning">■</Text>
464 return <Text color="error">✗</Text>
465}
466
467function section(K: Kit, title: string, right?: RenderChildren): RenderChildren {
468 const { Box, Text } = K
469 return (
470 <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
471 <Text bold color="claude">
472 {title.toUpperCase()}
473 </Text>
474 {right ?? null}
475 </Box>
476 )
477}
478
479function card(K: Kit, title: string, color: string, width: number, body: RenderChildren): RenderChildren {
480 const { Box, Text } = K
481 return (
482 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={width}>
483 <Text bold color={color}>
484 {title}
485 </Text>
486 {body}
487 </Box>
488 )
489}
490
491function segmented<T extends string>(
492 K: Kit,
493 key: string,
494 options: readonly { value: T; label: string }[],
495 current: T,
496 onPick: (value: T) => void,
497): RenderChildren {
498 const { Box, Button } = K
499 return (
500 <Box flexDirection="row" gap={1} flexWrap="wrap">
501 {options.map(o => (
502 <Button key={`${key}:${o.value}`} label={o.label} {...sel(o.value === current)} onPress={() => onPick(o.value)} />
503 ))}
504 </Box>
505 )
506}
507
508function settingRow(K: Kit, label: string, hint: string, control: RenderChildren): RenderChildren {
509 const { Box, Text } = K
510 return (
511 <Box flexDirection="column" marginTop={1}>
512 <Box flexDirection="row" gap={1}>
513 <Text bold>{label}</Text>
514 <Text dimColor>{hint}</Text>
515 </Box>
516 {control}
517 </Box>
518 )
519}
520
521// ── effort control (band, pane, settings) ────────────────────────────────────
522
523function effortControl($: $, K: Kit, d: Pick<Data, 'effort'>, isCompact: boolean): RenderChildren {
524 const { Box, Text, Button } = K
525 const level = effortOf(d)
526 const at = level === null ? -1 : LEVELS.indexOf(level)
527 const color = level === null ? 'subtle' : LEVEL_COLOR[level]
528 return (
529 <Box flexDirection="row" gap={1}>
530 <Text color={color} bold>
531 ⚡
532 </Text>
533 <Button key="effort-down" plain action="chat:decreaseEffort" onPress={() => void nudge($, d, -1)}>
534 <Text dimColor>◀</Text>
535 </Button>
536 <Box flexDirection="row">
537 {LEVELS.map((lv, i) => (
538 <Button key={`effort-${lv}`} plain onPress={() => void setEffort($, lv, 'clicked')}>
539 <Text color={i <= at ? LEVEL_COLOR[lv] : 'subtle'} dimColor={i > at}>
540 {i <= at ? '▰' : '▱'}
541 </Text>
542 </Button>
543 ))}
544 </Box>
545 <Button key="effort-up" plain action="chat:increaseEffort" onPress={() => void nudge($, d, 1)}>
546 <Text dimColor>▶</Text>
547 </Button>
548 <Text color={color} bold>
549 {level === null ? 'Default' : LEVEL_LABEL[level]}
550 </Text>
551 {d.effort.override !== null ? <Text dimColor>(this session)</Text> : null}
552 {isCompact ? null : <Text dimColor>⌥E up · ⌥⇧E down</Text>}
553 </Box>
554 )
555}
556
557function nudge($: $, d: Pick<Data, 'effort'>, dir: 1 | -1): Promise<unknown> {
558 const now = effortOf(d) ?? 'high'
559 const at = LEVELS.indexOf(now)
560 const next = LEVELS[Math.max(0, Math.min(LEVELS.length - 1, at + dir))] ?? now
561 return setEffort($, next, dir > 0 ? '⌥E' : '⌥⇧E')
562}
563
564// ── band above the prompt ────────────────────────────────────────────────────
565
566function drawBand($: $, K: Kit, d: Data): RenderChildren {
567 const { Box, Text, Button } = K
568 const running = d.agents.filter(a => a.status === 'running')
569 const done = d.agents.filter(a => a.status === 'done').length
570 const tok = tokensOf(d.turns)
571 const isWide = d.width >= 110
572 const activity = d.main.phase === 'working' ? d.main.activity : null
573 return (
574 <Box flexDirection="row" justifyContent="space-between" width={d.width}>
575 <Box flexDirection="row" gap={1}>
576 <Button key="band-open" plain onPress={() => void openFromBand($)}>
577 <Text color="claude" bold>
578 ✈ Cockpit
579 </Text>
580 </Button>
581 <Text dimColor>│</Text>
582 <Text dimColor>main</Text>
583 {mainChip(K, d.main, d.now)}
584 {activity !== null && isWide ? <Text dimColor>{clip(`${activity.tool} ${activity.detail}`, 28)}</Text> : null}
585 <Text dimColor>│</Text>
586 <Text dimColor>⑂</Text>
587 {running.length > 0 ? (
588 <Text color="warning" bold>
589 {running.length} running
590 </Text>
591 ) : (
592 <Text dimColor>0 running</Text>
593 )}
594 <Text color="success">{done} done</Text>
595 {isWide ? <Text dimColor>│</Text> : null}
596 {isWide ? <Text color="suggestion">Σ {fmtTok(tok.total)}</Text> : null}
597 {isWide && d.usage.contextPct !== null ? <Text dimColor>ctx {d.usage.contextPct}%</Text> : null}
598 </Box>
599 {effortControl($, K, d, !isWide)}
600 </Box>
601 )
602}
603
604async function openFromBand($: $): Promise<void> {
605 await $.ui.open({ id: 'cockpit', title: '✈ Cockpit' })
606}
607
608// ── the pane ─────────────────────────────────────────────────────────────────
609
610function drawPane($: $, K: Kit, d: Data, surface: string): RenderChildren {
611 const { Box, Text, Button } = K
612 const tok = tokensOf(d.turns)
613 const level = effortOf(d)
614 const body = (() => {
615 switch (d.view.tab) {
616 case 'overview':
617 return overview($, K, d)
618 case 'agents':
619 return agentsTab($, K, d)
620 case 'usage':
621 return usageTab(K, d)
622 case 'git':
623 return gitTab($, K, d)
624 case 'logs':
625 return logsTab($, K, d)
626 case 'terminal':
627 return terminalTab($, K, d, surface)
628 case 'settings':
629 return settingsTab($, K, d)
630 }
631 })()
632 return (
633 <Box flexDirection="column" width={d.width}>
634 <Box flexDirection="row" justifyContent="space-between">
635 <Box flexDirection="row" gap={1}>
636 <Text color="claude" bold>
637 ✈ COCKPIT
638 </Text>
639 <Text dimColor>{d.main.model || 'model —'}</Text>
640 <Text dimColor>·</Text>
641 <Text color={level === null ? 'subtle' : LEVEL_COLOR[level]}>⚡ {level === null ? 'default' : LEVEL_LABEL[level]}</Text>
642 </Box>
643 <Box flexDirection="row" gap={1}>
644 <Text color="suggestion">Σ {fmtTok(tok.total)}</Text>
645 <Text dimColor>·</Text>
646 <Text color="success">{fmtUsd(d.usage.costUsd)}</Text>
647 </Box>
648 </Box>
649 <Box flexDirection="row" gap={1} flexWrap="wrap" marginTop={1}>
650 {TABS.map(t => (
651 <Button
652 key={`tab-${t.tab}`}
653 label={d.width < 70 ? t.label.slice(0, 4) : t.label}
654 hotkey={t.hotkey}
655 {...sel(t.tab === d.view.tab)}
656 onPress={() => void setTab($, t.tab)}
657 />
658 ))}
659 </Box>
660 {body}
661 </Box>
662 )
663}
664
665// ── overview ─────────────────────────────────────────────────────────────────
666
667function overview($: $, K: Kit, d: Data): RenderChildren {
668 const { Box, Text } = K
669 const perRow = d.width >= 112 ? 4 : d.width >= 56 ? 2 : 1
670 const cw = Math.floor((d.width - (perRow - 1)) / perRow)
671 const inner = Math.max(8, cw - 4)
672 const running = d.agents.filter(a => a.status === 'running')
673 const done = d.agents.filter(a => a.status === 'done').length
674 const failed = d.agents.filter(a => a.status === 'failed' || a.status === 'aborted').length
675 const all = Math.max(1, d.agents.length)
676 const tok = tokensOf(d.turns)
677 const main = d.main
678 const recent = d.logs.slice(-Math.max(4, Math.min(10, d.rows - 22))).reverse()
679
680 const mainCard = card(
681 K,
682 'MAIN AGENT',
683 main.phase === 'working' ? 'warning' : main.phase === 'error' ? 'error' : 'success',
684 cw,
685 [
686 mainChip(K, main, d.now),
687 <Text dimColor>
688 {main.tools} tools · {main.errors} errors · {main.turns} turns
689 </Text>,
690 main.activity !== null ? (
691 <Text wrap="truncate-end">
692 <Text color="suggestion">→ {main.activity.tool}</Text> <Text dimColor>{main.activity.detail}</Text>
693 </Text>
694 ) : (
695 <Text dimColor>{main.phase === 'working' ? 'thinking…' : 'waiting for you'}</Text>
696 ),
697 ],
698 )
699
700 const subCard = card(K, 'SUBAGENTS', running.length > 0 ? 'warning' : 'merged', cw, [
701 <Text>
702 <Text color="warning" bold>
703 {running.length}
704 </Text>
705 <Text dimColor> running · </Text>
706 <Text color="success">{done}</Text>
707 <Text dimColor> done · </Text>
708 <Text color="error">{failed}</Text>
709 <Text dimColor> failed</Text>
710 </Text>,
711 <Text>
712 <Text color="success">{'█'.repeat(Math.round((done / all) * inner))}</Text>
713 <Text color="warning">{'█'.repeat(Math.round((running.length / all) * inner))}</Text>
714 <Text color="error">{'█'.repeat(Math.round((failed / all) * inner))}</Text>
715 <Text dimColor>{d.agents.length === 0 ? '░'.repeat(inner) : ''}</Text>
716 </Text>,
717 running.length > 0 ? (
718 <Text wrap="truncate-end">
719 <Text color="merged">{running[0]?.type}</Text>{' '}
720 <Text dimColor>{running[0]?.activity ? `${running[0].activity.tool} ${running[0].activity.detail}` : running[0]?.description}</Text>
721 </Text>
722 ) : (
723 <Text dimColor>{d.agents.length === 0 ? 'none spawned yet' : 'all finished'}</Text>
724 ),
725 ])
726
727 const tokCard = card(K, 'TOKENS', 'suggestion', cw, [
728 <Text>
729 <Text color="suggestion" bold>
730 {fmtTok(tok.total)}
731 </Text>
732 <Text dimColor> total · </Text>
733 <Text color="success">{fmtUsd(d.usage.costUsd)}</Text>
734 </Text>,
735 <Text dimColor>
736 in {fmtTok(tok.input)} · out {fmtTok(tok.output)} · cache {fmtTok(tok.cacheRead + tok.cacheWrite)}
737 </Text>,
738 <Text color="suggestion">{spark(d.turns.map(turnTotal), inner) || '·'}</Text>,
739 ])
740
741 const pct = d.usage.contextPct ?? 0
742 const ctxColor = pct >= 85 ? 'error' : pct >= 60 ? 'warning' : 'success'
743 const ctxCard = card(K, 'CONTEXT', ctxColor, cw, [
744 <Text>
745 <Text color={ctxColor} bold>
746 {d.usage.contextPct === null ? '—' : `${pct}%`}
747 </Text>
748 <Text dimColor>
749 {' '}
750 {d.usage.contextTokens !== null ? `${fmtTok(d.usage.contextTokens)} / ${fmtTok(d.usage.window)}` : 'no reading yet'}
751 </Text>
752 </Text>,
753 <Text>{meter(K, pct / 100, inner, ctxColor)}</Text>,
754 d.usage.limits[0] !== undefined ? (
755 <Text dimColor>
756 {d.usage.limits[0].kind.replace('_', ' ')} limit {d.usage.limits[0].pct}%
757 </Text>
758 ) : (
759 <Text dimColor>rate limits: —</Text>
760 ),
761 ])
762
763 return (
764 <Box flexDirection="column">
765 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
766 {mainCard}
767 {subCard}
768 {tokCard}
769 {ctxCard}
770 </Box>
771 {section(K, 'Effort')}
772 {effortControl($, K, d, false)}
773 {section(K, 'Live activity')}
774 {recent.length === 0 ? <Text dimColor>Nothing yet. Send a prompt and watch this fill up.</Text> : null}
775 {recent.map(entry => logLine(K, entry, d.width, false))}
776 </Box>
777 )
778}
779
780// ── agents (fleet view) ──────────────────────────────────────────────────────
781
782function ordered(agents: readonly AgentRec[]): { a: AgentRec; depth: number; isLast: boolean }[] {
783 const out: { a: AgentRec; depth: number; isLast: boolean }[] = []
784 const known = new Set(agents.map(a => a.id))
785 const walk = (parent: string | null, depth: number) => {
786 const kids = agents.filter(a => (a.parentId !== null && known.has(a.parentId) ? a.parentId : null) === parent)
787 kids.forEach((a, i) => {
788 out.push({ a, depth, isLast: i === kids.length - 1 })
789 walk(a.id, depth + 1)
790 })
791 }
792 walk(null, 0)
793 return out
794}
795
796function agentsTab($: $, K: Kit, d: Data): RenderChildren {
797 const { Box, Text, Button } = K
798 const W = d.width
799 const rows = ordered(d.agents).slice(-30)
800 const colTools = 6
801 const colTok = 8
802 const colTime = 8
803 const colName = Math.max(14, Math.min(26, Math.floor(W * 0.22)))
804 const colNow = Math.max(10, W - colName - colTools - colTok - colTime - 8)
805 const mainTok = tokensOf(d.turns.filter(t => t.agentId === null))
806 const selected = d.agents.find(a => a.id === d.view.agentSel) ?? null
807
808 const header = (
809 <Text dimColor>
810 {' '}
811 {pad('AGENT', colName)} {pad('NOW', colNow)} {pad('TOOLS', colTools)}
812 {pad('TOKENS', colTok)}
813 {pad('TIME', colTime)}
814 </Text>
815 )
816
817 const mainRow = (
818 <Text wrap="truncate-end">
819 {mainChip(K, d.main, d.now)} <Text bold>main</Text>{' '}
820 <Text dimColor>{d.main.activity ? clip(`${d.main.activity.tool} ${d.main.activity.detail}`, colNow + colName - 16) : ''}</Text>
821 <Text dimColor>
822 {' '}
823 · {d.main.tools} tools · {fmtTok(mainTok.input + mainTok.output)} tok
824 </Text>
825 </Text>
826 )
827
828 const fleet = rows.map(({ a, depth, isLast }, i) => {
829 const color = colorFor(d.agents.indexOf(a))
830 const branch = `${' '.repeat(depth)}${isLast ? '└─' : '├─'} `
831 const now = a.activity ? `${a.activity.tool} ${a.activity.detail}` : a.status === 'running' ? 'thinking…' : a.description
832 const time = fmtDur((a.endedAt || d.now) - a.startedAt)
833 return (
834 <Button key={`agent-${a.id}`} plain onPress={() => void selectAgent($, a.id)}>
835 <Text dimColor>{branch}</Text>
836 {agentGlyph(K, a, d.now)}
837 <Text color={color} bold={a.id === d.view.agentSel}>
838 {' '}
839 {pad(a.type, Math.max(4, colName - branch.length - 2))}
840 </Text>{' '}
841 <Text dimColor={a.status !== 'running'}>{pad(now, colNow)}</Text> <Text>{pad(String(a.tools), colTools)}</Text>
842 <Text color="suggestion">{pad(fmtTok(a.tokensIn + a.tokensOut), colTok)}</Text>
843 <Text dimColor>{pad(time, colTime)}</Text>
844 {i < 0 ? null : null}
845 </Button>
846 )
847 })
848
849 return (
850 <Box flexDirection="column">
851 {section(K, `Fleet · ${d.agents.length} subagents`, <Text dimColor>click a row for details</Text>)}
852 {header}
853 {mainRow}
854 {fleet.length === 0 ? <Text dimColor> No subagents yet. They appear here, live, as soon as one is spawned.</Text> : fleet}
855 {section(K, 'Timeline')}
856 {timeline(K, d)}
857 {d.agents.length > 0 ? section(K, 'Work by subagent') : null}
858 {d.agents.length > 0 ? workCharts(K, d) : null}
859 {selected !== null ? agentDetail($, K, d, selected) : null}
860 </Box>
861 )
862}
863
864async function selectAgent($: $, id: string): Promise<void> {
865 await update($, viewA, v => ({ ...v, agentSel: v.agentSel === id ? null : id }))
866}
867
868function lane(hits: readonly number[], from: number, to: number, start: number, end: number, width: number): string {
869 const span = Math.max(1, to - from)
870 let out = ''
871 for (let i = 0; i < width; i += 1) {
872 const a = from + (span * i) / width
873 const b = from + (span * (i + 1)) / width
874 const isAlive = start <= b && (end === 0 || end >= a)
875 const n = hits.filter(t => t >= a && t < b).length
876 out += n === 0 ? (isAlive ? '─' : ' ') : n === 1 ? '▪' : n < 4 ? '■' : '█'
877 }
878 return out
879}
880
881function timeline(K: Kit, d: Data): RenderChildren {
882 const { Box, Text } = K
883 const labelW = 14
884 const W = Math.max(10, d.width - labelW - 2)
885 const starts = [d.main.startedAt, ...d.agents.map(a => a.startedAt)].filter(t => t > 0)
886 const to = d.now
887 const from = starts.length === 0 ? to - 60_000 : Math.max(Math.min(...starts) - 1000, to - 30 * 60_000)
888 const lanes = [
889 {
890 label: 'main',
891 color: 'claude',
892 text: lane(d.main.hits, from, to, d.main.startedAt || to, d.main.phase === 'working' ? 0 : d.main.endedAt || to, W),
893 },
894 ...d.agents.slice(-8).map(a => ({
895 label: a.type,
896 color: colorFor(d.agents.indexOf(a)),
897 text: lane(a.hits, from, to, a.startedAt, a.endedAt, W),
898 })),
899 ]
900 return (
901 <Box flexDirection="column">
902 {lanes.map(l => (
903 <Text>
904 <Text color={l.color}>{pad(l.label, labelW)}</Text> <Text color={l.color}>{l.text}</Text>
905 </Text>
906 ))}
907 <Text dimColor>
908 {' '.repeat(labelW + 1)}
909 {pad(`◂ ${fmtDur(to - from)} ago`, W - 4)}now ▸
910 </Text>
911 <Text dimColor>
912 {' '.repeat(labelW + 1)}─ alive ▪ 1 tool ■ 2-3 █ 4+ per slot
913 </Text>
914 </Box>
915 )
916}
917
918function hbars(K: Kit, title: string, items: { label: string; value: number; color: string; text: string }[], width: number): RenderChildren {
919 const { Box, Text } = K
920 const labelW = Math.min(14, Math.max(6, ...items.map(i => i.label.length)))
921 const barW = Math.max(4, width - labelW - 10)
922 const max = Math.max(1, ...items.map(i => i.value))
923 return (
924 <Box flexDirection="column" width={width}>
925 <Text bold>{title}</Text>
926 {items.map(i => (
927 <Text>
928 <Text color={i.color}>{pad(i.label, labelW)}</Text> {meter(K, i.value / max, barW, i.color)} <Text dimColor>{i.text}</Text>
929 </Text>
930 ))}
931 </Box>
932 )
933}
934
935function workCharts(K: Kit, d: Data): RenderChildren {
936 const { Box } = K
937 const list = d.agents.slice(-8)
938 const perRow = d.width >= 120 ? 3 : d.width >= 80 ? 2 : 1
939 const w = Math.floor((d.width - (perRow - 1) * 2) / perRow)
940 const item = (a: AgentRec, value: number, text: string) => ({
941 label: a.type,
942 value,
943 color: colorFor(d.agents.indexOf(a)),
944 text,
945 })
946 return (
947 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
948 {hbars(K, 'Tool calls', list.map(a => item(a, a.tools, String(a.tools))), w)}
949 {hbars(K, 'Tokens', list.map(a => item(a, a.tokensIn + a.tokensOut, fmtTok(a.tokensIn + a.tokensOut))), w)}
950 {hbars(
951 K,
952 'Duration',
953 list.map(a => item(a, (a.endedAt || d.now) - a.startedAt, fmtDur((a.endedAt || d.now) - a.startedAt))),
954 w,
955 )}
956 </Box>
957 )
958}
959
960function agentDetail($: $, K: Kit, d: Data, a: AgentRec): RenderChildren {
961 const { Box, Text, Button } = K
962 const color = colorFor(d.agents.indexOf(a))
963 const mix = Object.entries(a.toolMix)
964 .sort((x, y) => y[1] - x[1])
965 .slice(0, 6)
966 const recent = d.logs.filter(l => l.agent === a.id).slice(-8).reverse()
967 return (
968 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} marginTop={1}>
969 <Box flexDirection="row" justifyContent="space-between">
970 <Text>
971 <Text color={color} bold>
972 {a.type}
973 </Text>
974 <Text dimColor> · {a.model || 'model —'} · {a.status} · {a.id.slice(0, 12)}</Text>
975 </Text>
976 <Button key="agent-close" label="Close" dimColor onPress={() => void selectAgent($, a.id)} />
977 </Box>
978 <Text wrap="wrap">{a.description}</Text>
979 <Text dimColor>
980 {a.tools} tools · {a.errors} errors · in {fmtTok(a.tokensIn)} · out {fmtTok(a.tokensOut)} · {fmtDur((a.endedAt || d.now) - a.startedAt)}
981 </Text>
982 {mix.length > 0
983 ? hbars(
984 K,
985 'Tool mix',
986 mix.map(([tool, n]) => ({ label: tool, value: n, color, text: String(n) })),
987 Math.min(60, d.width - 4),
988 )
989 : null}
990 {recent.length > 0 ? <Text bold>Recent</Text> : null}
991 {recent.map(entry => logLine(K, entry, d.width - 4, false))}
992 </Box>
993 )
994}
995
996// ── usage ────────────────────────────────────────────────────────────────────
997
998function drawUsageChart(K: Kit, d: Pick<Data, 'turns' | 'usage' | 'agents' | 'width'>, height: number): RenderChildren {
999 const { Box, Text } = K
1000 const axisW = 7
1001 const n = Math.max(1, Math.min(d.turns.length, d.width - axisW - 2))
1002 const shown = d.turns.slice(-n)
1003 const values = shown.map(turnTotal)
1004 const grid = columns(values, height)
1005 const max = Math.max(0, ...values)
1006 return (
1007 <Box flexDirection="column">
1008 {grid.map((row, r) => (
1009 <Text>
1010 <Text dimColor>{pad(r === 0 ? fmtTok(max) : r === height - 1 ? '0' : '', axisW - 1)}┤</Text>
1011 {row.map((ch, i) => (
1012 <Text color={shown[i]?.agentId === null ? 'claude' : 'merged'}>{ch}</Text>
1013 ))}
1014 </Text>
1015 ))}
1016 <Text dimColor>
1017 {' '.repeat(axisW - 1)}└{'─'.repeat(Math.max(0, shown.length))}
1018 </Text>
1019 <Text>
1020 {' '.repeat(axisW)}
1021 <Text color="claude">█ main</Text> <Text color="merged">█ subagents</Text>
1022 <Text dimColor> · last {shown.length} turns</Text>
1023 </Text>
1024 </Box>
1025 )
1026}
1027
1028function usageTab(K: Kit, d: Data): RenderChildren {
1029 const { Box, Text } = K
1030 const tok = tokensOf(d.turns)
1031 const mainTok = tokensOf(d.turns.filter(t => t.agentId === null))
1032 const subTok = tokensOf(d.turns.filter(t => t.agentId !== null))
1033 const barW = Math.max(10, d.width - 32)
1034 const byModel = new Map<string, number>()
1035 for (const t of d.turns) byModel.set(t.model || 'unknown', (byModel.get(t.model || 'unknown') ?? 0) + turnTotal(t))
1036 const kpi = (label: string, value: string, color: string) => (
1037 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} minWidth={14}>
1038 <Text dimColor>{label}</Text>
1039 <Text color={color} bold>
1040 {value}
1041 </Text>
1042 </Box>
1043 )
1044 const pct = d.usage.contextPct ?? 0
1045 const ctxColor = pct >= 85 ? 'error' : pct >= 60 ? 'warning' : 'success'
1046 return (
1047 <Box flexDirection="column">
1048 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
1049 {kpi('Total tokens', fmtTok(tok.total), 'suggestion')}
1050 {kpi('Input', fmtTok(tok.input), 'claude')}
1051 {kpi('Output', fmtTok(tok.output), 'merged')}
1052 {kpi('Cache read', fmtTok(tok.cacheRead), 'success')}
1053 {kpi('Cache write', fmtTok(tok.cacheWrite), 'warning')}
1054 {kpi('Cost', fmtUsd(d.usage.costUsd), 'success')}
1055 </Box>
1056 {section(K, 'Context window')}
1057 <Text>
1058 {meter(K, pct / 100, barW, ctxColor)}{' '}
1059 <Text color={ctxColor} bold>
1060 {d.usage.contextPct === null ? '—' : `${pct}%`}
1061 </Text>
1062 <Text dimColor>
1063 {' '}
1064 {d.usage.contextTokens !== null ? `${fmtTok(d.usage.contextTokens)} of ${fmtTok(d.usage.window)}` : ''}
1065 </Text>
1066 </Text>
1067 {section(K, 'Plan limits')}
1068 {d.usage.limits.length === 0 ? <Text dimColor>No rate-limit reading yet (shown on subscription plans after the first reply).</Text> : null}
1069 {d.usage.limits.map(l => {
1070 const c = l.pct >= 90 ? 'error' : l.pct >= 70 ? 'warning' : 'suggestion'
1071 return (
1072 <Text>
1073 <Text>{pad(l.kind.replace('_', ' '), 12)}</Text> {meter(K, l.pct / 100, barW - 6, c)}{' '}
1074 <Text color={c} bold>
1075 {l.pct}%
1076 </Text>
1077 <Text dimColor>{l.resetsAt !== null ? ` resets ${l.resetsAt.replace('T', ' ').slice(5, 16)}` : ''}</Text>
1078 </Text>
1079 )
1080 })}
1081 {section(K, 'Tokens per turn')}
1082 {d.turns.length === 0 ? <Text dimColor>No finished turns yet.</Text> : drawUsageChart(K, d, 7)}
1083 {section(K, 'Split')}
1084 <Text>
1085 <Text>{pad('main', 12)}</Text> {meter(K, mainTok.total / Math.max(1, tok.total), barW - 6, 'claude')}{' '}
1086 <Text dimColor>{fmtTok(mainTok.total)}</Text>
1087 </Text>
1088 <Text>
1089 <Text>{pad('subagents', 12)}</Text> {meter(K, subTok.total / Math.max(1, tok.total), barW - 6, 'merged')}{' '}
1090 <Text dimColor>{fmtTok(subTok.total)}</Text>
1091 </Text>
1092 {[...byModel.entries()].map(([model, n], i) => (
1093 <Text>
1094 <Text>{pad(model.replace(/^claude-/, ''), 12)}</Text> {meter(K, n / Math.max(1, tok.total), barW - 6, colorFor(i + 1))}{' '}
1095 <Text dimColor>{fmtTok(n)}</Text>
1096 </Text>
1097 ))}
1098 </Box>
1099 )
1100}
1101
1102// ── git graph ────────────────────────────────────────────────────────────────
1103
1104function graphLine(K: Kit, line: string, width: number): RenderChildren {
1105 const { Text } = K
1106 const at = line.indexOf('\u001f')
1107 const head = at < 0 ? line : line.slice(0, at)
1108 const m = /^([*|\\/ _.\-]*?)([0-9a-f]{6,})?$/.exec(head)
1109 const graph = m?.[1] ?? head
1110 const hash = m?.[2] ?? ''
1111 const glyphs = [...graph].map((ch, i) => (
1112 <Text color={ch === '*' ? 'claude' : colorFor(Math.floor(i / 2) + 1)} bold={ch === '*'}>
1113 {ch === '*' ? '●' : ch}
1114 </Text>
1115 ))
1116 if (at < 0) return <Text>{glyphs}</Text>
1117 const [, refs = '', subject = '', author = '', when = ''] = line.split('\u001f')
1118 const room = Math.max(10, width - graph.length - hash.length - refs.length - author.length - when.length - 10)
1119 return (
1120 <Text wrap="truncate-end">
1121 {glyphs}
1122 <Text color="warning">{hash}</Text>
1123 {refs !== '' ? (
1124 <Text color="success" bold>
1125 {' '}
1126 ({refs})
1127 </Text>
1128 ) : null}{' '}
1129 <Text>{clip(subject, room)}</Text>
1130 <Text dimColor>
1131 {' '}
1132 · {author} · {when}
1133 </Text>
1134 </Text>
1135 )
1136}
1137
1138function gitTab($: $, K: Kit, d: Data): RenderChildren {
1139 const { Box, Text, Button } = K
1140 const g = d.git
1141 const room = Math.max(8, d.rows - 12)
1142 return (
1143 <Box flexDirection="column">
1144 <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
1145 <Box flexDirection="row" gap={1}>
1146 <Text color="success" bold>
1147 ⎇ {g.branch || '—'}
1148 </Text>
1149 <Text color={g.ahead > 0 ? 'suggestion' : 'subtle'}>↑{g.ahead}</Text>
1150 <Text color={g.behind > 0 ? 'warning' : 'subtle'}>↓{g.behind}</Text>
1151 <Text color={g.changed > 0 ? 'warning' : 'success'}>
1152 {g.changed > 0 ? `● ${g.changed} changed` : '✓ clean'}
1153 </Text>
1154 <Text dimColor>{g.at > 0 ? `refreshed ${fmtClock(g.at)}` : ''}</Text>
1155 </Box>
1156 <Button key="git-refresh" label="Refresh" hotkey="r" onPress={() => void refreshGit($)} />
1157 </Box>
1158 {g.error !== null ? <Text color="error">{g.error}</Text> : null}
1159 {g.at === 0 && g.error === null ? <Text dimColor>Loading the graph…</Text> : null}
1160 <Box flexDirection="column" marginTop={1}>
1161 {g.graph.slice(0, room).map(line => graphLine(K, line, d.width))}
1162 </Box>
1163 {g.graph.length > room ? <Text dimColor>… {g.graph.length - room} more lines (make the pane taller to see them)</Text> : null}
1164 </Box>
1165 )
1166}
1167
1168// ── session logs ─────────────────────────────────────────────────────────────
1169
1170function logLine(K: Kit, entry: LogEntry, width: number, isExpanded: boolean): RenderChildren {
1171 const { Text } = K
1172 const room = Math.max(10, width - 30 - entry.title.length)
1173 return (
1174 <Text wrap="truncate-end">
1175 <Text dimColor>{fmtClock(entry.at)} </Text>
1176 <Text color={KIND_COLOR[entry.kind]} bold>
1177 {pad(KIND_LABEL[entry.kind], 6)}
1178 </Text>{' '}
1179 <Text color={entry.agent === null ? 'claude' : 'merged'}>{pad(entry.who, 10)}</Text> <Text>{entry.title}</Text>
1180 {entry.detail !== '' && !isExpanded ? <Text dimColor> {clip(entry.detail.replace(/\s+/g, ' '), room)}</Text> : null}
1181 </Text>
1182 )
1183}
1184
1185const FILTERS: readonly { value: LogFilter; label: string }[] = [
1186 { value: 'all', label: 'All' },
1187 { value: 'prompt', label: 'Prompts' },
1188 { value: 'tool', label: 'Tools' },
1189 { value: 'agent', label: 'Agents' },
1190 { value: 'turn', label: 'Turns' },
1191 { value: 'error', label: 'Errors' },
1192 { value: 'notify', label: 'Alerts' },
1193]
1194
1195function logsTab($: $, K: Kit, d: Data): RenderChildren {
1196 const { Box, Text, Button } = K
1197 const f = d.view.logFilter
1198 const matches = d.logs.filter(l => f === 'all' || l.kind === f).reverse()
1199 const pageSize = Math.max(6, d.rows - 12)
1200 const pages = Math.max(1, Math.ceil(matches.length / pageSize))hooks/lib.ts 158 lines1import type { Effort, LogKind } from '../types'
2
3export const LEVELS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
4
5export const LEVEL_LABEL: Record<Effort, string> = {
6 low: 'Low',
7 medium: 'Medium',
8 high: 'High',
9 xhigh: 'X-High',
10 max: 'Max',
11}
12
13export const LEVEL_COLOR: Record<Effort, string> = {
14 low: 'suggestion',
15 medium: 'success',
16 high: 'warning',
17 xhigh: 'claude',
18 max: 'error',
19}
20
21export const KIND_COLOR: Record<LogKind, string> = {
22 prompt: 'claude',
23 tool: 'suggestion',
24 error: 'error',
25 agent: 'merged',
26 turn: 'success',
27 notify: 'warning',
28 system: 'subtle',
29}
30
31export const KIND_LABEL: Record<LogKind, string> = {
32 prompt: 'PROMPT',
33 tool: 'TOOL',
34 error: 'ERROR',
35 agent: 'AGENT',
36 turn: 'TURN',
37 notify: 'NOTIFY',
38 system: 'SYSTEM',
39}
40
41export const PALETTE = ['claude', 'suggestion', 'merged', 'success', 'warning', 'autoAccept', 'planMode', 'ide'] as const
42
43export const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'] as const
46const RISE = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
47
48export function isEffort(value: unknown): value is Effort {
49 return typeof value === 'string' && (LEVELS as readonly string[]).includes(value)
50}
51
52export function stepEffort(current: Effort, dir: 1 | -1): Effort {
53 const at = LEVELS.indexOf(current)
54 return LEVELS[Math.max(0, Math.min(LEVELS.length - 1, at + dir))] ?? current
55}
56
57export function fmtTok(n: number): string {
58 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
59 if (n >= 1000) return `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k`
60 return String(Math.round(n))
61}
62
63export function fmtDur(ms: number): string {
64 const s = Math.max(0, Math.round(ms / 1000))
65 if (s < 60) return `${s}s`
66 const m = Math.floor(s / 60)
67 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
68 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
69}
70
71export function fmtClock(at: number): string {
72 const d = new Date(at)
73 const two = (n: number) => String(n).padStart(2, '0')
74 return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
75}
76
77export function fmtUsd(usd: number | null): string {
78 if (usd === null) return '—'
79 return usd < 1 ? `$${usd.toFixed(3)}` : `$${usd.toFixed(2)}`
80}
81
82export function oneLine(text: string): string {
83 return text.replace(/\s+/g, ' ').trim()
84}
85
86export function clip(text: string, max: number): string {
87 return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
88}
89
90export function pad(text: string, width: number): string {
91 const cut = clip(text, width)
92 return cut + ' '.repeat(Math.max(0, width - cut.length))
93}
94
95/** A horizontal bar split in the filled part and the track, so each is colored apart. */
96export function bar(frac: number, width: number): { fill: string; track: string } {
97 const w = Math.max(1, width)
98 const eighths = Math.round(Math.max(0, Math.min(1, frac)) * w * 8)
99 const full = Math.floor(eighths / 8)
100 const part = EIGHTHS[eighths % 8] ?? ''
101 const fill = '█'.repeat(full) + part
102 return { fill, track: '░'.repeat(Math.max(0, w - full - (part ? 1 : 0))) }
103}
104
105export function spark(values: readonly number[], width: number): string {
106 const vals = values.slice(-width)
107 const max = Math.max(1, ...vals)
108 return vals.map(v => RISE[Math.max(v > 0 ? 1 : 0, Math.round((v / max) * 8))] ?? ' ').join('')
109}
110
111/** Column chart cells, top row first: each row a char per value. */
112export function columns(values: readonly number[], height: number): string[][] {
113 const max = Math.max(1, ...values)
114 const rows: string[][] = []
115 for (let r = 0; r < height; r += 1) {
116 const floor = (height - 1 - r) * 8
117 rows.push(
118 values.map(v => {
119 const level = Math.round((v / max) * height * 8) - floor
120 if (v > 0 && r === height - 1 && level < 1) return '▁'
121 return RISE[Math.max(0, Math.min(8, level))] ?? ' '
122 }),
123 )
124 }
125 return rows
126}
127
128const DETAIL_KEYS = [
129 'command',
130 'file_path',
131 'notebook_path',
132 'pattern',
133 'url',
134 'query',
135 'description',
136 'skill',
137 'path',
138 'prompt',
139] as const
140
141export function detailOf(input: unknown): string {
142 if (typeof input !== 'object' || input === null) return ''
143 const record = input as Record<string, unknown>
144 for (const key of DETAIL_KEYS) {
145 const value = record[key]
146 if (typeof value === 'string' && value.trim() !== '') return clip(oneLine(value), 160)
147 }
148 return ''
149}
150
151export function colorFor(index: number): string {
152 return PALETTE[index % PALETTE.length] ?? 'claude'
153}
154
155export function stripAnsi(text: string): string {
156 return text.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\u001b\][^\u0007]*\u0007/g, '')
157}
158types/index.d.ts 138 lines1export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
2export type Phase = 'idle' | 'working' | 'done' | 'aborted' | 'error'
3export type Tab = 'overview' | 'agents' | 'usage' | 'git' | 'logs' | 'terminal' | 'settings'
4export type NotifyMode = 'off' | 'toast' | 'native' | 'both'
5export type LogKind = 'prompt' | 'tool' | 'error' | 'agent' | 'turn' | 'notify' | 'system'
6export type LogFilter = 'all' | LogKind
7export type SubModel = 'inherit' | 'haiku' | 'sonnet' | 'opus'
8
9/** What an agent is doing right now: the tool it is in and a one-line summary of the input. */
10export type Activity = { tool: string; detail: string; since: number }
11
12export type MainStatus = {
13 phase: Phase
14 startedAt: number
15 endedAt: number
16 tools: number
17 errors: number
18 turns: number
19 activity: Activity | null
20 model: string
21 hits: number[]
22}
23
24export type AgentStatusKind = 'running' | 'done' | 'failed' | 'aborted'
25
26export type AgentRec = {
27 id: string
28 type: string
29 description: string
30 model: string
31 parentId: string | null
32 status: AgentStatusKind
33 startedAt: number
34 endedAt: number
35 tools: number
36 errors: number
37 activity: Activity | null
38 tokensIn: number
39 tokensOut: number
40 toolMix: Record<string, number>
41 hits: number[]
42}
43
44export type TurnRec = {
45 at: number
46 durationMs: number
47 agentId: string | null
48 model: string
49 input: number
50 output: number
51 cacheRead: number
52 cacheWrite: number
53}
54
55export type RateLimit = { kind: string; pct: number; resetsAt: string | null }
56
57export type UsageSnap = {
58 startedAt: number
59 contextPct: number | null
60 contextTokens: number | null
61 window: number
62 costUsd: number | null
63 limits: RateLimit[]
64}
65
66export type LogEntry = {
67 id: number
68 at: number
69 kind: LogKind
70 agent: string | null
71 who: string
72 title: string
73 detail: string
74}
75
76/**
77 * `override` is the level the mod applies to each main-loop request, null when the
78 * model's own level stands; `base` is the level the engine asked for before the mod;
79 * `configKey` is the /config row the mod drives instead, when this build has one.
80 */
81export type EffortState = {
82 override: Effort | null
83 base: Effort | null
84 configKey: string | null
85}
86
87export type CockpitSettings = {
88 notifyMain: NotifyMode
89 notifySub: NotifyMode
90 notifyErrors: boolean
91 minSeconds: number
92 subModel: SubModel
93 subEffort: 'inherit' | Effort
94 subBackground: boolean
95 presets: Record<string, boolean>
96 showBand: boolean
97 showStatus: boolean
98}
99
100export type GitSnap = {
101 at: number
102 branch: string
103 ahead: number
104 behind: number
105 changed: number
106 graph: string[]
107 error: string | null
108}
109
110export type TermLine = { kind: 'cmd' | 'out' | 'err' | 'info'; text: string }
111export type TermState = { cwd: string; running: boolean; lines: TermLine[] }
112
113export type ViewState = {
114 tab: Tab
115 logFilter: LogFilter
116 expanded: number | null
117 logPage: number
118 agentSel: string | null
119}
120
121declare module 'claude-code' {
122 interface PluginState {
123 cockpit: {
124 main: MainStatus
125 agents: AgentRec[]
126 turns: TurnRec[]
127 usage: UsageSnap
128 logs: LogEntry[]
129 effort: EffortState
130 settings: CockpitSettings
131 git: GitSnap
132 term: TermState
133 view: ViewState
134 now: number
135 }
136 }
137}
138