SLOPSHOPPER

cockpit

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…

newpanebandrowsguardcommand
v0.1.0MITupdated 2026-10-12Shashwat-19/Claude-cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
│ ┃ ✈ Cockpit ✕ › fix the failing auth test and add an audit lo╭───────────────────╮ │ ┃ ✈ COCKPIT claude-opus-5-5 · ⚡ High Σ 98 │ cockpit │ │ ┃ ⏺ Read(src/auth.ts) │ ⚡ Effort → X-High │ │ ┃ [ Over ] [ Agen ] [ Usag ] [ Git ] [ Logs ] ⎿ Read 6 lines ╰───────────────────╯ │ ┃ ⏺ Update(src/auth.ts) ╭─────────────────╮ │ ┃ ⎇ — ↑0 ↓0 ● 2 changed refreshed 08:53:20 ⎿ Added 2 lines, removed 1 line │ cockpit │ │ ┃ ⏺ Bash(bun test) │ ⚡ Effort → High │ │ ┃ a1b2c3d fix: refresh expired tokens (2 minut ⎿ 3 pass, 1 fail ╰─────────────────╯ │ ┃ 9f8e7d6 feat: add audit log (3 hours ago) │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cockpit │ ⎿ cockpit: Cockpit opened. │ │ band-open │ main ✓ done 0s │ ⑂ 0 running 0 done⚡ effort-down effort-loweffort-mediumeffort-higheffort-xhigheffort-ma ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cockpit: ✓ main done in 0s · 9 tools │ ⑂ no subagents │ ⚡ default │ Σ 98.9k tok · $0.420 · ctx 49%

Draws

Band
band-open │ main ✓ done 0s │ ⑂ 0 running 0 done⚡ effort-down effort-loweffort-mediumeffort-higheffor
Pane · ✈ Cockpit
✈ COCKPIT claude-opus-5-5 · ⚡ High Σ 98.9k · $0.420 [ Over ] [ Agen ] [ Usag ] [ Git ] [ Logs ] [ Term ] [ Sett ⎇ — ↑0 ↓0 ● 2 changed refreshed 08:53:20 [ Refresh ] a1b2c3d fix: refresh expired tokens (2 minutes ago) 9f8e7d6 feat: add audit log (3 hours ago)
README

✈ Cockpit

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

Version License: MIT Claude Code mod TypeScript PRs welcome

Cockpit banner


Contents


Why Cockpit

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:

ProblemCockpit's answer
No visibility into parallel subagentsA live fleet tree showing the exact tool each agent is running right now
Token and cost spend is opaquePer-turn token charts, cost KPIs, context-window and rate-limit bars
Changing effort means typing commandsA clickable gauge plus ⌥E / ⌥⇧E chords
You have to babysit long turnsPop-up and/or system notifications when work finishes, with a minimum-duration threshold
Subagent behaviour is fixedGlobal model, effort and background overrides, plus preset agent types

Features

Status line

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%

Band above the prompt

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 pane

With the pane focused, press 1–7 to switch tabs.

#TabWhat you get
1OverviewCards for the main agent, subagents, tokens and context; effort control; live activity feed
2AgentsFleet 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
3UsageKPI tiles (input, output, cache, cost), context-window bar, plan rate-limit bars, tokens-per-turn column chart (main vs. subagents), split by model
4GitBranch, ahead/behind, changed files, and a coloured git log --graph --all — press r to refresh
5LogsSession log of prompts, tools, agents, turns, errors and alerts, with filter chips, paging, row inspection and Export to JSONL
6TerminalA zsh prompt in the session folder, independent of Claude's turn, with streaming output, Stop, Clear and quick commands
7SettingsNotifications, effort, subagent customisation and display toggles

Notifications

Configured independently for the main agent and for subagents:

  • Mode: Off · Pop-up · System · Both
  • Threshold: only notify for turns longer than 10s, 30s, 1m or 2m
  • Errors: choose whether failures trigger a notification

Where system notifications are unavailable, Cockpit falls back to the in-app pop-up.

Subagent customisation

  • Model override — run every subagent on Haiku, Sonnet or Opus
  • Effort override — applied to every subagent request
  • Always background — keep chatting while subagents work
  • Preset agent types — cockpit:reviewer, cockpit:tester, cockpit:scout (Haiku), each toggleable

Installation

From the marketplace (recommended)

In a Claude Code terminal session:

/plugin install cockpit --marketplace Shashwat-19/claude-cockpit
  1. Answer y to add the marketplace.
  2. Press Enter to install at user scope so Cockpit loads in every session, including the desktop app.

From a local clone

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.

Optional: effort keybindings (⌥E / ⌥⇧E)

Merge examples/keybindings.json into ~/.claude/keybindings.json.

On macOS, your terminal must send Option as Meta:

TerminalSetting
iTerm2Profiles → Keys → Left Option key = Esc+
Terminal.appSettings → Profiles → Keyboard → Use Option as Meta key

Quick start

/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.


Commands

CommandDescription
/cockpit [tab]Open the pane, optionally on a specific tab
/fleetOpen the Agents tab
/usage-chartRender a tokens-per-turn chart inline in the conversation
/session-logOpen the Logs tab
/git-graphOpen the Git tab
/effort-up, /effort-downChange effort level — works everywhere, including the desktop app

Keyboard shortcuts

KeyContextAction
1–7Pane focusedSwitch tab
rGit tabRefresh
⌥E / ⌥⇧ETerminal sessionsEffort up / down

Configuration

All settings live in the Settings tab and persist across sessions via $.store.

SettingOptions
Main-agent notificationsOff · Pop-up · System · Both
Subagent notificationsOff · Pop-up · System · Both
Minimum turn length to notify10s · 30s · 1m · 2m
Notify on errorsOn · Off
EffortModel default, or a fixed level
Subagent modelInherit · Haiku · Sonnet · Opus
Subagent effortInherit, or a fixed level
Always background subagentsOn · Off
Preset agent typesToggle reviewer / tester / scout individually

Architecture

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 map

HookUsed for
turn.start / turn.completeMain/subagent run status, per-turn token usage, notifications
tool.callLive "current tool" per agent, tool counts and errors, timeline entries
agent.spawn / agent.offerSubagent tracking; model / effort / background overrides; preset types
turn.stepApplies the selected effort level to each model request
session.measureContext window, cost and plan rate limits
ui.render (Pane, AbovePrompt, CommandOutput)The pane, the band and the inline usage chart

State model

  • Session state is held by the host in $.state (typed in types/index.d.ts), so a hot reload doesn't wipe it.
  • Settings are saved with $.store and survive across sessions.
  • Pure logic (formatting, text-chart rendering) is isolated in hooks/lib.ts, which keeps it unit-testable and free of $ dependencies.

Repository layout

.
├── .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

Development

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

  • Anything that touches $ goes in hooks/register.tsx; everything else belongs in hooks/lib.ts.
  • New persistent state must be declared in types/index.d.ts first — that file is the contract.
  • Run validate, test and tsc before opening a PR.

Data & privacy

  • Cockpit only observes sessions it is loaded into; it does not read past transcripts.
  • Session logs stay local. Export writes to ~/.claude/cockpit-logs/<session>.jsonl and nothing is sent anywhere else.
  • Exported logs contain your prompts and tool calls — review them before sharing.
  • The Terminal tab runs a real zsh shell in the session folder with your user's permissions. Treat it like any other terminal.

Limitations

  • ⌥E / ⌥⇧E work in terminal sessions only. In the desktop app, use the gauge or /effort-up / /effort-down.
  • Effort is applied per request. If a model rejects a level, select Model default in Settings.
  • Cockpit has no view into sessions it isn't loaded in.

Contributing

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.


License

This project is distributed under the MIT License. For detailed licensing information, please refer to the LICENSE file included in this repository.

Contact

Shashwat

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>


Open Source | Tech Innovation

Building robust applications and leveraging cloud technologies for high-performance solutions.


Find me here:

<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!

Source 3 files
hooks/register.tsx 2039 lines
1import { 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 lines
1import 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}
158
types/index.d.ts 138 lines
1export 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