SLOPSHOPPER

cockpit

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
│ ┃ Cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ COCKPIT opus-5-5 · ○ idle │ ┃ ctx ━━━━━━━──────── 49% · $0.42 · 1 turn · … ⏺ Read(src/auth.ts) │ ┃ 1: Agents 2: Tools 9 3: Changes 4: Con ⎿ Read 6 lines │ ┃ ▔▔▔▔▔▔▔▔▔ ⏺ Update(src/auth.ts) │ ┃ 5: Activity ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Session ───────────────────────────────────… ⎿ 3 pass, 1 fail │ ┃ ○ idle │ ┃ 1 turn · avg 42.0s · 9 tool calls · 1 failed ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ in 2.1k · out 1.5k · cache 95k · 93% hit │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Agents ──────────────────────────────── 0 l… │ ┃ No subagents yet. They appear here when the › /cockpit │ ┃ session starts one. ⎿ cockpit: Cockpit opened. Use the view buttons or /cockpit help. │ ┃ r: Refresh │ ┃ │ ┃ 1–5 views · Ctrl+X Tab focus · Ctrl+X ←/→ │ ┃ resize │ ⟨Claude Code's own drawing⟩ [ ◉ cockpit ] ○ idle · ctx 49% · $0.42 · last turn 42s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ [ ◉ cockpit ] ○ idle · ctx 49% · $0.42 · last turn 42s
Pane · Cockpit
COCKPIT opus-5-5 · ○ idle ctx ━━━━━━━──────── 49% · $0.42 · 1 turn · 30m session 1: Agents 2: Tools 9 3: Changes 4: Context 49% ▔▔▔▔▔▔▔▔▔ 5: Activity Session ────────────────────────────────────── opus-5-5 ○ idle 1 turn · avg 42.0s · 9 tool calls · 1 failed in 2.1k · out 1.5k · cache 95k · 93% hit Agents ──────────────────────────────── 0 live · 0 done No subagents yet. They appear here when the session starts one. r: Refresh 1–5 views · Ctrl+X Tab focus · Ctrl+X ←/→ resize
README

Cockpit

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.

Views

ViewContentData source
AgentsSession summary, plan, agent tree, and a detail card for the selected agent with its status, tokens, models, tool mix, todos, recent tools, and last answerNative agent, turn, and tool events
ToolsTotals with approval wait, running calls, the call log with actor filters, a detail card, time per tool, background tasks, and cronsNative tool, permission, and stop events
ChangesBranch and totals, file groups by Git state with diffstat bars, the selected diff, checks, review findings, and Git activityNative edit and shell results, and requested Git reads
ContextFill gauge with the auto-compact mark, headroom and trend, category estimates, a token table, cost and rate limits, largest consumers, and compactionsLocal summary and native measurements
ActivityBongo Cat, activity counts and history, a timeline per actor, and a summary of where the time wentObserved tool, agent, and turn activity

Band

A compact band appears above the prompt. It shows these parts, in priority order:

  1. The status: the lead tool and its elapsed time, "thinking" with the turn time, or "idle".
  2. Plan progress, such as 1/3 Writing tests.
  3. Running agents.
  4. The context fill, colored before auto-compact.
  5. The latest check result, such as ✗ npm test 2 failed.
  6. Changed files with added and removed lines.
  7. The session cost.
  8. Running background tasks.
  9. The duration of the last main turn, only when idle.

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.

Header

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.

Agents

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.

Tools

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.

Changes

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.

Context

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.

Activity

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:

  • Bongo Cat acts out the session. It sleeps on the drums when idle, taps while the model thinks, and drums on the bongos while tools run, faster with more work at once. After an error, it holds its paws up. A speech bubble names the lead tool and its elapsed time.
  • Timeline shows one lane for the main thread and one for each recent agent. Each cell shows the model, a tool, a delegated agent, an approval wait, or an error. "Zoom" changes the span between the session and the last 5, 15, or 60 minutes.
  • Where time went shows the busy share of the session, tool time by tool, agent time, approval wait, parallel work, and the call rate.

Preferences

Preferences

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.

Colors

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:

  • Home Manager writes the custom theme ~/.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.

Commands

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

Terminal controls

  1. Run /cockpit to open the pane.
  2. Use Ctrl+X Tab to focus the pane.
  3. While the pane has focus, press 1 through 5 to select a view.
  4. Press Tab to move between controls.
  5. To resize a docked pane, press Ctrl+X ← to widen it or Ctrl+X → to narrow it.
  6. To resize a pane above the prompt, press Ctrl+X ↑ to make it taller or Ctrl+X ↓ to make it shorter.
  7. Press Esc to close the pane.

The views also have letter keys while the pane has focus:

KeyAgentsToolsChangesContextActivity
rRefresh agentsRead GitEstimate categories
j / kNext or previous agentOlder or newer callNext or previous file
tShow its tools
pCopy the file path
c / qCopy or quote the patch
z / pZoom, 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.

Data and limits

History starts when the mod loads. Session memory holds the history. Hot reload keeps that memory, and /clear removes it.

  • The timeline holds at most 200 tool calls.
  • The agent view holds at most 100 agent records and 100 turns.
  • Each agent holds at most 4,000 characters of its last answer.
  • The plan holds at most 20 todo lists of 50 items each, and 100 tasks.
  • The background list holds at most 40 tasks.
  • The review view holds at most 100 files, 100 findings, 40 check summaries, and 30 Git operations.
  • Review patches hold at most 24,000 characters per file and 200,000 characters in total.
  • The context view holds at most 20 compactions, 100 cost measurements, and 100 fill measurements.
  • The activity history holds at most 240 samples.

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.

Safety

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.

Nix integration

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.

Development loading

  1. Run Claude Code with the tracked plugin path.
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.

Checks

  1. Run the native source check.
claude plugin validate --strict "$HOME/.nix-config/dotfiles/claude/.claude/plugins/cockpit"
  1. Run the behavior tests.
claude plugin test "$HOME/.nix-config/dotfiles/claude/.claude/plugins/cockpit"
  1. After the engine generates SDK declarations, run the TypeScript check.
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.

Source structure

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

Source 12 files
hooks/register.tsx 949 lines
1import { 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}
949
hooks/activity.tsx 586 lines
1import 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}
586
hooks/agents.tsx 285 lines
1import 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}
285
hooks/changes.tsx 278 lines
1import 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}
278
hooks/context.tsx 300 lines
1import 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}
300
hooks/data.ts 721 lines
1import 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  })
721
hooks/layout.tsx 184 lines
1import 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}
184
hooks/state.ts 76 lines
1import 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())
76
hooks/summary.ts 296 lines
1import 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}
296
hooks/theme.ts 124 lines
1import 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] ?? '·'
124
hooks/tools.tsx 240 lines
1import 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
240
types/index.d.ts 335 lines
1export 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