Live agent, tool, review, context, and activity views for Claude Code.

Cockpit adds five read-only views to Claude Code. It keeps the existing status line, prompts, tool results, and permission dialogs unchanged.
The mod uses the early-access function-hook API from Claude Code 2.1.292. A later engine release can change this API.
| View | Content | Data source |
|---|---|---|
| Agents | Session summary, plan, agent tree, and a detail card for the selected agent with its status, tokens, models, tool mix, todos, recent tools, and last answer | Native agent, turn, and tool events |
| Tools | Totals with approval wait, running calls, the call log with actor filters, a detail card, time per tool, background tasks, and crons | Native tool, permission, and stop events |
| Changes | Branch and totals, file groups by Git state with diffstat bars, the selected diff, checks, review findings, and Git activity | Native edit and shell results, and requested Git reads |
| Context | Fill gauge with the auto-compact mark, headroom and trend, category estimates, a token table, cost and rate limits, largest consumers, and compactions | Local summary and native measurements |
| Activity | Bongo Cat, activity counts and history, a timeline per actor, and a summary of where the time went | Observed tool, agent, and turn activity |
A compact band appears above the prompt. It shows these parts, in priority order:
1/3 Writing tests.✗ npm test 2 failed.When the band is too narrow, each part changes to a short form first. Then the parts with the lowest priority drop out. After a turn stops on an error, the band shows the error in red until the next turn starts. The pane opens only after a command or button press.
The pane header shows the model, the session status, a context meter, the cost, the number of turns, and the session time. Below it, the view tabs show counts: agents and tool calls, with ▸ when some are running, changed files, and the context fill. The active tab has an accent mark under it. A narrow pane drops the counts first, and then puts the tabs on more than one row, in order.
The session summary shows the current status, the turn count and average turn time, failed tool calls, token totals with the cache hit rate, and live agents.
The plan comes from the last TodoWrite result of the main thread and from the task tools (TaskCreate, TaskUpdate, TaskList). The list shows the items near the one in progress. A subagent plan appears in the detail card of that agent.
A completed Agent call reports run totals: tokens, tool uses, duration, changed lines, and models. The last answer of an agent appears as Markdown. Agents without totals show the usage of their last turn. "Show its tools" opens the Tools view, filtered to that agent.
When the transcript in view changes to a subagent, the pane selects that agent. A selection that you make stays until the transcript in view changes again.
The summary shows the total tool time, the number of approval prompts, and the time spent waiting for an answer. "Running now" lists the running calls with the lead call first.
A finished tool shows two durations. "Ran" is the execution time that the engine reports. "Approval" is the remaining time of the call, which includes the permission dialog and hooks. A tool that waits for a permission answer shows a ? mark.
"By tool" ranks the tools by total time, with call and failure counts. Agent calls are counted but get no bar, because their time is the time of the subagent.
Background tasks come from Bash calls with run_in_background, Agent calls with run_in_background, and Monitor calls. The engine snapshot at each stop updates their status. Scheduled crons appear below the background tasks.
Files are grouped as staged, changed, untracked, and edited but not yet read from Git. Each row shows the Git state, the path, the number of observed edits, the number of findings, a diffstat bar, and the line counts. The diff heading names the agent that made the last edit.
Git activity comes from shell results that the engine marks as commits, pushes, branch changes, or pull requests. A pull request with an https URL shows a link.
The gauge scales the category estimates to the live fill and marks the auto-compact threshold. The headroom line shows the tokens left before auto-compact. The engine measures the fill after each model response. The trend line shows the average growth of the last five measurements that grew, and an estimate of the responses left before auto-compact.
The context view lists the last 20 compactions with their trigger, token counts, and reduction. Cost per turn is the difference between consecutive cost measurements of the main thread.
The Activity view replaces the reactor view of earlier versions. The reactor drew an animated orb with an activity history. The orb was decorative, and the other views already showed the counts. The Activity view keeps the phase, the counts, the history, and the pause control, and adds these parts:
The selected view, the band setting, the animation setting, and the timeline zoom persist across sessions in plugin storage. A saved reactor view opens the Activity view. Storage errors go to the debug log and do not block the commands.
The pane does not paint a background. In the terminal dock, the engine fills the pane with the composerSidebarBackground color of the Claude Code theme. A plugin cannot paint the default terminal background over this fill.
All text and border colors are Claude Code theme keys, such as suggestion, success, and inactive. An ANSI theme, such as dark-ansi, draws these keys with the terminal palette.
Hex values and color names are fixed RGB values. Claude Code limits output to 256 colors in tmux unless CLAUDE_CODE_TMUX_TRUECOLOR is set. In that mode, a hex value changes to the nearest xterm color.
This configuration makes the dock match the terminal background:
~/.claude/themes/stylix.json. The theme extends the ANSI theme for the Stylix polarity, and sets composerSidebarBackground to Stylix base00.settings.json selects custom:stylix and sets CLAUDE_CODE_TMUX_TRUECOLOR to 1. Without truecolor, tmux sessions round base00 to a gray xterm color./cockpit
/cockpit agents
/cockpit tools
/cockpit changes
/cockpit context
/cockpit activity
/cockpit band off
/cockpit band on
/cockpit close
/cockpit help
The command can run during a model turn. /cockpit reactor is an alias for /cockpit activity.
/cockpit to open the pane.Ctrl+X Tab to focus the pane.1 through 5 to select a view.Tab to move between controls.Ctrl+X ← to widen it or Ctrl+X → to narrow it.Ctrl+X ↑ to make it taller or Ctrl+X ↓ to make it shorter.Esc to close the pane.The views also have letter keys while the pane has focus:
| Key | Agents | Tools | Changes | Context | Activity |
|---|---|---|---|---|---|
r | Refresh agents | Read Git | Estimate categories | ||
j / k | Next or previous agent | Older or newer call | Next or previous file | ||
t | Show its tools | ||||
p | Copy the file path | ||||
c / q | Copy or quote the patch | ||||
z / p | Zoom, pause or wake the cat |
The pane uses native scrolling and resizing. The cockpit asks for 58 columns in the dock and 24 rows above the prompt. A size that you set wins over that request. The footer shows the resize keys for the current placement, and every view fits its layout to the new size.
History starts when the mod loads. Session memory holds the history. Hot reload keeps that memory, and /clear removes it.
Agent status comes from the engine roster. An external teammate can leave an old status after its terminal closes. Missing entries show an unknown status, not proof that an agent stopped.
Tool durations measure the call, not the full life of a background process. Server-side tools appear retrospectively after the response. General live stdout is not available through passive tool hooks.
No event reports when the person answers a permission dialog. The approval mark therefore stays until the tool finishes, and the band does not show it. "Approval and hooks" is known only after the call ends.
Background status updates only when a turn or subagent stops. A task that ends between stops keeps its last reported status until the next stop.
Check summaries use known Jest, Vitest, pytest, cargo, and Go output formats. Unknown output shows a completed call without an invented pass count. A background check retains its last reported state.
The context summary estimates categories locally. It makes no token-count API request. Live input counts come from the last response and remain unknown before a response or after compaction.
The cost figure is the engine ledger, not a statement of account billing. Category estimates can differ from the live input count.
The cat is braille line art after the original Bongo Cat: the table edge falls to the right, the face tilts with it, and the two bongos stand in front. A raised paw shows its toe beans, and a hit shows impact lines. The cat is 40 columns wide, or 34 or fewer in a narrow pane, and uses theme colors.
The Activity view shows relative observed activity, not an exact running token rate. The activity history records each change in the number of running tools, running agents, and main turns, also while the pane is closed. The cat animates only in the visible Activity view. The pause control stops frame updates.
The timeline knows the start and end of each tool call and main turn. Model time is the part of a turn without a running tool. An agent lane starts when the mod first observes the agent.
While work runs, elapsed times redraw once a second, also while the pane is closed, so the band stays current.
The mod observes existing work. It does not start builds, reviews, browser capture, model requests, or HTTP requests automatically.
Git reads happen only after the changes view or its refresh control is requested. The commands use argument vectors, disable external diff drivers and text conversion, and disable filesystem monitor commands.
The mod does not retain raw prompts, shell command arguments, or shell output. A background shell task shows its description or its executable name, not its command. A cron shows its schedule, not its prompt.
Review patches, finding summaries, todo text, and the last answer of each agent remain in bounded session memory. The mod does not write them to disk. Plugin storage holds only the view, band, animation, and zoom preferences.
Copy path places the path of the selected file on the clipboard, relative to the repository root as the file list shows it. A file outside the repository gives its full path. Copy patch places a patch on the clipboard. Quote patch appends a patch to the draft. Quote patch does not submit the draft or replace existing text.
Observation errors go to the debug log. They do not change tool arguments, results, permission decisions, or streamed chunks. Existing shell hooks remain active.
The tracked source is dotfiles/claude/.claude/plugins/cockpit/. Home Manager links the complete root directly through home.file.".claude/skills/cockpit".source.
The generic managed-plugin wrapper can cache a manifest-only entry for an out-of-store source. The direct link preserves the original manifest, type contract, and hook modules.
The nono profile grants read access to the tracked target. Generated SDK declarations are optional for runtime use. A sandbox can refuse adjacent declaration writes without requiring source-wide write access.
The implementation does not activate Home Manager or rebuild NixOS. The persistent link appears after the usual configuration activation.
claude --plugin-dir "$HOME/.nix-config/dotfiles/claude/.claude/plugins/cockpit"
Interactive development paths support hot reload. Session development mods need the separate hot-reload approval from the person.
claude plugin validate --strict "$HOME/.nix-config/dotfiles/claude/.claude/plugins/cockpit"
claude plugin test "$HOME/.nix-config/dotfiles/claude/.claude/plugins/cockpit"
tsc -p "$HOME/.nix-config/dotfiles/claude/.claude/plugins/cockpit"
The engine generates .claude-plugin/types/ after a normal mod load. The generated declarations describe the current engine and connected tools. Git ignores this directory.
The tests use the native test engine, explicit surfaces, and mocked host operations. They cover descriptions and interactions, not the actual renderer paint.
hooks/register.tsx holds hooks, host operations, literal state references, the band, and the pane header.hooks/state.ts holds initial state factories and fills missing fields in state from an earlier version.hooks/data.ts holds bounded parsers and observation helpers.hooks/summary.ts derives the status, totals, plan, context fill, and band parts from state.hooks/layout.tsx holds shared drawing blocks: lines, headings, fields, cards, actions, and tabs.hooks/agents.tsx, hooks/tools.tsx, hooks/changes.tsx, hooks/context.tsx, and hooks/activity.tsx draw one view each.hooks/theme.ts holds shared theme-key colors and text formatting.types/index.d.ts declares the self-contained state contract.tests/ holds parser, summary, view, lifecycle, and safety tests. tests/kit.tsx holds the shared fixtures.The native source analyzer follows host capabilities only through top-level functions in the hooks module. It does not follow $ across imported helpers.
hooks/register.tsx 949 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
3import type {
4 CockpitActions, CockpitActivity, CockpitAgent, CockpitChange, CockpitCompaction, CockpitContext, CockpitFinding,
5 CockpitMascot, CockpitPreferences, CockpitReview, CockpitSpan, CockpitTool, CockpitView, CockpitViewProps,
6} from '../types'
7import { renderActivity, SPAN_ORDER } from './activity'
8import { renderAgents } from './agents'
9import { renderChanges } from './changes'
10import { renderContext } from './context'
11import {
12 agentResult, answerText, applyTaskResult, backgroundLaunch, backgroundSnapshot, changeState, cronSnapshot, detectCheck,
13 endBackground, gitOperationOf, mergeAgents, mergeBackground, normalizeObservedChanges, parseGitDiff,
14 parseGitStatus, summarizeCheck, todosOf, toolTarget, withSample,
15} from './data'
16import { line, paneFooter, tabMark, tabRows } from './layout'
17import { activityOf, contextOf, initialActivity, initialContext, initialReview, reviewOf } from './state'
18import type { Segment } from './summary'
19import {
20 bandSegments, changeTotals, contextColor, contextFill, isLive, mainTurns, runningTools, sessionStart, sessionStatus,
21} from './summary'
22import { brief, clip, colors, humanize, meter, percent, shortModel, usd } from './theme'
23import { renderTools } from './tools'
24
25const PANE = 'cockpit'
26const STORE_KEY = 'preferences'
27const VIEWS: CockpitView[] = ['agents', 'tools', 'changes', 'context', 'activity']
28
29const activity = atom({ plugin: 'cockpit', key: 'activity' } as const, initialActivity())
30const review = atom({ plugin: 'cockpit', key: 'review' } as const, initialReview())
31const context = atom({ plugin: 'cockpit', key: 'context' } as const, initialContext())
32const preferences = atom({ plugin: 'cockpit', key: 'preferences' } as const, {
33 view: 'agents', band: true, animation: true, span: 'fit', paneOpen: false,
34} satisfies CockpitPreferences)
35const mascot = atom({ plugin: 'cockpit', key: 'mascot' } as const, {
36 frame: 0, phase: 'idle',
37} satisfies CockpitMascot)
38
39// Session memory can hold an earlier version's shape. Every update reads it
40// filled in, and an updater that returns its input writes nothing.
41const normalized = <T,>(fill: (value: T) => T, change: (previous: T) => T) => (previous: T): T => {
42 const current = fill(previous)
43 const next = change(current)
44 return next === current ? previous : next
45}
46const updateActivity = async ($: EngineInterface, change: (previous: CockpitActivity) => CockpitActivity): Promise<void> => {
47 await update($, activity, normalized(activityOf, change))
48}
49const updateReview = async ($: EngineInterface, change: (previous: CockpitReview) => CockpitReview): Promise<void> => {
50 await update($, review, normalized(reviewOf, change))
51}
52const updateContext = async ($: EngineInterface, change: (previous: CockpitContext) => CockpitContext): Promise<void> => {
53 await update($, context, normalized(contextOf, change))
54}
55
56const errorText = (error: unknown): string =>
57 clip(error instanceof Error ? error.message : 'The operation could not complete.', 240)
58
59// The engine follows host capabilities only through functions in this module.
60const observe = async ($: EngineInterface, work: () => Promise<unknown>): Promise<void> => {
61 try {
62 await work()
63 } catch (error) {
64 // Never replay or change the operation when its observation fails.
65 $.ui.log(`cockpit: ${errorText(error)}`, { to: 'debug' })
66 }
67}
68
69let initializing: Promise<string> | undefined
70
71const ensureSession = async ($: EngineInterface): Promise<string> => {
72 if (initializing !== undefined) {
73 await initializing
74 return ensureSession($)
75 }
76 const initialize = async (): Promise<string> => {
77 const id = await $.session.id()
78 const previous = await read($, activity)
79 if (previous.sessionId === id) return id
80 const model = await $.session.model()
81 await update($, activity, (value): CockpitActivity => ({ ...initialActivity(), sessionId: id, model }))
82 await update($, review, (value): CockpitReview => ({ ...initialReview(), sessionId: id }))
83 await update($, context, (value): CockpitContext => ({ ...initialContext(), sessionId: id }))
84 await update($, mascot, (): CockpitMascot => ({ frame: 0, phase: 'idle' }))
85 return id
86 }
87 const pending = initialize()
88 initializing = pending
89 try {
90 return await pending
91 } finally {
92 if (initializing === pending) initializing = undefined
93 }
94}
95
96const resetSession = async ($: EngineInterface): Promise<void> => {
97 await update($, activity, initialActivity)
98 await update($, review, initialReview)
99 await update($, context, initialContext)
100 await update($, mascot, (): CockpitMascot => ({ frame: 0, phase: 'idle' }))
101}
102
103// Changes to tools, agents or the working flag also record the activity level.
104const changeActivity = async (
105 $: EngineInterface, sessionId: string, now: number,
106 change: (previous: CockpitActivity) => CockpitActivity,
107): Promise<void> => {
108 await updateActivity($, (previous): CockpitActivity => {
109 if (previous.sessionId !== sessionId) return previous
110 const next = change(previous)
111 return next === previous ? previous : withSample(next, now)
112 })
113}
114
115const refreshAgents = async ($: EngineInterface): Promise<void> => {
116 const id = await ensureSession($)
117 try {
118 const [incoming, now] = await Promise.all([$.agent.list(), $.clock.now()])
119 await changeActivity($, id, now, previous => {
120 const merged = mergeAgents(previous.agents, incoming, now)
121 const identity = (list: typeof merged) => list.map(agent => ({ ...agent, lastSeenAt: 0 }))
122 if (JSON.stringify(identity(previous.agents)) === JSON.stringify(identity(merged))) return previous
123 return { ...previous, agents: merged, updatedAt: now, error: null }
124 })
125 } catch (error) {
126 await updateActivity($, (previous): CockpitActivity =>
127 previous.sessionId === id ? { ...previous, error: errorText(error) } : previous,
128 )
129 }
130}
131
132const refreshContext = async ($: EngineInterface): Promise<void> => {
133 const id = await ensureSession($)
134 await updateContext($, previous => ({ ...previous, loading: true, error: null }))
135 try {
136 const usage = await $.session.usage({ breakdown: 'summary', columns: 80 })
137 const now = await $.clock.now()
138 await updateContext($, (previous): CockpitContext => previous.sessionId === id
139 ? { ...previous, usage, refreshedAt: now, loading: false, error: null } : previous)
140 } catch (error) {
141 await updateContext($, previous => previous.sessionId === id
142 ? { ...previous, loading: false, error: errorText(error) } : previous)
143 }
144}
145
146const relativePath = (root: string, path: string): string => {
147 const prefix = `${root.replace(/\/$/, '')}/`
148 return path.startsWith(prefix) ? path.slice(prefix.length) : path
149}
150
151const refreshChanges = async ($: EngineInterface): Promise<void> => {
152 const id = await ensureSession($)
153 await updateReview($, previous => ({ ...previous, loading: true, error: null }))
154 try {
155 const repo = await $.session.repo()
156 if (repo === null) throw new Error('This session is not inside a Git repository.')
157 const init = { cwd: repo.root, timeoutMs: 5000, env: { GIT_OPTIONAL_LOCKS: '0' } }
158 const git = ['git', '--no-pager', '-c', 'color.ui=false', '-c', 'core.fsmonitor=false']
159 const diff = ['diff', '--no-ext-diff', '--no-textconv', '--no-renames']
160 const [status, branch, unstaged, staged] = await Promise.all([
161 $.process.run([...git, 'status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=normal'], init),
162 $.process.run([...git, 'symbolic-ref', '--quiet', '--short', 'HEAD'], init),
163 $.process.run([...git, ...diff], init),
164 $.process.run([...git, ...diff, '--cached'], init),
165 ])
166 if (status.exitCode !== 0 || unstaged.exitCode !== 0 || staged.exitCode !== 0) {
167 throw new Error('Git could not read the working tree. Check repository access.')
168 }
169 const now = await $.clock.now()
170 const entries = parseGitStatus(status.stdout)
171 const patches = [...parseGitDiff(unstaged.stdout, now), ...parseGitDiff(staged.stdout, now, true)]
172 const truncated = unstaged.isStdoutTruncated || staged.isStdoutTruncated
173 let remaining = 200000
174 const changes: CockpitChange[] = entries.slice(0, 100).map(entry => {
175 const matching = patches.filter(change => change.path === entry.path)
176 const full = matching.map(change => change.patch).join('\n')
177 const patch = full.slice(0, Math.max(0, Math.min(24000, remaining)))
178 remaining -= patch.length
179 return {
180 path: entry.path, source: 'git', updatedAt: now, staged: entry.staged,
181 untracked: entry.untracked, patch, state: changeState(entry.status),
182 additions: matching.reduce((total, change) => total + change.additions, 0),
183 deletions: matching.reduce((total, change) => total + change.deletions, 0),
184 truncated: Boolean(truncated || full.length > patch.length || matching.some(change => change.truncated)),
185 }
186 })
187 await updateReview($, (previous): CockpitReview => {
188 if (previous.sessionId !== id) return previous
189 const selected = previous.selectedPath === null ? null : relativePath(repo.root, previous.selectedPath)
190 // Git knows the state. The session knows who edited the file and how often.
191 const history = new Map(previous.changes.map(change => [relativePath(repo.root, change.path), change]))
192 return {
193 ...previous,
194 changes: changes.map(change => {
195 const prior = history.get(change.path)
196 return prior ? { ...change, edits: prior.edits, agentId: prior.agentId } : change
197 }),
198 selectedPath: changes.some(change => change.path === selected) ? selected : changes[0]?.path ?? null,
199 branch: branch.exitCode === 0 ? branch.stdout.trim() : 'detached HEAD',
200 root: repo.root, refreshedAt: now, loading: false,
201 error: status.isStdoutTruncated || truncated ? 'Git output was truncated. Some changes are not shown.' : null,
202 }
203 })
204 } catch (error) {
205 await updateReview($, previous => previous.sessionId === id
206 ? { ...previous, loading: false, error: errorText(error) } : previous)
207 }
208}
209
210const rememberChange = async ($: EngineInterface, change: CockpitChange, sessionId: string): Promise<void> => {
211 await updateReview($, (previous): CockpitReview => {
212 if (previous.sessionId !== sessionId) return previous
213 const path = previous.root === null ? change.path : relativePath(previous.root, change.path)
214 const prior = previous.changes.find(item => item.path === path)
215 const next = {
216 ...change, path, patch: change.patch.slice(0, 24000),
217 edits: (prior?.edits ?? 0) + 1, agentId: change.agentId,
218 ...(prior?.state === undefined ? {} : { state: prior.state, staged: prior.staged }),
219 }
220 const changes = [...previous.changes.filter(item => item.path !== path), next].slice(-100)
221 let remaining = 200000
222 const bounded = changes.reverse().map(item => {
223 const patch = item.patch.slice(0, Math.max(0, remaining))
224 remaining -= patch.length
225 return { ...item, patch, truncated: item.truncated || patch.length !== item.patch.length }
226 }).reverse()
227 return { ...previous, changes: bounded, selectedPath: previous.selectedPath ?? path }
228 })
229}
230
231const record = (value: unknown): Record<string, unknown> | null =>
232 value !== null && typeof value === 'object' && !Array.isArray(value)
233 ? value as Record<string, unknown>
234 : null
235
236const loadPreferences = async ($: EngineInterface): Promise<void> => {
237 const saved = record(await $.store.get(STORE_KEY))
238 if (saved === null) return
239 // The activity view replaced the reactor; a saved reactor choice opens it.
240 const view = saved.view === 'reactor' ? 'activity' : saved.view
241 await update($, preferences, previous => ({
242 ...previous,
243 view: VIEWS.includes(view as CockpitView) ? view as CockpitView : previous.view,
244 band: typeof saved.band === 'boolean' ? saved.band : previous.band,
245 animation: typeof saved.animation === 'boolean' ? saved.animation : previous.animation,
246 span: SPAN_ORDER.includes(saved.span as CockpitSpan) ? saved.span as CockpitSpan : previous.span,
247 }))
248}
249
250// $.state lasts one session. The store keeps the person's choices for the next.
251const savePreferences = ($: EngineInterface): Promise<void> => observe($, async () => {
252 const { view, band, animation, span } = await read($, preferences)
253 await $.store.set(STORE_KEY, { view, band, animation, span })
254})
255
256const recordRunTime = async ($: EngineInterface, id: string, ms: number | undefined): Promise<void> => {
257 if (ms === undefined || !Number.isFinite(ms) || ms < 0) return
258 await updateActivity($, (previous): CockpitActivity => previous.tools.some(tool => tool.id === id)
259 ? { ...previous, tools: previous.tools.map(tool => tool.id === id ? { ...tool, runMs: Math.round(ms) } : tool) }
260 : previous)
261}
262
263const recordBackground = async (
264 $: EngineInterface, tasks: unknown, crons: unknown, endedAgent?: string,
265): Promise<void> => {
266 const id = await ensureSession($)
267 const now = await $.clock.now()
268 await updateActivity($, (previous): CockpitActivity => {
269 if (previous.sessionId !== id) return previous
270 const snapshot = Array.isArray(tasks)
271 let background = snapshot ? mergeBackground(previous.background, backgroundSnapshot(tasks, now), now) : previous.background
272 if (endedAgent !== undefined) background = endBackground(background, endedAgent, 'completed', now)
273 return {
274 ...previous, background,
275 crons: Array.isArray(crons) ? cronSnapshot(crons) : previous.crons,
276 backgroundAt: snapshot ? now : previous.backgroundAt,
277 }
278 })
279}
280
281const selectView = async ($: EngineInterface, view: CockpitView): Promise<void> => {
282 await ensureSession($)
283 await update($, preferences, previous => ({ ...previous, view }))
284 await savePreferences($)
285 if (view === 'agents' || view === 'tools') await refreshAgents($)
286 if (view === 'changes') await refreshChanges($)
287 if (view === 'context') await refreshContext($)
288}
289
290const openCockpit = async ($: EngineInterface, view?: CockpitView): Promise<void> => {
291 await ensureSession($)
292 if (view !== undefined) await selectView($, view)
293 const opened = await $.ui.open({ id: PANE, title: 'Cockpit', focus: true, closeOnEscape: true, rows: 24, columns: 58 })
294 if (!opened.isPlaced) $.ui.toast('Cockpit is waiting for a surface with enough room.')
295 await update($, preferences, previous => ({ ...previous, paneOpen: true }))
296}
297
298const actionsFor = ($: EngineInterface, surface: RenderSurface, viewedAgent: string | null): CockpitActions => {
299 const patchFor = async (path: string): Promise<string | null> => {
300 const state = await read($, review)
301 const patch = state.changes.find(change => change.path === path)?.patch
302 if (!patch) { $.ui.toast('No patch is available for this file.'); return null }
303 return patch
304 }
305 return {
306 selectView: view => selectView($, view),
307 refreshAgents: () => refreshAgents($),
308 selectAgent: async id => {
309 await updateActivity($, previous => ({ ...previous, selectedAgent: id, selectedFor: viewedAgent }))
310 },
311 selectTool: async id => { await updateActivity($, previous => ({ ...previous, selectedTool: id })) },
312 refreshChanges: () => refreshChanges($),
313 selectChange: async path => { await updateReview($, previous => ({ ...previous, selectedPath: path })) },
314 copyPath: async path => {
315 if (path === '') return
316 // An edit seen before the first Git read keeps the absolute path the tool reported.
317 const root = (await read($, review)).root ?? (await $.session.repo())?.root ?? null
318 const relative = root === null ? path : relativePath(root, path)
319 const copied = await $.ui.copy({ text: relative, surface })
320 if (!copied.isCopied) $.ui.toast('Clipboard access is not available on this surface.')
321 else $.ui.toast(relative.startsWith('/') ? 'The file is outside the repository. Its full path was copied.' : `Copied ${clip(relative, 60)}.`)
322 },
323 copyPatch: async path => {
324 const patch = await patchFor(path)
325 if (patch !== null) {
326 const copied = await $.ui.copy({ text: patch, surface })
327 $.ui.toast(copied.isCopied ? 'Patch copied.' : 'Clipboard access is not available on this surface.')
328 }
329 },
330 quotePatch: async path => {
331 const patch = await patchFor(path)
332 if (patch !== null) {
333 const longest = Math.max(2, ...(patch.match(/`+/g) ?? []).map(run => run.length))
334 const fence = '`'.repeat(longest + 1)
335 const filled = await $.prompt.fill({ text: `\n${fence}diff\n${patch}\n${fence}\n`, mode: 'append' })
336 $.ui.toast(filled.isFilled ? 'Patch added to the draft. Nothing was submitted.' : 'The prompt cannot accept a draft right now.')
337 }
338 },
339 refreshContext: () => refreshContext($),
340 toggleAnimation: async () => {
341 await update($, preferences, previous => ({ ...previous, animation: !previous.animation }))
342 await savePreferences($)
343 },
344 cycleSpan: async () => {
345 await update($, preferences, previous => ({
346 ...previous, span: SPAN_ORDER[(SPAN_ORDER.indexOf(previous.span) + 1) % SPAN_ORDER.length] ?? 'fit',
347 }))
348 await savePreferences($)
349 },
350 }
351}
352
353const viewLabels: Record<CockpitView, string> = {
354 agents: 'Agents',
355 tools: 'Tools',
356 changes: 'Changes',
357 context: 'Context',
358 activity: 'Activity',
359}
360
361let timer: Timer | undefined
362let ticking = false
363let tick = 0
364let lastAgentRefresh = 0
365
366/** What the cat acts out: tools first, then a working turn or a live agent. */
367const mascotPhase = (state: CockpitActivity): CockpitActivity['phase'] =>
368 state.tools.some(tool => tool.outcome === 'running') ? 'tools'
369 : state.working || state.agents.some(agent => agent.status === 'running') ? 'thinking'
370 : state.phase
371
372// Four ticks a second: the cat's frames while the activity view shows. Elapsed
373// times redraw once a second while work runs, in the band as well as the pane.
374const startTimer = ($: EngineInterface) => {
375 if (timer !== undefined) return
376 timer = $.clock.every(250, async () => {
377 if (ticking) return
378 ticking = true
379 tick = (tick + 1) % 1000000
380 try {
381 const prefs = await read($, preferences)
382 const pane = prefs.paneOpen ? (await $.ui.panes()).find(item => item.id === PANE) : undefined
383 const shown = pane?.isPlaced === true && pane.isShown
384 if (!shown && tick % 4 !== 0) return
385 const state = await read($, activity)
386 const busy = state.working || state.tools.some(tool => tool.outcome === 'running')
387 if (shown) {
388 await ensureSession($)
389 const now = await $.clock.now()
390 if (now - lastAgentRefresh >= 2000) {
391 lastAgentRefresh = now
392 await refreshAgents($)
393 }
394 }
395 if (shown && prefs.view === 'activity' && prefs.animation) {
396 await update($, mascot, (previous): CockpitMascot => ({ frame: (previous.frame + 1) % 1000000, phase: mascotPhase(state) }))
397 }
398 if (busy && tick % 4 === 0) $.ui.invalidate('ui.render')
399 } catch (error) {
400 $.ui.log(`cockpit timer: ${errorText(error)}`, { to: 'debug' })
401 } finally {
402 ticking = false
403 }
404 })
405}
406
407export const register: Register = on => {
408 on('session.start', async ($, e, next) => {
409 const result = await next(e)
410 // A store that cannot be read leaves the defaults and skips nothing else.
411 await observe($, () => loadPreferences($))
412 await observe($, async () => {
413 await ensureSession($)
414 await $.command.register({
415 name: 'cockpit',
416 description: 'Open agent, tool, change, context, or activity views.',
417 argumentHint: '[agents|tools|changes|context|activity|band|close]',
418 immediate: true,
419 })
420 await refreshAgents($)
421 const id = await $.session.id()
422 const usage = await $.session.usage()
423 await updateContext($, (previous): CockpitContext => previous.sessionId === id ? { ...previous, usage } : previous)
424 if (e.isInteractive) startTimer($)
425 })
426 return result
427 }).catch(($, e, next) => next(e))
428
429 on('session.end', async ($, e, next) => {
430 const result = await next(e)
431 await observe($, async () => {
432 await resetSession($)
433 if (e.reason !== 'clear' && e.reason !== 'resume') {
434 timer?.cancel()
435 timer = undefined
436 await update($, preferences, previous => ({ ...previous, paneOpen: false }))
437 }
438 })
439 return result
440 }).catch(($, e, next) => next(e))
441
442 on('command.run', { command: 'cockpit' }, async ($, e) => {
443 try {
444 const args = e.args.trim()
445 const cut = args.search(/\s/)
446 const command = cut < 0 ? args : args.slice(0, cut)
447 const tail = cut < 0 ? '' : args.slice(cut).trim()
448 if (command === 'help') {
449 return { text: [
450 '/cockpit [agents|tools|changes|context|activity]', '/cockpit band [on|off]', '/cockpit close', '',
451 'Ctrl+X Tab focuses the pane. Then Ctrl+X ← or → resizes a docked pane, and Ctrl+X ↑ or ↓ a pane above the prompt.',
452 ].join('\n') }
453 }
454 if (command === 'close') {
455 await $.ui.close({ id: PANE })
456 await update($, preferences, previous => ({ ...previous, paneOpen: false }))
457 return { text: 'Cockpit closed.' }
458 }
459 if (command === 'band') {
460 if (tail !== '' && tail !== 'on' && tail !== 'off') {
461 return { text: 'Use /cockpit band [on|off].', exitCode: 1 }
462 }
463 await update($, preferences, previous => ({
464 ...previous,
465 band: tail === '' ? !previous.band : tail === 'on',
466 }))
467 await savePreferences($)
468 const prefs = await read($, preferences)
469 return { text: `Cockpit band ${prefs.band ? 'enabled' : 'hidden'}.` }
470 }
471 const view = command === 'reactor' ? 'activity' : command
472 if (view !== '' && !VIEWS.includes(view as CockpitView)) {
473 return { text: 'Unknown cockpit view. Use /cockpit help.', exitCode: 1 }
474 }
475 if (tail !== '') {
476 return { text: 'Cockpit views take no arguments. Use /cockpit help.', exitCode: 1 }
477 }
478 await openCockpit($, view === '' ? undefined : view as CockpitView)
479 startTimer($)
480 return { text: 'Cockpit opened. Use the view buttons or /cockpit help.' }
481 } catch (error) {
482 return { text: `Cockpit: ${errorText(error)}`, exitCode: 1 }
483 }
484 })
485
486 on('ui.close', async ($, e, next) => {
487 const result = await next(e)
488 if (e.id === PANE && !('deny' in result)) {
489 await observe($, () => update($, preferences, previous => ({ ...previous, paneOpen: false })))
490 }
491 return result
492 }).catch(($, e, next) => next(e))
493
494 on('turn.start', async ($, e, next) => {
495 await observe($, async () => {
496 const id = await ensureSession($)
497 const now = await $.clock.now()
498 await changeActivity($, id, now, previous => ({
499 ...previous, working: true, turnStartedAt: now, phase: 'thinking', stopFailure: null, updatedAt: now,
500 }))
501 })
502 return next(e)
503 }).catch(($, e, next) => next(e))
504
505 on('turn.step', async function* ($, e, next) {
506 let sessionId: string | undefined
507 await observe($, async () => {
508 sessionId = await ensureSession($)
509 if (e.agentId === undefined) {
510 await updateActivity($, (previous): CockpitActivity => ({ ...previous, model: e.model, phase: 'thinking' }))
511 }
512 })
513 const result = yield* next(e)
514 await observe($, async () => {
515 if (sessionId === undefined || await $.session.id() !== sessionId) return
516 const now = await $.clock.now()
517 const retrospective: CockpitTool[] = (result.serverToolUses ?? []).map(tool => ({
518 id: tool.id,
519 tool: tool.name,
520 agentId: e.agentId,
521 startedAt: tool.startedAt,
522 finishedAt: tool.endedAt ?? now,
523 outcome: tool.endedAt === undefined ? 'interrupted' : 'success',
524 retrospective: true,
525 }))
526 if (retrospective.length > 0) {
527 await updateActivity($, (previous): CockpitActivity => ({
528 ...previous,
529 tools: [...previous.tools.filter(tool => !retrospective.some(server => server.id === tool.id)), ...retrospective].slice(-200),
530 updatedAt: now,
531 }))
532 }
533 })
534 return result
535 }).catch(async function* ($, e, next) { return yield* next(e) })
536
537 on('agent.spawn', async ($, e, next) => {
538 const result = await next(e)
539 await observe($, () => refreshAgents($))
540 return result
541 }).catch(($, e, next) => next(e))
542
543 on('session.receive', async ($, e, next) => {
544 const result = await next(e)
545 await observe($, () => refreshAgents($))
546 return result
547 }).catch(($, e, next) => next(e))
548
549 on('tool.check', async ($, e, next) => {
550 const result = await next(e)
551 const id = e.tool_use_id
552 // A query from $.tool.check has no call id and decides nothing that runs.
553 if (result.decision === 'ask' && id !== undefined) {
554 await observe($, () => updateActivity($, (previous): CockpitActivity => ({
555 ...previous,
556 tools: previous.tools.map(tool => tool.id === id ? { ...tool, approval: 'asked' } : tool),
557 })))
558 }
559 return result
560 }).catch(($, e, next) => next(e))
561
562 // PostToolUse is the only source of execution time without the permission
563 // prompt. No event marks the moment a person answers the prompt.
564 on('classic.PostToolUse', async ($, e, next) => {
565 const result = await next(e)
566 await observe($, () => recordRunTime($, e.tool_use_id, e.duration_ms))
567 return result
568 }).catch(($, e, next) => next(e))
569
570 on('classic.PostToolUseFailure', async ($, e, next) => {
571 const result = await next(e)
572 await observe($, () => recordRunTime($, e.tool_use_id, e.duration_ms))
573 return result
574 }).catch(($, e, next) => next(e))
575
576 on('tool.call', async ($, e, next) => {
577 let sessionId: string | undefined
578 let startedAt = 0
579 const checkLabel = e.tool === 'Bash' && typeof e.command === 'string' ? detectCheck(e.command) : undefined
580 await observe($, async () => {
581 sessionId = await ensureSession($)
582 startedAt = await $.clock.now()
583 const tool: CockpitTool = {
584 id: e.tool_use_id,
585 tool: e.tool,
586 agentId: e.agentId,
587 target: toolTarget(e.tool, e),
588 startedAt,
589 outcome: 'running',
590 }
591 await changeActivity($, sessionId, startedAt, previous => ({
592 ...previous,
593 tools: [...previous.tools.filter(item => item.id !== tool.id), tool].slice(-200),
594 phase: 'tools',
595 updatedAt: startedAt,
596 }))
597 if (checkLabel !== undefined) {
598 await updateReview($, (previous): CockpitReview => ({
599 ...previous,
600 checks: [...previous.checks.filter(check => check.id !== tool.id), { id: tool.id, label: checkLabel, status: 'running' as const }].slice(-40),
601 }))
602 }
603 })
604 try {
605 const result = await next(e)
606 await observe($, async () => {
607 if (sessionId === undefined || await $.session.id() !== sessionId) return
608 const id = sessionId
609 const now = await $.clock.now()
610 const output = record(result.result)
611 const outcome: CockpitTool['outcome'] = result.deny ? 'denied' : result.isError ? 'error' : output?.interrupted === true ? 'interrupted' : 'success'
612 const succeeded = !result.isError && !result.deny
613 const todos = succeeded && e.tool === 'TodoWrite' ? todosOf(result) : undefined
614 const launch = succeeded ? backgroundLaunch(e.tool, e, result, now) : undefined
615 const stopped = succeeded && e.tool === 'TaskStop' && typeof output?.task_id === 'string' ? output.task_id : undefined
616 const finished = succeeded && e.tool === 'Agent' ? agentResult(result) : undefined
617 const spawned: CockpitAgent | undefined = finished && e.tool === 'Agent' ? {
618 id: finished.agentId,
619 name: typeof e.name === 'string' ? clip(e.name, 80) : undefined,
620 description: clip(e.description, 240),
621 type: clip(e.subagent_type ?? 'general-purpose', 80),
622 status: 'completed',
623 startedAt,
624 lastSeenAt: now,
625 completedAt: now,
626 } : undefined
627 await changeActivity($, id, now, previous => {
628 const tools = previous.tools.map(tool => tool.id === e.tool_use_id ? { ...tool, outcome, finishedAt: now } : tool)
629 const phase = tools.some(tool => tool.outcome === 'running') ? 'tools' : previous.working ? 'thinking' : outcome === 'error' ? 'error' : 'idle'
630 const tasks = succeeded ? applyTaskResult(previous.tasks, e.tool, e, result) : undefined
631 let background = launch ? [...previous.background.filter(task => task.id !== launch.id), launch].slice(-40) : previous.background
632 if (stopped !== undefined) background = endBackground(background, stopped, 'stopped', now)
633 // An agent can leave the roster before a refresh lists it. Keep its report anyway.
634 const agents = finished === undefined ? previous.agents
635 : previous.agents.some(agent => agent.id === finished.agentId)
636 ? previous.agents.map(agent => agent.id === finished.agentId
637 ? { ...agent, totals: finished.totals, answer: finished.answer ?? agent.answer } : agent)
638 : spawned ? [...previous.agents, { ...spawned, totals: finished.totals, answer: finished.answer }].slice(-100) : previous.agents
639 return {
640 ...previous, tools, phase, agents, background,
641 tasks: tasks ?? previous.tasks,
642 todos: todos === undefined ? previous.todos : [
643 ...previous.todos.filter(list => list.agentId !== e.agentId),
644 { agentId: e.agentId, items: todos, updatedAt: now },
645 ].slice(-20),
646 updatedAt: now,
647 }
648 })
649 if (checkLabel !== undefined) {
650 // Prefer the execution time, which leaves out a permission prompt.
651 const runMs = (await read($, activity)).tools.find(tool => tool.id === e.tool_use_id)?.runMs
652 const check = summarizeCheck(e.tool_use_id, checkLabel, result, { error: result.isError, denied: result.deny !== undefined }, runMs ?? Math.max(0, now - startedAt))
653 await updateReview($, (previous): CockpitReview => previous.sessionId === id ? {
654 ...previous,
655 checks: [...previous.checks.filter(item => item.id !== check.id), { ...check, finishedAt: now }].slice(-40),
656 } : previous)
657 }
658 if (succeeded) {
659 const changes = normalizeObservedChanges(e.tool, e, result, now)
660 if ((e.tool === 'Edit' || e.tool === 'Write') && changes.length === 0
661 && typeof output?.filePath === 'string' && output.staged !== true) {
662 changes.push({ path: output.filePath, patch: '', additions: 0, deletions: 0, source: 'observed', updatedAt: now })
663 }
664 for (const change of changes) await rememberChange($, { ...change, agentId: e.agentId }, id)
665 }
666 const gitOp = succeeded && e.tool === 'Bash' ? gitOperationOf(e.tool_use_id, result, now) : undefined
667 if (gitOp !== undefined) {
668 await updateReview($, (previous): CockpitReview => previous.sessionId === id
669 ? { ...previous, gitOps: [...previous.gitOps.filter(item => item.id !== gitOp.id), gitOp].slice(-30) } : previous)
670 }
671 if (succeeded && e.tool === 'ReportFindings' && Array.isArray(e.findings)) {
672 const findings: CockpitFinding[] = e.findings.flatMap((value, index) => {
673 const finding = record(value)
674 if (finding === null || typeof finding.file !== 'string' || typeof finding.summary !== 'string') return []
675 return [{
676 id: `${e.tool_use_id}:${index}`,
677 path: finding.file,
678 summary: clip(finding.summary, 1000),
679 line: typeof finding.line === 'number' && finding.line > 0 ? finding.line : undefined,
680 category: typeof finding.category === 'string' ? clip(finding.category, 40) : undefined,
681 verdict: typeof finding.verdict === 'string' ? clip(finding.verdict, 40) : undefined,
682 outcome: typeof finding.outcome === 'string' ? clip(finding.outcome, 40) : undefined,
683 }]
684 })
685 await updateReview($, (previous): CockpitReview => previous.sessionId === id
686 ? { ...previous, findings: [...previous.findings, ...findings].slice(-100) } : previous)
687 }
688 if (e.tool === 'Agent' || e.agentId !== undefined) await refreshAgents($)
689 })
690 return result
691 } catch (error) {
692 await observe($, async () => {
693 if (sessionId === undefined) return
694 const now = await $.clock.now()
695 await changeActivity($, sessionId, now, previous => ({
696 ...previous,
697 tools: previous.tools.map(tool => tool.id === e.tool_use_id ? { ...tool, outcome: 'error', finishedAt: now } : tool),
698 phase: 'error',
699 updatedAt: now,
700 }))
701 })
702 throw error
703 }
704 }).catch(($, e, next) => next(e))
705
706 on('turn.complete', async ($, e, next) => {
707 let id: string | undefined
708 await observe($, async () => { id = await ensureSession($) })
709 const result = await next(e)
710 await observe($, async () => {
711 if (id === undefined || await $.session.id() !== id) return
712 const now = await $.clock.now()
713 await changeActivity($, id, now, previous => ({
714 ...previous,
715 turns: [...previous.turns.filter(turn => turn.id !== e.turnId), {
716 id: e.turnId,
717 agentId: e.agentId,
718 durationMs: e.durationMs,
719 reason: e.reason,
720 finishedAt: now,
721 usage: e.usage,
722 }].slice(-100),
723 working: e.agentId === undefined ? false : previous.working,
724 turnStartedAt: e.agentId === undefined ? null : previous.turnStartedAt,
725 phase: e.agentId === undefined ? e.reason === 'error' ? 'error' : 'idle' : previous.phase,
726 agents: previous.agents.map(agent => agent.id === e.agentId ? {
727 ...agent,
728 completedAt: now,
729 durationMs: e.durationMs,
730 usage: e.usage,
731 answer: answerText(e.answer) ?? agent.answer,
732 } : agent),
733 updatedAt: now,
734 }))
735 await refreshAgents($)
736 })
737 return result
738 }).catch(($, e, next) => next(e))
739
740 on('classic.Stop', async ($, e, next) => {
741 const result = await next(e)
742 await observe($, () => recordBackground($, e.background_tasks, e.session_crons))
743 return result
744 }).catch(($, e, next) => next(e))
745
746 on('classic.SubagentStop', async ($, e, next) => {
747 const result = await next(e)
748 await observe($, async () => {
749 await recordBackground($, e.background_tasks, e.session_crons, e.agent_id)
750 const answer = answerText(e.last_assistant_message)
751 if (answer !== undefined) {
752 await updateActivity($, (previous): CockpitActivity => ({
753 ...previous,
754 agents: previous.agents.map(agent => agent.id === e.agent_id && agent.answer === undefined ? { ...agent, answer } : agent),
755 }))
756 }
757 })
758 return result
759 }).catch(($, e, next) => next(e))
760
761 on('classic.StopFailure', async ($, e, next) => {
762 const result = await next(e)
763 // A subagent's failure reaches its spawner as a tool error.
764 if (e.agent_id === undefined) {
765 await observe($, async () => {
766 const id = await ensureSession($)
767 const now = await $.clock.now()
768 await changeActivity($, id, now, previous => ({
769 ...previous,
770 stopFailure: { error: e.error, details: e.error_details ? clip(e.error_details, 240) : undefined, at: now },
771 working: false,
772 turnStartedAt: null,
773 phase: 'error',
774 updatedAt: now,
775 }))
776 })
777 }
778 return result
779 }).catch(($, e, next) => next(e))
780
781 on('session.compact', async ($, e, next) => {
782 const result = await next(e)
783 // A precompute installs nothing. Its result waits for a later compaction.
784 if (e.trigger !== 'precompute') {
785 await observe($, async () => {
786 const id = await ensureSession($)
787 const now = await $.clock.now()
788 const entry: CockpitCompaction = result.skip !== undefined
789 ? { at: now, trigger: e.trigger, agentId: e.agentId, skipped: clip(result.skip, 240) }
790 : { at: now, trigger: e.trigger, agentId: e.agentId, tokensBefore: result.tokensBefore, tokensAfter: result.tokensAfter }
791 await updateContext($, (previous): CockpitContext => previous.sessionId === id
792 ? { ...previous, compactions: [...previous.compactions, entry].slice(-20) } : previous)
793 })
794 }
795 return result
796 }).catch(($, e, next) => next(e))
797
798 on('session.measure', async ($, e, next) => {
799 const result = await next(e)
800 await observe($, async () => {
801 const id = await ensureSession($)
802 const now = await $.clock.now()
803 const usd = e.cost?.usd
804 const fill = e.context.tokens
805 await updateContext($, (previous): CockpitContext => previous.sessionId === id ? {
806 ...previous,
807 usage: {
808 startedAt: previous.usage?.startedAt ?? now,
809 context: { ...e.context, breakdown: previous.usage?.context.breakdown },
810 rateLimits: e.rateLimits,
811 cost: e.cost,
812 },
813 costs: usd !== undefined && Number.isFinite(usd) && previous.costs.at(-1)?.usd !== usd
814 ? [...previous.costs, { at: now, usd }].slice(-100) : previous.costs,
815 fills: fill !== undefined && Number.isFinite(fill) && previous.fills.at(-1)?.tokens !== fill
816 ? [...previous.fills, { at: now, tokens: fill }].slice(-100) : previous.fills,
817 } : previous)
818 })
819 return result
820 }).catch(($, e, next) => next(e))
821
822 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
823 const prefs = await read($, preferences)
824 if (e.props.hasSurvey || !prefs.band || e.props.maxRows < 1) return next(e)
825 const [stored, r, c, now] = await Promise.all([read($, activity), read($, review), read($, context), $.clock.now()])
826 const ui = $.ui.resolve(e)
827 const { Box, Button } = ui
828 const segments = bandSegments(activityOf(stored), reviewOf(r), contextOf(c), {
829 isWorking: e.props.isWorking, columns: Math.max(8, e.props.bodyColumns - 13), now,
830 })
831 const base = await next(e)
832 return (
833 <Box flexDirection="column">
834 {base}
835 <Box gap={1}>
836 <Button key="cockpit-open" label="◉ cockpit" onPress={() => { void observe($, () => openCockpit($)) }} />
837 {line(ui, 'cockpit-band', segments)}
838 </Box>
839 </Box>
840 )
841 })
842
843 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
844 const [stored, r, c, storedPrefs, frame, now] = await Promise.all([
845 read($, activity), read($, review), read($, context), read($, preferences),
846 read($, mascot), $.clock.now(),
847 ])
848 // Hot reload keeps session memory, which an earlier version can have shaped.
849 const a = activityOf(stored)
850 const prefs: CockpitPreferences = {
851 ...storedPrefs,
852 view: VIEWS.includes(storedPrefs.view) ? storedPrefs.view : 'agents',
853 span: SPAN_ORDER.includes(storedPrefs.span) ? storedPrefs.span : 'fit',
854 }
855 const viewedAgent = e.props.view.agentId ?? null
856 // Follow the transcript in view until the person chooses while viewing it.
857 const selectedAgent = viewedAgent !== null && viewedAgent !== a.selectedFor ? viewedAgent : a.selectedAgent
858 const ui = $.ui.resolve(e)
859 const { Box, Button, Text } = ui
860 const props: CockpitViewProps = {
861 activity: { ...a, selectedAgent },
862 review: reviewOf(r),
863 context: contextOf(c),
864 preferences: prefs,
865 mascot: { frame: Number.isFinite(frame?.frame) ? frame.frame : 0, phase: mascotPhase(a) },
866 actions: actionsFor($, e.surface, viewedAgent),
867 viewedAgent,
868 columns: Math.max(12, e.props.bodyColumns),
869 rows: Math.max(6, e.props.scroll.bodyRows - 5),
870 now,
871 }
872 const content = prefs.view === 'agents' ? renderAgents(ui, props)
873 : prefs.view === 'tools' ? renderTools(ui, props)
874 : prefs.view === 'changes' ? renderChanges(ui, props)
875 : prefs.view === 'context' ? renderContext(ui, props)
876 : renderActivity(ui, props)
877 const width = Math.max(12, props.columns - 1)
878 const status = sessionStatus(props.activity, a.working)
879 const fill = contextFill(props.context)
880 const cost = props.context.usage?.cost?.usd
881 const started = sessionStart(props.activity, props.context)
882 const turns = mainTurns(props.activity).length
883 const since = status.since === undefined ? '' : ` ${brief(now - status.since)}`
884 const statusLine: Segment[] = [
885 { text: 'COCKPIT', color: colors.accent, bold: true },
886 { text: a.model ? ` ${shortModel(a.model)}` : '', color: colors.muted },
887 { text: ' · ', color: colors.muted },
888 { text: clip(`${status.glyph} ${status.label}${since}`, Math.max(8, width - 12 - shortModel(a.model).length)), color: status.color, bold: status.color !== colors.muted },
889 ]
890 const ctxColor = contextColor(fill)
891 const meterLine: Segment[] = [
892 { text: 'ctx ', color: colors.muted },
893 { text: meter(fill.percent ?? 0, Math.max(6, Math.min(16, width - 40))), color: ctxColor === colors.muted ? colors.accent : ctxColor },
894 { text: ` ${percent(fill.percent)}`, color: ctxColor === colors.muted ? undefined : ctxColor },
895 { text: cost !== undefined && Number.isFinite(cost) ? ` · ${usd(cost)}` : '', color: colors.muted },
896 { text: ` · ${turns} ${turns === 1 ? 'turn' : 'turns'}`, color: colors.muted },
897 { text: started === undefined ? '' : ` · ${brief(now - started)} session`, color: colors.muted },
898 ]
899 const live = props.activity.agents.filter(isLive).length
900 const running = runningTools(props.activity).length
901 const files = changeTotals(props.review).files
902 const ctxBadge = fill.percent !== undefined && Number.isFinite(fill.percent) ? percent(fill.percent) : ''
903 const badges: Record<CockpitView, Segment> = {
904 agents: live > 0 ? { text: `${live}▸`, color: colors.accent } : { text: props.activity.agents.length > 0 ? `${props.activity.agents.length}` : '', color: colors.muted },
905 tools: running > 0 ? { text: `${running}▸`, color: colors.accent } : { text: props.activity.tools.length > 0 ? `${props.activity.tools.length}` : '', color: colors.muted },
906 changes: { text: files > 0 ? `${files}` : '', color: colors.muted },
907 context: { text: ctxBadge, color: ctxColor },
908 activity: { text: '' },
909 }
910 const tabs = VIEWS.map(view => ({ label: viewLabels[view], badge: badges[view].text || undefined }))
911 const tabLayout = tabRows(tabs, width)
912 const activeTab = VIEWS.indexOf(prefs.view)
913 // Each plain tab reads `1: Label`; the active one is at full strength over
914 // an accent mark, the rest dim.
915 const tabBar = tabLayout.rows.flatMap((row, number) => [
916 <Box key={`cockpit:tabs:${number}`} flexDirection="row" columnGap={tabLayout.gap}>
917 {row.map(index => {
918 const view = VIEWS[index] ?? 'agents'
919 const badge = badges[view]
920 return (
921 <Box key={`cockpit:tab:${view}`} flexDirection="row">
922 <Button key={`view-${view}`} plain dimColor={prefs.view !== view} label={viewLabels[view]}
923 hotkey={String(index + 1) as '1' | '2' | '3' | '4' | '5'}
924 onPress={() => props.actions.selectView(view)} />
925 {tabLayout.badges && badge.text !== '' && <Text color={badge.color} dimColor={prefs.view !== view && badge.color === colors.muted}>{` ${badge.text}`}</Text>}
926 </Box>
927 )
928 })}
929 </Box>,
930 ...[tabMark(tabs, row, activeTab, tabLayout.badges, tabLayout.gap)].flatMap(mark => mark ? [line(ui, `cockpit:tabs:${number}:mark`, mark)] : []),
931 ])
932 const failure = a.stopFailure
933 // No backgroundColor: the dock paints its own theme fill, and a plugin cannot
934 // paint the terminal's default background over it.
935 return (
936 <Box flexDirection="column" gap={1}>
937 <Box flexDirection="column">
938 {line(ui, 'cockpit:status', statusLine)}
939 {line(ui, 'cockpit:meter', meterLine)}
940 {tabBar}
941 {failure && <Text color={colors.red} wrap="wrap">{clip(`✗ Last turn stopped: ${humanize(failure.error)}${failure.details ? ` · ${failure.details}` : ''}`, 320)}</Text>}
942 </Box>
943 {content}
944 <Text dimColor>{paneFooter(e.surface, e.props.placement, props.columns)}</Text>
945 </Box>
946 )
947 })
948}
949hooks/activity.tsx 586 lines1import type { RenderElement, ThemeKey } from 'claude-code'
2import type { CockpitActivity, CockpitContext, CockpitMascot, CockpitSample, CockpitSpan, CockpitViewProps } from '../types'
3import { agentName } from './agents'
4import { actions, field, heading, line, muted, section, sparkline, viewWidth } from './layout'
5import type { Segment } from './summary'
6import {
7 agentElapsed, approvalWait, isLive, leadTool, mainTurns, runningTools, sessionStart, sessionStatus, toolDuration, toolLabel,
8 toolStats, waitDuration,
9} from './summary'
10import type { CockpitElements } from './theme'
11import { brief, clip, colors, duration, humanize, padEnd, percent, plural } from './theme'
12
13const finite = (value: number | undefined | null): value is number =>
14 typeof value === 'number' && Number.isFinite(value)
15
16// ---------------------------------------------------------------- Bongo Cat
17
18export type Pose = 'up' | 'left' | 'right' | 'both' | 'rest'
19
20/** Running tools and live agents: how hard the cat drums. */
21export const activityLoad = (activity: CockpitActivity): number =>
22 runningTools(activity).length + activity.agents.filter(agent => agent.status === 'running').length
23
24/**
25 * The cat acts out the phase: asleep on the drums when idle, a slow tap while
26 * the model thinks, both paws while tools run (faster with more at once), and
27 * paws up after an error. A paused animation holds frame 0.
28 */
29export const catPose = (phase: CockpitMascot['phase'], frame: number, load: number): Pose => {
30 if (phase === 'error') return 'up'
31 if (phase === 'idle') return 'rest'
32 if (phase === 'thinking') return frame % 4 === 0 ? 'right' : 'up'
33 if (load >= 3) return frame % 2 === 0 ? 'both' : 'up'
34 if (load === 2) return frame % 2 === 0 ? 'left' : 'right'
35 return (['left', 'up', 'right', 'up'] as const)[frame % 4] ?? 'up'
36}
37
38type Eyes = 'open' | 'closed' | 'squint' | 'crossed'
39
40const eyesFor = (phase: CockpitMascot['phase'], pose: Pose, frame: number): Eyes => {
41 if (phase === 'error') return 'crossed'
42 if (phase === 'idle') return 'closed'
43 if (pose === 'both') return 'squint'
44 return pose === 'up' && frame % 8 === 7 ? 'closed' : 'open'
45}
46
47type Glyph = { char: string; color?: ThemeKey }
48type Point = readonly [number, number]
49type Layer = 'table' | 'drum' | 'cat' | 'eyes' | 'bean' | 'hit'
50
51// The drawing follows the original Bongo Cat, in the 612 × 354 space of the
52// overlay art: the table edge falls to the right, the face tilts with it, and
53// the bongos stand in front. A raised paw is an arch with toe beans; a paw
54// that hits curls onto its drum, with impact lines under it.
55const spline = (points: readonly Point[], steps = 40): Point[] => {
56 const out: Point[] = []
57 const at = (index: number): Point => points[Math.max(0, Math.min(points.length - 1, index))] ?? [0, 0]
58 for (let index = 0; index < points.length - 1; index += 1) {
59 const [p0, p1, p2, p3] = [at(index - 1), at(index), at(index + 1), at(index + 2)]
60 for (let step = 0; step < steps; step += 1) {
61 const t = step / steps
62 const curve = (a: number, b: number, c: number, d: number): number =>
63 0.5 * (2 * b + (c - a) * t + (2 * a - 5 * b + 4 * c - d) * t * t + (3 * b - a - 3 * c + d) * t * t * t)
64 out.push([curve(p0[0], p1[0], p2[0], p3[0]), curve(p0[1], p1[1], p2[1], p3[1])])
65 }
66 }
67 out.push(at(points.length - 1))
68 return out
69}
70
71const segment = (from: Point, to: Point, steps = 30): Point[] =>
72 Array.from({ length: steps + 1 }, (_, step): Point => [from[0] + (to[0] - from[0]) * step / steps, from[1] + (to[1] - from[1]) * step / steps])
73
74const oval = (cx: number, cy: number, rx: number, ry: number, from = 0, to = 360, steps = 120): Point[] =>
75 Array.from({ length: steps + 1 }, (_, step): Point => {
76 const angle = (from + (to - from) * step / steps) * Math.PI / 180
77 return [cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)]
78 })
79
80const blob = (cx: number, cy: number, rx: number, ry: number): Point[] => {
81 const out: Point[] = []
82 for (let x = -10; x <= 10; x += 1) {
83 for (let y = -10; y <= 10; y += 1) if (x * x + y * y <= 100) out.push([cx + rx * x / 10, cy + ry * y / 10])
84 }
85 return out
86}
87
88type Drum = { cx: number; cy: number; rx: number; ry: number; bottom: number }
89
90const DRUMS: readonly Drum[] = [
91 { cx: 196, cy: 214, rx: 62, ry: 20, bottom: 262 },
92 { cx: 332, cy: 236, rx: 58, ry: 19, bottom: 280 },
93]
94
95const insideDrum = (drum: Drum, x: number, y: number): boolean => {
96 if (y < drum.cy) return ((x - drum.cx) / drum.rx) ** 2 + ((y - drum.cy) / drum.ry) ** 2 <= 1
97 if (y <= drum.bottom) return Math.abs(x - drum.cx) <= drum.rx - 8 * (y - drum.cy) / (drum.bottom - drum.cy)
98 return ((x - drum.cx) / (drum.rx - 8)) ** 2 + ((y - drum.bottom) / drum.ry) ** 2 <= 1
99}
100
101const drumLines = (drum: Drum): Point[] => [
102 ...oval(drum.cx, drum.cy, drum.rx, drum.ry),
103 ...segment([drum.cx - drum.rx, drum.cy], [drum.cx - drum.rx + 8, drum.bottom], 60),
104 ...segment([drum.cx + drum.rx, drum.cy], [drum.cx + drum.rx - 8, drum.bottom], 60),
105 ...oval(drum.cx, drum.bottom, drum.rx - 8, drum.ry, 0, 180),
106]
107
108const CAT_PARTS = {
109 body: spline([[172, 106], [205, 90], [240, 72], [268, 52], [290, 26], [297, 20], [306, 30], [318, 50], [360, 55], [405, 72],
110 [440, 104], [462, 92], [481, 87], [487, 125], [476, 150], [482, 192], [497, 226], [503, 240]]),
111 table: segment([0, 150], [612, 252], 200),
112 smile: spline([[264, 138], [270, 152], [281, 155], [288, 146], [294, 155], [305, 156], [312, 146]], 12),
113 gasp: oval(288, 149, 8, 7),
114 open: [...blob(241, 131, 7, 7), ...blob(357, 166, 7, 7)],
115 closed: [...segment([231, 129], [251, 133]), ...segment([347, 164], [367, 168])],
116 squint: [...segment([232, 123], [246, 131]), ...segment([246, 131], [232, 139]), ...segment([366, 158], [352, 166]), ...segment([352, 166], [366, 174])],
117 crossed: [...segment([233, 123], [249, 139]), ...segment([249, 123], [233, 139]), ...segment([349, 158], [365, 174]), ...segment([365, 158], [349, 174])],
118 leftUp: spline([[138, 172], [133, 122], [145, 93], [165, 88], [190, 100], [207, 145]]),
119 leftBeans: [...blob(150, 135, 5, 6), ...blob(162, 118, 5, 6), ...blob(178, 133, 5, 6), ...blob(168, 153, 9, 12)],
120 leftDown: spline([[172, 108], [158, 140], [160, 190], [180, 218], [200, 224], [216, 213], [221, 196], [219, 180]]),
121 leftHit: [...segment([184, 234], [180, 252]), ...segment([207, 236], [214, 249]), ...segment([232, 221], [254, 228])],
122 rightUp: spline([[386, 215], [381, 166], [392, 136], [412, 132], [437, 146], [455, 188]]),
123 rightBeans: [...blob(398, 180, 5, 6), ...blob(409, 162, 5, 6), ...blob(425, 177, 5, 6), ...blob(416, 198, 9, 12)],
124 rightDown: spline([[425, 170], [380, 190], [342, 213], [328, 232], [350, 246], [400, 246], [450, 237], [488, 225]]),
125 rightHit: [...segment([268, 229], [298, 228]), ...segment([291, 258], [305, 245])],
126} satisfies Record<string, Point[]>
127
128type CatPart = keyof typeof CAT_PARTS
129
130/** The part of the art the cat shows: from the left table edge to past the cat, ears to drums. */
131const CAT_VIEW = { left: 40, top: 12, width: 530, height: 290 }
132/** Where the bubble's tail points (the top of the head) and the right ear, in art space. */
133const CAT_HEAD_X = 360
134const CAT_EAR_X = 481
135
136type CatSheet = { columns: number; rows: number; scale: number; base: (Layer | undefined)[]; parts: Record<CatPart, number[]> }
137
138const sheets = new Map<number, CatSheet>()
139
140// Braille cells hold 2 × 4 dots, close to square on a terminal, so one scale
141// serves both axes. Each size is traced once and kept.
142const catSheet = (columns: number): CatSheet => {
143 const cached = sheets.get(columns)
144 if (cached) return cached
145 const scale = CAT_VIEW.width / (columns * 2)
146 const rows = Math.ceil(CAT_VIEW.height / scale / 4)
147 const width = columns * 2
148 const height = rows * 4
149 const dotsOf = (points: readonly Point[]): number[] => {
150 const dots = new Set<number>()
151 for (const [x, y] of points) {
152 const dx = Math.floor((x - CAT_VIEW.left) / scale)
153 const dy = Math.floor((y - CAT_VIEW.top) / scale)
154 if (dx >= 0 && dx < width && dy >= 0 && dy < height) dots.add(dy * width + dx)
155 }
156 return [...dots]
157 }
158 const base: (Layer | undefined)[] = Array.from({ length: width * height }, () => undefined)
159 for (const dot of dotsOf(CAT_PARTS.body)) base[dot] = 'cat'
160 for (const dot of dotsOf(CAT_PARTS.table)) base[dot] = 'table'
161 // The bongos stand in front: they hide the table edge and the cat behind them.
162 for (const drum of DRUMS) {
163 for (let dot = 0; dot < base.length; dot += 1) {
164 const x = CAT_VIEW.left + (dot % width + 0.5) * scale
165 const y = CAT_VIEW.top + (Math.floor(dot / width) + 0.5) * scale
166 if (insideDrum(drum, x, y)) base[dot] = undefined
167 }
168 for (const dot of dotsOf(drumLines(drum))) base[dot] = 'drum'
169 }
170 const parts = Object.fromEntries(Object.entries(CAT_PARTS).map(([name, points]) => [name, dotsOf(points)])) as Record<CatPart, number[]>
171 const sheet = { columns, rows, scale, base, parts }
172 sheets.set(columns, sheet)
173 return sheet
174}
175
176/** The widest cat that fits: 40 columns, then 34, then down to 20. */
177export const catColumns = (width: number): number =>
178 width >= 44 ? 40 : width >= 36 ? 34 : Math.max(20, Math.min(28, Math.floor(Number.isFinite(width) ? width : 28)))
179
180/** The column of the top of the cat's head in a cat `columns` wide. */
181export const catHead = (columns: number): number => Math.floor((CAT_HEAD_X - CAT_VIEW.left) / catSheet(columns).scale / 2)
182
183const LAYER_ORDER: readonly Layer[] = ['table', 'drum', 'cat', 'eyes', 'bean', 'hit']
184const BRAILLE_BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]] as const
185
186/**
187 * The cat as rows of colored braille glyphs, `columns` wide. Each cell takes
188 * the color of its strongest layer: impact lines, toe beans, eyes, the cat,
189 * the drums, then the table edge.
190 */
191export const bongoCat = (phase: CockpitMascot['phase'], frame: number, load: number, columns = 40): Glyph[][] => {
192 const safeFrame = Number.isFinite(frame) ? Math.max(0, Math.floor(frame)) : 0
193 const pose = catPose(phase, safeFrame, load)
194 const sheet = catSheet(Math.max(20, Math.min(40, Math.floor(Number.isFinite(columns) ? columns : 40))))
195 const dots = sheet.base.slice()
196 const draw = (part: CatPart, layer: Layer): void => { for (const dot of sheet.parts[part]) dots[dot] = layer }
197 const leftDown = pose === 'left' || pose === 'both' || pose === 'rest'
198 const rightDown = pose === 'right' || pose === 'both' || pose === 'rest'
199 const hit = pose !== 'rest'
200 draw(phase === 'error' ? 'gasp' : 'smile', 'cat')
201 draw(eyesFor(phase, pose, safeFrame), 'eyes')
202 if (leftDown) { draw('leftDown', 'cat'); if (hit) draw('leftHit', 'hit') } else { draw('leftUp', 'cat'); draw('leftBeans', 'bean') }
203 if (rightDown) { draw('rightDown', 'cat'); if (hit) draw('rightHit', 'hit') } else { draw('rightUp', 'cat'); draw('rightBeans', 'bean') }
204 const colorOf: Record<Layer, ThemeKey | undefined> = {
205 table: colors.muted, drum: colors.yellow, cat: undefined, eyes: phase === 'error' ? colors.red : undefined, bean: colors.magenta, hit: colors.accent,
206 }
207 const width = sheet.columns * 2
208 const grid: Glyph[][] = Array.from({ length: sheet.rows }, (_, row) => Array.from({ length: sheet.columns }, (_, column): Glyph => {
209 let bits = 0
210 let strongest = -1
211 for (let dy = 0; dy < 4; dy += 1) {
212 for (let dx = 0; dx < 2; dx += 1) {
213 const layer = dots[(row * 4 + dy) * width + column * 2 + dx]
214 if (layer === undefined) continue
215 bits |= BRAILLE_BITS[dy]?.[dx] ?? 0
216 strongest = Math.max(strongest, LAYER_ORDER.indexOf(layer))
217 }
218 }
219 // An empty cell is a space: some fonts draw the blank braille pattern.
220 if (bits === 0) return { char: ' ' }
221 const layer = LAYER_ORDER[strongest]
222 return { char: String.fromCharCode(0x2800 + bits), color: layer === undefined ? undefined : colorOf[layer] }
223 }))
224 const ear = Math.floor((CAT_EAR_X - CAT_VIEW.left) / sheet.scale / 2)
225 const put = (row: number, column: number, char: string, color: ThemeKey): void => {
226 const cell = grid[row]?.[Math.min(sheet.columns - 1, column)]
227 if (cell) { cell.char = char; cell.color = color }
228 }
229 if (phase === 'idle') {
230 const drift = safeFrame % 4 < 2 ? 0 : 1
231 put(1, ear + 2 + drift, 'z', colors.muted)
232 put(0, ear + 4 + drift, 'Z', colors.muted)
233 }
234 if (phase === 'error') put(0, ear + 3, '!', colors.red)
235 if (phase === 'thinking' && safeFrame % 8 >= 4) put(0, ear + 3, '?', colors.cyan)
236 return grid
237}
238
239/** What the cat says: the tool it drums for, the thought, the error, or sleep. */
240export const catSays = (activity: CockpitActivity, now: number): { text: string; color: ThemeKey } => {
241 const status = sessionStatus(activity, activity.working)
242 if (activity.stopFailure && !activity.working) return { text: `stopped: ${humanize(activity.stopFailure.error)}`, color: colors.red }
243 const running = runningTools(activity)
244 const lead = leadTool(running)
245 if (lead) {
246 const more = running.length > 1 ? ` +${running.length - 1}` : ''
247 return { text: `${toolLabel(lead)} · ${brief(now - lead.startedAt)}${more}`, color: colors.cyan }
248 }
249 if (activity.working) {
250 return { text: activity.turnStartedAt === null ? 'thinking' : `thinking · ${brief(now - activity.turnStartedAt)}`, color: colors.cyan }
251 }
252 if (status.label === 'last turn failed') return { text: 'last turn failed', color: colors.red }
253 const last = mainTurns(activity).at(-1)
254 return { text: last ? `zzz · idle ${brief(now - last.finishedAt)}` : 'zzz', color: colors.muted }
255}
256
257const toSegments = (row: readonly Glyph[]): Segment[] => {
258 const segments: Segment[] = []
259 for (const glyph of row) {
260 const last = segments[segments.length - 1]
261 if (last && last.color === glyph.color) last.text += glyph.char
262 else segments.push({ text: glyph.char, color: glyph.color })
263 }
264 return segments
265}
266
267const catBlock = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement => {
268 const { Box } = ui
269 const load = activityLoad(props.activity)
270 const frame = props.preferences.animation ? props.mascot.frame : 0
271 const columns = catColumns(width)
272 const grid = bongoCat(props.mascot.phase, frame, load, columns)
273 const says = catSays(props.activity, props.now)
274 const text = clip(says.text, Math.max(8, width - 6))
275 const inner = [...text].length + 2
276 const blockWidth = Math.max(columns, inner + 2)
277 // Center the bubble and the cat in one block, the tail over the cat's head.
278 const bubbleAt = Math.floor((blockWidth - inner - 2) / 2)
279 const catAt = Math.floor((blockWidth - columns) / 2)
280 const tail = Math.max(bubbleAt + 1, Math.min(bubbleAt + inner, catAt + catHead(columns)))
281 const border = colors.muted
282 const pad = (segments: Segment[], used: number): Segment[] => [...segments, { text: ' '.repeat(Math.max(0, blockWidth - used)) }]
283 const rows: Segment[][] = [
284 pad([{ text: `${' '.repeat(bubbleAt)}╭${'─'.repeat(inner)}╮`, color: border }], bubbleAt + inner + 2),
285 pad([{ text: `${' '.repeat(bubbleAt)}│ `, color: border }, { text, color: says.color, bold: true }, { text: ' │', color: border }], bubbleAt + inner + 2),
286 pad([{ text: `${' '.repeat(bubbleAt)}╰${'─'.repeat(tail - bubbleAt - 1)}┬${'─'.repeat(bubbleAt + inner - tail)}╯`, color: border }], bubbleAt + inner + 2),
287 pad([{ text: `${' '.repeat(tail)}│`, color: border }], tail + 1),
288 ...grid.map(row => pad([{ text: ' '.repeat(catAt) }, ...toSegments(row)], catAt + columns)),
289 ]
290 return (
291 <Box key="activity:cat" flexDirection="column" alignItems="center" width="100%">
292 {rows.map((segments, index) => line(ui, `activity:cat:${index}`, segments))}
293 </Box>
294 )
295}
296
297// ---------------------------------------------------------------- history
298
299export const HISTORY_BUCKET_MS = 5000
300
301// The level per bucket for the newest buckets up to `now`: the highest level
302// reached in the bucket, or the level carried from the sample before it.
303export const activityHistory = (
304 samples: readonly CockpitSample[], now: number, columns: number, bucketMs = HISTORY_BUCKET_MS,
305): number[] => {
306 const sorted = samples.filter(sample => finite(sample?.at) && finite(sample?.level)).slice().sort((left, right) => left.at - right.at)
307 const first = sorted[0]
308 if (first === undefined || !finite(now)) return []
309 const width = Math.max(1, Math.min(120, Math.floor(Number.isFinite(columns) ? columns : 40)))
310 const end = Math.floor(now / bucketMs)
311 const begin = Math.max(end - width + 1, Math.floor(first.at / bucketMs))
312 let carried = 0
313 let cursor = 0
314 while (cursor < sorted.length && Math.floor((sorted[cursor]?.at ?? 0) / bucketMs) < begin) {
315 carried = sorted[cursor]?.level ?? carried
316 cursor += 1
317 }
318 const levels: number[] = []
319 for (let bucket = begin; bucket <= end; bucket += 1) {
320 let peak = carried
321 while (cursor < sorted.length && Math.floor((sorted[cursor]?.at ?? 0) / bucketMs) === bucket) {
322 carried = sorted[cursor]?.level ?? carried
323 peak = Math.max(peak, carried)
324 cursor += 1
325 }
326 levels.push(peak)
327 }
328 return levels
329}
330
331// ---------------------------------------------------------------- timeline
332
333/** Cell states, in the order one overrides another within a cell. */
334export const LANE = { absent: -1, idle: 0, model: 1, agent: 2, tool: 3, approval: 4, error: 5 } as const
335type LaneState = typeof LANE[keyof typeof LANE]
336
337const laneGlyph: Record<LaneState, Glyph> = {
338 [-1]: { char: ' ' },
339 0: { char: '·', color: colors.muted },
340 1: { char: '▒', color: colors.accent },
341 2: { char: '▓', color: colors.magenta },
342 3: { char: '█', color: colors.cyan },
343 4: { char: '█', color: colors.yellow },
344 5: { char: '█', color: colors.red },
345}
346
347const SPANS: Record<Exclude<CockpitSpan, 'fit'>, number> = { '5m': 300000, '15m': 900000, '60m': 3600000 }
348const NICE_CELLS = [1, 2, 3, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 300, 600, 900, 1200, 1800, 3600].map(seconds => seconds * 1000)
349
350export const SPAN_ORDER: CockpitSpan[] = ['fit', '5m', '15m', '60m']
351
352/** The cell size and start: a fixed span, or since the first event for `fit`. */
353export const timelineScale = (span: CockpitSpan, first: number | undefined, now: number, cells: number): { cellMs: number; start: number } => {
354 const columns = Math.max(1, Math.floor(cells))
355 const wanted = span === 'fit' ? Math.max(60000, now - (first ?? now)) : SPANS[span]
356 const cellMs = NICE_CELLS.find(size => size * columns >= wanted) ?? NICE_CELLS[NICE_CELLS.length - 1] ?? 3600000
357 return { cellMs, start: now - cellMs * columns }
358}
359
360type Interval = { from: number; to: number; state: LaneState }
361export type Lane = { id?: string; label: string; cells: LaneState[] }
362
363const laneIntervals = (activity: CockpitActivity, id: string | undefined, now: number): { intervals: Interval[]; from?: number; to?: number } => {
364 const intervals: Interval[] = []
365 for (const turn of activity.turns.filter(item => item.agentId === id)) {
366 intervals.push({ from: turn.finishedAt - turn.durationMs, to: turn.finishedAt, state: LANE.model })
367 }
368 let from: number | undefined
369 let to: number | undefined
370 if (id === undefined) {
371 if (activity.working && activity.turnStartedAt !== null) intervals.push({ from: activity.turnStartedAt, to: now, state: LANE.model })
372 } else {
373 const agent = activity.agents.find(item => item.id === id)
374 if (agent?.startedAt !== undefined) {
375 from = agent.startedAt
376 to = isLive(agent) ? now : agent.completedAt ?? agent.lastSeenAt
377 intervals.push({ from, to, state: LANE.model })
378 }
379 }
380 for (const tool of activity.tools.filter(item => item.agentId === id)) {
381 const end = tool.finishedAt ?? (tool.outcome === 'running' ? now : tool.startedAt)
382 intervals.push({ from: tool.startedAt, to: end, state: tool.tool === 'Agent' ? LANE.agent : LANE.tool })
383 const wait = tool.approval === 'asked' ? waitDuration(tool) : undefined
384 if (wait !== undefined && wait > 0) intervals.push({ from: tool.startedAt, to: tool.startedAt + wait, state: LANE.approval })
385 if (tool.outcome === 'error' || tool.outcome === 'denied') intervals.push({ from: end, to: end, state: LANE.error })
386 from = from === undefined ? tool.startedAt : Math.min(from, tool.startedAt)
387 to = to === undefined ? end : Math.max(to, end)
388 }
389 return { intervals, from, to }
390}
391
392export const timelineLanes = (
393 activity: CockpitActivity, now: number, start: number, cellMs: number, cells: number, maxLanes = 6, sessionFrom?: number,
394): Lane[] => {
395 const windowEnd = start + cellMs * cells
396 const agents = activity.agents
397 .filter(agent => agent.startedAt !== undefined || activity.tools.some(tool => tool.agentId === agent.id))
398 .map(agent => ({ agent, span: laneIntervals(activity, agent.id, now) }))
399 .filter(entry => (entry.span.to ?? 0) >= start)
400 .sort((left, right) => (left.span.from ?? 0) - (right.span.from ?? 0))
401 .slice(-Math.max(0, maxLanes - 1))
402 const main = laneIntervals(activity, undefined, now)
403 const lanes = [
404 { id: undefined, label: 'main', span: { ...main, from: sessionFrom ?? main.from, to: now }, bounded: sessionFrom !== undefined },
405 ...agents.map(entry => ({ id: entry.agent.id, label: agentName(entry.agent), span: entry.span, bounded: true })),
406 ]
407 return lanes.map(lane => {
408 const states: LaneState[] = []
409 for (let index = 0; index < cells; index += 1) {
410 const from = start + index * cellMs
411 const to = Math.min(windowEnd, from + cellMs)
412 let state: LaneState = lane.bounded && (lane.span.from === undefined || to <= lane.span.from || from >= (lane.span.to ?? now)) ? LANE.absent : LANE.idle
413 if (from >= now) state = LANE.absent
414 for (const interval of lane.span.intervals) {
415 const overlaps = interval.from === interval.to ? interval.from >= from && interval.from < to : interval.from < to && interval.to > from
416 if (overlaps && interval.state > state) state = interval.state
417 }
418 states.push(state)
419 }
420 return { id: lane.id, label: lane.label, cells: states }
421 })
422}
423
424const laneSegments = (cells: readonly LaneState[]): Segment[] => toSegments(cells.map(state => laneGlyph[state]))
425
426export type TimeBudget = {
427 sessionMs?: number
428 busyMs: number
429 toolMs: number
430 topTools: { tool: string; ms: number }[]
431 agentMs: number
432 agents: number
433 approvals: number
434 approvalMs: number
435 peak: number
436 average?: number
437 calls: number
438 failures: number
439}
440
441/** Where the session's time went: the main turn, tools, agents, and approval prompts. */
442export const timeBudget = (activity: CockpitActivity, context: CockpitContext, now: number): TimeBudget => {
443 const started = sessionStart(activity, context)
444 const turns = mainTurns(activity)
445 const busy = turns.reduce((total, turn) => total + turn.durationMs, 0)
446 + (activity.working && activity.turnStartedAt !== null ? Math.max(0, now - activity.turnStartedAt) : 0)
447 const tools = activity.tools.filter(tool => tool.tool !== 'Agent')
448 const wait = approvalWait(activity.tools)
449 const sorted = activity.samples.filter(sample => finite(sample.at) && finite(sample.level)).slice().sort((left, right) => left.at - right.at)
450 let weighted = 0
451 let active = 0
452 sorted.forEach((sample, index) => {
453 const end = sorted[index + 1]?.at ?? now
454 if (sample.level > 0) { weighted += sample.level * (end - sample.at); active += end - sample.at }
455 })
456 return {
457 sessionMs: finite(started) ? Math.max(0, now - started) : undefined,
458 busyMs: busy,
459 toolMs: tools.reduce((total, tool) => total + (toolDuration(tool, now) ?? 0), 0),
460 topTools: toolStats(tools, now).slice(0, 3).map(stat => ({ tool: stat.tool, ms: stat.totalMs })),
461 agentMs: activity.agents.reduce((total, agent) => total + (agentElapsed(agent, now) ?? 0), 0),
462 agents: activity.agents.length,
463 approvals: wait.asked,
464 approvalMs: wait.totalMs,
465 peak: Math.max(0, ...sorted.map(sample => sample.level)),
466 average: active > 0 ? weighted / active : undefined,
467 calls: activity.tools.length,
468 failures: activity.tools.filter(tool => tool.outcome === 'error' || tool.outcome === 'denied').length,
469 }
470}
471
472const timelineSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement => {
473 const { Box } = ui
474 const labelWidth = Math.min(10, Math.max(6, Math.floor(width / 6)))
475 const cells = Math.max(10, width - labelWidth - 1)
476 const first = sessionStart(props.activity, props.context)
477 const { cellMs, start } = timelineScale(props.preferences.span, first, props.now, cells)
478 const lanes = timelineLanes(props.activity, props.now, start, cellMs, cells, 6, first)
479 const left = `-${brief(props.now - start)}`
480 const middle = `-${brief((props.now - start) / 2)}`
481 const axisGap = Math.max(1, Math.floor((cells - left.length - middle.length - 3) / 2))
482 const legend: Segment[] = [
483 { text: '█', color: colors.cyan }, { text: ' tool ', color: colors.muted },
484 { text: '▒', color: colors.accent }, { text: ' model ', color: colors.muted },
485 { text: '▓', color: colors.magenta }, { text: ' agent ', color: colors.muted },
486 { text: '█', color: colors.yellow }, { text: ' approval ', color: colors.muted },
487 { text: '█', color: colors.red }, { text: ' error', color: colors.muted },
488 ]
489 return (
490 <Box key="activity:timeline" flexDirection="column" width="100%">
491 {heading(ui, 'activity:timeline:title', 'Timeline', [
492 { text: `${brief(cellMs * cells)} · 1 cell ${brief(cellMs)}`, color: colors.muted },
493 ], width)}
494 {lanes.map(lane => line(ui, `activity:lane:${lane.id ?? 'main'}`, [
495 { text: `${padEnd(clip(lane.label, labelWidth - 1), labelWidth - 1)} `, color: lane.id === undefined ? colors.accent : colors.magenta },
496 ...laneSegments(lane.cells),
497 ]))}
498 {line(ui, 'activity:axis', [{ text: `${' '.repeat(labelWidth)}${left}${' '.repeat(axisGap)}${middle}${' '.repeat(axisGap)}now`, color: colors.muted }])}
499 {line(ui, 'activity:legend', legend, true)}
500 {actions(ui, 'activity:timeline:actions', [
501 { key: 'activity:span', label: `Zoom: ${props.preferences.span === 'fit' ? 'session' : `last ${props.preferences.span}`}`, hotkey: 'z', onPress: props.actions.cycleSpan },
502 { key: 'activity:animation', label: props.preferences.animation ? 'Pause cat' : 'Wake cat', hotkey: 'p', onPress: props.actions.toggleAnimation },
503 ])}
504 </Box>
505 )
506}
507
508const budgetSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement => {
509 const budget = timeBudget(props.activity, props.context, props.now)
510 const share = finite(budget.sessionMs) && budget.sessionMs > 0 ? ` (${percent(budget.busyMs / budget.sessionMs * 100)})` : ''
511 const busyMinutes = budget.busyMs / 60000
512 return section(ui, 'activity:budget', [
513 heading(ui, 'activity:budget:title', 'Where time went', [], width),
514 field(ui, 'activity:budget:session', 'Session', [
515 { text: finite(budget.sessionMs) ? duration(budget.sessionMs) : 'unknown' },
516 { text: ' · main busy ', color: colors.muted }, { text: `${duration(budget.busyMs)}${share}` },
517 ], 10),
518 field(ui, 'activity:budget:tools', 'Tools', [
519 { text: duration(budget.toolMs) },
520 ...budget.topTools.flatMap(tool => [{ text: ' · ', color: colors.muted }, { text: tool.tool }, { text: ` ${brief(tool.ms)}`, color: colors.muted }]),
521 ], 10),
522 budget.agents > 0 ? field(ui, 'activity:budget:agents', 'Agents', [
523 { text: plural(budget.agents, 'agent') }, { text: ` · ${duration(budget.agentMs)} total`, color: colors.muted },
524 ], 10) : null,
525 field(ui, 'activity:budget:approval', 'Approval', budget.approvals === 0 ? [{ text: 'none asked', color: colors.muted }] : [
526 { text: plural(budget.approvals, 'prompt'), color: colors.yellow },
527 { text: budget.approvalMs > 0 ? ` · ${duration(budget.approvalMs)} waiting on you` : '', color: colors.yellow },
528 ], 10),
529 field(ui, 'activity:budget:parallel', 'Parallel', [
530 { text: `peak ${budget.peak}` },
531 { text: budget.average === undefined ? '' : ` · avg ${budget.average.toFixed(1)} while active`, color: colors.muted },
532 ], 10),
533 field(ui, 'activity:budget:pace', 'Pace', [
534 { text: plural(budget.calls, 'call') },
535 { text: busyMinutes >= 0.5 ? ` · ${(budget.calls / busyMinutes).toFixed(1)}/min busy` : '', color: colors.muted },
536 { text: budget.failures > 0 ? ` · ${budget.failures} failed` : '', color: colors.red },
537 ], 10),
538 ])
539}
540
541const phaseLabel: Record<CockpitMascot['phase'], string> = {
542 idle: 'idle', thinking: 'thinking', tools: 'running tools', error: 'error',
543}
544
545export const renderActivity = (ui: CockpitElements, props: CockpitViewProps): RenderElement => {
546 const { Box } = ui
547 const width = viewWidth(props)
548 const status = sessionStatus(props.activity, props.activity.working)
549 const tools = runningTools(props.activity).length
550 const agents = props.activity.agents.filter(agent => agent.status === 'running').length
551 const history = activityHistory(props.activity.samples, props.now, width)
552 const wide = width >= 100
553 const left = wide ? Math.floor((width - 2) / 2) : width
554 const right = wide ? width - left - 2 : width
555 const top = [
556 heading(ui, 'activity:title', 'Activity', [
557 { text: phaseLabel[props.mascot.phase], color: status.color },
558 { text: props.preferences.animation ? '' : ' · paused', color: colors.muted },
559 ], width),
560 catBlock(ui, props, width),
561 line(ui, 'activity:counts', [
562 { text: plural(tools, 'tool'), color: tools > 0 ? colors.cyan : colors.muted },
563 { text: ' · ', color: colors.muted },
564 { text: plural(agents, 'agent'), color: agents > 0 ? colors.magenta : colors.muted },
565 { text: props.activity.working && props.activity.turnStartedAt !== null ? ` · turn ${brief(props.now - props.activity.turnStartedAt)}` : '', color: colors.muted },
566 ]),
567 history.length > 0 ? line(ui, 'activity:history', [{ text: sparkline(history, width), color: colors.cyan }]) : null,
568 history.length > 0 ? muted(ui, 'activity:history:legend', `Concurrency, last ${brief(history.length * HISTORY_BUCKET_MS)}: tools, agents, and the main turn`) : null,
569 ].filter((child): child is RenderElement => Boolean(child))
570 const timeline = timelineSection(ui, props, left)
571 const budget = budgetSection(ui, props, right)
572 return (
573 <Box flexDirection="column" gap={1} width="100%">
574 <Box key="activity:top" flexDirection="column" width="100%">{top}</Box>
575 {wide ? (
576 <Box key="activity:bottom" flexDirection="row" gap={2} width="100%" alignItems="flex-start">
577 <Box flexDirection="column" width={left} flexShrink={0}>{timeline}</Box>
578 <Box flexDirection="column" width={right} flexShrink={0}>{budget}</Box>
579 </Box>
580 ) : (
581 <Box key="activity:bottom" flexDirection="column" gap={1} width="100%">{timeline}{budget}</Box>
582 )}
583 </Box>
584 )
585}
586hooks/agents.tsx 285 lines1import type { RenderElement, ThemeKey } from 'claude-code'
2import type { CockpitAgent, CockpitTodo, CockpitViewProps } from '../types'
3import { actions, card, field, heading, line, listLimit, muted, neighboringIndex, section, spread, viewWidth, visibleWindow } from './layout'
4import type { Segment } from './summary'
5import {
6 agentElapsed, agentTokens, cacheHit, isLive, mainTurns, runningTools, sessionStatus, tokenTotals, toolDuration,
7 toolLabel, toolStats,
8} from './summary'
9import type { CockpitElements } from './theme'
10import {
11 ago, brief, cleanText, clip, colors, compact, count, duration, meter, padStart, percent, plural, shortModel, statusColor,
12 statusGlyph,
13} from './theme'
14
15export const agentStatusLabel = (agent: CockpitAgent): string => {
16 if (agent.status === 'missing') return 'unknown (not listed)'
17 return agent.teammateId ? `last reported: ${agent.status}` : agent.status
18}
19
20export type AgentRow = { agent: CockpitAgent; depth: number; last: boolean }
21
22export const agentHierarchy = (agents: readonly CockpitAgent[]): AgentRow[] => {
23 const bounded = agents.slice(0, 100)
24 const ids = new Set(bounded.map(agent => agent.id))
25 const children = new Map<string | undefined, CockpitAgent[]>()
26 for (const agent of bounded) {
27 const parent = agent.parentId && ids.has(agent.parentId) ? agent.parentId : undefined
28 const siblings = children.get(parent) ?? []
29 siblings.push(agent)
30 children.set(parent, siblings)
31 }
32 const rows: AgentRow[] = []
33 const visited = new Set<string>()
34 const visit = (agent: CockpitAgent, depth: number, last: boolean): void => {
35 if (visited.has(agent.id)) return
36 visited.add(agent.id)
37 rows.push({ agent, depth, last })
38 const kids = (children.get(agent.id) ?? []).filter(child => !visited.has(child.id))
39 kids.forEach((child, index) => visit(child, depth + 1, index === kids.length - 1))
40 }
41 const roots = children.get(undefined) ?? []
42 roots.forEach((agent, index) => visit(agent, 0, index === roots.length - 1))
43 // Cycles have no root. Keep those agents visible without walking the cycle twice.
44 for (const agent of bounded) visit(agent, 0, true)
45 return rows
46}
47
48export const agentName = (agent: CockpitAgent): string => cleanText(agent.name || agent.description || agent.id)
49
50export const actorName = (props: CockpitViewProps, id: string | undefined): string => {
51 if (!id) return 'main'
52 const agent = props.activity.agents.find(item => item.id === id)
53 return agent ? agentName(agent) : `agent ${cleanText(id)}`
54}
55
56// Stored answers are already cleaned. `clip` would also turn their newlines into spaces.
57const excerpt = (value: string, limit: number): string =>
58 value.length <= limit ? value : `${value.slice(0, limit - 1)}…`
59
60const todoMark: Record<CockpitTodo['status'], string> = { completed: '✓', in_progress: '▸', pending: '○' }
61const todoColor: Record<CockpitTodo['status'], ThemeKey> = { completed: colors.green, in_progress: colors.cyan, pending: colors.muted }
62
63/** The plan in its own order, windowed around the item in progress. */
64const checklist = (
65 ui: CockpitElements, key: string, items: readonly { status: CockpitTodo['status']; text: string }[], columns: number, limit = 8,
66): RenderElement[] => {
67 const current = Math.max(0, items.findIndex(item => item.status === 'in_progress'))
68 const shown = visibleWindow(items, current, limit)
69 const start = items.indexOf(shown[0] as typeof items[number])
70 const rows = shown.map((item, index) =>
71 line(ui, `${key}:${start + index}`, [
72 { text: `${todoMark[item.status]} `, color: todoColor[item.status] },
73 { text: clip(item.text, columns - 2), color: item.status === 'completed' ? colors.muted : undefined, bold: item.status === 'in_progress' },
74 ]))
75 if (start > 0) rows.unshift(muted(ui, `${key}:before`, ` ${start} done above`))
76 const after = items.length - start - shown.length
77 if (after > 0) rows.push(muted(ui, `${key}:after`, ` ${after} more`))
78 return rows
79}
80
81const progressDetail = (done: number, total: number, columns: number): Segment[] => [
82 { text: `${done}/${total} `, color: done === total ? colors.green : colors.accent },
83 { text: meter(total === 0 ? 0 : done / total * 100, Math.max(6, Math.min(16, columns))), color: done === total ? colors.green : colors.cyan },
84]
85
86const planSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement | null => {
87 const todos = props.activity.todos.find(list => list.agentId === undefined)?.items ?? []
88 const tasks = props.activity.tasks
89 if (todos.length === 0 && tasks.length === 0) return null
90 const items = todos.length > 0
91 ? todos.map(todo => ({ status: todo.status, text: todo.status === 'in_progress' ? todo.activeForm : todo.content }))
92 : tasks.map(task => ({ status: task.status, text: `#${task.id} ${task.subject}${task.owner ? ` · ${task.owner}` : ''}` }))
93 const done = items.filter(item => item.status === 'completed').length
94 return section(ui, 'agents:plan', [
95 heading(ui, 'agents:plan:title', todos.length > 0 ? 'Plan' : 'Tasks', progressDetail(done, items.length, width - 20), width),
96 ...checklist(ui, 'agents:plan:item', items, width),
97 todos.length > 0 && tasks.length > 0 ? muted(ui, 'agents:plan:tasks', `Tasks ${tasks.filter(task => task.status === 'completed').length}/${tasks.length} done`) : null,
98 ])
99}
100
101const sessionSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement => {
102 const { activity } = props
103 const status = sessionStatus(activity, activity.working)
104 const turns = mainTurns(activity)
105 const totals = tokenTotals(turns.map(turn => turn.usage))
106 const hit = cacheHit(totals)
107 const failures = activity.tools.filter(tool => tool.outcome === 'error' || tool.outcome === 'denied').length
108 const agents = activity.agents
109 const agentTotal = agents.reduce((total, agent) => total + (agentTokens(agent) ?? 0), 0)
110 const average = turns.length > 0 ? turns.reduce((total, turn) => total + turn.durationMs, 0) / turns.length : undefined
111 const since = status.since === undefined ? '' : ` · ${brief(props.now - status.since)}`
112 const turnClock = activity.working && activity.turnStartedAt !== null && status.label !== 'thinking'
113 ? ` · turn ${brief(props.now - activity.turnStartedAt)}` : ''
114 return section(ui, 'agents:session', [
115 heading(ui, 'agents:session:title', 'Session', [{ text: shortModel(activity.model || 'model unknown'), color: colors.muted }], width),
116 line(ui, 'agents:session:status', [
117 { text: `${status.glyph} `, color: status.color },
118 { text: clip(`${status.label}${since}${turnClock}`, width - 2), color: status.color, bold: true },
119 ]),
120 line(ui, 'agents:session:turns', [
121 { text: plural(turns.length, 'turn') },
122 { text: average === undefined ? '' : ` · avg ${duration(average)}`, color: colors.muted },
123 { text: ` · ${plural(activity.tools.length, 'tool call')}` },
124 { text: failures > 0 ? ` · ${failures} failed` : '', color: colors.red },
125 ]),
126 totals.input + totals.output + totals.cacheRead > 0 ? line(ui, 'agents:session:tokens', [
127 { text: 'in ', color: colors.muted }, { text: compact(totals.input) },
128 { text: ' · out ', color: colors.muted }, { text: compact(totals.output) },
129 { text: ' · cache ', color: colors.muted }, { text: compact(totals.cacheRead + totals.cacheWrite) },
130 { text: hit === undefined ? '' : ` · ${percent(hit)} hit`, color: hit !== undefined && hit < 50 ? colors.yellow : colors.green },
131 ]) : null,
132 agents.length > 0 ? line(ui, 'agents:session:agents', [
133 { text: plural(agents.length, 'agent') },
134 { text: ` · ${agents.filter(isLive).length} live`, color: colors.cyan },
135 { text: agentTotal > 0 ? ` · ${compact(agentTotal)} tokens` : '', color: colors.muted },
136 ]) : null,
137 ])
138}
139
140const rowDetail = (props: CockpitViewProps, agent: CockpitAgent): Segment[] => {
141 const elapsed = agentElapsed(agent, props.now)
142 const time = elapsed === undefined ? '' : brief(elapsed)
143 if (isLive(agent)) {
144 const current = runningTools(props.activity).find(tool => tool.agentId === agent.id)
145 return [
146 { text: current ? `▸ ${clip(current.tool, 12)} ` : `${agent.status} `, color: colors.cyan },
147 { text: padStart(time, 5), color: colors.muted },
148 ]
149 }
150 const tokens = agentTokens(agent)
151 return [
152 { text: tokens === undefined ? `${agent.status} ` : `${compact(tokens)} `, color: tokens === undefined ? statusColor(agent.status) : colors.muted },
153 { text: padStart(time, 5), color: colors.muted },
154 ]
155}
156
157const agentList = (ui: CockpitElements, props: CockpitViewProps, width: number, selected: CockpitAgent | undefined): RenderElement => {
158 const { Box, Button, Text } = ui
159 const rows = agentHierarchy(props.activity.agents)
160 const selectedIndex = Math.max(0, rows.findIndex(row => row.agent.id === selected?.id))
161 const visible = visibleWindow(rows, selectedIndex, listLimit(props))
162 const live = props.activity.agents.filter(isLive).length
163 const done = props.activity.agents.filter(agent => agent.status === 'completed').length
164 const failed = props.activity.agents.filter(agent => agent.status === 'failed' || agent.status === 'killed').length
165 const chooseNeighbor = (direction: number): Promise<void> => {
166 const next = rows[neighboringIndex(rows.length, selectedIndex, direction)]?.agent
167 return next ? props.actions.selectAgent(next.id) : Promise.resolve()
168 }
169 return section(ui, 'agents:list', [
170 heading(ui, 'agents:list:title', 'Agents', [
171 { text: `${live} live`, color: live > 0 ? colors.cyan : colors.muted },
172 { text: ` · ${done} done`, color: colors.muted },
173 { text: failed > 0 ? ` · ${failed} failed` : '', color: colors.red },
174 ], width),
175 rows.length === 0 ? muted(ui, 'agents:list:empty', 'No subagents yet. They appear here when the session starts one.') : null,
176 ...visible.map(({ agent, depth, last }) => {
177 const indent = depth === 0 ? '' : `${' '.repeat(Math.min(3, depth - 1))}${last ? '└ ' : '├ '}`
178 const marker = agent.id === selected?.id ? '›' : ' '
179 const viewing = agent.id === props.viewedAgent ? ' ◉' : ''
180 const detail = rowDetail(props, agent)
181 const detailWidth = detail.reduce((total, segment) => total + [...segment.text].length, 0)
182 const nameWidth = Math.max(6, width - detailWidth - [...indent].length - 5 - viewing.length)
183 return spread(ui, `agents:row:${agent.id}`, (
184 <Box flexDirection="row">
185 <Text color={agent.id === selected?.id ? colors.accent : colors.muted}>{marker} {indent}</Text>
186 <Text color={statusColor(agent.status)}>{statusGlyph(agent.status)} </Text>
187 <Button key={`agents:select:${agent.id}`} plain label={`${clip(agentName(agent), nameWidth)}${viewing}`} onPress={() => props.actions.selectAgent(agent.id)} />
188 </Box>
189 ), detail)
190 }),
191 rows.length > visible.length ? muted(ui, 'agents:list:more', `Showing ${visible.length} of ${rows.length}.`) : null,
192 actions(ui, 'agents:list:actions', [
193 { key: 'agents:refresh', label: 'Refresh', hotkey: 'r', onPress: props.actions.refreshAgents },
194 { key: 'agents:prev', label: 'Previous', hotkey: 'k', onPress: () => chooseNeighbor(-1), hidden: rows.length < 2 },
195 { key: 'agents:next', label: 'Next', hotkey: 'j', onPress: () => chooseNeighbor(1), hidden: rows.length < 2 },
196 ]),
197 ])
198}
199
200const agentDetail = (ui: CockpitElements, props: CockpitViewProps, agent: CockpitAgent, width: number): RenderElement => {
201 const { Markdown } = ui
202 const inner = width - 4
203 const tools = props.activity.tools.filter(tool => tool.agentId === agent.id)
204 const recent = tools.slice().sort((left, right) => right.startedAt - left.startedAt).slice(0, 4)
205 const mix = toolStats(tools, props.now).sort((left, right) => right.calls - left.calls).slice(0, 4)
206 const todos = props.activity.todos.find(list => list.agentId === agent.id)?.items ?? []
207 const elapsed = agentElapsed(agent, props.now)
208 const tokens = agentTokens(agent)
209 const usage = agent.usage
210 const totals = agent.totals
211 const timing = isLive(agent)
212 ? `${elapsed === undefined ? 'running' : `running ${duration(elapsed)}`}`
213 : `${agentStatusLabel(agent)}${agent.completedAt === undefined ? '' : ` ${ago(props.now, agent.completedAt)}`}${elapsed === undefined ? '' : ` · ran ${duration(elapsed)}`}`
214 return card(ui, `agents:detail:${agent.id}`, [
215 spread(ui, 'agents:detail:title', line(ui, 'agents:detail:name', [
216 { text: `${statusGlyph(agent.status)} `, color: statusColor(agent.status) },
217 { text: clip(agentName(agent), inner - 18), bold: true },
218 ]), [{ text: clip(agent.type, 16), color: colors.magenta }]),
219 agent.name && agent.description && agent.description !== agent.name ? muted(ui, 'agents:detail:description', clip(agent.description, inner * 2)) : null,
220 field(ui, 'agents:detail:status', 'Status', [{ text: timing, color: statusColor(agent.status) }]),
221 field(ui, 'agents:detail:parent', 'Parent', [{ text: actorName(props, agent.parentId) }, { text: agent.id === props.viewedAgent ? ' · in view' : '', color: colors.accent }]),
222 tokens !== undefined ? field(ui, 'agents:detail:tokens', 'Tokens', [
223 { text: count(tokens) },
224 { text: totals ? ` · ${plural(totals.toolUses, 'tool use')}` : usage ? ` · in ${compact(usage.input_tokens)} out ${compact(usage.output_tokens)}` : '', color: colors.muted },
225 ]) : null,
226 totals && (totals.linesAdded !== undefined || totals.linesRemoved !== undefined) ? field(ui, 'agents:detail:lines', 'Lines', [
227 { text: `+${count(totals.linesAdded ?? 0)}`, color: colors.green }, { text: ' ' }, { text: `−${count(totals.linesRemoved ?? 0)}`, color: colors.red },
228 ]) : null,
229 totals && totals.models.length > 0 ? field(ui, 'agents:detail:models', 'Models', [{ text: totals.models.map(shortModel).join(', '), color: colors.muted }])
230 : usage ? field(ui, 'agents:detail:model', 'Model', [{ text: shortModel(usage.model), color: colors.muted }]) : null,
231 mix.length > 0 ? field(ui, 'agents:detail:mix', 'Tools', mix.flatMap((stat, index) => [
232 { text: index > 0 ? ' · ' : '', color: colors.muted }, { text: stat.tool }, { text: ` ${stat.calls}`, color: colors.muted },
233 ])) : null,
234 agent.teammateId ? muted(ui, 'agents:detail:teammate', 'A teammate status is its last report.') : null,
235 agent.status === 'missing' ? muted(ui, 'agents:detail:missing', 'Not listed on the last refresh.') : null,
236 todos.length > 0 ? heading(ui, 'agents:detail:todos', 'Todos', progressDetail(todos.filter(todo => todo.status === 'completed').length, todos.length, 10), inner) : null,
237 ...(todos.length > 0 ? checklist(ui, `agents:detail:todo:${agent.id}`, todos.map(todo => ({ status: todo.status, text: todo.status === 'in_progress' ? todo.activeForm : todo.content })), inner, 5) : []),
238 recent.length > 0 ? heading(ui, 'agents:detail:recent', 'Recent', [{ text: `${tools.length} calls`, color: colors.muted }], inner) : null,
239 ...recent.map(tool => {
240 const elapsed = toolDuration(tool, props.now)
241 return spread(ui, `agents:detail:tool:${tool.id}`, line(ui, `agents:detail:tool:${tool.id}:label`, [
242 { text: `${statusGlyph(tool.outcome)} `, color: statusColor(tool.outcome) },
243 { text: clip(toolLabel(tool), inner - 10) },
244 ]), [{ text: elapsed === undefined ? '' : brief(elapsed), color: colors.muted }])
245 }),
246 agent.answer ? heading(ui, 'agents:detail:answer', 'Answer', [], inner) : null,
247 agent.answer ? <Markdown key={`agents:answer:${agent.id}`} text={excerpt(agent.answer, 1200)} /> : null,
248 tools.length > 0 ? actions(ui, 'agents:detail:actions', [{ key: 'agents:show-tools', label: 'Show its tools', hotkey: 't', onPress: async () => {
249 await props.actions.selectAgent(agent.id)
250 await props.actions.selectTool(null)
251 await props.actions.selectView('tools')
252 } }]) : null,
253 ])
254}
255
256
257export const renderAgents = (ui: CockpitElements, props: CockpitViewProps): RenderElement => {
258 const { Box } = ui
259 const width = viewWidth(props)
260 const wide = width >= 100
261 const left = wide ? Math.floor((width - 2) / 2) : width
262 const right = wide ? width - left - 2 : width
263 const rows = agentHierarchy(props.activity.agents)
264 const selected = rows.find(row => row.agent.id === props.activity.selectedAgent)?.agent ?? rows[0]?.agent
265 const error = props.activity.error
266 const main = [
267 sessionSection(ui, props, left),
268 planSection(ui, props, left),
269 agentList(ui, props, left, selected),
270 error ? line(ui, 'agents:error', [{ text: clip(error, 280), color: colors.red }], true) : null,
271 ].filter((child): child is RenderElement => Boolean(child))
272 const detail = selected ? agentDetail(ui, props, selected, right) : null
273 return wide ? (
274 <Box flexDirection="row" gap={2} width="100%" alignItems="flex-start">
275 <Box flexDirection="column" gap={1} width={left} flexShrink={0}>{main}</Box>
276 <Box flexDirection="column" width={right} flexShrink={0}>{detail ?? muted(ui, 'agents:detail:none', 'Select an agent to see its run.')}</Box>
277 </Box>
278 ) : (
279 <Box flexDirection="column" gap={1} width="100%">
280 {main}
281 {detail}
282 </Box>
283 )
284}
285hooks/changes.tsx 278 lines1import type { RenderElement, ThemeKey } from 'claude-code'
2import type { CockpitChange, CockpitChangeState, CockpitCheck, CockpitGitOperation, CockpitViewProps } from '../types'
3import { actorName } from './agents'
4import { actions, card, heading, line, listLimit, muted, neighboringIndex, section, spread, viewWidth, visibleWindow } from './layout'
5import type { Segment } from './summary'
6import { changeTotals, checkResult } from './summary'
7import type { CockpitElements } from './theme'
8import { ago, brief, cleanText, clip, clipStart, colors, compact, humanize, padStart, statusColor, statusGlyph } from './theme'
9
10export const patchPreview = (patch: string, maxChars = 24000): { source: string; truncated: boolean } => {
11 const source = patch.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, ' ')
12 const limit = Math.max(0, Math.min(24000, Math.floor(maxChars)))
13 if (source.length <= limit) return { source, truncated: false }
14 // Code needs complete hunks. Do not turn a partial hunk into an apparent complete diff.
15 const starts = [...source.matchAll(/^@@ /gm)].map(match => match.index)
16 const boundary = starts.filter(index => index <= limit).at(-1)
17 return { source: boundary === undefined || boundary === starts[0] ? '' : source.slice(0, boundary), truncated: true }
18}
19
20export const gitOperationLines = (operation: CockpitGitOperation): string[] => [
21 operation.commit && `${humanize(operation.commit.kind)} ${operation.commit.sha.slice(0, 8)}${operation.commit.branch ? ` on ${operation.commit.branch}` : ''}`,
22 operation.push && `pushed ${operation.push.branch}`,
23 operation.branch && `${humanize(operation.branch.action)} ${operation.branch.ref}`,
24 operation.pr && `PR #${operation.pr.number} ${humanize(operation.pr.action)}`,
25].filter((entry): entry is string => Boolean(entry)).map(entry => cleanText(entry))
26
27const stateLetter: Record<CockpitChangeState, string> = {
28 modified: 'M', added: 'A', deleted: 'D', renamed: 'R', untracked: '?', conflict: 'U',
29}
30const stateColor: Record<CockpitChangeState, ThemeKey> = {
31 modified: colors.yellow, added: colors.green, deleted: colors.red, renamed: colors.cyan, untracked: colors.muted, conflict: colors.red,
32}
33
34type Group = { key: string; title: string; changes: CockpitChange[] }
35
36/** Staged, then changed, untracked, and edits that no Git read has placed yet. */
37export const changeGroups = (changes: readonly CockpitChange[]): Group[] => [
38 { key: 'staged', title: 'Staged', changes: changes.filter(change => change.source === 'git' && change.staged) },
39 { key: 'changed', title: 'Changed', changes: changes.filter(change => change.source === 'git' && !change.staged && !change.untracked) },
40 { key: 'untracked', title: 'Untracked', changes: changes.filter(change => change.source === 'git' && !change.staged && change.untracked) },
41 { key: 'edited', title: 'Edited, not yet read from Git', changes: changes.filter(change => change.source === 'observed') },
42].filter(group => group.changes.length > 0)
43
44/** A git-style `+++--` bar scaled to the largest change in view. */
45const diffstat = (change: CockpitChange, largest: number, cells: number): Segment[] => {
46 const total = change.additions + change.deletions
47 if (total === 0 || cells <= 0) return []
48 const width = Math.max(1, Math.round(total / Math.max(1, largest) * cells))
49 const plus = Math.round(change.additions / total * width)
50 return [{ text: '+'.repeat(plus), color: colors.green }, { text: '-'.repeat(width - plus), color: colors.red }]
51}
52
53const fileList = (
54 ui: CockpitElements, props: CockpitViewProps, selected: CockpitChange | undefined, width: number,
55): RenderElement => {
56 const { Box, Button, Text } = ui
57 const ordered = changeGroups(props.review.changes)
58 const flat = ordered.flatMap(group => group.changes)
59 const selectedIndex = Math.max(0, flat.findIndex(change => change.path === selected?.path))
60 const visible = new Set(visibleWindow(flat, selectedIndex, listLimit(props)))
61 const largest = Math.max(1, ...flat.map(change => change.additions + change.deletions))
62 const findingCounts = new Map<string, number>()
63 for (const finding of props.review.findings) findingCounts.set(finding.path, (findingCounts.get(finding.path) ?? 0) + 1)
64 const statWidth = 10
65 const rows: RenderElement[] = []
66 for (const group of ordered) {
67 const shown = group.changes.filter(change => visible.has(change))
68 if (shown.length === 0) continue
69 rows.push(line(ui, `changes:group:${group.key}`, [
70 { text: group.title, color: colors.magenta }, { text: ` ${group.changes.length}`, color: colors.muted },
71 ]))
72 for (const change of shown) {
73 const state = change.state ?? (change.untracked ? 'untracked' : 'modified')
74 const findings = findingCounts.get(change.path) ?? 0
75 const counts = change.additions + change.deletions > 0 || change.patch.length > 0
76 ? `+${compact(change.additions)} −${compact(change.deletions)}` : ''
77 const marks = `${change.edits && change.edits > 1 ? ` ✎${change.edits}` : ''}${findings > 0 ? ` ⚑${findings}` : ''}`
78 const stat = diffstat(change, largest, statWidth)
79 const statLength = stat.reduce((total, segment) => total + segment.text.length, 0)
80 // The bar pads to its width, so the counts line up at the right edge.
81 const right: Segment[] = [
82 { text: marks ? `${marks} ` : '', color: colors.yellow },
83 ...stat,
84 { text: ' '.repeat(Math.max(0, statWidth - statLength)) },
85 { text: padStart(counts, 10), color: colors.muted },
86 ]
87 const rightWidth = right.reduce((total, segment) => total + [...segment.text].length, 0)
88 rows.push(spread(ui, `changes:row:${change.path}`, (
89 <Box flexDirection="row">
90 <Text color={change.path === selected?.path ? colors.accent : colors.muted}>{change.path === selected?.path ? '› ' : ' '}</Text>
91 <Text color={stateColor[state]} bold>{stateLetter[state]} </Text>
92 <Button key={`changes:select:${change.path}`} plain label={clipStart(change.path, Math.max(8, width - rightWidth - 6))} onPress={() => props.actions.selectChange(change.path)} />
93 </Box>
94 ), right))
95 }
96 }
97 return section(ui, 'changes:files', [
98 ...rows,
99 flat.length > visible.size ? muted(ui, 'changes:files:more', `Showing ${visible.size} of ${flat.length}.`) : null,
100 ])
101}
102
103const checkLine = (check: CockpitCheck, now: number): Segment[] => {
104 const failed = check.status === 'failed' || (check.failed ?? 0) > 0
105 const color = check.status === 'running' ? colors.cyan : failed ? colors.red : statusColor(check.status)
106 return [
107 { text: `${check.status === 'running' ? '◌' : statusGlyph(failed ? 'failed' : check.status)} `, color },
108 { text: clip(check.label, 24) },
109 { text: ` ${checkResult(check)}`, color },
110 { text: check.durationMs === undefined ? '' : ` · ${brief(check.durationMs)}`, color: colors.muted },
111 { text: check.finishedAt === undefined ? '' : ` · ${ago(now, check.finishedAt)}`, color: colors.muted },
112 ]
113}
114
115/** The newest result of each check label, running ones included. */
116const latestChecks = (checks: readonly CockpitCheck[]): CockpitCheck[] => {
117 const latest = new Map<string, CockpitCheck>()
118 for (const check of checks) latest.set(check.label, check)
119 return [...latest.values()]
120}
121
122const summary = (ui: CockpitElements, props: CockpitViewProps, selected: CockpitChange | undefined, width: number): RenderElement => {
123 const totals = changeTotals(props.review)
124 const checks = latestChecks(props.review.checks)
125 const confirmed = props.review.findings.filter(finding => /confirm/i.test(finding.verdict ?? '')).length
126 const git = props.review.gitOps.at(-1)
127 const changes = props.review.changes
128 const chooseNeighbor = (direction: number): Promise<void> => {
129 const ordered = changeGroups(changes).flatMap(group => group.changes)
130 const index = Math.max(0, ordered.findIndex(change => change.path === props.review.selectedPath))
131 const next = ordered[neighboringIndex(ordered.length, index, direction)]
132 return next ? props.actions.selectChange(next.path) : Promise.resolve()
133 }
134 return section(ui, 'changes:summary', [
135 heading(ui, 'changes:title', 'Changes', [
136 { text: '⎇ ', color: colors.muted },
137 { text: clip(props.review.branch ?? 'branch unknown', Math.max(8, width - 16)), color: colors.magenta },
138 ], width),
139 line(ui, 'changes:totals', [
140 { text: `${totals.files} ${totals.files === 1 ? 'file' : 'files'} ` },
141 { text: `+${compact(totals.additions)}`, color: colors.green }, { text: ' ' },
142 { text: `−${compact(totals.deletions)}`, color: colors.red },
143 { text: totals.staged > 0 ? ` · ${totals.staged} staged` : '', color: colors.muted },
144 { text: totals.untracked > 0 ? ` · ${totals.untracked} untracked` : '', color: colors.muted },
145 { text: ` · git ${props.review.refreshedAt === null ? 'not read' : ago(props.now, props.review.refreshedAt)}`, color: colors.muted },
146 ], true),
147 checks.length > 0 ? line(ui, 'changes:checks-summary', checks.slice(-3).flatMap((check, index) => {
148 const failed = check.status === 'failed' || (check.failed ?? 0) > 0
149 const color = check.status === 'running' ? colors.cyan : failed ? colors.red : statusColor(check.status)
150 return [
151 { text: index > 0 ? ' · ' : 'Checks ', color: colors.muted },
152 { text: `${check.status === 'running' ? '◌' : statusGlyph(failed ? 'failed' : check.status)} ${clip(check.label, 16)}`, color },
153 ]
154 }), true) : null,
155 props.review.findings.length > 0 ? line(ui, 'changes:findings-summary', [
156 { text: 'Review ', color: colors.muted },
157 { text: `⚑ ${props.review.findings.length} ${props.review.findings.length === 1 ? 'finding' : 'findings'}`, color: colors.yellow },
158 { text: confirmed > 0 ? ` · ${confirmed} confirmed` : '', color: colors.red },
159 ]) : null,
160 git ? line(ui, 'changes:git-summary', [
161 { text: 'Git ', color: colors.muted },
162 { text: clip(gitOperationLines(git).join(' · '), width - 16) },
163 { text: ` · ${ago(props.now, git.at)}`, color: colors.muted },
164 ]) : null,
165 props.review.error ? line(ui, 'changes:error', [{ text: clip(props.review.error, 320), color: colors.red }], true) : null,
166 actions(ui, 'changes:actions', [
167 { key: 'changes:refresh', label: props.review.loading ? 'Reading git…' : 'Read git', hotkey: 'r', onPress: props.actions.refreshChanges },
168 { key: 'changes:prev', label: 'Previous', hotkey: 'k', onPress: () => chooseNeighbor(-1), hidden: changes.length < 2 },
169 { key: 'changes:next', label: 'Next', hotkey: 'j', onPress: () => chooseNeighbor(1), hidden: changes.length < 2 },
170 { key: 'changes:copy-path', label: 'Copy path', hotkey: 'p', onPress: () => props.actions.copyPath(selected?.path ?? ''), hidden: !selected },
171 { key: 'changes:copy', label: 'Copy patch', hotkey: 'c', onPress: () => props.actions.copyPatch(selected?.path ?? ''), hidden: !selected?.patch },
172 { key: 'changes:quote', label: 'Quote patch', hotkey: 'q', onPress: () => props.actions.quotePatch(selected?.path ?? ''), hidden: !selected?.patch },
173 ]),
174 ])
175}
176
177const diffSection = (ui: CockpitElements, props: CockpitViewProps, change: CockpitChange, width: number): RenderElement => {
178 const { Code } = ui
179 const preview = patchPreview(change.patch)
180 const findings = props.review.findings.filter(finding => finding.path === change.path)
181 const by = change.agentId !== undefined || change.edits ? [
182 { text: change.edits ? ` · edited ${change.edits}×` : '', color: colors.muted },
183 { text: change.agentId !== undefined ? ` by ${clip(actorName(props, change.agentId), 14)}` : '', color: colors.magenta },
184 ] : []
185 return section(ui, 'changes:diff', [
186 heading(ui, 'changes:diff:title', clipStart(change.path, Math.max(12, width - 30)), [
187 { text: `+${compact(change.additions)}`, color: colors.green }, { text: ' ' },
188 { text: `−${compact(change.deletions)}`, color: colors.red },
189 ...by,
190 ], width),
191 ...findings.slice(0, 4).map(finding => line(ui, `changes:diff:finding:${finding.id}`, [
192 { text: '⚑ ', color: colors.yellow },
193 { text: finding.line === undefined ? '' : `L${finding.line} `, color: colors.muted },
194 { text: clip(finding.summary, width * 2) },
195 { text: finding.verdict ? ` · ${finding.verdict}` : '', color: /confirm/i.test(finding.verdict ?? '') ? colors.red : colors.yellow },
196 ], true)),
197 change.truncated || preview.truncated ? muted(ui, 'changes:diff:truncated', 'Truncated patch.') : null,
198 preview.source
199 ? <Code key="changes:diff:code" source={preview.source} path={change.path} format="diff" wrap="wrap" />
200 : muted(ui, 'changes:diff:none', preview.truncated
201 ? 'No complete hunk fits the preview limit. Copy or quote the captured patch to inspect it.'
202 : change.truncated ? 'Patch omitted: no complete hunk fits the capture limit.'
203 : change.untracked ? 'Untracked file: Git shows no patch until it is added.' : 'No text patch captured. Read git to load it.'),
204 ])
205}
206
207const checksSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement | null => {
208 const checks = props.review.checks.slice(-6).reverse()
209 if (checks.length === 0) return null
210 const passed = props.review.checks.filter(check => check.status === 'passed').length
211 const failed = props.review.checks.filter(check => check.status === 'failed').length
212 return section(ui, 'changes:checks', [
213 heading(ui, 'changes:checks:title', 'Checks', [
214 { text: `${passed} passed`, color: passed > 0 ? colors.green : colors.muted },
215 { text: ' · ', color: colors.muted },
216 { text: `${failed} failed`, color: failed > 0 ? colors.red : colors.muted },
217 ], width),
218 ...checks.map(check => line(ui, `changes:check:${check.id}`, checkLine(check, props.now))),
219 props.review.checks.length > checks.length ? muted(ui, 'changes:checks:more', `Showing the latest ${checks.length} of ${props.review.checks.length}.`) : null,
220 ])
221}
222
223const findingsSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement | null => {
224 const findings = props.review.findings.slice(-8).reverse()
225 if (findings.length === 0) return null
226 return section(ui, 'changes:findings', [
227 heading(ui, 'changes:findings:title', 'Findings', [{ text: `${props.review.findings.length}`, color: colors.yellow }], width),
228 ...findings.flatMap(finding => [
229 line(ui, `changes:finding:${finding.id}:where`, [
230 { text: '⚑ ', color: /confirm/i.test(finding.verdict ?? '') ? colors.red : colors.yellow },
231 { text: clipStart(`${finding.path}${finding.line === undefined ? '' : `:${finding.line}`}`, Math.max(12, width - 30)) },
232 { text: [finding.category, finding.verdict, finding.outcome].filter(Boolean).map(value => ` · ${value}`).join(''), color: colors.muted },
233 ]),
234 line(ui, `changes:finding:${finding.id}:summary`, [{ text: ` ${clip(finding.summary, 360)}` }], true),
235 ]),
236 ])
237}
238
239const gitSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement | null => {
240 const { Link } = ui
241 const operations = props.review.gitOps.slice(-6).reverse()
242 if (operations.length === 0) return null
243 return section(ui, 'changes:git', [
244 heading(ui, 'changes:git:title', 'Git activity', [{ text: `${props.review.gitOps.length}`, color: colors.muted }], width),
245 ...operations.flatMap(operation => [
246 spread(ui, `changes:git:${operation.id}`, line(ui, `changes:git:${operation.id}:text`, [
247 { text: operation.pr ? '⇡ ' : operation.push ? '↑ ' : '● ', color: colors.magenta },
248 { text: clip(gitOperationLines(operation).join(' · '), width - 12) },
249 ]), [{ text: ago(props.now, operation.at), color: colors.muted }]),
250 operation.pr?.url ? <Link key={`changes:git:pr:${operation.id}`} href={operation.pr.url} label={` ${clip(operation.pr.url, width - 2)}`} /> : null,
251 ].filter((child): child is RenderElement => Boolean(child))),
252 ])
253}
254
255export const renderChanges = (ui: CockpitElements, props: CockpitViewProps): RenderElement => {
256 const { Box } = ui
257 const width = viewWidth(props)
258 const wide = width >= 100
259 const left = wide ? Math.min(60, Math.floor((width - 2) * 0.45)) : width
260 const right = wide ? width - left - 2 : width
261 const selected = props.review.changes.find(change => change.path === props.review.selectedPath) ?? changeGroups(props.review.changes)[0]?.changes[0]
262 const keep = (items: (RenderElement | null)[]) => items.filter((item): item is RenderElement => Boolean(item))
263 const files = props.review.changes.length > 0 ? fileList(ui, props, selected, left)
264 : muted(ui, 'changes:empty', 'No changes yet. Edits appear as the session makes them; Read git loads the working tree.')
265 const diff = selected ? diffSection(ui, props, selected, right) : null
266 const extras = [checksSection(ui, props, left), findingsSection(ui, props, left), gitSection(ui, props, left)]
267 return wide ? (
268 <Box flexDirection="row" gap={2} width="100%" alignItems="flex-start">
269 <Box flexDirection="column" gap={1} width={left} flexShrink={0}>{keep([summary(ui, props, selected, left), files, ...extras])}</Box>
270 <Box flexDirection="column" width={right} flexShrink={0}>{diff ? card(ui, 'changes:diff:card', [diff]) : muted(ui, 'changes:diff:empty', 'Select a file to see its patch.')}</Box>
271 </Box>
272 ) : (
273 <Box flexDirection="column" gap={1} width="100%">
274 {keep([summary(ui, props, selected, width), files, diff, ...extras])}
275 </Box>
276 )
277}
278hooks/context.tsx 300 lines1import type { ContextCategory, RenderElement, SessionContextBreakdown, ThemeKey } from 'claude-code'
2import type { CockpitCompaction, CockpitCostSample, CockpitViewProps } from '../types'
3import { actions, field, heading, line, muted, section, sparkline, viewWidth } from './layout'
4import type { Segment } from './summary'
5import { cacheHit, contextColor, contextFill, mainTurns, sessionStart, tokenTotals } from './summary'
6import type { CockpitElements } from './theme'
7import { ago, bar, brief, cleanText, clip, colors, compact, count, humanize, meter, padEnd, padStart, percent, shortModel, usd } from './theme'
8
9const palette: ThemeKey[] = [colors.accent, colors.cyan, colors.green, colors.magenta, colors.yellow, colors.red]
10
11const finite = (value: number | undefined): value is number => value !== undefined && Number.isFinite(value)
12
13export const categoryColor = (category: Pick<ContextCategory, 'kind'>, index: number): ThemeKey => {
14 if (category.kind === 'free') return colors.muted
15 if (category.kind === 'buffer') return colors.yellow
16 if (category.kind === 'deferred') return colors.magenta
17 return palette[index % palette.length] ?? colors.accent
18}
19
20export type RankedEstimate = { label: string; tokens: number; detail?: string }
21
22export const rankedEstimates = (breakdown: SessionContextBreakdown): {
23 memory: RankedEstimate[]
24 mcp: RankedEstimate[]
25 skills: RankedEstimate[]
26} => {
27 const servers = new Map<string, { tokens: number; loaded: number; total: number; deferred: number }>()
28 for (const tool of breakdown.mcpTools) {
29 const server = servers.get(tool.serverName) ?? { tokens: 0, loaded: 0, total: 0, deferred: 0 }
30 const tokens = Number.isFinite(tool.tokens) ? Math.max(0, tool.tokens) : 0
31 server.tokens += tokens
32 server.total += 1
33 if (tool.isLoaded) server.loaded += 1
34 else server.deferred += tokens
35 servers.set(tool.serverName, server)
36 }
37 const descending = (a: RankedEstimate, b: RankedEstimate): number => b.tokens - a.tokens
38 return {
39 memory: breakdown.memoryFiles
40 .map(file => ({ label: file.path, tokens: file.tokens, detail: file.type }))
41 .sort(descending),
42 mcp: Array.from(servers, ([label, server]) => ({
43 label,
44 tokens: server.tokens,
45 detail: `${server.loaded}/${server.total} loaded; ${count(server.deferred)} deferred`,
46 })).sort(descending),
47 skills: (breakdown.skills?.skillFrontmatter ?? [])
48 .map(skill => ({ label: skill.name, tokens: skill.tokens, detail: skill.pluginName ?? skill.source }))
49 .sort(descending),
50 }
51}
52
53// Each cost sample is the session total after a main turn grew it. The first
54// sample can hold earlier turns, so only differences between samples count.
55export const costPerTurn = (costs: readonly CockpitCostSample[]): number[] =>
56 costs.slice(1).map((sample, index) => Math.max(0, sample.usd - (costs[index]?.usd ?? sample.usd)))
57
58export const compactionLine = (entry: CockpitCompaction, now: number): string => {
59 const who = `${humanize(entry.trigger)}${entry.agentId ? ' (agent)' : ''}`
60 if (entry.skipped !== undefined) return `${who} · skipped: ${entry.skipped} · ${ago(now, entry.at)}`
61 const saved = finite(entry.tokensBefore) && finite(entry.tokensAfter) && entry.tokensBefore > 0
62 ? ` (−${Math.round((1 - entry.tokensAfter / entry.tokensBefore) * 100)}%)` : ''
63 return `${who} · ${compact(entry.tokensBefore)} → ${compact(entry.tokensAfter)}${saved} · ${ago(now, entry.at)}`
64}
65
66type Cell = { glyph: string; color: ThemeKey }
67
68/**
69 * One line across the window: each used category in its color, free space up
70 * to the auto-compaction threshold, the threshold mark, and the reserve after it.
71 */
72export const contextGauge = (
73 breakdown: SessionContextBreakdown | undefined, tokens: number | undefined, window: number | undefined,
74 threshold: number | undefined, columns: number,
75): Cell[] => {
76 const cells = Math.max(8, Math.floor(columns))
77 const size = finite(window) && window > 0 ? window : breakdown?.rawMaxTokens
78 if (!finite(size) || size <= 0) return []
79 const used = breakdown?.categories.filter(category => category.kind === 'used' || category.kind === 'deferred') ?? []
80 const estimate = used.reduce((total, category) => total + Math.max(0, category.tokens), 0)
81 // Scale the estimate to the live fill, which the last response measured.
82 const scale = finite(tokens) && estimate > 0 ? tokens / estimate : 1
83 const result: Cell[] = []
84 if (used.length > 0) {
85 let carried = 0
86 used.forEach((category, index) => {
87 const exact = Math.max(0, category.tokens) * scale / size * cells + carried
88 // The epsilon keeps float error in the carried fractions from losing a cell.
89 const whole = Math.floor(exact + 1e-9)
90 carried = exact - whole
91 const colorIndex = breakdown?.categories.indexOf(category) ?? index
92 for (let cell = 0; cell < whole && result.length < cells; cell += 1) result.push({ glyph: '█', color: categoryColor(category, colorIndex) })
93 })
94 } else if (finite(tokens)) {
95 for (let cell = 0; cell < Math.round(tokens / size * cells) && result.length < cells; cell += 1) result.push({ glyph: '█', color: colors.accent })
96 }
97 const mark = finite(threshold) ? Math.min(cells - 1, Math.round(threshold / size * cells)) : undefined
98 while (result.length < cells) {
99 const index = result.length
100 if (mark !== undefined && index === mark) result.push({ glyph: '┃', color: colors.yellow })
101 else if (mark !== undefined && index > mark) result.push({ glyph: '░', color: colors.muted })
102 else result.push({ glyph: '─', color: colors.muted })
103 }
104 if (mark !== undefined && result[mark]?.glyph === '█') result[mark] = { glyph: '┃', color: colors.red }
105 return result
106}
107
108const runs = (cells: readonly Cell[]): Segment[] => {
109 const merged: Segment[] = []
110 for (const cell of cells) {
111 const last = merged[merged.length - 1]
112 if (last && last.color === cell.color) last.text += cell.glyph
113 else merged.push({ text: cell.glyph, color: cell.color })
114 }
115 return merged
116}
117
118const resetText = (value: string | undefined, now: number): string => {
119 if (!value) return 'reset unknown'
120 const at = Date.parse(value)
121 return Number.isFinite(at) ? `resets in ${brief(Math.max(0, at - now))}` : `resets ${cleanText(value)}`
122}
123
124const fillSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement => {
125 const { Box } = ui
126 const usage = props.context.usage
127 const breakdown = usage?.context.breakdown
128 const fill = contextFill(props.context)
129 const gauge = contextGauge(breakdown, fill.tokens, fill.window, fill.threshold, width)
130 const trend = props.context.fills.map(sample => sample.tokens)
131 const color = contextColor(fill)
132 return (
133 <Box key="context:fill" flexDirection="column" width="100%">
134 {heading(ui, 'context:title', 'Context', [
135 { text: shortModel(breakdown?.model ?? props.activity.model ?? ''), color: colors.muted },
136 { text: finite(fill.window) ? ` · ${compact(fill.window)} window` : '', color: colors.muted },
137 ], width)}
138 {line(ui, 'context:headline', [
139 { text: finite(fill.tokens) ? count(fill.tokens) : 'Not measured yet', bold: true },
140 { text: finite(fill.tokens) ? ' tokens' : '', color: colors.muted },
141 { text: finite(fill.percent) ? ` · ${percent(fill.percent)}` : '', color: color === colors.muted ? colors.accent : color, bold: true },
142 { text: finite(fill.window) ? ` of ${compact(fill.window)}` : '', color: colors.muted },
143 ])}
144 {gauge.length > 0 && line(ui, 'context:gauge', runs(gauge))}
145 {finite(fill.threshold) ? line(ui, 'context:headroom', [
146 { text: `${compact(fill.headroom)} left`, color: color === colors.muted ? colors.green : color, bold: true },
147 { text: ` before auto-compact at ${compact(fill.threshold)}`, color: colors.muted },
148 ], true) : breakdown && !breakdown.isAutoCompactEnabled ? muted(ui, 'context:no-compact', 'Auto-compact is off.') : null}
149 {trend.length > 1 && line(ui, 'context:trend', [
150 { text: 'Trend ', color: colors.muted },
151 { text: sparkline(trend, Math.min(12, trend.length)), color: colors.cyan },
152 { text: finite(fill.growth) ? ` +${compact(fill.growth)} per response` : '' },
153 { text: finite(fill.responsesLeft) ? ` · ~${fill.responsesLeft < 10 ? fill.responsesLeft.toFixed(1) : Math.round(fill.responsesLeft)} responses to compact` : '', color: color === colors.muted ? colors.muted : color },
154 ], true)}
155 {props.context.error && line(ui, 'context:error', [{ text: clip(props.context.error, 280), color: colors.red }], true)}
156 {actions(ui, 'context:actions', [
157 { key: 'context:refresh', label: props.context.loading ? 'Estimating…' : 'Estimate categories', hotkey: 'r', onPress: props.actions.refreshContext },
158 ])}
159 </Box>
160 )
161}
162
163const breakdownSection = (ui: CockpitElements, props: CockpitViewProps, breakdown: SessionContextBreakdown, width: number): RenderElement => {
164 const nameWidth = Math.min(18, Math.max(10, ...breakdown.categories.map(category => category.name.length)))
165 const barWidth = Math.max(0, width - nameWidth - 7 - 5 - 2)
166 const size = Math.max(1, breakdown.rawMaxTokens)
167 const largest = Math.max(1, ...breakdown.categories.filter(category => category.kind === 'used' || category.kind === 'deferred').map(category => category.tokens))
168 return section(ui, 'context:breakdown', [
169 heading(ui, 'context:breakdown:title', 'Breakdown', [
170 { text: `estimated ${props.context.refreshedAt === null ? '' : ago(props.now, props.context.refreshedAt)}`, color: colors.muted },
171 ], width),
172 ...breakdown.categories.slice(0, 14).map((category, index) => {
173 const reserved = category.kind === 'free' || category.kind === 'buffer'
174 return line(ui, `context:category:${index}`, [
175 { text: '■ ', color: categoryColor(category, index) },
176 { text: padEnd(clip(`${category.name}${category.kind === 'deferred' ? '*' : ''}`, nameWidth), nameWidth), color: reserved ? colors.muted : undefined },
177 { text: padStart(compact(category.tokens), 7), color: reserved ? colors.muted : undefined },
178 { text: padStart(percent(category.tokens / size * 100), 5), color: colors.muted },
179 { text: reserved || barWidth === 0 ? '' : ` ${bar(category.tokens / largest, barWidth)}`, color: categoryColor(category, index) },
180 ])
181 }),
182 breakdown.categories.some(category => category.kind === 'deferred') ? muted(ui, 'context:deferred', '* deferred: loaded only when used.') : null,
183 ])
184}
185
186const consumers = (ui: CockpitElements, breakdown: SessionContextBreakdown, width: number): RenderElement | null => {
187 const ranked = rankedEstimates(breakdown)
188 const list = (entries: { label: string; tokens: number }[], limit: number): Segment[] =>
189 entries.slice(0, limit).flatMap((entry, index) => [
190 { text: index > 0 ? ' · ' : '', color: colors.muted },
191 { text: clip(entry.label.replace(/^.*\/(?=[^/]+\/[^/]+$)/, ''), 24) },
192 { text: ` ${compact(entry.tokens)}`, color: colors.cyan },
193 ])
194 const rows = [
195 ranked.memory.length > 0 ? field(ui, 'context:consumers:memory', 'Memory', list(ranked.memory, 3)) : null,
196 ranked.mcp.length > 0 ? field(ui, 'context:consumers:mcp', 'MCP', list(ranked.mcp, 3)) : null,
197 breakdown.skills ? field(ui, 'context:consumers:skills', 'Skills', [
198 { text: `${count(breakdown.skills.includedSkills)}/${count(breakdown.skills.totalSkills)} listed`, color: colors.muted },
199 { text: ranked.skills.length > 0 ? ' · ' : '', color: colors.muted },
200 ...list(ranked.skills, 2),
201 ]) : null,
202 ]
203 if (rows.every(row => row === null)) return null
204 return section(ui, 'context:consumers', [heading(ui, 'context:consumers:title', 'Largest', [{ text: 'estimated', color: colors.muted }], width), ...rows])
205}
206
207const usageSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement | null => {
208 const turns = mainTurns(props.activity)
209 const last = turns.filter(turn => turn.usage).at(-1)?.usage
210 if (!last) return null
211 const lastTotals = tokenTotals([last])
212 const session = tokenTotals(turns.map(turn => turn.usage))
213 const lastHit = cacheHit(lastTotals)
214 const sessionHit = cacheHit(session)
215 const column = 7
216 const row = (key: string, label: string, totals: typeof session, hit: number | undefined): RenderElement => line(ui, key, [
217 { text: padEnd(label, 10), color: colors.muted },
218 ...[totals.input, totals.output, totals.cacheRead, totals.cacheWrite].map(value => ({ text: padStart(compact(value), column) })),
219 { text: padStart(percent(hit), column), color: hit !== undefined && hit < 50 ? colors.yellow : colors.green },
220 ])
221 return section(ui, 'context:usage', [
222 heading(ui, 'context:usage:title', 'Tokens', [{ text: 'main session', color: colors.muted }], width),
223 line(ui, 'context:usage:columns', [{ text: `${padEnd('', 10)}${['input', 'output', 'read', 'write', 'hit'].map(name => padStart(name, column)).join('')}`, color: colors.muted }]),
224 row('context:usage:last', 'Last turn', lastTotals, lastHit),
225 row('context:usage:session', `${turns.length} ${turns.length === 1 ? 'turn' : 'turns'}`, session, sessionHit),
226 ])
227}
228
229const costSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement => {
230 const usage = props.context.usage
231 const total = usage?.cost?.usd
232 const perTurn = costPerTurn(props.context.costs)
233 const last = perTurn.at(-1)
234 const average = perTurn.length > 0 ? perTurn.reduce((sum, value) => sum + value, 0) / perTurn.length : undefined
235 const started = sessionStart(props.activity, props.context)
236 const hours = finite(started) ? (props.now - started) / 3600000 : undefined
237 const rate = finite(total) && finite(hours) && hours > 0.05 ? total / hours : undefined
238 const limits = usage?.rateLimits ?? []
239 return section(ui, 'context:cost', [
240 heading(ui, 'context:cost:title', 'Cost', [{ text: finite(total) ? usd(total) : 'not reported', color: finite(total) ? colors.accent : colors.muted, bold: true }], width),
241 last !== undefined ? line(ui, 'context:cost:turns', [
242 { text: 'Last turn ', color: colors.muted }, { text: usd(last) },
243 { text: ' · avg ', color: colors.muted }, { text: average === undefined ? '?' : usd(average) },
244 { text: rate === undefined ? '' : ' · ', color: colors.muted }, { text: rate === undefined ? '' : `${usd(rate)}/h` },
245 { text: perTurn.length > 1 ? ` ${sparkline(perTurn, Math.min(20, width - 44))}` : '', color: colors.cyan },
246 ]) : null,
247 ...limits.slice(0, 6).map((limit, index) => {
248 const used = limit.percentUsed
249 const color = used >= 90 ? colors.red : used >= 75 ? colors.yellow : colors.cyan
250 return line(ui, `context:limit:${index}`, [
251 { text: padEnd(clip(humanize(limit.kind), 10), 10), color: colors.muted },
252 { text: padStart(percent(used), 5), color },
253 { text: ` ${meter(used, Math.max(6, Math.min(20, width - 36)))} `, color },
254 { text: resetText(limit.resetsAt, props.now), color: colors.muted },
255 ])
256 }),
257 limits.length === 0 ? muted(ui, 'context:limits:none', 'Rate limits not reported.') : null,
258 ])
259}
260
261const compactionSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement | null => {
262 const entries = props.context.compactions
263 if (entries.length === 0) return null
264 return section(ui, 'context:compactions', [
265 heading(ui, 'context:compactions:title', 'Compactions', [{ text: `${entries.length}`, color: colors.muted }], width),
266 ...entries.slice(-5).reverse().map((entry, index) => line(ui, `context:compaction:${index}`, [
267 { text: entry.skipped === undefined ? '⇣ ' : '· ', color: entry.skipped === undefined ? colors.cyan : colors.yellow },
268 { text: clip(compactionLine(entry, props.now), width - 2), color: entry.skipped === undefined ? undefined : colors.yellow },
269 ])),
270 ])
271}
272
273export const renderContext = (ui: CockpitElements, props: CockpitViewProps): RenderElement => {
274 const { Box } = ui
275 const width = viewWidth(props)
276 const wide = width >= 100
277 const left = wide ? Math.floor((width - 2) / 2) : width
278 const right = wide ? width - left - 2 : width
279 const breakdown = props.context.usage?.context.breakdown
280 const keep = (items: (RenderElement | null)[]) => items.filter((item): item is RenderElement => Boolean(item))
281 const first = keep([
282 fillSection(ui, props, left),
283 breakdown ? breakdownSection(ui, props, breakdown, left) : muted(ui, 'context:breakdown:none', 'Estimate categories to see what fills the window.'),
284 ])
285 const second = keep([
286 usageSection(ui, props, right),
287 costSection(ui, props, right),
288 breakdown ? consumers(ui, breakdown, right) : null,
289 compactionSection(ui, props, right),
290 ])
291 return wide ? (
292 <Box flexDirection="row" gap={2} width="100%" alignItems="flex-start">
293 <Box flexDirection="column" gap={1} width={left} flexShrink={0}>{first}</Box>
294 <Box flexDirection="column" gap={1} width={right} flexShrink={0}>{second}</Box>
295 </Box>
296 ) : (
297 <Box flexDirection="column" gap={1} width="100%">{[...first, ...second]}</Box>
298 )
299}
300hooks/data.ts 721 lines1import type { AgentInfo } from 'claude-code'
2import type {
3 CockpitActivity, CockpitAgent, CockpitAgentTotals, CockpitBackgroundTask, CockpitChange, CockpitChangeState,
4 CockpitCheck, CockpitCron, CockpitGitOperation, CockpitTask, CockpitTodo,
5} from '../types'
6import { cleanText } from './theme'
7
8const MAX_ITEMS = 100
9const MAX_PATH = 4096
10const MAX_INPUT = 2 * 1024 * 1024
11const MAX_PATCH = 24000
12const MAX_PATCH_TOTAL = 200000
13const MAX_COUNT = 1_000_000
14
15const record = (value: unknown): Record<string, unknown> | undefined =>
16 typeof value === 'object' && value !== null && !Array.isArray(value)
17 ? value as Record<string, unknown>
18 : undefined
19
20const label = (value: string, limit = 240): string => cleanText(value.slice(0, limit))
21const finite = (value: unknown, limit = MAX_COUNT): number | undefined =>
22 typeof value === 'number' && Number.isFinite(value) && value >= 0
23 ? Math.min(limit, Math.floor(value))
24 : undefined
25const hunkNumber = (value: unknown): number | undefined =>
26 typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= MAX_COUNT
27 ? value
28 : undefined
29const pathValue = (value: unknown): string | undefined =>
30 typeof value === 'string' && value.length > 0 && value.length <= MAX_PATH && !value.includes('\0')
31 ? value
32 : undefined
33const terminal = (status: CockpitAgent['status']): boolean =>
34 status === 'completed' || status === 'failed' || status === 'killed'
35
36export const mergeAgents = (
37 previous: CockpitAgent[], incoming: AgentInfo[], now: number,
38): CockpitAgent[] => {
39 const seenAt = finite(now, Number.MAX_SAFE_INTEGER) ?? 0
40 const old = new Map(previous.slice(0, MAX_ITEMS).map(agent => [agent.id, agent]))
41 const merged: CockpitAgent[] = []
42 const seen = new Set<string>()
43 for (const agent of incoming.slice(0, MAX_ITEMS)) {
44 if (seen.has(agent.id)) continue
45 seen.add(agent.id)
46 const prior = old.get(agent.id)
47 merged.push({
48 id: agent.id,
49 name: agent.name === undefined ? undefined : label(agent.name, 80),
50 description: label(agent.description),
51 type: label(agent.type, 80),
52 status: agent.status,
53 parentId: agent.parentId,
54 teammateId: agent.teammateId,
55 spawnedBy: agent.spawnedBy,
56 startedAt: prior?.startedAt ?? seenAt,
57 lastSeenAt: seenAt,
58 completedAt: terminal(agent.status) ? prior?.completedAt ?? seenAt : undefined,
59 durationMs: prior?.durationMs,
60 usage: prior?.usage,
61 totals: prior?.totals,
62 answer: prior?.answer,
63 })
64 }
65 for (const agent of previous.slice(0, MAX_ITEMS)) {
66 if (seen.has(agent.id)) continue
67 seen.add(agent.id)
68 merged.push({ ...agent, status: terminal(agent.status) ? agent.status : 'missing' })
69 }
70 return merged.slice(0, MAX_ITEMS)
71}
72
73// Parse only enough shell syntax to identify commands. Never retain arguments.
74const commandSegments = (command: string): string[][] => {
75 const segments: string[][] = []
76 let words: string[] = []
77 let word = ''
78 let quote = ''
79 let escaped = false
80 let comment = false
81 const pushWord = (): void => {
82 if (word) words.push(word)
83 word = ''
84 }
85 const pushSegment = (): void => {
86 pushWord()
87 if (words.length) segments.push(words)
88 words = []
89 }
90 if (command.length > 8192 || command.includes('<<')) return []
91 for (const char of command) {
92 if (segments.length >= 16 || words.length >= 100) break
93 if (comment) {
94 if (char === '\n') { comment = false; pushSegment() }
95 continue
96 }
97 if (escaped) { word += char; escaped = false; continue }
98 if (char === '\\' && quote !== "'") { escaped = true; continue }
99 if (quote) {
100 if (char === quote) quote = ''
101 else word += char
102 continue
103 }
104 if (char === '"' || char === "'") { quote = char; continue }
105 if (char === '#' && !word) { comment = true; continue }
106 if (';&|\n'.includes(char)) { pushSegment(); continue }
107 if (/\s/.test(char)) { pushWord(); continue }
108 word += char
109 }
110 if (!quote && !escaped) pushSegment()
111 return segments
112}
113
114const commandWords = (words: string[]): string[] => {
115 let index = words[0] === 'env' ? 1 : 0
116 while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index] ?? '')) index++
117 return words.slice(index)
118}
119const executable = (word: string | undefined): string | undefined => {
120 if (!word) return undefined
121 const name = word.slice(word.lastIndexOf('/') + 1)
122 return /^[A-Za-z0-9_.+-]{1,80}$/.test(name) ? name : undefined
123}
124
125export const toolTarget = (tool: string, input: unknown): string | undefined => {
126 const value = record(input)
127 if (!value) return undefined
128 if (['Read', 'Edit', 'Write', 'Glob', 'Grep', 'NotebookEdit'].includes(tool)) {
129 const path = pathValue(value.file_path ?? value.notebook_path ?? value.path)
130 return path === undefined ? undefined : label(path, 240)
131 }
132 if (tool === 'Bash' && typeof value.command === 'string') {
133 return executable(commandWords(commandSegments(value.command)[0] ?? [])[0])
134 }
135 if (tool === 'Agent' && typeof value.description === 'string') return label(value.description)
136 return undefined
137}
138
139const checkWords = (words: string[]): string | undefined => {
140 const exe = executable(words[0])
141 const args = words.slice(1)
142 if (!exe) return undefined
143 if (['npm', 'pnpm', 'yarn'].includes(exe)) {
144 const script = args[0] === 'run' ? args[1] : args[0]
145 if (script && /^(test|lint|typecheck|type-check|check|format|fmt)(?::[A-Za-z0-9_-]{1,40})?$/.test(script)) {
146 return `${exe}${args[0] === 'run' ? ' run' : ''} ${script}`
147 }
148 if (args[0] === 'exec') {
149 const nested = checkWords(args.slice(1))
150 if (nested) return label(`${exe} exec ${nested}`)
151 }
152 }
153 if (['npx', 'bunx'].includes(exe)) {
154 const nested = checkWords(args.filter((arg, index) => index > 0 || !['--yes', '-y', '--no-install'].includes(arg)))
155 if (nested) return label(`${exe} ${nested}`)
156 }
157 if (['pytest', 'py.test', 'jest', 'vitest'].includes(exe)) return exe
158 if (/^python(?:3(?:\.\d+)?)?$/.test(exe) && args[0] === '-m' && args[1] === 'pytest') return 'pytest'
159 if (exe === 'cargo' && ['test', 'check', 'clippy', 'fmt'].includes(args[0] ?? '')) return `cargo ${args[0]}`
160 if (exe === 'go' && ['test', 'vet', 'fmt'].includes(args[0] ?? '')) return `go ${args[0]}`
161 if (exe === 'nix') {
162 const nixArgs = [...args]
163 while (nixArgs[0]?.startsWith('--')) {
164 const flag = nixArgs.shift()
165 if (['--extra-experimental-features', '--experimental-features', '--option'].includes(flag ?? '')) {
166 nixArgs.shift()
167 if (flag === '--option') nixArgs.shift()
168 } else break
169 }
170 if (nixArgs[0] === 'flake' && nixArgs[1] === 'check') return 'nix flake check'
171 if (['eval', 'build', 'check'].includes(nixArgs[0] ?? '')) return `nix ${nixArgs[0]}`
172 }
173 if (['tsc', 'eslint', 'prettier', 'mypy', 'black', 'isort', 'rustfmt', 'nixfmt', 'nixpkgs-fmt', 'alejandra'].includes(exe)) return exe
174 if (['ruff', 'biome'].includes(exe) && ['check', 'format'].includes(args[0] ?? '')) return `${exe} ${args[0]}`
175 return undefined
176}
177
178export const detectCheck = (command: string): string | undefined => {
179 for (const segment of commandSegments(command)) {
180 const detected = checkWords(commandWords(segment))
181 if (detected) return detected
182 }
183 return undefined
184}
185
186export type CheckFlags = { error?: boolean; denied?: boolean; interrupted?: boolean }
187
188export const summarizeCheck = (
189 id: string, checkLabel: string, result: unknown, flags: CheckFlags = {}, durationMs?: number,
190): CockpitCheck => {
191 const wrapper = record(result)
192 const value = record(wrapper?.result) ?? wrapper
193 const check: CockpitCheck = { id, label: label(checkLabel), status: 'completed' }
194 const duration = finite(durationMs, Number.MAX_SAFE_INTEGER)
195 if (duration !== undefined) check.durationMs = duration
196 if (flags.denied || typeof wrapper?.deny === 'string') return { ...check, status: 'denied' }
197 if (flags.interrupted || value?.interrupted === true) return { ...check, status: 'interrupted' }
198 const nativeError = flags.error || wrapper?.isError === true
199 // Summaries usually appear at the end. Output is transient, never returned.
200 const output = typeof result === 'string' ? result.slice(-65536) : [
201 typeof value?.stdout === 'string' ? value.stdout.slice(-65536) : '',
202 typeof value?.stderr === 'string' ? value.stderr.slice(-65536) : '',
203 ].join('\n')
204 const text = output.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
205 let recognizedPass = false
206 let recognizedFail = false
207 let passed: number | undefined
208 let failed: number | undefined
209 const counts = (summary: string): void => {
210 for (const match of summary.matchAll(/(\d+)\s+(passed|failed)\b/g)) {
211 const count = Math.min(MAX_COUNT, Number(match[1]))
212 if (match[2] === 'passed') passed = count
213 else failed = count
214 }
215 if ((passed ?? 0) > 0) recognizedPass = true
216 if ((failed ?? 0) > 0) recognizedFail = true
217 }
218 const cargo = [...text.matchAll(/^test result: (ok|FAILED)\.\s*(\d+) passed;\s*(\d+) failed;/gm)]
219 if (cargo.length) {
220 passed = 0
221 failed = 0
222 for (const match of cargo) {
223 passed = Math.min(MAX_COUNT, passed + Number(match[2]))
224 failed = Math.min(MAX_COUNT, failed + Number(match[3]))
225 recognizedPass ||= match[1] === 'ok'
226 recognizedFail ||= match[1] === 'FAILED'
227 }
228 } else {
229 const summaries = [...text.matchAll(/^\s*Tests\s*:?\s+(.*)$/gm)]
230 const testSummary = summaries.at(-1)?.[1]
231 if (testSummary !== undefined) counts(testSummary)
232 else {
233 const pytest = [...text.matchAll(/^\s*(?:=+\s*)?(\d+ (?:passed|failed|skipped|error|errors|xfailed|xpassed).*?\bin \d+(?:\.\d+)?(?:s| seconds).*?)(?:\s*=+)?\s*$/gm)]
234 const summary = pytest.at(-1)?.[1]
235 if (summary !== undefined) {
236 counts(summary)
237 if (/\b[1-9]\d* errors?\b/.test(summary)) recognizedFail = true
238 }
239 }
240 }
241 if (/^ok[ \t]+\S+[ \t]+(?:\d+(?:\.\d+)?s|\(cached\))(?:[ \t]|$)/m.test(text)) recognizedPass = true
242 if (/^(?:FAIL(?:\s|$)|--- FAIL:)/m.test(text)) recognizedFail = true
243 if (passed !== undefined) check.passed = passed
244 if (failed !== undefined) check.failed = failed
245 if (nativeError || recognizedFail) check.status = 'failed'
246 else if (typeof value?.backgroundTaskId === 'string') check.status = 'running'
247 else if (recognizedPass) check.status = 'passed'
248 return check
249}
250
251export type GitStatusEntry = {
252 path: string
253 status: string
254 staged: boolean
255 untracked: boolean
256 originalPath?: string
257}
258
259// Porcelain v1 XY codes: X is the index, Y the work tree.
260export const changeState = (status: string): CockpitChangeState => {
261 if (status === '??') return 'untracked'
262 if (status.includes('U') || status === 'AA' || status === 'DD') return 'conflict'
263 if (/[RC]/.test(status)) return 'renamed'
264 if (status.charAt(0) === 'A') return 'added'
265 if (status.includes('D')) return 'deleted'
266 return 'modified'
267}
268
269export const parseGitStatus = (porcelain: string): GitStatusEntry[] => {
270 const entries: GitStatusEntry[] = []
271 const end = Math.min(porcelain.length, MAX_INPUT)
272 let offset = 0
273 const next = (): string | undefined => {
274 const nul = porcelain.indexOf('\0', offset)
275 if (nul < 0 || nul >= end) return undefined
276 const item = porcelain.slice(offset, nul)
277 offset = nul + 1
278 return item
279 }
280 while (offset < end && entries.length < MAX_ITEMS) {
281 const entry = next()
282 if (entry === undefined) break
283 const status = entry.slice(0, 2)
284 if (!/^[ MADRCUT?!]{2}$/.test(status) || entry[2] !== ' ') continue
285 const originalPath = /[RC]/.test(status) ? next() : undefined
286 const path = pathValue(entry.slice(3))
287 if (!path || status === '!!') continue
288 if (/[RC]/.test(status) && !pathValue(originalPath)) continue
289 entries.push({
290 path, status, staged: ![' ', '?', '!'].includes(status.charAt(0)), untracked: status === '??',
291 ...(originalPath === undefined ? {} : { originalPath }),
292 })
293 }
294 return entries
295}
296
297const decodeGitPath = (value: string): string | undefined => {
298 let decoded = value
299 if (value.startsWith('"')) {
300 const match = /^"((?:\\.|[^"\\])*)"/.exec(value)
301 if (!match) return undefined
302 decoded = (match[1] ?? '').replace(/(?:\\[0-7]{3})+|\\([abtnvfr\\"])/g, (escaped, named: string | undefined) => {
303 if (named) {
304 const escapes: Record<string, string> = { a: '\x07', b: '\b', t: '\t', n: '\n', v: '\v', f: '\f', r: '\r', '\\': '\\', '"': '"' }
305 return escapes[named] ?? named
306 }
307 const octets = escaped.match(/\\([0-7]{3})/g) ?? []
308 return new TextDecoder().decode(new Uint8Array(octets.map((octet: string) => parseInt(octet.slice(1), 8))))
309 })
310 }
311 return pathValue(decoded)
312}
313const markerPath = (value: string): string | undefined => {
314 const path = decodeGitPath(value.startsWith('"') ? value : value.split('\t')[0] ?? '')
315 if (!path || path === '/dev/null') return undefined
316 return path.replace(/^[ab]\//, '')
317}
318const headerPath = (line: string): string | undefined => {
319 const value = line.slice('diff --git '.length)
320 const boundary = Math.max(value.lastIndexOf(' b/'), value.lastIndexOf(' "b/'))
321 return boundary < 0 ? undefined : markerPath(value.slice(boundary + 1))
322}
323const safePatch = (patch: string): string =>
324 patch.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
325
326// A native diff view needs complete hunks. Omit a large first hunk instead of
327// presenting a cut hunk as a valid patch. Counts still describe observed data.
328const completePatch = (patch: string, wasTruncated = false): { patch: string; truncated: boolean } => {
329 const text = patch.slice(0, MAX_PATCH)
330 const cut = wasTruncated || patch.length > MAX_PATCH
331 let offset = 0
332 let boundary = 0
333 let oldRemaining = 0
334 let newRemaining = 0
335 let inHunk = false
336 let sawHunk = false
337 for (const line of text.split('\n')) {
338 const end = offset + line.length
339 const terminated = end < text.length
340 if (!terminated && cut) break
341 const next = end + (terminated ? 1 : 0)
342 const hunk = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line)
343 if (hunk) {
344 oldRemaining = Number(hunk[1] ?? 1)
345 newRemaining = Number(hunk[2] ?? 1)
346 inHunk = oldRemaining + newRemaining > 0
347 sawHunk = true
348 if (!inHunk) boundary = next
349 } else if (inHunk) {
350 if (line.startsWith('+') && newRemaining > 0) newRemaining--
351 else if (line.startsWith('-') && oldRemaining > 0) oldRemaining--
352 else if (line.startsWith(' ') && oldRemaining > 0 && newRemaining > 0) { oldRemaining--; newRemaining-- }
353 else if (line !== '\\ No newline at end of file') break
354 if (oldRemaining === 0 && newRemaining === 0) { inHunk = false; boundary = next }
355 } else if (line === '\\ No newline at end of file' && boundary === offset) boundary = next
356 offset = next
357 }
358 if (!sawHunk) return { patch: cut ? '' : text, truncated: cut }
359 const value = text.slice(0, boundary)
360 return { patch: value, truncated: cut || value.length < text.trimEnd().length }
361}
362
363export const parseGitDiff = (diff: string, now: number, staged = false): CockpitChange[] => {
364 const changes: CockpitChange[] = []
365 const input = diff.slice(0, MAX_INPUT)
366 const lines = input.split('\n')
367 let current: CockpitChange | undefined
368 let chunks: string[] = []
369 let size = 0
370 let total = 0
371 let inHunk = false
372 let hadHunk = false
373 let oldRemaining = 0
374 let newRemaining = 0
375 const finish = (): void => {
376 if (!current) return
377 if (current.path) {
378 const bounded = completePatch(chunks.join(''), current.truncated)
379 current.patch = bounded.patch
380 current.truncated = bounded.truncated
381 total += current.patch.length
382 changes.push(current)
383 }
384 current = undefined
385 chunks = []
386 size = 0
387 inHunk = false
388 hadHunk = false
389 oldRemaining = 0
390 newRemaining = 0
391 }
392 const start = (path: string | undefined): void => {
393 current = { path: path ?? '', patch: '', additions: 0, deletions: 0, source: 'git', staged, updatedAt: now }
394 }
395 for (let index = 0; index < lines.length; index++) {
396 const line = lines[index] ?? ''
397 if (line.startsWith('diff --git ')) {
398 finish()
399 if (changes.length >= MAX_ITEMS || total >= MAX_PATCH_TOTAL) break
400 start(headerPath(line))
401 } else if (!inHunk && line.startsWith('--- ') && (!current || hadHunk)) {
402 finish()
403 if (changes.length >= MAX_ITEMS || total >= MAX_PATCH_TOTAL) break
404 start(markerPath(line.slice(4)))
405 }
406 if (!current) continue
407 if (!inHunk && line.startsWith('--- ')) current.path = markerPath(line.slice(4)) ?? current.path
408 if (!inHunk && line.startsWith('+++ ')) current.path = markerPath(line.slice(4)) ?? current.path
409 if (!inHunk && line.startsWith('rename to ')) current.path = decodeGitPath(line.slice(10)) ?? current.path
410 const hunk = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line)
411 if (hunk) {
412 oldRemaining = Number(hunk[1] ?? 1)
413 newRemaining = Number(hunk[2] ?? 1)
414 inHunk = oldRemaining + newRemaining > 0
415 hadHunk = true
416 } else if (inHunk) {
417 if (line.startsWith('+')) {
418 current.additions = Math.min(MAX_COUNT, current.additions + 1)
419 newRemaining--
420 } else if (line.startsWith('-')) {
421 current.deletions = Math.min(MAX_COUNT, current.deletions + 1)
422 oldRemaining--
423 } else if (line.startsWith(' ')) {
424 oldRemaining--
425 newRemaining--
426 }
427 if (oldRemaining <= 0 && newRemaining <= 0) inHunk = false
428 }
429 const chunk = safePatch(line) + (index < lines.length - 1 ? '\n' : '')
430 const available = Math.min(MAX_PATCH - size, MAX_PATCH_TOTAL - total - size)
431 if (chunk.length > available) current.truncated = true
432 if (available > 0) { chunks.push(chunk.slice(0, available)); size += Math.min(chunk.length, available) }
433 }
434 finish()
435 const last = changes.at(-1)
436 if (last && diff.length > MAX_INPUT) last.truncated = true
437 return changes
438}
439
440export const normalizeObservedChanges = (
441 tool: string, input: unknown, result: unknown, now: number,
442): CockpitChange[] => {
443 if (tool === 'Bash') {
444 const wrapper = record(result)
445 if (typeof wrapper?.deny === 'string') return []
446 const value = record(wrapper?.result) ?? wrapper
447 const files = record(value?.bashEditDiff)?.files
448 if (!Array.isArray(files)) return []
449 const changes: CockpitChange[] = []
450 let total = 0
451 for (const file of files.slice(0, MAX_ITEMS)) {
452 const item = record(file)
453 if (!item) continue
454 const change = normalizeObservedChanges('Edit', {}, { filePath: item.filePath, structuredPatch: item.hunks }, now)[0]
455 if (!change) continue
456 const remaining = Math.max(0, MAX_PATCH_TOTAL - total)
457 if (change.patch.length > remaining) {
458 const bounded = completePatch(change.patch.slice(0, remaining), true)
459 change.patch = bounded.patch
460 change.truncated = true
461 }
462 total += change.patch.length
463 changes.push(change)
464 }
465 return changes
466 }
467 if (tool !== 'Edit' && tool !== 'Write') return []
468 const wrapper = record(result)
469 if (wrapper?.isError === true || typeof wrapper?.deny === 'string') return []
470 const value = record(wrapper?.result) ?? wrapper
471 if (!value || value.staged === true) return []
472 const path = pathValue(value.filePath) ?? pathValue(record(input)?.file_path)
473 if (!path) return []
474 const change: CockpitChange = { path, patch: '', additions: 0, deletions: 0, source: 'observed', updatedAt: now }
475 const gitDiff = record(value.gitDiff)
476 if (typeof gitDiff?.patch === 'string' && /^(?:diff --git |--- |\+\+\+ |@@ -|Binary files )/m.test(gitDiff.patch.slice(0, MAX_INPUT))) {
477 const patch = safePatch(gitDiff.patch.slice(0, MAX_INPUT))
478 const bounded = completePatch(patch, gitDiff.patch.length > MAX_INPUT)
479 change.patch = bounded.patch
480 change.truncated = bounded.truncated
481 const parsed = parseGitDiff(patch, now)[0]
482 change.additions = finite(gitDiff.additions) ?? parsed?.additions ?? 0
483 change.deletions = finite(gitDiff.deletions) ?? parsed?.deletions ?? 0
484 return [change]
485 }
486 if (!Array.isArray(value.structuredPatch) || value.structuredPatch.length === 0) return []
487 const chunks = [`--- ${JSON.stringify(`a/${path}`)}\n+++ ${JSON.stringify(`b/${path}`)}\n`]
488 let size = chunks[0]?.length ?? 0
489 let accepted = 0
490 let lineCount = 0
491 const append = (text: string): void => {
492 const chunk = safePatch(text.slice(0, MAX_PATCH + 1))
493 const available = Math.max(0, MAX_PATCH - size)
494 if (text.length > MAX_PATCH || chunk.length > available) change.truncated = true
495 if (available) { chunks.push(chunk.slice(0, available)); size += Math.min(chunk.length, available) }
496 }
497 for (const hunk of value.structuredPatch.slice(0, 1000)) {
498 const item = record(hunk)
499 if (!item || !Array.isArray(item.lines)) continue
500 const oldStart = hunkNumber(item.oldStart)
501 const oldLines = hunkNumber(item.oldLines)
502 const newStart = hunkNumber(item.newStart)
503 const newLines = hunkNumber(item.newLines)
504 if ([oldStart, oldLines, newStart, newLines].some(number => number === undefined)) continue
505 const body = item.lines.slice(0, 10001)
506 if (!body.length || body.some(line => typeof line !== 'string' || line.includes('\n') || !line.length || (!' +-'.includes(line.charAt(0)) && line !== '\\ No newline at end of file'))) continue
507 accepted++
508 append(`@@ -${oldStart},${oldLines} +${newStart},${newLines} @@\n`)
509 for (const line of body) {
510 if (++lineCount > 10000) { change.truncated = true; break }
511 if (typeof line !== 'string') continue
512 if (line.startsWith('+')) change.additions++
513 if (line.startsWith('-')) change.deletions++
514 append(`${line}\n`)
515 }
516 if (lineCount > 10000) break
517 }
518 if (!accepted) return []
519 if (value.structuredPatch.length > 1000) change.truncated = true
520 const bounded = completePatch(chunks.join(''), change.truncated)
521 change.patch = bounded.patch
522 change.truncated = bounded.truncated
523 return [change]
524}
525
526const MAX_SAMPLES = 240
527const MAX_TODOS = 50
528const MAX_TASKS = 100
529const MAX_BACKGROUND = 40
530const MAX_ANSWER = 4000
531
532const text = (value: unknown, limit = 240): string | undefined =>
533 typeof value === 'string' && value.length > 0 ? label(value, limit) : undefined
534const array = (value: unknown, limit: number): unknown[] =>
535 Array.isArray(value) ? value.slice(0, limit) : []
536const resultOf = (result: unknown): Record<string, unknown> | undefined => {
537 const wrapper = record(result)
538 return record(wrapper?.result) ?? wrapper
539}
540const workStatus = (value: unknown): CockpitTodo['status'] | undefined =>
541 value === 'pending' || value === 'in_progress' || value === 'completed' ? value : undefined
542
543export const activityLevel = (activity: Pick<CockpitActivity, 'tools' | 'agents' | 'working'>): number =>
544 activity.tools.filter(tool => tool.outcome === 'running').length
545 + activity.agents.filter(agent => agent.status === 'running').length
546 + (activity.working ? 1 : 0)
547
548// Record the level only where it changes. The level holds between samples, so
549// a history needs no timer and has no gaps while the pane is closed.
550export const withSample = (activity: CockpitActivity, now: number): CockpitActivity => {
551 const level = activityLevel(activity)
552 const samples = Array.isArray(activity.samples) ? activity.samples : []
553 if (samples.at(-1)?.level === level) return activity
554 return { ...activity, samples: [...samples, { at: now, level }].slice(-MAX_SAMPLES) }
555}
556
557// Answers keep newlines and tabs for Markdown, and lose other control characters.
558export const answerText = (value: unknown): string | undefined => {
559 if (typeof value !== 'string') return undefined
560 return safePatch(value.slice(0, MAX_ANSWER)).trim() || undefined
561}
562
563export const agentResult = (result: unknown): { agentId: string; totals: CockpitAgentTotals; answer?: string } | undefined => {
564 const value = resultOf(result)
565 const agentId = text(value?.agentId, 200)
566 if (!value || !agentId || value.status !== 'completed') return undefined
567 const stats = record(value.toolStats)
568 const models = array(value.modelsUsed, 8).flatMap(model => text(model, 80) ?? [])
569 const resolved = text(value.resolvedModel, 80)
570 const answer = answerText(array(value.content, 50).flatMap(block => {
571 const blockText = record(block)?.text
572 return typeof blockText === 'string' ? [blockText] : []
573 }).join('\n'))
574 return {
575 agentId,
576 totals: {
577 tokens: finite(value.totalTokens, Number.MAX_SAFE_INTEGER) ?? 0,
578 toolUses: finite(value.totalToolUseCount) ?? 0,
579 durationMs: finite(value.totalDurationMs, Number.MAX_SAFE_INTEGER) ?? 0,
580 linesAdded: finite(stats?.linesAdded),
581 linesRemoved: finite(stats?.linesRemoved),
582 models: models.length > 0 ? models : resolved ? [resolved] : [],
583 },
584 answer,
585 }
586}
587
588export const todosOf = (result: unknown): CockpitTodo[] | undefined => {
589 const value = resultOf(result)
590 if (!Array.isArray(value?.newTodos)) return undefined
591 return value.newTodos.slice(0, MAX_TODOS).flatMap(item => {
592 const todo = record(item)
593 const status = workStatus(todo?.status)
594 const content = text(todo?.content)
595 if (!status || !content) return []
596 return [{ content, status, activeForm: text(todo?.activeForm) ?? content }]
597 })
598}
599
600// TaskList is authoritative. Create, Get and Update change one entry.
601export const applyTaskResult = (
602 tasks: CockpitTask[], tool: string, input: unknown, result: unknown,
603): CockpitTask[] | undefined => {
604 const value = resultOf(result)
605 const args = record(input)
606 if (!value) return undefined
607 const task = (item: unknown): CockpitTask | undefined => {
608 const entry = record(item)
609 const id = text(entry?.id, 80)
610 const subject = text(entry?.subject)
611 if (!id || !subject) return undefined
612 return { id, subject, status: workStatus(entry?.status) ?? 'pending', owner: text(entry?.owner, 80) }
613 }
614 const upsert = (next: CockpitTask): CockpitTask[] =>
615 [...tasks.filter(item => item.id !== next.id), next].slice(-MAX_TASKS)
616 if (tool === 'TaskList' && Array.isArray(value.tasks)) {
617 return value.tasks.slice(0, MAX_TASKS).flatMap(item => task(item) ?? [])
618 }
619 if (tool === 'TaskCreate' || tool === 'TaskGet') {
620 const next = task(value.task)
621 return next ? upsert({ ...tasks.find(item => item.id === next.id), ...next }) : undefined
622 }
623 if (tool === 'TaskUpdate' && value.success === true) {
624 const id = text(value.taskId, 80)
625 const existing = tasks.find(item => item.id === id)
626 if (!id) return undefined
627 if (args?.status === 'deleted') return tasks.filter(item => item.id !== id)
628 const status = workStatus(record(value.statusChange)?.to) ?? workStatus(args?.status)
629 const subject = text(args?.subject) ?? existing?.subject
630 if (!subject) return undefined
631 return upsert({
632 id, subject, status: status ?? existing?.status ?? 'pending',
633 owner: text(args?.owner, 80) ?? existing?.owner,
634 })
635 }
636 return undefined
637}
638
639export const gitOperationOf = (id: string, result: unknown, now: number): CockpitGitOperation | undefined => {
640 const operation = record(resultOf(result)?.gitOperation)
641 if (!operation) return undefined
642 const commit = record(operation.commit)
643 const push = record(operation.push)
644 const branch = record(operation.branch)
645 const pr = record(operation.pr)
646 const found: CockpitGitOperation = { id, at: now }
647 const sha = text(commit?.sha, 64)
648 if (sha) found.commit = { sha, kind: text(commit?.kind, 40) ?? 'committed', branch: text(commit?.branch, 200) }
649 const pushed = text(push?.branch, 200)
650 if (pushed) found.push = { branch: pushed }
651 const ref = text(branch?.ref, 200)
652 if (ref) found.branch = { ref, action: text(branch?.action, 40) ?? 'changed' }
653 const number = finite(pr?.number)
654 const url = text(pr?.url, 2048)
655 if (number !== undefined) {
656 found.pr = { number, action: text(pr?.action, 40) ?? 'changed', url: url?.startsWith('https://') ? url : undefined }
657 }
658 return found.commit || found.push || found.branch || found.pr ? found : undefined
659}
660
661// A launch is known from the tool result. The Stop snapshots report later
662// states. A shell command line is never kept: only its description.
663export const backgroundLaunch = (
664 tool: string, input: unknown, result: unknown, now: number,
665): CockpitBackgroundTask | undefined => {
666 const value = resultOf(result)
667 const args = record(input)
668 const launched = (id: string | undefined, type: string, description: string | undefined): CockpitBackgroundTask | undefined =>
669 id ? { id, type, status: 'running', description: description ?? type, startedAt: now, updatedAt: now } : undefined
670 if (tool === 'Bash') {
671 const command = typeof args?.command === 'string' ? toolTarget('Bash', args) : undefined
672 return launched(text(value?.backgroundTaskId, 80), 'shell', text(args?.description) ?? command)
673 }
674 if (tool === 'Agent' && value?.status === 'async_launched') {
675 return launched(text(value.agentId, 200), 'subagent', text(value.description) ?? text(args?.description))
676 }
677 if (tool === 'Monitor') return launched(text(value?.taskId, 80), 'monitor', text(args?.description))
678 return undefined
679}
680
681export const backgroundSnapshot = (list: unknown, now: number): CockpitBackgroundTask[] =>
682 array(list, MAX_BACKGROUND).flatMap(item => {
683 const task = record(item)
684 const id = text(task?.id, 200)
685 if (!id) return []
686 const type = text(task?.type, 40) ?? 'task'
687 return [{
688 id, type, status: text(task?.status, 40) ?? 'unknown',
689 description: text(task?.description) ?? text(task?.name, 80) ?? type,
690 agentType: text(task?.agent_type, 80), updatedAt: now,
691 }]
692 })
693
694// The snapshot lists in-flight work only. A task that leaves it has ended,
695// without a reported outcome.
696export const mergeBackground = (
697 previous: CockpitBackgroundTask[], snapshot: CockpitBackgroundTask[], now: number,
698): CockpitBackgroundTask[] => {
699 const live = new Map(snapshot.map(task => [task.id, task]))
700 const kept = previous.map(task => {
701 const current = live.get(task.id)
702 if (current) return { ...task, ...current, startedAt: task.startedAt }
703 return task.endedAt === undefined ? { ...task, status: 'ended', endedAt: now, updatedAt: now } : task
704 })
705 const added = snapshot.filter(task => !previous.some(item => item.id === task.id))
706 return [...kept, ...added].slice(-MAX_BACKGROUND)
707}
708
709export const endBackground = (
710 previous: CockpitBackgroundTask[], id: string, status: string, now: number,
711): CockpitBackgroundTask[] =>
712 previous.map(task => task.id === id && task.endedAt === undefined ? { ...task, status, endedAt: now, updatedAt: now } : task)
713
714export const cronSnapshot = (list: unknown): CockpitCron[] =>
715 array(list, 20).flatMap(item => {
716 const cron = record(item)
717 const id = text(cron?.id, 80)
718 const schedule = text(cron?.schedule, 80)
719 return id && schedule ? [{ id, schedule, recurring: cron?.recurring === true }] : []
720 })
721hooks/layout.tsx 184 lines1import type { RenderElement } from 'claude-code'
2import type { CockpitViewProps } from '../types'
3import type { Segment } from './summary'
4import type { CockpitElements } from './theme'
5import { clip, colors, padEnd, rule } from './theme'
6
7const width = (segments: readonly Segment[]): number =>
8 segments.reduce((total, segment) => total + [...segment.text].length, 0)
9
10/** Columns a view can fill: the pane body less one, so a full line never wraps. */
11export const viewWidth = (props: CockpitViewProps): number => Math.max(24, Math.floor(props.columns) - 1)
12
13/** Rows a list can take before it windows around the selection. */
14export const listLimit = (props: CockpitViewProps): number => Math.max(6, Math.min(20, Math.floor(props.rows) - 12))
15
16export const line = (ui: CockpitElements, key: string, segments: readonly Segment[], wrap = false): RenderElement => {
17 const { Text } = ui
18 return (
19 <Text key={key} wrap={wrap ? 'wrap' : 'truncate-end'}>
20 {segments.map(segment => <Text color={segment.color} bold={segment.bold}>{segment.text}</Text>)}
21 </Text>
22 )
23}
24
25/** `Title ───────── detail`: the rule fills the line between them. */
26export const heading = (
27 ui: CockpitElements, key: string, title: string, detail: readonly Segment[], columns: number,
28): RenderElement => {
29 const detailWidth = width(detail)
30 const fill = columns - [...title].length - detailWidth - (detailWidth > 0 ? 2 : 1)
31 return line(ui, key, [
32 { text: title, color: colors.accent, bold: true },
33 { text: fill >= 2 ? ` ${rule(fill)}${detailWidth > 0 ? ' ' : ''}` : ' ', color: colors.muted },
34 ...detail,
35 ])
36}
37
38export type Action = { key: string; label: string; hotkey?: string; onPress: () => Promise<void> | void; hidden?: boolean }
39
40/** Plain buttons that read `r: Refresh`, as the view tabs do. */
41export const actions = (ui: CockpitElements, key: string, items: readonly Action[]): RenderElement => {
42 const { Box, Button } = ui
43 return (
44 <Box key={key} flexDirection="row" flexWrap="wrap" columnGap={2}>
45 {items.filter(item => !item.hidden).map(item => (
46 <Button key={item.key} plain label={item.label} hotkey={item.hotkey} onPress={item.onPress} />
47 ))}
48 </Box>
49 )
50}
51
52/** A muted label column and a value that wraps under itself. */
53export const field = (
54 ui: CockpitElements, key: string, label: string, value: readonly Segment[], labelWidth = 9,
55): RenderElement => {
56 const { Box, Text } = ui
57 return (
58 <Box key={key} flexDirection="row">
59 <Box width={labelWidth} flexShrink={0}><Text color={colors.muted}>{padEnd(label, labelWidth)}</Text></Box>
60 <Box flexGrow={1} flexShrink={1} minWidth={0}>{line(ui, `${key}:value`, value, true)}</Box>
61 </Box>
62 )
63}
64
65/** A row with content at the left edge and a detail at the right edge. */
66export const spread = (
67 ui: CockpitElements, key: string, left: RenderElement, right: readonly Segment[],
68): RenderElement => {
69 const { Box } = ui
70 return (
71 <Box key={key} flexDirection="row" justifyContent="space-between" gap={1}>
72 <Box flexShrink={1} minWidth={0}>{left}</Box>
73 <Box flexShrink={0}>{line(ui, `${key}:right`, right)}</Box>
74 </Box>
75 )
76}
77
78export const card = (ui: CockpitElements, key: string, children: (RenderElement | null | false | undefined)[]): RenderElement => {
79 const { Box } = ui
80 return (
81 <Box key={key} flexDirection="column" borderStyle="round" borderColor={colors.muted} paddingX={1} width="100%">
82 {children.filter((child): child is RenderElement => Boolean(child))}
83 </Box>
84 )
85}
86
87export const section = (ui: CockpitElements, key: string, children: (RenderElement | null | false | undefined)[]): RenderElement => {
88 const { Box } = ui
89 return (
90 <Box key={key} flexDirection="column" width="100%">
91 {children.filter((child): child is RenderElement => Boolean(child))}
92 </Box>
93 )
94}
95
96export const muted = (ui: CockpitElements, key: string, text: string): RenderElement => {
97 const { Text } = ui
98 return <Text key={key} color={colors.muted} wrap="wrap">{text}</Text>
99}
100
101export const visibleWindow = <T,>(items: readonly T[], selected: number, limit: number): readonly T[] => {
102 const size = Math.max(1, Math.floor(limit))
103 const anchor = Math.max(0, Math.min(items.length - 1, selected))
104 const start = Math.max(0, Math.min(items.length - size, anchor - Math.floor(size / 2)))
105 return items.slice(start, start + size)
106}
107
108export const neighboringIndex = (length: number, selected: number, direction: number): number =>
109 length > 0 ? (Math.max(0, selected) + direction + length) % length : 0
110
111export const sparkline = (values: readonly number[], columns: number): string => {
112 const shown = values.slice(-Math.max(1, Math.min(120, Math.floor(Number.isFinite(columns) ? columns : 40))))
113 if (shown.length === 0) return ''
114 const maximum = Math.max(1e-9, ...shown.filter(Number.isFinite))
115 const glyphs = '▁▂▃▄▅▆▇█'
116 return shown.map(value => glyphs[Math.max(0, Math.min(7, Math.round((Number.isFinite(value) ? Math.max(0, value) : 0) / maximum * 7)))] ?? '▁').join('')
117}
118
119export type Tab = { label: string; badge?: string }
120
121/** Columns of a plain tab: the Button's `1: Label`, then its badge after a space. */
122export const tabWidth = (tabs: readonly Tab[], index: number, badges: boolean): number => {
123 const tab = tabs[index]
124 if (tab === undefined) return 0
125 return [...`${index + 1}: ${tab.label}`].length + (badges && tab.badge ? 1 + [...tab.badge].length : 0)
126}
127
128/**
129 * How the tabs fit `columns`: one row three columns apart, then without badges,
130 * then two apart; else rows packed in order, with badges when they add no row.
131 */
132export const tabRows = (tabs: readonly Tab[], columns: number): { rows: number[][]; badges: boolean; gap: number } => {
133 const all = tabs.map((_, index) => index)
134 const total = (row: readonly number[], badges: boolean, gap: number): number =>
135 row.reduce((sum, index) => sum + tabWidth(tabs, index, badges), 0) + gap * Math.max(0, row.length - 1)
136 for (const [badges, gap] of [[true, 3], [false, 3], [false, 2]] as const) {
137 if (total(all, badges, gap) <= columns) return { rows: [all], badges, gap }
138 }
139 const pack = (badges: boolean): number[][] => {
140 const rows: number[][] = []
141 for (const index of all) {
142 const row = rows[rows.length - 1]
143 if (row && total([...row, index], badges, 3) <= columns) row.push(index)
144 else rows.push([index])
145 }
146 return rows
147 }
148 const badged = pack(true)
149 const plain = pack(false)
150 return badged.length <= plain.length ? { rows: badged, badges: true, gap: 3 } : { rows: plain, badges: false, gap: 3 }
151}
152
153/** The mark under the row that holds the active tab: a thin accent bar under it. */
154export const tabMark = (
155 tabs: readonly Tab[], row: readonly number[], active: number, badges: boolean, gap: number,
156): Segment[] | undefined => {
157 let used = 0
158 for (const [position, index] of row.entries()) {
159 if (position > 0) used += gap
160 const size = tabWidth(tabs, index, badges)
161 if (index === active) return [{ text: ' '.repeat(used) }, { text: '▔'.repeat(size), color: colors.accent }]
162 used += size
163 }
164 return undefined
165}
166
167/**
168 * The pane footer: the keys that work in this pane, in reading order. When it
169 * does not fit, the least needed part drops first: read-only, close, views.
170 */
171export const paneFooter = (surface: string, placement: 'dock' | 'inline', columns: number): string => {
172 if (surface !== 'terminal') return 'Read-only'
173 const parts = [
174 { text: 'Read-only', need: 4 },
175 { text: '1–5 views', need: 2 },
176 { text: 'Ctrl+X Tab focus', need: 0 },
177 { text: placement === 'dock' ? 'Ctrl+X ←/→ resize' : 'Ctrl+X ↑/↓ resize', need: 1 },
178 { text: 'Esc close', need: 3 },
179 ]
180 const text = (limit: number): string => parts.filter(part => part.need <= limit).map(part => part.text).join(' · ')
181 for (let limit = 4; limit > 0; limit -= 1) if ([...text(limit)].length <= columns) return text(limit)
182 return clip(text(0), columns)
183}
184hooks/state.ts 76 lines1import type {
2 CockpitActivity,
3 CockpitContext,
4 CockpitReview,
5} from '../types'
6
7export const initialActivity = (): CockpitActivity => ({
8 sessionId: null,
9 model: '',
10 working: false,
11 turnStartedAt: null,
12 phase: 'idle',
13 tools: [],
14 agents: [],
15 turns: [],
16 samples: [],
17 todos: [],
18 tasks: [],
19 background: [],
20 crons: [],
21 backgroundAt: null,
22 stopFailure: null,
23 selectedAgent: null,
24 selectedFor: null,
25 selectedTool: null,
26 updatedAt: 0,
27 error: null,
28})
29
30export const initialReview = (): CockpitReview => ({
31 sessionId: null,
32 changes: [],
33 findings: [],
34 checks: [],
35 gitOps: [],
36 selectedPath: null,
37 branch: null,
38 root: null,
39 refreshedAt: null,
40 loading: false,
41 error: null,
42})
43
44export const initialContext = (): CockpitContext => ({
45 sessionId: null,
46 usage: null,
47 compactions: [],
48 costs: [],
49 fills: [],
50 refreshedAt: null,
51 loading: false,
52 error: null,
53})
54
55// Hot reload keeps session memory in the shape an earlier version wrote. Fill
56// the fields it lacks, and drop activity samples from before they were records.
57// A complete value comes back as the same object, so an unchanged update stays one.
58const complete = <T extends object>(value: T, initial: T): boolean =>
59 Object.keys(initial).every(key => (value as Record<string, unknown>)[key] !== undefined)
60
61const filled = <T extends object>(value: T, initial: T): T =>
62 ({ ...initial, ...Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)) })
63
64export const activityOf = (value: CockpitActivity): CockpitActivity => {
65 const samples = Array.isArray(value.samples) ? value.samples : []
66 const records = samples.every(sample => typeof sample === 'object' && sample !== null)
67 if (complete(value, initialActivity()) && records && samples === value.samples) return value
68 return { ...filled(value, initialActivity()), samples: samples.filter(sample => typeof sample === 'object' && sample !== null) }
69}
70
71export const reviewOf = (value: CockpitReview): CockpitReview =>
72 complete(value, initialReview()) ? value : filled(value, initialReview())
73
74export const contextOf = (value: CockpitContext): CockpitContext =>
75 complete(value, initialContext()) ? value : filled(value, initialContext())
76hooks/summary.ts 296 lines1import type { ThemeKey } from 'claude-code'
2import type {
3 CockpitActivity, CockpitAgent, CockpitCheck, CockpitContext, CockpitReview, CockpitTodo, CockpitTokenUsage,
4 CockpitTool, CockpitTurn,
5} from '../types'
6import { brief, clip, colors, compact, humanize, percent, statusColor, statusGlyph, usd } from './theme'
7
8export type Segment = { text: string; color?: ThemeKey; bold?: boolean }
9
10const finite = (value: number | undefined | null): value is number =>
11 typeof value === 'number' && Number.isFinite(value)
12
13// A finished call with a PostToolUse time reports execution alone. Otherwise
14// the time includes any permission prompt and hook time.
15export const toolDuration = (tool: CockpitTool, now: number): number | undefined => {
16 if (tool.outcome !== 'running' && tool.runMs !== undefined) return tool.runMs
17 const end = tool.finishedAt ?? (tool.outcome === 'running' ? now : undefined)
18 if (end === undefined || !Number.isFinite(end) || !Number.isFinite(tool.startedAt)) return undefined
19 return Math.max(0, end - tool.startedAt)
20}
21
22// The time around the execution: the permission prompt when one was asked, and hooks.
23export const waitDuration = (tool: CockpitTool): number | undefined => {
24 if (tool.finishedAt === undefined || tool.runMs === undefined) return undefined
25 return Math.max(0, tool.finishedAt - tool.startedAt - tool.runMs)
26}
27
28export const runningTools = (activity: CockpitActivity): CockpitTool[] =>
29 activity.tools.filter(tool => tool.outcome === 'running').sort((left, right) => right.startedAt - left.startedAt)
30
31export const isLive = (agent: CockpitAgent): boolean =>
32 agent.status === 'running' || agent.status === 'pending' || agent.status === 'waiting'
33
34export const mainTurns = (activity: CockpitActivity): CockpitTurn[] =>
35 activity.turns.filter(turn => turn.agentId === undefined)
36
37export type TokenTotals = { input: number; output: number; cacheRead: number; cacheWrite: number }
38
39export const tokenTotals = (usages: readonly (CockpitTokenUsage | undefined)[]): TokenTotals =>
40 usages.reduce<TokenTotals>((total, usage) => usage ? {
41 input: total.input + (usage.input_tokens || 0),
42 output: total.output + (usage.output_tokens || 0),
43 cacheRead: total.cacheRead + (usage.cache_read_input_tokens || 0),
44 cacheWrite: total.cacheWrite + (usage.cache_creation_input_tokens || 0),
45 } : total, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
46
47/** The share of prompt tokens read from the cache; undefined before any prompt. */
48export const cacheHit = (totals: TokenTotals): number | undefined => {
49 const prompt = totals.input + totals.cacheRead + totals.cacheWrite
50 return prompt > 0 ? totals.cacheRead / prompt * 100 : undefined
51}
52
53export const agentTokens = (agent: CockpitAgent): number | undefined => {
54 if (agent.totals) return agent.totals.tokens
55 if (!agent.usage) return undefined
56 const totals = tokenTotals([agent.usage])
57 return totals.input + totals.output + totals.cacheRead + totals.cacheWrite
58}
59
60/** How long an agent ran: its reported duration, or the time since it was first seen. */
61export const agentElapsed = (agent: CockpitAgent, now: number): number | undefined => {
62 if (agent.totals) return agent.totals.durationMs
63 if (agent.durationMs !== undefined) return agent.durationMs
64 if (agent.startedAt === undefined) return undefined
65 return Math.max(0, (agent.completedAt ?? now) - agent.startedAt)
66}
67
68export const mainTodos = (activity: CockpitActivity): CockpitTodo[] =>
69 activity.todos.find(list => list.agentId === undefined)?.items ?? []
70
71export type PlanState = { done: number; total: number; current?: string; noun: 'todos' | 'tasks' }
72
73export const planState = (activity: Pick<CockpitActivity, 'todos' | 'tasks'>): PlanState | undefined => {
74 const todos = activity.todos.find(list => list.agentId === undefined)?.items ?? []
75 if (todos.length > 0) {
76 const current = todos.find(todo => todo.status === 'in_progress')
77 return { done: todos.filter(todo => todo.status === 'completed').length, total: todos.length, current: current?.activeForm, noun: 'todos' }
78 }
79 if (activity.tasks.length === 0) return undefined
80 const current = activity.tasks.find(task => task.status === 'in_progress')
81 return { done: activity.tasks.filter(task => task.status === 'completed').length, total: activity.tasks.length, current: current?.subject, noun: 'tasks' }
82}
83
84export const planProgress = (activity: Pick<CockpitActivity, 'todos' | 'tasks'>): string | undefined => {
85 const plan = planState(activity)
86 return plan ? `${plan.done}/${plan.total} ${plan.noun}` : undefined
87}
88
89export type ChangeTotals = { files: number; additions: number; deletions: number; staged: number; untracked: number }
90
91export const changeTotals = (review: CockpitReview): ChangeTotals => ({
92 files: review.changes.length,
93 additions: review.changes.reduce((total, change) => total + change.additions, 0),
94 deletions: review.changes.reduce((total, change) => total + change.deletions, 0),
95 staged: review.changes.filter(change => change.staged).length,
96 untracked: review.changes.filter(change => change.untracked).length,
97})
98
99/** A running check first; otherwise the one that finished last. */
100export const latestCheck = (review: CockpitReview): CockpitCheck | undefined =>
101 review.checks.filter(check => check.status === 'running').at(-1)
102 ?? review.checks.filter(check => check.status !== 'running').slice().sort((left, right) => (left.finishedAt ?? 0) - (right.finishedAt ?? 0)).at(-1)
103
104export const checkResult = (check: CockpitCheck): string => {
105 if (check.status === 'running') return 'running'
106 if (check.failed !== undefined && check.failed > 0) return `${compact(check.failed)} failed`
107 if (check.passed !== undefined) return `${compact(check.passed)} passed`
108 return check.status
109}
110
111export type ContextFill = {
112 tokens?: number
113 window?: number
114 percent?: number
115 threshold?: number
116 headroom?: number
117 /** Average growth per measurement, after compactions are left out. */
118 growth?: number
119 responsesLeft?: number
120}
121
122export const contextFill = (context: CockpitContext): ContextFill => {
123 const live = context.usage?.context
124 const breakdown = live?.breakdown
125 const threshold = breakdown?.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined
126 const tokens = live?.tokens
127 const deltas = context.fills.slice(1).map((sample, index) => sample.tokens - (context.fills[index]?.tokens ?? sample.tokens))
128 .filter(delta => delta > 0).slice(-5)
129 const growth = deltas.length > 0 ? deltas.reduce((total, delta) => total + delta, 0) / deltas.length : undefined
130 const headroom = finite(threshold) && finite(tokens) ? Math.max(0, threshold - tokens) : undefined
131 return {
132 tokens, window: live?.window, percent: live?.percent, threshold, headroom, growth,
133 responsesLeft: finite(headroom) && finite(growth) && growth > 0 ? headroom / growth : undefined,
134 }
135}
136
137/** The context share at which the band and header warn: near auto-compaction, or 80% without a threshold. */
138export const contextColor = (fill: ContextFill): ThemeKey => {
139 if (!finite(fill.percent)) return colors.muted
140 const limit = finite(fill.threshold) && finite(fill.window) && fill.window > 0 ? fill.threshold / fill.window * 100 : 80
141 if (fill.percent >= limit) return colors.red
142 if (fill.percent >= limit - 10) return colors.yellow
143 return colors.muted
144}
145
146export type ToolStat = { tool: string; calls: number; failures: number; totalMs: number; maxMs: number }
147
148export const toolStats = (tools: readonly CockpitTool[], now: number): ToolStat[] => {
149 const stats = new Map<string, ToolStat>()
150 for (const tool of tools) {
151 const stat = stats.get(tool.tool) ?? { tool: tool.tool, calls: 0, failures: 0, totalMs: 0, maxMs: 0 }
152 const elapsed = toolDuration(tool, now) ?? 0
153 stat.calls += 1
154 if (tool.outcome === 'error' || tool.outcome === 'denied') stat.failures += 1
155 stat.totalMs += elapsed
156 stat.maxMs = Math.max(stat.maxMs, elapsed)
157 stats.set(tool.tool, stat)
158 }
159 return [...stats.values()].sort((left, right) => right.totalMs - left.totalMs || right.calls - left.calls)
160}
161
162export const approvalWait = (tools: readonly CockpitTool[]): { asked: number; totalMs: number; measured: number } => {
163 const asked = tools.filter(tool => tool.approval === 'asked')
164 const waits = asked.map(waitDuration).filter(finite)
165 return { asked: asked.length, totalMs: waits.reduce((total, wait) => total + wait, 0), measured: waits.length }
166}
167
168export const sessionStart = (activity: CockpitActivity, context: CockpitContext): number | undefined => {
169 const started = context.usage?.startedAt
170 if (finite(started)) return started
171 const times = [
172 ...activity.tools.map(tool => tool.startedAt),
173 ...activity.turns.map(turn => turn.finishedAt - turn.durationMs),
174 ...activity.samples.map(sample => sample.at),
175 ].filter(finite)
176 return times.length > 0 ? Math.min(...times) : undefined
177}
178
179export const toolLabel = (tool: CockpitTool): string => tool.target ? `${tool.tool} ${tool.target}` : tool.tool
180
181/** The main thread's own call first, then a subagent it waits on, then any running call. */
182export const leadTool = (running: readonly CockpitTool[]): CockpitTool | undefined =>
183 running.find(tool => tool.agentId === undefined && tool.tool !== 'Agent')
184 ?? running.find(tool => tool.agentId === undefined)
185 ?? running[0]
186
187export type StatusSummary = { glyph: string; label: string; color: ThemeKey; since?: number }
188
189/** What the main session is doing now, for the band and the pane header. */
190export const sessionStatus = (activity: CockpitActivity, isWorking: boolean): StatusSummary => {
191 const failure = isWorking ? null : activity.stopFailure
192 if (failure) return { glyph: '✗', label: `stopped: ${humanize(failure.error)}`, color: colors.red }
193 const running = runningTools(activity)
194 const lead = leadTool(running)
195 if (lead) {
196 const more = running.length > 1 ? ` +${running.length - 1}` : ''
197 return { glyph: '▸', label: `${toolLabel(lead)}${more}`, color: colors.cyan, since: lead.startedAt }
198 }
199 if (isWorking) return { glyph: '▸', label: 'thinking', color: colors.cyan, since: activity.turnStartedAt ?? undefined }
200 if (activity.phase === 'error') return { glyph: '✗', label: 'last turn failed', color: colors.red }
201 return { glyph: '○', label: 'idle', color: colors.muted }
202}
203
204const segmentWidth = (segments: readonly Segment[]): number =>
205 segments.reduce((total, segment) => total + [...segment.text].length, 0)
206
207type Part = { priority: number; full: Segment[]; short?: Segment[] }
208
209/**
210 * The band line: what runs now, the plan, agents, context, checks, changes,
211 * cost, background work. When the line is too wide, parts take their short
212 * form, the lowest priorities drop, and the highest regain their full form.
213 */
214export const bandSegments = (
215 activity: CockpitActivity, review: CockpitReview, context: CockpitContext,
216 options: { isWorking: boolean; columns: number; now: number },
217): Segment[] => {
218 const parts: Part[] = []
219 const status = sessionStatus(activity, options.isWorking)
220 const since = finite(status.since) ? ` ${brief(options.now - status.since)}` : ''
221 const lead = status.since === undefined ? undefined : leadTool(runningTools(activity))
222 parts.push({
223 priority: 0,
224 full: [{ text: `${status.glyph} ${status.label}${since}`, color: status.color }],
225 short: lead ? [{ text: `${status.glyph} ${lead.tool}${since}`, color: status.color }] : undefined,
226 })
227 const plan = planState(activity)
228 if (plan) {
229 parts.push({
230 priority: 1,
231 full: [{ text: `${plan.done}/${plan.total}${plan.current ? ` ${clip(plan.current, 32)}` : ` ${plan.noun}`}`, color: colors.accent }],
232 short: [{ text: `${plan.done}/${plan.total}`, color: colors.accent }],
233 })
234 }
235 const agents = activity.agents.filter(isLive).length
236 if (agents > 0) parts.push({ priority: 2, full: [{ text: `${agents} ${agents === 1 ? 'agent' : 'agents'}`, color: colors.magenta }] })
237 const fill = contextFill(context)
238 if (finite(fill.percent)) {
239 parts.push({ priority: 3, full: [{ text: `ctx ${percent(fill.percent)}`, color: contextColor(fill) }] })
240 }
241 const check = latestCheck(review)
242 if (check) {
243 const failed = check.status === 'failed' || (check.failed ?? 0) > 0
244 const glyph = check.status === 'running' ? '◌' : statusGlyph(failed ? 'failed' : check.status)
245 const color = check.status === 'running' ? colors.cyan : failed ? colors.red : statusColor(check.status)
246 parts.push({
247 priority: 4,
248 full: [{ text: `${glyph} ${clip(check.label, 24)}${check.status === 'running' ? '' : ` ${checkResult(check)}`}`, color }],
249 short: [{ text: `${glyph} ${clip(check.label, 12)}`, color }],
250 })
251 }
252 const changes = changeTotals(review)
253 if (changes.files > 0) {
254 const counts: Segment[] = [
255 { text: `+${compact(changes.additions)}`, color: colors.green }, { text: ' ' },
256 { text: `−${compact(changes.deletions)}`, color: colors.red },
257 ]
258 parts.push({ priority: 5, full: [{ text: `${changes.files} ${changes.files === 1 ? 'file' : 'files'} ` }, ...counts], short: counts })
259 }
260 const cost = context.usage?.cost?.usd
261 if (finite(cost)) parts.push({ priority: 6, full: [{ text: usd(cost), color: colors.muted }] })
262 const background = activity.background.filter(task => task.endedAt === undefined).length
263 if (background > 0) parts.push({ priority: 7, full: [{ text: `${background} bg`, color: colors.muted }] })
264 const last = mainTurns(activity).at(-1)
265 if (!options.isWorking && last) parts.push({ priority: 8, full: [{ text: `last turn ${brief(last.durationMs)}`, color: colors.muted }] })
266
267 const separator = 3
268 const measure = (kept: readonly Part[], form: (part: Part) => Segment[]) =>
269 kept.reduce((total, part) => total + segmentWidth(form(part)), 0) + separator * Math.max(0, kept.length - 1)
270 const full = new Set<Part>(parts)
271 const form = (part: Part): Segment[] => full.has(part) ? part.full : part.short ?? part.full
272 let kept = parts
273 if (measure(kept, form) > options.columns) {
274 full.clear()
275 while (kept.length > 1 && measure(kept, form) > options.columns) {
276 const lowest = Math.max(...kept.map(part => part.priority))
277 kept = kept.filter(part => part.priority !== lowest)
278 }
279 for (const part of kept) {
280 full.add(part)
281 if (measure(kept, form) > options.columns) full.delete(part)
282 }
283 }
284 const joined: Segment[] = []
285 kept.forEach((part, index) => {
286 if (index > 0) joined.push({ text: ' · ', color: colors.muted })
287 joined.push(...form(part))
288 })
289 // The status part alone can still be too wide.
290 if (kept.length === 1 && segmentWidth(joined) > options.columns) {
291 const first = joined[0]
292 if (first) return [{ ...first, text: clip(first.text, options.columns) }]
293 }
294 return joined
295}
296hooks/theme.ts 124 lines1import type { Elements, RenderSurface, ThemeKey } from 'claude-code'
2
3export type CockpitElements = Elements[RenderSurface]
4
5// Theme keys follow the person's theme. An ANSI theme draws them with the
6// terminal's own palette. Raw hex and color names are fixed RGB values, which
7// a 256-color terminal (any tmux session) rounds to the nearest xterm color.
8export const colors = {
9 accent: 'suggestion',
10 cyan: 'planMode',
11 green: 'success',
12 yellow: 'warning',
13 red: 'error',
14 magenta: 'merged',
15 muted: 'inactive',
16} as const satisfies Record<string, ThemeKey>
17
18export const cleanText = (text: string): string =>
19 text.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
20
21const length = (text: string): number => [...text].length
22
23export const clip = (text: string, columns: number): string => {
24 const value = cleanText(text)
25 const limit = Math.max(1, Math.floor(columns))
26 return length(value) <= limit ? value : `${[...value].slice(0, Math.max(0, limit - 1)).join('')}…`
27}
28
29// Paths keep their file name: the end says more than the start.
30export const clipStart = (text: string, columns: number): string => {
31 const value = cleanText(text)
32 const limit = Math.max(1, Math.floor(columns))
33 return length(value) <= limit ? value : `…${[...value].slice(length(value) - limit + 1).join('')}`
34}
35
36export const padEnd = (text: string, columns: number): string =>
37 `${text}${' '.repeat(Math.max(0, Math.floor(columns) - length(text)))}`
38
39export const padStart = (text: string, columns: number): string =>
40 `${' '.repeat(Math.max(0, Math.floor(columns) - length(text)))}${text}`
41
42export const duration = (ms: number): string => {
43 const safe = Math.max(0, Number.isFinite(ms) ? ms : 0)
44 if (safe < 1000) return `${Math.round(safe)}ms`
45 if (safe < 60000) return `${(safe / 1000).toFixed(1)}s`
46 if (safe < 3600000) return `${Math.floor(safe / 60000)}m ${Math.floor((safe % 60000) / 1000)}s`
47 return `${Math.floor(safe / 3600000)}h ${Math.floor((safe % 3600000) / 60000)}m`
48}
49
50/** One unit, for columns and ages: 45s, 9m, 2h, 3d. */
51export const brief = (ms: number): string => {
52 const safe = Math.max(0, Number.isFinite(ms) ? ms : 0)
53 if (safe < 10000) return `${(safe / 1000).toFixed(1)}s`
54 if (safe < 60000) return `${Math.floor(safe / 1000)}s`
55 if (safe < 3600000) return `${Math.floor(safe / 60000)}m`
56 if (safe < 86400000) return `${Math.floor(safe / 3600000)}h`
57 return `${Math.floor(safe / 86400000)}d`
58}
59
60export const ago = (now: number, at: number | null | undefined): string =>
61 at === null || at === undefined || !Number.isFinite(at) || !Number.isFinite(now) ? 'never' : `${brief(now - at)} ago`
62
63export const count = (value: number | undefined): string => {
64 if (value === undefined || !Number.isFinite(value)) return 'unknown'
65 return Math.round(Math.max(0, value)).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
66}
67
68/** 950, 1.2k, 61k, 1.4M. */
69export const compact = (value: number | undefined): string => {
70 if (value === undefined || !Number.isFinite(value)) return '?'
71 const safe = Math.max(0, value)
72 if (safe < 1000) return `${Math.round(safe)}`
73 if (safe < 10000) return `${(safe / 1000).toFixed(1)}k`
74 if (safe < 1000000) return `${Math.round(safe / 1000)}k`
75 return `${(safe / 1000000).toFixed(1)}M`
76}
77
78export const usd = (value: number): string =>
79 !Number.isFinite(value) ? '$?' : value >= 0.995 || value === 0 ? `$${value.toFixed(2)}` : `$${value.toFixed(value >= 0.01 ? 2 : 4)}`
80
81export const percent = (value: number | undefined): string =>
82 value === undefined || !Number.isFinite(value) ? '?' : `${Math.round(value)}%`
83
84export const humanize = (code: string): string => cleanText(code).replace(/_/g, ' ')
85
86export const plural = (value: number, noun: string): string =>
87 `${count(value)} ${value === 1 ? noun : `${noun}s`}`
88
89export const shortModel = (model: string): string =>
90 cleanText(model).replace(/^claude-/, '').replace(/-\d{8}$/, '').replace(/\[1m\]$/, '')
91
92export const meter = (value: number, width = 16): string => {
93 const cells = Math.max(1, Math.min(120, Math.floor(width)))
94 const filled = Math.round((Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0)) / 100) * cells)
95 return `${'━'.repeat(filled)}${'─'.repeat(cells - filled)}`
96}
97
98const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
99
100/** A left-aligned bar in eighths of a cell, for one row of a ranking. */
101export const bar = (fraction: number, width: number): string => {
102 const cells = Math.max(0, Math.floor(width))
103 const eighths = Math.round(Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0)) * cells * 8)
104 return `${'█'.repeat(Math.floor(eighths / 8))}${EIGHTHS[eighths % 8] ?? ''}`
105}
106
107export const rule = (width: number): string => '─'.repeat(Math.max(0, Math.floor(width)))
108
109export const statusColor = (status: string): ThemeKey => {
110 if (['failed', 'error', 'denied', 'killed', 'interrupted'].includes(status)) return colors.red
111 if (['running', 'thinking', 'tools', 'pending'].includes(status)) return colors.cyan
112 if (['waiting', 'missing'].includes(status)) return colors.yellow
113 if (['completed', 'success', 'passed'].includes(status)) return colors.green
114 return colors.muted
115}
116
117const glyphs: Record<string, string> = {
118 running: '▸', thinking: '▸', tools: '▸', pending: '○', waiting: '◐', idle: '○',
119 success: '✓', completed: '✓', passed: '✓',
120 error: '✗', failed: '✗', denied: '⊘', killed: '■', interrupted: '■', missing: '?',
121}
122
123export const statusGlyph = (status: string): string => glyphs[status] ?? '·'
124hooks/tools.tsx 240 lines1import type { RenderElement } from 'claude-code'
2import type { CockpitBackgroundTask, CockpitTool, CockpitViewProps } from '../types'
3import { actorName, agentName } from './agents'
4import { actions, card, field, heading, line, listLimit, muted, neighboringIndex, section, spread, viewWidth, visibleWindow } from './layout'
5import type { Segment } from './summary'
6import { approvalWait, runningTools, toolDuration, toolLabel, toolStats, waitDuration } from './summary'
7import type { CockpitElements } from './theme'
8import { ago, bar, brief, clip, colors, duration, padEnd, padStart, plural, statusColor, statusGlyph } from './theme'
9
10const outcomeColor = (tool: CockpitTool) =>
11 tool.outcome === 'running' && tool.approval === 'asked' ? colors.yellow : statusColor(tool.outcome)
12
13const failed = (tool: CockpitTool): boolean => tool.outcome === 'error' || tool.outcome === 'denied'
14
15const summary = (ui: CockpitElements, props: CockpitViewProps, tools: readonly CockpitTool[], width: number): RenderElement => {
16 const running = tools.filter(tool => tool.outcome === 'running').length
17 const failures = tools.filter(failed).length
18 const ran = tools.filter(tool => tool.tool !== 'Agent').reduce((total, tool) => total + (toolDuration(tool, props.now) ?? 0), 0)
19 const wait = approvalWait(tools)
20 return section(ui, 'tools:summary', [
21 heading(ui, 'tools:title', 'Tools', [
22 { text: plural(tools.length, 'call'), color: colors.muted },
23 { text: running > 0 ? ` · ${running} running` : '', color: colors.cyan },
24 { text: failures > 0 ? ` · ${failures} failed` : '', color: colors.red },
25 ], width),
26 line(ui, 'tools:time', [
27 { text: 'Tool time ', color: colors.muted }, { text: duration(ran) },
28 ...(wait.asked > 0 ? [
29 { text: ' · ', color: colors.muted },
30 { text: `${plural(wait.asked, 'approval')}`, color: colors.yellow },
31 { text: wait.measured > 0 ? ` · ${duration(wait.totalMs)} waiting on you` : '', color: colors.yellow },
32 ] : []),
33 ], true),
34 ])
35}
36
37const runningSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement | null => {
38 const running = runningTools(props.activity)
39 if (running.length === 0) return null
40 return section(ui, 'tools:running', [
41 heading(ui, 'tools:running:title', 'Running now', [{ text: `${running.length}`, color: colors.cyan }], width),
42 ...running.slice(0, 6).map(tool => {
43 const actor = clip(actorName(props, tool.agentId), 14)
44 const elapsed = brief(props.now - tool.startedAt)
45 return spread(ui, `tools:running:${tool.id}`, line(ui, `tools:running:${tool.id}:label`, [
46 { text: tool.approval === 'asked' ? '? ' : '▸ ', color: outcomeColor(tool) },
47 { text: clip(toolLabel(tool), width - actor.length - elapsed.length - 6), bold: true },
48 ]), [
49 { text: `${actor} `, color: colors.magenta },
50 { text: elapsed, color: tool.approval === 'asked' ? colors.yellow : colors.cyan },
51 ])
52 }),
53 running.some(tool => tool.approval === 'asked') ? muted(ui, 'tools:running:approval', '? asked for approval: the time includes the prompt.') : null,
54 ])
55}
56
57const statsSection = (ui: CockpitElements, props: CockpitViewProps, tools: readonly CockpitTool[], width: number): RenderElement | null => {
58 const stats = toolStats(tools, props.now)
59 if (stats.length === 0) return null
60 const nameWidth = Math.min(16, Math.max(8, ...stats.map(stat => stat.tool.length)))
61 const fixed = nameWidth + 6 + 5 + 7
62 const barWidth = Math.max(0, width - fixed - 1)
63 // A subagent's run would dwarf every tool, so Agent calls get no bar.
64 const longest = Math.max(1, ...stats.filter(stat => stat.tool !== 'Agent').map(stat => stat.totalMs))
65 return section(ui, 'tools:stats', [
66 heading(ui, 'tools:stats:title', 'By tool', [{ text: 'where the time goes', color: colors.muted }], width),
67 line(ui, 'tools:stats:columns', [{ text: `${padEnd('', nameWidth)}${padStart('calls', 6)}${padStart('fail', 5)}${padStart('time', 7)}`, color: colors.muted }]),
68 ...stats.slice(0, 8).map(stat => line(ui, `tools:stat:${stat.tool}`, [
69 { text: padEnd(clip(stat.tool, nameWidth), nameWidth), color: stat.tool === 'Agent' ? colors.magenta : undefined },
70 { text: padStart(`${stat.calls}`, 6), color: colors.muted },
71 { text: padStart(stat.failures > 0 ? `${stat.failures}` : '·', 5), color: stat.failures > 0 ? colors.red : colors.muted },
72 { text: padStart(brief(stat.totalMs), 7) },
73 { text: barWidth > 0 && stat.tool !== 'Agent' ? ` ${bar(Math.min(1, stat.totalMs / longest), barWidth)}` : '', color: colors.cyan },
74 ])),
75 stats.length > 8 ? muted(ui, 'tools:stats:more', `${stats.length - 8} more tools`) : null,
76 ])
77}
78
79const filterRow = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement | null => {
80 const { Box, Button } = ui
81 const agents = props.activity.agents
82 if (agents.length === 0) return null
83 const setFilter = async (id: string | null): Promise<void> => {
84 await props.actions.selectAgent(id)
85 await props.actions.selectTool(null)
86 }
87 const index = agents.findIndex(agent => agent.id === props.activity.selectedAgent)
88 const shown = visibleWindow(agents, index, 3)
89 return (
90 <Box key="tools:filters" flexDirection="row" flexWrap="wrap" columnGap={2}>
91 <Button key="tools:filter:all" plain dimColor={props.activity.selectedAgent !== null} label={`${props.activity.selectedAgent === null ? '● ' : ''}All`} onPress={() => setFilter(null)} />
92 {shown.map(agent => (
93 <Button key={`tools:filter:${agent.id}`} plain dimColor={agent.id !== props.activity.selectedAgent}
94 label={`${agent.id === props.activity.selectedAgent ? '● ' : ''}${clip(agentName(agent), Math.min(18, width - 8))}`}
95 onPress={() => setFilter(agent.id)} />
96 ))}
97 {agents.length > shown.length && <Button key="tools:filter:next" plain dimColor label="more ›" onPress={() => {
98 const next = agents[index < 0 ? 0 : neighboringIndex(agents.length, index, 1)]
99 return next ? setFilter(next.id) : Promise.resolve()
100 }} />}
101 </Box>
102 )
103}
104
105const recentSection = (
106 ui: CockpitElements, props: CockpitViewProps, filtered: readonly CockpitTool[], selected: CockpitTool | undefined, width: number,
107): RenderElement => {
108 const { Box, Button, Text } = ui
109 const selectedIndex = Math.max(0, filtered.findIndex(tool => tool.id === selected?.id))
110 const visible = visibleWindow(filtered, selectedIndex, Math.min(12, listLimit(props)))
111 const showActor = props.activity.selectedAgent === null
112 const chooseNeighbor = (direction: number): Promise<void> => {
113 const next = filtered[neighboringIndex(filtered.length, selectedIndex, direction)]
114 return next ? props.actions.selectTool(next.id) : Promise.resolve()
115 }
116 return section(ui, 'tools:recent', [
117 heading(ui, 'tools:recent:title', 'Calls', [
118 { text: props.activity.selectedAgent === null ? 'all actors' : clip(actorName(props, props.activity.selectedAgent), 24), color: colors.magenta },
119 { text: ' · newest first', color: colors.muted },
120 ], width),
121 filterRow(ui, props, width),
122 filtered.length === 0 ? muted(ui, 'tools:recent:empty', 'No tool calls for this filter yet.') : null,
123 ...visible.map(tool => {
124 const elapsed = toolDuration(tool, props.now)
125 const actor = showActor && tool.agentId ? clip(actorName(props, tool.agentId), 10) : ''
126 const right: Segment[] = [
127 { text: actor ? `${actor} ` : '', color: colors.magenta },
128 { text: padStart(elapsed === undefined ? '?' : brief(elapsed), 5), color: tool.outcome === 'running' ? colors.cyan : colors.muted },
129 { text: padStart(tool.outcome === 'running' ? '' : ago(props.now, tool.startedAt).replace(' ago', ''), 5), color: colors.muted },
130 ]
131 const rightWidth = right.reduce((total, segment) => total + [...segment.text].length, 0)
132 return spread(ui, `tools:row:${tool.id}`, (
133 <Box flexDirection="row">
134 <Text color={tool.id === selected?.id ? colors.accent : colors.muted}>{tool.id === selected?.id ? '› ' : ' '}</Text>
135 <Text color={outcomeColor(tool)}>{statusGlyph(tool.outcome)} </Text>
136 <Text color={colors.yellow}>{tool.approval === 'asked' ? '? ' : ''}</Text>
137 <Button key={`tools:select:${tool.id}`} plain label={clip(toolLabel(tool), Math.max(8, width - rightWidth - 6))} onPress={() => props.actions.selectTool(tool.id)} />
138 </Box>
139 ), right)
140 }),
141 filtered.length > visible.length ? muted(ui, 'tools:recent:more', `Showing ${visible.length} of ${filtered.length}.`) : null,
142 filtered.length > 1 ? actions(ui, 'tools:recent:actions', [
143 { key: 'tools:prev', label: 'Newer', hotkey: 'k', onPress: () => chooseNeighbor(-1) },
144 { key: 'tools:next', label: 'Older', hotkey: 'j', onPress: () => chooseNeighbor(1) },
145 ]) : null,
146 ])
147}
148
149const toolDetail = (ui: CockpitElements, props: CockpitViewProps, tool: CockpitTool, width: number): RenderElement => {
150 const elapsed = toolDuration(tool, props.now)
151 const wait = waitDuration(tool)
152 const inner = width - 4
153 return card(ui, `tools:detail:${tool.id}`, [
154 spread(ui, 'tools:detail:title', line(ui, 'tools:detail:name', [
155 { text: `${statusGlyph(tool.outcome)} `, color: outcomeColor(tool) },
156 { text: clip(tool.tool, inner - 14), bold: true },
157 ]), [{ text: tool.outcome, color: outcomeColor(tool) }]),
158 field(ui, 'tools:detail:actor', 'Actor', [{ text: actorName(props, tool.agentId), color: tool.agentId ? colors.magenta : undefined }]),
159 tool.target ? field(ui, 'tools:detail:target', 'Target', [{ text: clip(tool.target, 240) }]) : null,
160 field(ui, 'tools:detail:started', 'Started', [{ text: ago(props.now, tool.startedAt) }]),
161 field(ui, 'tools:detail:time', tool.outcome === 'running' ? 'Elapsed' : tool.runMs !== undefined ? 'Ran' : 'Took', [
162 { text: elapsed === undefined ? 'unknown' : duration(elapsed) },
163 ]),
164 wait !== undefined ? field(ui, 'tools:detail:wait', tool.approval === 'asked' ? 'Approval' : 'Hooks', [
165 { text: duration(wait), color: tool.approval === 'asked' ? colors.yellow : colors.muted },
166 { text: tool.approval === 'asked' ? ' with hooks' : '', color: colors.muted },
167 ]) : tool.approval === 'asked' ? field(ui, 'tools:detail:asked', 'Approval', [{ text: 'asked', color: colors.yellow }]) : null,
168 tool.retrospective ? muted(ui, 'tools:detail:server', 'Server tool, reported after the response.') : null,
169 ])
170}
171
172const backgroundColor = (task: CockpitBackgroundTask) =>
173 task.endedAt !== undefined ? colors.muted : statusColor(task.status === 'pending' ? 'pending' : 'running')
174
175const backgroundSection = (ui: CockpitElements, props: CockpitViewProps, width: number): RenderElement | null => {
176 const tasks = props.activity.background
177 const crons = props.activity.crons
178 if (tasks.length === 0 && crons.length === 0) return null
179 const live = tasks.filter(task => task.endedAt === undefined)
180 // In-flight work first, then the most recently ended.
181 const shown = [...live, ...tasks.filter(task => task.endedAt !== undefined).reverse()].slice(0, 6)
182 return section(ui, 'tools:background', [
183 heading(ui, 'tools:background:title', 'Background', [
184 { text: `${live.length} running`, color: live.length > 0 ? colors.cyan : colors.muted },
185 { text: props.activity.backgroundAt === null ? '' : ` · ${ago(props.now, props.activity.backgroundAt)}`, color: colors.muted },
186 ], width),
187 ...shown.map(task => {
188 const time = task.startedAt === undefined ? '' : brief((task.endedAt ?? props.now) - task.startedAt)
189 const right: Segment[] = [
190 { text: `${clip(task.type.replace(/^local_/, ''), 10)} `, color: colors.muted },
191 { text: padStart(time, 4), color: colors.muted },
192 ]
193 return spread(ui, `tools:background:${task.id}`, line(ui, `tools:background:${task.id}:label`, [
194 { text: task.endedAt === undefined ? '● ' : '○ ', color: backgroundColor(task) },
195 { text: clip(task.description, width - 20), color: task.endedAt === undefined ? undefined : colors.muted },
196 { text: task.endedAt === undefined ? '' : ` · ${task.status}`, color: colors.muted },
197 ]), right)
198 }),
199 tasks.length > shown.length ? muted(ui, 'tools:background:more', `${tasks.length - shown.length} more`) : null,
200 ...crons.slice(0, 4).map(cron => line(ui, `tools:cron:${cron.id}`, [
201 { text: '⏲ ', color: colors.accent }, { text: cron.schedule },
202 { text: cron.recurring ? ' · recurring' : ' · once', color: colors.muted },
203 ])),
204 ])
205}
206
207export const renderTools = (ui: CockpitElements, props: CockpitViewProps): RenderElement => {
208 const { Box } = ui
209 const width = viewWidth(props)
210 const wide = width >= 100
211 const left = wide ? Math.floor((width - 2) * 0.55) : width
212 const right = wide ? width - left - 2 : width
213 const all = props.activity.tools.slice().sort((a, b) => b.startedAt - a.startedAt)
214 const filtered = props.activity.selectedAgent === null ? all : all.filter(tool => tool.agentId === props.activity.selectedAgent)
215 const selected = filtered.find(tool => tool.id === props.activity.selectedTool) ?? filtered[0]
216 const scope = props.activity.selectedAgent === null ? all : filtered
217 const main = [
218 summary(ui, props, scope, left),
219 runningSection(ui, props, left),
220 recentSection(ui, props, filtered, selected, left),
221 ]
222 const side = [
223 selected ? toolDetail(ui, props, selected, right) : null,
224 statsSection(ui, props, scope, right),
225 backgroundSection(ui, props, right),
226 ]
227 const keep = (items: (RenderElement | null)[]) => items.filter((item): item is RenderElement => Boolean(item))
228 return wide ? (
229 <Box flexDirection="row" gap={2} width="100%" alignItems="flex-start">
230 <Box flexDirection="column" gap={1} width={left} flexShrink={0}>{keep(main)}</Box>
231 <Box flexDirection="column" gap={1} width={right} flexShrink={0}>{keep(side)}</Box>
232 </Box>
233 ) : (
234 <Box flexDirection="column" gap={1} width="100%">
235 {keep([...main, ...side])}
236 </Box>
237 )
238}
239
240types/index.d.ts 335 lines1export type CockpitView = 'agents' | 'tools' | 'changes' | 'context' | 'activity'
2
3/** How much time the activity timeline spans: from the first event, or a fixed window. */
4export type CockpitSpan = 'fit' | '5m' | '15m' | '60m'
5
6export type CockpitTokenUsage = {
7 input_tokens: number
8 output_tokens: number
9 cache_read_input_tokens: number
10 cache_creation_input_tokens: number
11}
12
13export type CockpitTurnUsage = CockpitTokenUsage & { model: string }
14
15export type CockpitBreakdown = {
16 categories: {
17 name: string
18 tokens: number
19 color: string
20 isDeferred: boolean
21 kind: 'used' | 'free' | 'buffer' | 'deferred'
22 }[]
23 totalTokens: number
24 maxTokens: number
25 rawMaxTokens: number
26 autocompactSource: 'env' | 'settings' | 'clientdata' | 'experiment' | 'model-default' | 'unknown-model' | 'auto'
27 percentage: number
28 gridRows: {
29 color: string
30 isFilled: boolean
31 categoryName: string
32 tokens: number
33 percentage: number
34 squareFullness: number
35 }[][]
36 model: string
37 memoryFiles: { path: string; type: string; tokens: number }[]
38 mcpTools: { name: string; serverName: string; tokens: number; isLoaded: boolean }[]
39 agents: { agentType: string; source: string; tokens: number }[]
40 slashCommands?: { totalCommands: number; includedCommands: number; tokens: number }
41 skills?: {
42 totalSkills: number
43 includedSkills: number
44 tokens: number
45 skillFrontmatter: { name: string; source: string; pluginName?: string; tokens: number }[]
46 }
47 autoCompactThreshold?: number
48 isAutoCompactEnabled: boolean
49 apiUsage: CockpitTokenUsage | null
50}
51
52export type CockpitUsage = {
53 startedAt: number
54 context: { window: number; tokens?: number; percent?: number; breakdown?: CockpitBreakdown }
55 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
56 cost?: { usd: number }
57}
58
59export type CockpitTool = {
60 id: string
61 tool: string
62 agentId?: string
63 target?: string
64 startedAt: number
65 finishedAt?: number
66 outcome: 'running' | 'success' | 'error' | 'denied' | 'interrupted'
67 retrospective?: boolean
68 /** The permission check put the call to the mode's decider (a dialog, a classifier). */
69 approval?: 'asked'
70 /** Execution time from PostToolUse, without permission-prompt and hook time. */
71 runMs?: number
72}
73
74export type CockpitAgentTotals = {
75 tokens: number
76 toolUses: number
77 durationMs: number
78 linesAdded?: number
79 linesRemoved?: number
80 models: string[]
81}
82
83export type CockpitAgent = {
84 id: string
85 name?: string
86 description: string
87 type: string
88 status: 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed' | 'missing'
89 parentId?: string
90 teammateId?: string
91 spawnedBy?: string
92 /** When the roster or a tool result first reported the agent. */
93 startedAt?: number
94 lastSeenAt: number
95 completedAt?: number
96 durationMs?: number
97 usage?: CockpitTurnUsage
98 totals?: CockpitAgentTotals
99 answer?: string
100}
101
102export type CockpitTodo = {
103 content: string
104 status: 'pending' | 'in_progress' | 'completed'
105 activeForm: string
106}
107
108/** One loop's TodoWrite list; `agentId` is absent for the main session. */
109export type CockpitTodoList = {
110 agentId?: string
111 items: CockpitTodo[]
112 updatedAt: number
113}
114
115export type CockpitTask = {
116 id: string
117 subject: string
118 status: 'pending' | 'in_progress' | 'completed'
119 owner?: string
120}
121
122export type CockpitBackgroundTask = {
123 id: string
124 type: string
125 status: string
126 description: string
127 agentType?: string
128 startedAt?: number
129 endedAt?: number
130 updatedAt: number
131}
132
133export type CockpitCron = {
134 id: string
135 schedule: string
136 recurring: boolean
137}
138
139/** A change in the activity level: running tools + running agents + main turn. */
140export type CockpitSample = {
141 at: number
142 level: number
143}
144
145export type CockpitStopFailure = {
146 error: string
147 details?: string
148 at: number
149}
150
151export type CockpitTurn = {
152 id: string
153 agentId?: string
154 durationMs: number
155 reason: string
156 finishedAt: number
157 usage?: CockpitTurnUsage
158}
159
160export type CockpitActivity = {
161 sessionId: string | null
162 model: string
163 working: boolean
164 /** When the main turn in progress started; null between turns. */
165 turnStartedAt: number | null
166 phase: 'idle' | 'thinking' | 'tools' | 'error'
167 tools: CockpitTool[]
168 agents: CockpitAgent[]
169 turns: CockpitTurn[]
170 samples: CockpitSample[]
171 todos: CockpitTodoList[]
172 tasks: CockpitTask[]
173 background: CockpitBackgroundTask[]
174 crons: CockpitCron[]
175 backgroundAt: number | null
176 stopFailure: CockpitStopFailure | null
177 selectedAgent: string | null
178 /** The agent in view when the person last chose; a later view change is followed. */
179 selectedFor: string | null
180 selectedTool: string | null
181 updatedAt: number
182 error: string | null
183}
184
185export type CockpitChangeState = 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'conflict'
186
187export type CockpitChange = {
188 path: string
189 patch: string
190 additions: number
191 deletions: number
192 source: 'observed' | 'git'
193 /** The Git status of the file; absent for an edit before the next Git read. */
194 state?: CockpitChangeState
195 staged?: boolean
196 untracked?: boolean
197 /** How many observed edits changed the file, and which agent made the last one. */
198 edits?: number
199 agentId?: string
200 updatedAt: number
201 truncated?: boolean
202}
203
204export type CockpitFinding = {
205 id: string
206 path: string
207 line?: number
208 summary: string
209 category?: string
210 verdict?: string
211 outcome?: string
212}
213
214export type CockpitCheck = {
215 id: string
216 label: string
217 status: 'running' | 'completed' | 'passed' | 'failed' | 'interrupted' | 'denied'
218 passed?: number
219 failed?: number
220 durationMs?: number
221 finishedAt?: number
222}
223
224/** A Bash call's `gitOperation`: what the engine detected the command did. */
225export type CockpitGitOperation = {
226 id: string
227 at: number
228 commit?: { sha: string; kind: string; branch?: string }
229 push?: { branch: string }
230 branch?: { ref: string; action: string }
231 pr?: { number: number; url?: string; action: string }
232}
233
234export type CockpitReview = {
235 sessionId: string | null
236 changes: CockpitChange[]
237 findings: CockpitFinding[]
238 checks: CockpitCheck[]
239 gitOps: CockpitGitOperation[]
240 selectedPath: string | null
241 branch: string | null
242 root: string | null
243 refreshedAt: number | null
244 loading: boolean
245 error: string | null
246}
247
248export type CockpitCompaction = {
249 at: number
250 trigger: string
251 agentId?: string
252 tokensBefore?: number
253 tokensAfter?: number
254 skipped?: string
255}
256
257/** The session cost total each time it grew, about once per main turn. */
258export type CockpitCostSample = {
259 at: number
260 usd: number
261}
262
263/** The live context fill each time a measurement moved it. */
264export type CockpitFillSample = {
265 at: number
266 tokens: number
267}
268
269export type CockpitContext = {
270 sessionId: string | null
271 usage: CockpitUsage | null
272 compactions: CockpitCompaction[]
273 costs: CockpitCostSample[]
274 fills: CockpitFillSample[]
275 refreshedAt: number | null
276 loading: boolean
277 error: string | null
278}
279
280export type CockpitPreferences = {
281 view: CockpitView
282 band: boolean
283 animation: boolean
284 span: CockpitSpan
285 paneOpen: boolean
286}
287
288/** The Bongo Cat's animation frame and the phase it acts out. */
289export type CockpitMascot = {
290 frame: number
291 phase: CockpitActivity['phase']
292}
293
294export type CockpitActions = {
295 selectView: (view: CockpitView) => Promise<void>
296 refreshAgents: () => Promise<void>
297 selectAgent: (id: string | null) => Promise<void>
298 selectTool: (id: string | null) => Promise<void>
299 refreshChanges: () => Promise<void>
300 selectChange: (path: string) => Promise<void>
301 /** Copies the path relative to the repository root, as the file list shows it. */
302 copyPath: (path: string) => Promise<void>
303 copyPatch: (path: string) => Promise<void>
304 quotePatch: (path: string) => Promise<void>
305 refreshContext: () => Promise<void>
306 toggleAnimation: () => Promise<void>
307 cycleSpan: () => Promise<void>
308}
309
310export type CockpitViewProps = {
311 activity: CockpitActivity
312 review: CockpitReview
313 context: CockpitContext
314 preferences: CockpitPreferences
315 mascot: CockpitMascot
316 actions: CockpitActions
317 /** The agent whose transcript is beside the pane; null for the main session. */
318 viewedAgent: string | null
319 columns: number
320 rows: number
321 now: number
322}
323
324declare module 'claude-code' {
325 interface PluginState {
326 cockpit: {
327 activity: CockpitActivity
328 review: CockpitReview
329 context: CockpitContext
330 preferences: CockpitPreferences
331 mascot: CockpitMascot
332 }
333 }
334}
335