SLOPSHOPPER

overview

A session overview pane: context, cost, time, turns, tools, files, subagents, limits and git; cells in the terminal, charts in the desktop app

newpaneguardcommandtoastprocess
v0.5.0no licenseupdated 2026-10-10satoramoto/awesome-claude/plugins/overview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · overview
│ ┃ Overview ✕ › fix the failing auth test and add an audit log call │ ┃ Measuring the context window… │ ┃ ⏺ Read(src/auth.ts) │ ┃ Account limits ⎿ Read 6 lines │ ┃ ■ 5-hour █████████░░░░░░░░░░░░░░░░░░░░░ 31 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Tasks ⏺ Bash(bun test) │ ┃ No subagents yet. ⎿ 3 pass, 1 fail │ ┃ │ ┃ Reading git… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /overview │ ┃ ⎿ overview: Overview pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Overview
Measuring the context window… Account limits ■ 5-hour █████████░░░░░░░░░░░░░░░░░░░░░ 31% Tasks No subagents yet. Reading git…
README

awesome-claude

Ryan's Claude Code setup: mods, the kit they're built from, and the skills Claude uses to make more of them.

FolderWhat it is
mods/<name>/a mod's source: .claude-plugin/plugin.json, hooks/, types/, tests. Imports shared code as @kit/…
kit/the shared components (ui.tsx), colors and glyphs (tokens.ts), number formatting (format.ts)
plugins/<name>/the compiled mods: what Claude Code loads. Generated by bun build.ts; don't edit
playground/a browser page that draws every mod live with sample data, on each surface
stories/Storybook stories: the kit's components and each mod's scenarios
skills/skills for making mods

Why a compiler

The engine reads a mod's source as it is and refuses any import from outside the mod's folder (symlinks included). build.ts copies kit/ into each compiled mod and rewrites @kit/… to relative paths; nothing else changes, so the output is still readable source and passes claude plugin validate.

Loading the mods

Point Claude Code at the compiled folders, in ~/.claude/settings.json:

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "~/Code/satoramoto/awesome-claude/plugins/overview",
  "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}

With bun run watch running, a save under mods/ or kit/ recompiles and the session reloads the mod.

The playground

bun run playground

Every mod's ui.render hooks, drawn with the scenarios in playground/fixtures/<mod>.ts, side by side in desktop light, desktop dark and terminal, at any pane width. On each save it also compiles and runs claude plugin test on the mod, and shows the result in the bar.

Storybook

bun run storybook

Each kit component (stories/kit.stories.ts) with controls for its inputs, and each mod scenario (stories/<mod>.stories.ts, from the playground's fixtures). The toolbar picks the surface (desktop or terminal), the desktop theme and the pane width. Storybook is for how things look; the playground is for wiring up a whole mod with the engine's checks running.

Drawing for the desktop

What the desktop accepts (Claude Code 2.1.293), found by probing the engine's validator: whole percentages only (25.5% is refused); flexGrow up to 10000; fractional heights and margins; no Box hover. Borders: round draws rounded corners, and single, double, bold and classic draw; dashed doesn't. SVG is drawn as an image the page scales, so it blurs as the pane resizes: draw with boxes and text.

Source 9 files
hooks/register.tsx 1309 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  AgentInfo,
4  EngineInterface,
5  ModelUsage,
6  ProcessRunResult,
7  Register,
8  RenderElement,
9  SessionContextBreakdown,
10  SessionRateLimit,
11  Timer,
12} from 'claude-code'
13
14import type {
15  Activity,
16  Agent,
17  AgentExtra,
18  BranchView,
19  GhInfo,
20  GitFile,
21  GitInfo,
22  GitNote,
23  GitPr,
24  Item,
25  Limit,
26  Segment,
27  Snapshot,
28  Tracked,
29  TreeRow,
30  TurnPoint,
31} from '../types'
32import { parseLeftRight, startedBy } from './branches'
33import { createPerf } from '../kit/perf'
34
35import { renderDesktop } from './desktop'
36import type { AppElements, Helpers } from './desktop'
37import { NETTOP, NET_PER_PROCESS, resourcesSh, takeNettop, toResources } from './resources'
38
39const PANE = 'overview'
40const snapshot = atom({ plugin: 'overview', key: 'snapshot' } as const, null)
41const agents = atom({ plugin: 'overview', key: 'agents' } as const, [])
42const agentExtra = atom({ plugin: 'overview', key: 'agentExtra' } as const, {})
43const now = atom({ plugin: 'overview', key: 'now' } as const, 0)
44const resources = atom({ plugin: 'overview', key: 'resources' } as const, null)
45const TICK_MS = 1_000
46
47// One hue per used category, in the order /context lists them.
48export const PALETTE = ['#7aa2f7', '#bb9af7', '#7dcfff', '#9ece6a', '#e0af68', '#f7768e', '#ff9e64', '#2ac3de']
49const DETAIL_ITEMS = 5
50
51export const formatTokens = (n: number): string => {
52  if (n >= 999_500) return `${trim(n / 1_000_000)}M`
53  if (n >= 1_000) return `${trim(n / 1_000)}k`
54  return String(Math.round(n))
55}
56
57const trim = (n: number) => (n >= 100 ? String(Math.round(n)) : n.toFixed(1).replace(/\.0$/, ''))
58
59const basename = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
60
61const top = (items: Item[]) => [...items].sort((a, b) => b.tokens - a.tokens).slice(0, DETAIL_ITEMS)
62
63/** The breakdown as the band keeps it: used rows first, then free space, then the compaction buffer. */
64export const toSnapshot = (b: SessionContextBreakdown): Snapshot => {
65  const rows = b.categories.filter(c => c.kind !== 'deferred')
66  const order = { used: 0, free: 1, buffer: 2, deferred: 3 }
67  const segments: Segment[] = rows
68    .map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind }))
69    .sort((x, y) => order[x.kind] - order[y.kind])
70
71  const servers = new Map<string, number>()
72  for (const tool of b.mcpTools) {
73    if (tool.isLoaded) servers.set(tool.serverName, (servers.get(tool.serverName) ?? 0) + tool.tokens)
74  }
75
76  return {
77    model: b.model,
78    total: b.totalTokens,
79    max: b.rawMaxTokens,
80    percent: b.percentage,
81    ...(b.isAutoCompactEnabled && b.autoCompactThreshold ? { compactAt: b.autoCompactThreshold } : {}),
82    segments,
83    memory: top(b.memoryFiles.map(f => ({ label: `${basename(f.path)} (${f.type})`, tokens: f.tokens }))),
84    mcp: top([...servers].map(([label, tokens]) => ({ label, tokens }))),
85    skills: top((b.skills?.skillFrontmatter ?? []).map(s => ({ label: s.name, tokens: s.tokens }))),
86    agents: top(b.agents.map(a => ({ label: a.agentType, tokens: a.tokens }))),
87  }
88}
89
90/**
91 * Cells per segment for a bar `width` wide, by largest remainder. Every
92 * non-empty used segment gets at least one cell, taken from free space.
93 */
94export const layoutBar = (segments: Segment[], max: number, width: number): number[] => {
95  // A negative count (free space past the window) draws as nothing, never as a negative run.
96  const sum = segments.reduce((n, s) => n + Math.max(0, s.tokens), 0)
97  const scale = Math.max(max, sum, 1)
98  const exact = segments.map(s => (Math.max(0, s.tokens) / scale) * width)
99  const cells = exact.map(Math.floor)
100  let left = width - cells.reduce((n, c) => n + c, 0)
101  const byRemainder = exact.map((x, i) => ({ i, r: x - Math.floor(x) })).sort((a, b) => b.r - a.r)
102  for (const { i } of byRemainder) {
103    if (left <= 0) break
104    cells[i] = (cells[i] ?? 0) + 1
105    left -= 1
106  }
107
108  segments.forEach((s, i) => {
109    if (s.kind !== 'used' || s.tokens <= 0 || (cells[i] ?? 0) > 0) return
110    const donor = segments.findIndex((d, j) => d.kind === 'free' && (cells[j] ?? 0) > 1)
111    if (donor < 0) return
112    cells[donor] = (cells[donor] ?? 0) - 1
113    cells[i] = 1
114  })
115
116  return cells
117}
118
119export const colorOf = (segments: Segment[], i: number): string | undefined => {
120  const s = segments[i]
121  if (!s || s.kind !== 'used') return undefined
122  const used = segments.slice(0, i).filter(x => x.kind === 'used').length
123  return PALETTE[used % PALETTE.length]
124}
125
126const GLYPH = { used: '█', free: '░', buffer: '▒', deferred: ' ' } as const
127
128/** `$.agent.list()`'s row as plain data, the fields the Tasks tree draws. */
129export const toAgent = (info: AgentInfo): Agent => {
130  const agent: Agent = { id: info.id, description: info.description, type: info.type, status: info.status }
131  if (info.parentId) agent.parentId = info.parentId
132  return agent
133}
134
135const isActive = (status: string) => status === 'running' || status === 'pending' || status === 'idle'
136
137/**
138 * Every agent once, depth first, each under the agent that spawned it, in spawn order,
139 * with the connectors each row draws. A `folded` id keeps its row and hides its descendants.
140 */
141export const buildTree = (list: Agent[], folded: ReadonlySet<string> = new Set()): TreeRow[] => {
142  const ids = new Set(list.map(a => a.id))
143  const children = new Map<string, Agent[]>()
144  const roots: Agent[] = []
145  for (const agent of list) {
146    // A parent the list doesn't name puts the agent at the top rather than hiding it.
147    if (agent.parentId && agent.parentId !== agent.id && ids.has(agent.parentId)) {
148      children.set(agent.parentId, [...(children.get(agent.parentId) ?? []), agent])
149    } else {
150      roots.push(agent)
151    }
152  }
153  // Claim each agent once (it has one parent), so a parent cycle can't loop.
154  const kids = new Map<string, Agent[]>()
155  const claimed = new Set<string>()
156  const claim = (agent: Agent) => {
157    claimed.add(agent.id)
158    const mine = (children.get(agent.id) ?? []).filter(c => !claimed.has(c.id))
159    kids.set(agent.id, mine)
160    for (const c of mine) claim(c)
161  }
162  const tops: Agent[] = []
163  // Roots first; then a parent cycle, which has no root, still shows every agent.
164  for (const agent of [...roots, ...list]) {
165    if (claimed.has(agent.id)) continue
166    tops.push(agent)
167    claim(agent)
168  }
169
170  const counts = new Map<string, { descendants: number; running: number }>()
171  const count = (agent: Agent): { descendants: number; running: number } => {
172    let descendants = 0
173    let running = 0
174    for (const c of kids.get(agent.id) ?? []) {
175      const sub = count(c)
176      descendants += 1 + sub.descendants
177      running += (isActive(c.status) ? 1 : 0) + sub.running
178    }
179    const n = { descendants, running }
180    counts.set(agent.id, n)
181    return n
182  }
183  for (const t of tops) count(t)
184
185  const rows: TreeRow[] = []
186  const walk = (agent: Agent, depth: number, last: boolean, guides: boolean[]) => {
187    const n = counts.get(agent.id) ?? { descendants: 0, running: 0 }
188    const fold = folded.has(agent.id) && n.descendants > 0
189    rows.push({ agent, depth, last, guides, descendants: n.descendants, running: n.running, folded: fold })
190    if (fold) return
191    const mine = kids.get(agent.id) ?? []
192    // A root's children start the connectors; deeper ones carry a guide for this row's later siblings.
193    const next = depth === 0 ? [] : [...guides, !last]
194    mine.forEach((c, i) => walk(c, depth + 1, i === mine.length - 1, next))
195  }
196  tops.forEach((t, i) => walk(t, 0, i === tops.length - 1, []))
197  return rows
198}
199
200/**
201 * The tree cut to `budget` rows: finished subtrees fold into their parent first, the
202 * topmost (oldest) first, so running agents and their ancestors stay; the rest is cut.
203 */
204export const fitTree = (list: Agent[], budget: number): { rows: TreeRow[]; hidden: number } => {
205  const folded = new Set<string>()
206  let rows = buildTree(list)
207  while (rows.length > budget) {
208    // The topmost row whose whole subtree is finished and still shown.
209    const next = rows.find(r => r.descendants > 0 && r.running === 0 && !r.folded && !folded.has(r.agent.id))
210    if (!next) break
211    folded.add(next.agent.id)
212    rows = buildTree(list, folded)
213  }
214  if (rows.length <= budget) return { rows, hidden: 0 }
215  const shown = rows.slice(0, Math.max(0, budget - 1))
216  return { rows: shown, hidden: rows.length - shown.length }
217}
218
219/** The connector before a row's status marker: `│ ` guides, then `├─`/`└─`. */
220export const connectorOf = (row: TreeRow): string =>
221  row.depth === 0 ? '' : `${row.guides.map(g => (g ? '│ ' : '  ')).join('')}${row.last ? '└─' : '├─'}`
222
223/** The rolled-up children of a parent row, e.g. `3 sub · 1 running`. */
224export const subSummary = (row: TreeRow): string => {
225  if (row.descendants === 0) return ''
226  const parts = [`${row.descendants} sub`]
227  if (row.running > 0) parts.push(`${row.running} running`)
228  if (row.folded) parts.push('folded')
229  return parts.join(' · ')
230}
231
232const usageTokens = (u: ModelUsage) =>
233  u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
234
235export const formatDuration = (ms: number): string => {
236  const s = Math.max(0, Math.floor(ms / 1_000))
237  if (s < 60) return `${s}s`
238  const m = Math.floor(s / 60)
239  if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
240  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
241}
242
243/** The status as a marker and a color. */
244export const statusOf = (status: string): { glyph: string; color: string } => {
245  switch (status) {
246    case 'running':
247    case 'pending':
248      return { glyph: '●', color: 'warning' }
249    case 'completed':
250      return { glyph: '✓', color: 'success' }
251    case 'failed':
252      return { glyph: '✗', color: 'error' }
253    case 'killed':
254      return { glyph: '■', color: 'inactive' }
255    default:
256      return { glyph: '?', color: 'inactive' }
257  }
258}
259
260/** The dim detail after an agent's description: type, tokens, elapsed. */
261export const agentDetail = (agent: Agent, x: AgentExtra | undefined, at: number): string => {
262  const parts = [agent.type]
263  if (x?.tokens !== undefined) parts.push(`${formatTokens(x.tokens)} tok`)
264  if (agent.status === 'running' && x?.startedAt !== undefined) parts.push(formatDuration(at - x.startedAt))
265  else if (x?.durationMs !== undefined) parts.push(formatDuration(x.durationMs))
266  else if (agent.status !== 'running' && agent.status !== 'pending') parts.push(agent.status)
267  return parts.join(' · ')
268}
269
270// ---- Git section ---------------------------------------------------------
271
272const git = atom({ plugin: 'overview', key: 'git' } as const, null)
273const gh = atom({ plugin: 'overview', key: 'gh' } as const, null)
274const GIT_FILES = 4
275const GIT_PRS = 3
276
277export const C = {
278  ok: '#9ece6a',
279  bad: '#f7768e',
280  warn: '#e0af68',
281  blue: '#7aa2f7',
282  purple: '#bb9af7',
283  cyan: '#7dcfff',
284} as const
285
286type Parsed = Omit<GitInfo, 'ok' | 'root' | 'repo' | 'worktree' | 'insertions' | 'deletions'>
287
288const fileOf = (xy: string, path: string, renamed = false): GitFile => {
289  const x = xy[0] ?? '.'
290  const y = xy[1] ?? '.'
291  const kind: GitFile['kind'] = renamed
292    ? 'renamed'
293    : x === 'A'
294      ? 'added'
295      : x === 'D' || y === 'D'
296        ? 'deleted'
297        : 'modified'
298  return { path, kind, staged: x !== '.' && y === '.' }
299}
300
301/** `git status --porcelain=v2 --branch`, counted and listed. */
302export const parseStatus = (text: string): Parsed => {
303  const out: Parsed = {
304    branch: '',
305    detached: false,
306    ahead: 0,
307    behind: 0,
308    staged: 0,
309    modified: 0,
310    untracked: 0,
311    conflicted: 0,
312    files: [],
313    fileCount: 0,
314  }
315  let oid = ''
316  for (const line of text.split('\n')) {
317    if (!line) continue
318    const parts = line.split(' ')
319    if (line.startsWith('# branch.oid ')) oid = parts[2] ?? ''
320    else if (line.startsWith('# branch.head ')) out.branch = parts.slice(2).join(' ')
321    else if (line.startsWith('# branch.upstream ')) out.upstream = parts.slice(2).join(' ')
322    else if (line.startsWith('# branch.ab ')) {
323      out.ahead = Math.abs(Number(parts[2]) || 0)
324      out.behind = Math.abs(Number(parts[3]) || 0)
325    } else if (parts[0] === '1' || parts[0] === '2') {
326      const xy = parts[1] ?? '..'
327      if (xy[0] !== '.') out.staged++
328      if (xy[1] !== '.') out.modified++
329      const path = parts[0] === '1' ? parts.slice(8).join(' ') : (parts.slice(9).join(' ').split('\t')[0] ?? '')
330      out.files.push(fileOf(xy, path, parts[0] === '2'))
331    } else if (parts[0] === 'u') {
332      out.conflicted++
333      out.files.push({ path: parts.slice(10).join(' '), kind: 'conflict', staged: false })
334    } else if (parts[0] === '?') {
335      out.untracked++
336      out.files.push({ path: parts.slice(1).join(' '), kind: 'untracked', staged: false })
337    }
338  }
339  if (out.branch === '(detached)' || !out.branch) {
340    out.detached = true
341    out.branch = oid && oid !== '(initial)' ? oid.slice(0, 7) : 'detached'
342  }
343  // Conflicts first, then tracked changes, then untracked.
344  const rank = { conflict: 0, added: 1, deleted: 1, renamed: 1, modified: 1, untracked: 2 }
345  out.fileCount = out.files.length
346  out.files = [...out.files].sort((a, b) => rank[a.kind] - rank[b.kind]).slice(0, GIT_FILES)
347  return out
348}
349
350/** `git worktree list --porcelain`: each worktree's path, the main checkout first. */
351export const parseWorktrees = (text: string): string[] =>
352  text
353    .split(/\n\s*\n/)
354    .map(block => block.split('\n').find(l => l.startsWith('worktree '))?.slice('worktree '.length) ?? '')
355    .filter(Boolean)
356
357/** `git diff --shortstat`: insertions and deletions. */
358export const parseShortstat = (text: string) => ({
359  insertions: Number(/(\d+) insertion/.exec(text)?.[1] ?? 0),
360  deletions: Number(/(\d+) deletion/.exec(text)?.[1] ?? 0),
361})
362
363type RollupItem = { name?: string; context?: string; status?: string; conclusion?: string; state?: string }
364
365/** Check runs and commit statuses, counted as GitHub's PR page counts them. */
366export const summarizeChecks = (rollup: RollupItem[] | null | undefined) => {
367  const checks = { passed: 0, failed: 0, pending: 0 }
368  const failing: string[] = []
369  for (const c of rollup ?? []) {
370    const outcome = (c.conclusion || c.state || '').toUpperCase()
371    if (c.status !== undefined && c.status !== 'COMPLETED') checks.pending++
372    else if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(outcome)) checks.passed++
373    else if (['PENDING', 'EXPECTED', ''].includes(outcome)) checks.pending++
374    else {
375      checks.failed++
376      const name = c.name || c.context
377      if (name && !failing.includes(name)) failing.push(name)
378    }
379  }
380  return { checks, failing }
381}
382
383export const toGitPr = (json: any, branch: string): GitPr => ({
384  number: Number(json.number),
385  title: String(json.title ?? ''),
386  url: String(json.url ?? ''),
387  state: String(json.state ?? 'OPEN'),
388  isDraft: Boolean(json.isDraft),
389  review: String(json.reviewDecision ?? ''),
390  ...summarizeChecks(json.statusCheckRollup),
391  current: json.headRefName === branch,
392})
393
394/** A one-line reason from gh's stderr. */
395export const ghNote = (stderr: string): string => {
396  if (/auth login|not logged|authenticat/i.test(stderr)) return 'gh not authenticated (run gh auth login)'
397  if (/no git remote|none of the git remotes|not a git/i.test(stderr)) return 'No GitHub remote for this repo'
398  return (stderr.trim().split('\n')[0] || 'gh failed').slice(0, 120)
399}
400
401const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
402
403const loadGit = async ($: EngineInterface): Promise<GitInfo | GitNote> => {
404  const cwd = await $.session.cwd()
405  // --no-optional-locks: a read never rewrites the index.
406  const run = (args: string[]) => $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 10_000 })
407  let top: ProcessRunResult
408  try {
409    top = await run(['rev-parse', '--show-toplevel'])
410  } catch {
411    return { ok: false, note: 'git not found' }
412  }
413  if (top.exitCode !== 0) return { ok: false, note: 'Not a git repository' }
414  const root = top.stdout.trim()
415  const [status, wt, diff] = await Promise.all([
416    run(['status', '--porcelain=v2', '--branch']).catch(() => null),
417    run(['worktree', 'list', '--porcelain']).catch(() => null),
418    run(['diff', '--shortstat', 'HEAD']).catch(() => null),
419  ])
420  if (!status || status.exitCode !== 0) {
421    return { ok: false, note: (status?.stderr || 'git status failed').trim().split('\n')[0] ?? 'git status failed' }
422  }
423  const trees = wt && wt.exitCode === 0 ? parseWorktrees(wt.stdout) : []
424  const main = trees[0] ?? root
425  return {
426    ok: true,
427    root,
428    repo: basename(main).replace(/\.git$/, ''),
429    ...parseStatus(status.stdout),
430    worktree: { name: basename(root), path: root, linked: trees.length > 0 && root !== main, count: trees.length },
431    ...(diff && diff.exitCode === 0 ? parseShortstat(diff.stdout) : { insertions: 0, deletions: 0 }),
432  }
433}
434
435const GH_FIELDS = 'number,title,state,isDraft,reviewDecision,statusCheckRollup,url,headRefName'
436
437const loadGh = async ($: EngineInterface, info: GitInfo): Promise<GhInfo> => {
438  const at = Date.now()
439  const run = (args: string[]) => $.process.run(['gh', ...args], { cwd: info.root, timeoutMs: 20_000 })
440  let list: ProcessRunResult
441  let here: ProcessRunResult | null
442  try {
443    ;[list, here] = await Promise.all([
444      run(['pr', 'list', '--author', '@me', '--state', 'open', '--limit', '5', '--json', GH_FIELDS]),
445      // The current branch's PR, whoever opened it; exits 1 when there is none.
446      info.detached ? Promise.resolve(null) : run(['pr', 'view', '--json', GH_FIELDS]),
447    ])
448  } catch {
449    return { root: info.root, prs: [], note: 'gh not installed', at }
450  }
451  if (list.exitCode !== 0) return { root: info.root, prs: [], note: ghNote(list.stderr), at }
452  const prs: GitPr[] = []
453  try {
454    if (here && here.exitCode === 0) prs.push({ ...toGitPr(JSON.parse(here.stdout), info.branch), current: true })
455    for (const json of JSON.parse(list.stdout) as any[]) {
456      if (!prs.some(p => p.number === json.number)) prs.push(toGitPr(json, info.branch))
457    }
458  } catch {
459    return { root: info.root, prs: [], note: 'Could not read gh output', at }
460  }
461  prs.sort((a, b) => Number(b.current) - Number(a.current) || b.number - a.number)
462  return { root: info.root, prs, at }
463}
464
465let gitBusy = false
466let gitAgain = false
467let ghBusy = false
468
469/** Re-read git; a call made while one runs queues one more run after it. */
470const refreshGit = async ($: EngineInterface): Promise<void> => {
471  if (gitBusy) {
472    gitAgain = true
473    return
474  }
475  gitBusy = true
476  try {
477    do {
478      gitAgain = false
479      const fresh = await loadGit($)
480      if (!same(await read($, git), fresh)) await update($, git, () => fresh)
481      const cached = await read($, gh)
482      // A new repo (the session cd'd) shouldn't show the old repo's PRs.
483      if (fresh.ok && cached?.root !== fresh.root) stale.gh = true
484    } while (gitAgain)
485  } finally {
486    gitBusy = false
487  }
488}
489
490// ---- Branches the session works on ----------------------------------------------
491
492const tracked = atom({ plugin: 'overview', key: 'tracked' } as const, [])
493const hidden = atom({ plugin: 'overview', key: 'hidden' } as const, [])
494const branches = atom({ plugin: 'overview', key: 'branches' } as const, [])
495const creating = atom({ plugin: 'overview', key: 'creating' } as const, null)
496const MAX_BRANCHES = 6
497
498/** Adds a branch to the tracked list once; a path relative to `cwd` is resolved. */
499const track = async ($: EngineInterface, branch: string, path: string, cwd: string) => {
500  const full = path.startsWith('/') ? path : `${cwd.replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`
501  await update($, tracked, list => (list.some(t => t.branch === branch) ? list : [...list, { branch, path: full, at: Date.now() }]))
502}
503
504/** One tracked branch's git state against the default branch. */
505const loadBranch = async ($: EngineInterface, t: Tracked, base: string, prs: Map<string, GitPr>): Promise<BranchView> => {
506  const run = (args: string[]) => $.process.run(['git', '--no-optional-locks', ...args], { cwd: t.path, timeoutMs: 10_000 }).catch(() => null)
507  const [head, exists, lr, committed, status, remote, top] = await Promise.all([
508    run(['rev-parse', '--abbrev-ref', 'HEAD']),
509    run(['rev-parse', '--verify', '--quiet', `refs/heads/${t.branch}`]),
510    run(['rev-list', '--left-right', '--count', `${base}...${t.branch}`]),
511    run(['diff', '--shortstat', `${base}...${t.branch}`]),
512    run(['status', '--porcelain=v2']),
513    run(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${t.branch}`]),
514    run(['rev-parse', '--show-toplevel', '--git-dir', '--git-common-dir']),
515  ])
516  const checkedOut = head?.exitCode === 0 && head.stdout.trim() === t.branch
517  const counts = checkedOut && status?.exitCode === 0 ? parseStatus(status.stdout) : null
518  // Uncommitted lines count too while the branch is checked out, as the app counts them.
519  const working = checkedOut ? await run(['diff', '--shortstat', 'HEAD']) : null
520  const lines = parseShortstat(committed?.exitCode === 0 ? committed.stdout : '')
521  const extra = parseShortstat(working?.exitCode === 0 ? working.stdout : '')
522  // A linked worktree's git dir differs from the repository's common one.
523  const [toplevel = t.path, gitDir = '.git', common = '.git'] = top?.exitCode === 0 ? top.stdout.trim().split('\n') : []
524  return {
525    branch: t.branch,
526    path: t.path,
527    place: basename(toplevel),
528    worktree: gitDir !== common,
529    exists: exists?.exitCode === 0,
530    checkedOut,
531    ...(lr?.exitCode === 0 ? parseLeftRight(lr.stdout) : { ahead: 0, behind: 0 }),
532    insertions: lines.insertions + extra.insertions,
533    deletions: lines.deletions + extra.deletions,
534    staged: counts?.staged ?? 0,
535    modified: counts?.modified ?? 0,
536    untracked: counts?.untracked ?? 0,
537    conflicted: counts?.conflicted ?? 0,
538    pushed: remote?.exitCode === 0,
539    pr: prs.get(t.branch) ?? null,
540  }
541}
542
543let branchesBusy = false
544
545/** PRs by head branch, from the last gh read; gh runs on its own slower cadence. */
546let prCache = new Map<string, GitPr>()
547let baseCache: string | null = null
548
549/** Re-reads every shown tracked branch; with `withGh`, all their PRs too, in one gh call. */
550const refreshBranches = async ($: EngineInterface, withGh = false): Promise<void> => {
551  if (branchesBusy) return
552  branchesBusy = true
553  try {
554    const info = await read($, git)
555    if (!info?.ok) return
556    // The session's own branch counts as started here.
557    if (!info.detached) await track($, info.branch, info.root, info.root)
558    const off = await read($, hidden)
559    const list = (await read($, tracked)).filter(t => !off.includes(t.branch)).slice(-MAX_BRANCHES).reverse()
560    const run = (cmd: string[]) => $.process.run(cmd, { cwd: info.root, timeoutMs: 20_000 }).catch(() => null)
561    if (baseCache === null) {
562      const ref = await run(['git', 'rev-parse', '--abbrev-ref', 'origin/HEAD'])
563      baseCache = ref?.exitCode === 0 ? ref.stdout.trim() : 'origin/main'
564    }
565    if (withGh) {
566      const prList = await run(['gh', 'pr', 'list', '--state', 'all', '--limit', '40', '--json', GH_FIELDS])
567      if (prList?.exitCode === 0) {
568        try {
569          // Newest first from gh, so the first PR per branch is its latest.
570          const prs = new Map<string, GitPr>()
571          for (const json of JSON.parse(prList.stdout) as any[]) if (!prs.has(json.headRefName)) prs.set(json.headRefName, toGitPr(json, json.headRefName))
572          prCache = prs
573        } catch {
574          // Unreadable gh output: keep the last PRs.
575        }
576      }
577    }
578    const fresh = await Promise.all(list.map(t => loadBranch($, t, baseCache!, prCache)))
579    if (!same(await read($, branches), fresh)) await update($, branches, () => fresh)
580  } finally {
581    branchesBusy = false
582  }
583}
584
585/** The ✕ on a branch: stop showing it. */
586const hideBranch = async ($: EngineInterface, branch: string) => {
587  await update($, hidden, list => (list.includes(branch) ? list : [...list, branch]))
588  await update($, branches, list => list.filter(b => b.branch !== branch))
589}
590
591/**
592 * Create PR, as the desktop app's own button does it: hand it to the session as a
593 * prompt, so Claude writes the description, opens the PR and starts watching it
594 * (auto-merge and Auto-fix, per the person's instructions). Runs as a turn of its
595 * own once the session is idle.
596 */
597const createPr = async ($: EngineInterface, branch: string) => {
598  if (await read($, creating)) return
599  const view = (await read($, branches)).find(b => b.branch === branch)
600  if (!view) return
601  await update($, creating, () => branch)
602  try {
603    await $.prompt.submit({
604      text:
605        `Create a pull request for the \`${branch}\` branch (checked out at ${view.path}). ` +
606        'Push it if needed, write the title and body from its commits, open it, and start watching it.',
607      asUser: true,
608    })
609  } catch {
610    $.ui.toast('Create PR: could not send the request to the session')
611  } finally {
612    await update($, creating, () => null)
613  }
614}
615
616const refreshGh = async ($: EngineInterface): Promise<void> => {
617  if (ghBusy) return
618  const info = await read($, git)
619  if (!info?.ok) return
620  ghBusy = true
621  try {
622    const fresh = await loadGh($, info)
623    const before = await read($, gh)
624    // Keep the old timestamp when nothing changed, so the pane doesn't redraw for nothing.
625    if (!before || !same({ ...before, at: 0 }, { ...fresh, at: 0 })) await update($, gh, () => fresh)
626  } finally {
627    ghBusy = false
628  }
629}
630
631const FILE_TOOLS = ['Bash', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit']
632
633export const fileGlyph = (f: GitFile): { glyph: string; color?: string } => {
634  switch (f.kind) {
635    case 'conflict':
636      return { glyph: '✗', color: C.bad }
637    case 'untracked':
638      return { glyph: '?', color: C.cyan }
639    case 'added':
640      return { glyph: '✚', color: C.ok }
641    case 'deleted':
642      return { glyph: '−', color: C.bad }
643    case 'renamed':
644      return { glyph: '→', color: C.blue }
645    default:
646      return { glyph: '●', color: f.staged ? C.ok : C.warn }
647  }
648}
649
650/** The PR's state as a glyph and color: merged purple, draft dim, open blue. */
651export const prGlyph = (pr: GitPr): { glyph: string; color?: string } => {
652  if (pr.state === 'MERGED') return { glyph: '⚑', color: C.purple }
653  if (pr.state === 'CLOSED') return { glyph: '⚑' }
654  if (pr.isDraft) return { glyph: '◌' }
655  return { glyph: '⚑', color: C.blue }
656}
657
658// ---- Account limits --------------------------------------------------------
659
660const limits = atom({ plugin: 'overview', key: 'limits' } as const, null)
661
662const KNOWN_WINDOWS: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly', spend_limit: 'Spend' }
663
664const prettify = (s: string) => {
665  const t = s.replace(/[_-]+/g, ' ').trim()
666  return t ? t[0]!.toUpperCase() + t.slice(1) : s
667}
668
669/** A window's name for people: `five_hour` -> 5-hour, `seven_day_opus` -> Weekly opus. */
670export const limitLabel = (kind: string): string => {
671  const known = KNOWN_WINDOWS[kind]
672  if (known) return known
673  for (const [prefix, label] of Object.entries(KNOWN_WINDOWS)) {
674    if (kind.startsWith(`${prefix}_`)) return `${label} ${kind.slice(prefix.length + 1).replace(/[_-]+/g, ' ')}`
675  }
676  return prettify(kind)
677}
678
679export const toLimits = (list: SessionRateLimit[]): Limit[] =>
680  list.map(l => ({ kind: l.kind, percent: l.percentUsed, ...(l.resetsAt ? { resetsAt: l.resetsAt } : {}) }))
681
682/** Green under 75%, amber from 75%, red from 90%. */
683export const limitColor = (percent: number): string => (percent >= 90 ? C.bad : percent >= 75 ? C.warn : C.ok)
684
685/** "resets in 2h 14m", "resets in 3d 4h", or "" when the time is unknown. */
686export const resetIn = (resetsAt: string | undefined, at: number): string => {
687  if (!resetsAt) return ''
688  const t = Date.parse(resetsAt)
689  if (Number.isNaN(t)) return ''
690  const m = Math.ceil((t - at) / 60_000)
691  if (m <= 0) return 'resets now'
692  const d = Math.floor(m / 1_440)
693  const h = Math.floor((m % 1_440) / 60)
694  if (d > 0) return `resets in ${d}d ${h}h`
695  if (h > 0) return `resets in ${h}h ${m % 60}m`
696  return `resets in ${m}m`
697}
698
699const storeLimits = async ($: EngineInterface, list: SessionRateLimit[]) => {
700  const fresh = toLimits(list)
701  if (!same(await read($, limits), fresh)) await update($, limits, () => fresh)
702}
703
704// Ticks once a second while any agent runs, so elapsed times move; stopped when none do.
705let ticker: Timer | undefined
706
707const refreshAgents = async ($: EngineInterface): Promise<void> => {
708  const extras = await read($, agentExtra)
709  const fresh = (await $.agent.list()).map(info => {
710    const agent = toAgent(info)
711    // The list should name the spawning agent; the spawn hook's parentAgentId backs it up.
712    const parentId = extras[agent.id]?.parentId
713    if (!agent.parentId && parentId) agent.parentId = parentId
714    return agent
715  })
716  const before = await read($, agents)
717  if (JSON.stringify(before) !== JSON.stringify(fresh)) await update($, agents, () => fresh)
718  const running = fresh.some(a => a.status === 'running' || a.status === 'pending')
719  if (running) {
720    // Elapsed times move only where someone can see them.
721    if (await isShown($)) await update($, now, () => Date.now())
722    ticker ??= $.clock.every(TICK_MS, () => void perf.time('agents', () => refreshAgents($)).catch(() => undefined))
723  } else if (ticker) {
724    ticker.cancel()
725    ticker = undefined
726  }
727}
728
729// ---- Session activity --------------------------------------------------------
730
731const activity = atom({ plugin: 'overview', key: 'activity' } as const, {
732  turns: 0,
733  tools: {},
734  files: [],
735  turnLog: [],
736  contextLog: [],
737})
738const TURN_LOG = 40
739const CONTEXT_LOG = 60
740const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
741
742/** The path an editing tool's arguments name, if any. */
743export const editedPath = (tool: string, args: { file_path?: unknown; notebook_path?: unknown }): string | undefined => {
744  if (!EDIT_TOOLS.includes(tool)) return undefined
745  const path = args.file_path ?? args.notebook_path
746  return typeof path === 'string' && path ? path : undefined
747}
748
749/** The activity with one more tool call. */
750export const countTool = (a: Activity, tool: string, isError: boolean, path?: string): Activity => {
751  const old = a.tools[tool] ?? { n: 0, err: 0 }
752  const tools = { ...a.tools, [tool]: { n: old.n + 1, err: old.err + (isError ? 1 : 0) } }
753  const files = path && !a.files.includes(path) ? [...a.files, path] : a.files
754  return { ...a, tools, files }
755}
756
757/** The activity with one more main-loop turn, the log cut to its last TURN_LOG. */
758export const countTurn = (a: Activity, ms: number, usage: ModelUsage | undefined): Activity => {
759  const point: TurnPoint = {
760    ms,
761    input: usage?.input_tokens ?? 0,
762    output: usage?.output_tokens ?? 0,
763    cacheRead: usage?.cache_read_input_tokens ?? 0,
764    cacheWrite: usage?.cache_creation_input_tokens ?? 0,
765  }
766  return { ...a, turns: a.turns + 1, turnLog: [...a.turnLog, point].slice(-TURN_LOG) }
767}
768
769export const refresh = async ($: EngineInterface) => {
770  const usage = await $.session.usage({ breakdown: 'summary' })
771  const b = usage.context.breakdown
772  if (b) {
773    const fresh = toSnapshot(b)
774    if (!same(await read($, snapshot), fresh)) await update($, snapshot, () => fresh)
775  }
776  await storeLimits($, usage.rateLimits ?? [])
777  const percent = b?.percentage ?? usage.context.percent
778  await update($, activity, a => {
779    const log =
780      percent === undefined || a.contextLog.at(-1) === percent ? a.contextLog : [...a.contextLog, percent].slice(-CONTEXT_LOG)
781    const next: Activity = { ...a, startedAt: usage.startedAt, contextLog: log }
782    if (usage.cost) next.cost = usage.cost.usd
783    return same(a, next) ? a : next
784  })
785}
786
787// ---- When to work ------------------------------------------------------------
788// Nothing runs while the pane isn't shown: events only mark what went stale,
789// and one timer reads it again, at most so often, while the pane is on screen.
790
791let shownAt = 0
792let shown = false
793
794/** Whether the overview pane is the one shown and placed; asked at most every 2 s. */
795const isShown = async ($: EngineInterface): Promise<boolean> => {
796  if (Date.now() - shownAt < 2_000) return shown
797  shownAt = Date.now()
798  try {
799    shown = (await $.ui.panes()).some(p => p.id === PANE && p.isShown && p.isPlaced)
800  } catch {
801    shown = false
802  }
803  return shown
804}
805
806const stale = { usage: true, git: true, gh: true }
807const last = { usage: 0, git: 0, gh: 0, clock: 0, resources: 0 }
808const RESOURCES_EVERY = 10_000
809let nettopBusy = false
810
811/** One sample of the session's share of the machine; see resources.ts. */
812const sampleResources = async ($: EngineInterface) => {
813  const run = await $.process.run(resourcesSh(), { timeoutMs: 5_000 })
814  if (run.exitCode !== 0) return null
815  if (NET_PER_PROCESS && !nettopBusy) {
816    nettopBusy = true
817    $.process
818      .run(NETTOP, { timeoutMs: 10_000 })
819      .then(r => (r.exitCode === 0 ? takeNettop(r.stdout, Date.now()) : undefined))
820      .catch(() => undefined)
821      .finally(() => (nettopBusy = false))
822  }
823  return toResources(run.stdout, Date.now())
824}
825// Even with nothing marked stale, read again this often while shown.
826const EVERY = { usage: 60_000, git: 60_000, gh: 180_000, clock: 60_000 }
827// And never more often than this, however much happens.
828const AT_MOST = { usage: 3_000, git: 3_000, gh: 20_000 }
829let worker: Timer | undefined
830
831export const due = (kind: 'usage' | 'git' | 'gh', t: number) =>
832  t - last[kind] >= (stale[kind] ? AT_MOST[kind] : EVERY[kind])
833
834const tick = async ($: EngineInterface) => {
835  if (!(await isShown($))) return
836  const t = Date.now()
837  if (due('usage', t)) {
838    stale.usage = false
839    last.usage = t
840    await perf.time('usage', () => refresh($))
841  }
842  if (due('git', t)) {
843    stale.git = false
844    last.git = t
845    await perf.time('git', () => refreshGit($))
846    if (!due('gh', t)) await perf.time('branches', () => refreshBranches($))
847  }
848  if (due('gh', t)) {
849    stale.gh = false
850    last.gh = t
851    await perf.time('gh', () => refreshGh($))
852    await perf.time('branches', () => refreshBranches($, true))
853  }
854  // The session's share of the machine: a few shell commands, every 10 s while shown.
855  if (t - last.resources >= RESOURCES_EVERY) {
856    last.resources = t
857    const res = await perf.time('resources', () => sampleResources($)).catch(() => null)
858    if (res) await update($, resources, () => res)
859  }
860  // The session's elapsed time moves even when nothing else does.
861  if (t - last.clock >= EVERY.clock) {
862    last.clock = t
863    await update($, now, () => t)
864  }
865}
866
867const HELPERS: Helpers = { colorOf, limitLabel, resetIn, buildTree }
868
869/** What this mod costs, drawn in the pane's debug footer. */
870export const perf = createPerf()
871
872/** The app mode's sections the person folded; kept for the session. */
873const collapsed = atom({ plugin: 'overview', key: 'collapsed' } as const, [])
874
875export const register: Register = on => {
876  on('session.start', async ($, e, next) => {
877    await $.command.register({
878      name: 'overview',
879      description: 'Open the session overview pane: context, cost, turns, tools, agents, limits and git',
880    })
881    void refreshAgents($).catch(() => undefined)
882    worker?.cancel()
883    worker = $.clock.every(3_000, () => void perf.time('tick', () => tick($)).catch(() => undefined))
884    void $.ui.open({ id: PANE, title: 'Overview' })
885
886    return next(e)
887  })
888
889  // A tool that may have changed files (or pushed, or opened a PR) marks git stale;
890  // a gh or push command marks the PRs stale too. Nothing waits on the bookkeeping.
891  on('tool.call', async ($, e, next) => {
892    const ran = await next(e)
893    if (!('deny' in ran && ran.deny)) {
894      const path = editedPath(e.tool, e as { file_path?: unknown; notebook_path?: unknown })
895      void perf.time('tool.call', () => update($, activity, a => countTool(a, e.tool, Boolean(ran.isError), path))).catch(() => undefined)
896    }
897    // A command that starts a branch or a worktree: track it.
898    if (e.tool === 'Bash') {
899      const started = startedBy(e.command)
900      if (started.length) {
901        void (async () => {
902          const cwd = await $.session.cwd()
903          for (const s of started) await track($, s.branch, s.path ?? cwd, cwd)
904          stale.gh = true
905        })().catch(() => undefined)
906      }
907    }
908    if (FILE_TOOLS.includes(e.tool)) {
909      stale.git = true
910      if (e.tool === 'Bash' && /\bgh pr\b|\bgit (push|commit|checkout|switch)\b/.test(e.command)) stale.gh = true
911    }
912    return ran
913  })
914
915  // The list has no start time; the spawn does, and the loop it happened in.
916  on('agent.spawn', async ($, e, next) => {
917    const started = await next(e)
918    const id = started.agentId
919    if (id) {
920      const parentId = e.parentAgentId
921      await update($, agentExtra, all => {
922        const x: AgentExtra = { ...all[id], startedAt: Date.now() }
923        if (parentId && parentId !== id) x.parentId = parentId
924        return { ...all, [id]: x }
925      })
926      await refreshAgents($).catch(() => undefined)
927    }
928    return started
929  })
930
931  // Each run of a subagent's loop is one turn; its end carries duration and usage.
932  on('turn.complete', async ($, e, next) => {
933    const done = await next(e)
934    stale.git = true
935    const id = e.agentId
936    if (id) {
937      await update($, agentExtra, all => {
938        const old = all[id] ?? {}
939        const x: AgentExtra = { ...old, durationMs: (old.durationMs ?? 0) + e.durationMs }
940        if (e.usage) x.tokens = (old.tokens ?? 0) + usageTokens(e.usage)
941        return { ...all, [id]: x }
942      })
943      void refreshAgents($).catch(() => undefined)
944    } else {
945      void update($, activity, a => countTurn(a, e.durationMs, e.usage)).catch(() => undefined)
946    }
947    return done
948  })
949
950  on('command.run', { command: 'overview' }, async $ => {
951    // Opened by the person: read everything now rather than on the next tick.
952    shownAt = 0
953    stale.usage = stale.git = stale.gh = true
954    last.usage = last.git = last.gh = 0
955    await refresh($)
956    await $.ui.open({ id: PANE, title: 'Overview' })
957
958    return { text: 'Overview pane opened.' }
959  })
960
961  on('session.measure', async ($, e, next) => {
962    const result = await next(e)
963    if (e.changed.includes('rateLimits')) await storeLimits($, e.rateLimits)
964    // The breakdown is worked out on the next tick, if the pane is shown.
965    stale.usage = true
966
967    return result
968  })
969
970  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
971    const snap = await read($, snapshot)
972    const list = await read($, agents)
973    const extras = await read($, agentExtra)
974    const at = (await read($, now)) || Date.now()
975    const repo = await read($, git)
976    const hub = await read($, gh)
977
978    // The app surfaces draw it their own way: no cells to count, real charts.
979    if (e.surface !== 'terminal') {
980      const data = {
981        snap,
982        limits: await read($, limits),
983        agents: list,
984        extras,
985        git: repo,
986        gh: hub,
987        activity: await read($, activity),
988        collapsed: await read($, collapsed),
989        resources: await read($, resources),
990        branches: await read($, branches),
991        creating: await read($, creating),
992        hideBranch: (branch: string) => void hideBranch($, branch).catch(() => undefined),
993        // Merge, like Create PR: the session does it, and reports back.
994        mergePr: (pr: GitPr) =>
995          void $.prompt
996            .submit({ text: `Merge PR #${pr.number} (${pr.url}) once its checks pass: turn on auto-merge, or merge it now if it's already green.`, asUser: true })
997            .catch(() => $.ui.toast('Merge: could not send the request to the session')),
998        createPr: (branch: string) => void createPr($, branch).catch(() => undefined),
999        toggle: (id: string) =>
1000          void update($, collapsed, list => (list.includes(id) ? list.filter(x => x !== id) : [...list, id])).catch(() => undefined),
1001        columns: e.props.bodyColumns,
1002        rows: e.props.scroll?.bodyRows,
1003        at: Date.now(),
1004        perf: perf.snapshot(),
1005      }
1006      return perf.time('render', () => renderDesktop($.ui.resolve(e) as AppElements, data, HELPERS))
1007    }
1008    const { Box, Text } = $.ui.resolve(e)
1009    const docked = e.props.placement === 'dock'
1010    const bodyRows = e.props.scroll?.bodyRows ?? 0
1011
1012    // ---- the git section, one element per row ----
1013    const gitLines: RenderElement[] = []
1014    const sep = <Text dimColor>{' · '}</Text>
1015    if (repo === null) {
1016      gitLines.push(<Text dimColor>Reading git…</Text>)
1017    } else if (!repo.ok) {
1018      gitLines.push(<Text dimColor wrap="truncate">{`⎇ ${repo.note}`}</Text>)
1019    } else {
1020      const g = repo
1021      gitLines.push(
1022        <Text wrap="truncate">
1023          <Text color={C.blue} bold>{`⎇ ${g.branch}`}</Text>
1024          {g.detached ? <Text color={C.warn}>{' (detached)'}</Text> : null}
1025          {g.ahead ? <Text color={C.ok}>{` ↑${g.ahead}`}</Text> : null}
1026          {g.behind ? <Text color={C.warn}>{` ↓${g.behind}`}</Text> : null}
1027          {!g.upstream && !g.detached ? <Text dimColor>{' ◌ no upstream'}</Text> : null}
1028          {g.upstream && !g.ahead && !g.behind ? <Text color={C.ok}>{' ✓'}</Text> : null}
1029          {sep}
1030          <Text dimColor>{g.repo}</Text>
1031        </Text>,
1032      )
1033      gitLines.push(
1034        g.worktree.linked ? (
1035          <Text wrap="truncate">
1036            <Text color={C.purple}>{`⑂ worktree ${g.worktree.name}`}</Text>
1037            <Text dimColor>{` (linked, ${g.worktree.count} total) ${g.worktree.path}`}</Text>
1038          </Text>
1039        ) : (
1040          <Text dimColor wrap="truncate">
1041            {`▣ main checkout${g.worktree.count > 1 ? ` (+${g.worktree.count - 1} worktrees)` : ''} ${g.worktree.path}`}
1042          </Text>
1043        ),
1044      )
1045      const counts: RenderElement[] = []
1046      if (g.conflicted) counts.push(<Text color={C.bad}>{`✗ ${g.conflicted} conflicted  `}</Text>)
1047      if (g.staged) counts.push(<Text color={C.ok}>{`✚ ${g.staged} staged  `}</Text>)
1048      if (g.modified) counts.push(<Text color={C.warn}>{`● ${g.modified} modified  `}</Text>)
1049      if (g.untracked) counts.push(<Text color={C.cyan}>{`? ${g.untracked} untracked  `}</Text>)
1050      if (g.insertions) counts.push(<Text color={C.ok}>{`+${g.insertions} `}</Text>)
1051      if (g.deletions) counts.push(<Text color={C.bad}>{`−${g.deletions}`}</Text>)
1052      gitLines.push(
1053        counts.length === 0 ? (
1054          <Text color={C.ok}>✓ working tree clean</Text>
1055        ) : (
1056          <Text wrap="truncate">{counts}</Text>
1057        ),
1058      )
1059      for (const f of g.files) {
1060        const s = fileGlyph(f)
1061        gitLines.push(
1062          <Text wrap="truncate-start">
1063            <Text color={s.color}>{`  ${s.glyph} `}</Text>
1064            {f.path}
1065          </Text>,
1066        )
1067      }
1068      if (g.fileCount > g.files.length) gitLines.push(<Text dimColor>{`  … ${g.fileCount - g.files.length} more`}</Text>)
1069
1070      // ---- pull requests ----
1071      if (hub === null || hub.root !== g.root) {
1072        gitLines.push(<Text dimColor>⟳ Reading pull requests…</Text>)
1073      } else if (hub.note) {
1074        gitLines.push(<Text dimColor wrap="truncate">{`⚑ ${hub.note}`}</Text>)
1075      } else if (hub.prs.length === 0) {
1076        gitLines.push(<Text dimColor>⚑ No open pull requests</Text>)
1077      } else {
1078        for (const pr of hub.prs.slice(0, GIT_PRS)) {
1079          const p = prGlyph(pr)
1080          gitLines.push(
1081            <Text wrap="truncate">
1082              <Text color={p.color} dimColor={!p.color} bold={pr.current}>{`${p.glyph} #${pr.number} `}</Text>
1083              <Text dimColor={pr.state !== 'OPEN' || pr.isDraft}>{pr.title}</Text>
1084            </Text>,
1085          )
1086          const bits: RenderElement[] = []
1087          if (pr.state === 'MERGED') bits.push(<Text color={C.purple}>merged</Text>)
1088          else if (pr.state === 'CLOSED') bits.push(<Text dimColor>closed</Text>)
1089          else {
1090            if (pr.isDraft) bits.push(<Text dimColor>◌ draft</Text>)
1091            const { passed, failed, pending } = pr.checks
1092            if (failed) {
1093              bits.push(
1094                <Text color={C.bad}>{`✗ ${failed} failing${pr.failing.length ? `: ${pr.failing.slice(0, 3).join(', ')}` : ''}`}</Text>,
1095              )
1096            }
1097            if (pending) bits.push(<Text color={C.warn}>{`⟳ ${pending} pending`}</Text>)
1098            if (passed && !failed && !pending) bits.push(<Text color={C.ok}>✓ checks pass</Text>)
1099            if (!passed && !failed && !pending) bits.push(<Text dimColor>no checks</Text>)
1100            if (pr.review === 'APPROVED') bits.push(<Text color={C.ok}>✓ approved</Text>)
1101            else if (pr.review === 'CHANGES_REQUESTED') bits.push(<Text color={C.bad}>✗ changes requested</Text>)
1102            else if (pr.review === 'REVIEW_REQUIRED') bits.push(<Text color={C.warn}>◌ review required</Text>)
1103          }
1104          if (pr.current) bits.push(<Text color={C.blue}>⎇ this branch</Text>)
1105          gitLines.push(
1106            <Text wrap="truncate">
1107              {'  '}
1108              {bits.flatMap((b, i) => (i === 0 ? [b] : [sep, b]))}
1109            </Text>,
1110          )
1111        }
1112        if (hub.prs.length > GIT_PRS) gitLines.push(<Text dimColor>{`  … ${hub.prs.length - GIT_PRS} more PRs`}</Text>)
1113      }
1114    }
1115    const gitSection = (
1116      <Box flexDirection="column" marginTop={1} flexShrink={0}>
1117        {gitLines}
1118      </Box>
1119    )
1120    const gitRows = gitLines.length + 1
1121
1122    // ---- account limits, between the context rows and the tasks ----
1123    const windows = (await read($, limits)) ?? []
1124    const paneColumns = Math.max(20, e.props.bodyColumns)
1125    const drawnAt = Date.now()
1126    const labelWidth = Math.min(16, Math.max(7, ...windows.map(w => limitLabel(w.kind).length)) + 1)
1127    const resetTexts = windows.map(w => resetIn(w.resetsAt, drawnAt))
1128    const resetWidth = Math.max(0, ...resetTexts.map(t => t.length))
1129    // glyph (2), label, bar, percent (5), the reset text
1130    const limitBar = Math.max(4, Math.min(30, paneColumns - 2 - labelWidth - 5 - (resetWidth ? resetWidth + 2 : 0)))
1131    const limitLines: RenderElement[] =
1132      windows.length === 0
1133        ? [<Text dimColor>No limits reported yet.</Text>]
1134        : windows.map((w, i) => {
1135            const color = limitColor(w.percent)
1136            const filled = Math.min(limitBar, Math.round((Math.min(100, Math.max(0, w.percent)) / 100) * limitBar))
1137            const pct = `${Math.round(w.percent)}%`.padStart(5)
1138            return (
1139              <Box flexDirection="row">
1140                <Text color={color}>■ </Text>
1141                <Box width={labelWidth} flexShrink={0}>
1142                  <Text wrap="truncate">{limitLabel(w.kind)}</Text>
1143                </Box>
1144                <Text color={color}>{'█'.repeat(filled)}</Text>
1145                <Text dimColor>{'░'.repeat(limitBar - filled)}</Text>
1146                <Text color={color} bold>
1147                  {pct}
1148                </Text>
1149                {resetTexts[i] ? (
1150                  <Text dimColor wrap="truncate">{`  ${resetTexts[i]}`}</Text>
1151                ) : null}
1152              </Box>
1153            )
1154          })
1155    const limitsSection = (
1156      <Box flexDirection="column" marginTop={1} flexShrink={0}>
1157        <Text bold>Account limits</Text>
1158        {limitLines}
1159      </Box>
1160    )
1161    const limitRows = limitLines.length + 2
1162
1163    // ---- tasks, cut to the rows the git section leaves ----
1164    const tasksFor = (budget: number) => {
1165      // budget: rows for the tree itself, under the margin and the heading.
1166      if (budget < 1) return null
1167      const { rows: shown, hidden } = fitTree(list, budget)
1168      return (
1169        <Box flexDirection="column" marginTop={1} flexShrink={1} overflow="hidden">
1170          <Text bold>Tasks</Text>
1171          {list.length === 0 ? (
1172            <Text dimColor>No subagents yet.</Text>
1173          ) : (
1174            shown.map(row => {
1175              const { agent } = row
1176              const s = statusOf(agent.status)
1177              const connector = connectorOf(row)
1178              const sub = subSummary(row)
1179              return (
1180                <Box key={agent.id} flexDirection="row">
1181                  {connector ? <Text dimColor>{connector}</Text> : null}
1182                  <Text color={s.color}>{`${s.glyph} `}</Text>
1183                  <Text wrap="truncate">
1184                    {agent.description || agent.type}
1185                    <Text dimColor>{`  ${agentDetail(agent, extras[agent.id], at)}`}</Text>
1186                    {sub ? <Text dimColor>{`  ${sub}`}</Text> : null}
1187                  </Text>
1188                </Box>
1189              )
1190            })
1191          )}
1192          {hidden > 0 ? <Text dimColor>{`… ${hidden} more tasks`}</Text> : null}
1193        </Box>
1194      )
1195    }
1196
1197    /** The context content, the account limits, the tasks, then the git section on the last rows when docked. */
1198    const compose = (head: RenderElement, headRows: number, width?: number) => {
1199      const budget = docked ? bodyRows - headRows - limitRows - gitRows - 2 : Number.POSITIVE_INFINITY
1200      const tasks = tasksFor(budget)
hooks/branches.ts 38 lines
1/*
2 * The branches a session works on, as the desktop app tracks them: each branch
3 * or worktree the session starts, with its git state and its PR. This file
4 * holds the parsing; register.tsx runs git and gh.
5 */
6
7/** Branches a Bash command starts, and where (relative to the command's directory when not absolute). */
8export const startedBy = (command: string): { branch: string; path?: string }[] => {
9  const found: { branch: string; path?: string }[] = []
10  for (const part of command.split(/&&|\|\||;|\n/)) {
11    const words = part.trim().split(/\s+/)
12    const at = words.indexOf('git')
13    if (at < 0) continue
14    const w = words.slice(at + 1).filter(x => x !== '-q' && x !== '--quiet')
15    const flag = (names: string[]) => {
16      const i = w.findIndex(x => names.includes(x))
17      return i >= 0 ? w[i + 1] : undefined
18    }
19    if (w[0] === 'checkout' || w[0] === 'switch') {
20      const branch = flag(['-b', '-B', '-c', '-C'])
21      if (branch) found.push({ branch })
22    } else if (w[0] === 'worktree' && w[1] === 'add') {
23      const rest = w.slice(2)
24      const branch = flag(['-b', '-B'])
25      const positional = rest.filter((x, i) => !x.startsWith('-') && !['-b', '-B', '--reason'].includes(rest[i - 1] ?? ''))
26      const path = positional[0]
27      if (path) found.push({ branch: branch ?? positional[1] ?? path.split('/').pop()!, path: path.replace(/^["']|["']$/g, '') })
28    }
29  }
30  return found
31}
32
33/** `git rev-list --left-right --count A...B`: behind, ahead. */
34export const parseLeftRight = (text: string) => {
35  const [behind = 0, ahead = 0] = text.trim().split(/\s+/).map(Number)
36  return { behind, ahead }
37}
38
kit/perf.ts 101 lines
1/**
2 * What a mod costs: how often each named thing ran, how long it took, and how
3 * much of the last minute it spent. Each compiled mod gets its own copy of the
4 * kit, so these are that mod's numbers. Recording never redraws: the footer
5 * shows them as of the pane's latest draw, so measuring adds no load.
6 *
7 *   const done = perf.start('render'); …; done()
8 *   await perf.time('git', () => run(...))
9 *   perf.count('state write')
10 */
11
12const now = (): number => (globalThis as { performance?: { now(): number } }).performance?.now() ?? Date.now()
13
14const WINDOW = 60_000
15
16type Entry = { n: number; total: number; last: number; max: number; recent: Array<[at: number, ms: number]> }
17
18export type PerfRow = {
19  name: string
20  /** Runs since the mod loaded. */
21  n: number
22  /** Runs in the last minute. */
23  perMin: number
24  /** Milliseconds per run, on average; 0 for counts. */
25  avgMs: number
26  lastMs: number
27  maxMs: number
28  /** Milliseconds spent in the last minute. */
29  busyMs: number
30}
31
32export type PerfSnapshot = { sinceMs: number; rows: PerfRow[] }
33
34export const createPerf = () => {
35  const entries = new Map<string, Entry>()
36  const loadedAt = Date.now()
37
38  const record = (name: string, ms: number) => {
39    const e = entries.get(name) ?? { n: 0, total: 0, last: 0, max: 0, recent: [] }
40    const at = Date.now()
41    e.n++
42    e.total += ms
43    e.last = ms
44    e.max = Math.max(e.max, ms)
45    e.recent.push([at, ms])
46    // Keep only the window, so a long session doesn't grow this.
47    while (e.recent.length && at - e.recent[0]![0] > WINDOW) e.recent.shift()
48    entries.set(name, e)
49  }
50
51  /** Starts timing `name`; call what it returns when it's done. */
52  const start = (name: string) => {
53    const t0 = now()
54    return () => record(name, now() - t0)
55  }
56
57  /** Times `fn`, sync or async, under `name`. */
58  const time = <T>(name: string, fn: () => T): T => {
59    const done = start(name)
60    let out: T
61    try {
62      out = fn()
63    } catch (err) {
64      done()
65      throw err
66    }
67    if (out instanceof Promise) return out.finally(done) as T
68    done()
69    return out
70  }
71
72  /** Counts one occurrence of `name`, with no time. */
73  const count = (name: string) => record(name, 0)
74
75  const snapshot = (): PerfSnapshot => {
76    const at = Date.now()
77    const rows = [...entries].map(([name, e]) => {
78      const recent = e.recent.filter(([t]) => at - t <= WINDOW)
79      return {
80        name,
81        n: e.n,
82        perMin: recent.length,
83        avgMs: e.n ? e.total / e.n : 0,
84        lastMs: e.last,
85        maxMs: e.max,
86        busyMs: recent.reduce((s, [, ms]) => s + ms, 0),
87      }
88    })
89    // What cost the most in the last minute first, then what ran most.
90    rows.sort((a, b) => b.busyMs - a.busyMs || b.perMin - a.perMin)
91    return { sinceMs: at - loadedAt, rows }
92  }
93
94  return { start, time, count, snapshot }
95}
96
97export type Perf = ReturnType<typeof createPerf>
98
99/** Milliseconds, short: 0.4ms, 12ms, 1.2s. */
100export const ms = (v: number): string => (v >= 1_000 ? `${(v / 1_000).toFixed(1)}s` : v >= 10 ? `${Math.round(v)}ms` : `${v.toFixed(1)}ms`)
101
hooks/desktop.tsx 409 lines
1import type { Elements, RenderElement } from 'claude-code'
2
3import { bytes, duration, money, short, tokens } from '../kit/format'
4import type { PerfSnapshot } from '../kit/perf'
5import { C, GLYPH, MUTED, levelColor } from '../kit/tokens'
6import { kit } from '../kit/ui'
7
8import type { Activity, Agent, AgentExtra, BranchView, GhInfo, GitInfo, GitNote, GitPr, Limit, Resources, Snapshot } from '../types'
9
10/**
11 * The session's share of the machine: CPU, memory, disk and network, each ours
12 * against the system's. Ours turns amber or red when it's a big share.
13 */
14const machine = (k: ReturnType<typeof kit>, r: Resources | null): RenderElement[] => {
15  if (!r) return []
16  const hot = (part: number, whole: number) => (whole > 0 && part / whole > 0.5 ? C.bad : whole > 0 && part / whole > 0.25 ? C.warn : undefined)
17  const pctOf = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`
18  // Tight on purpose: the footer is one row. Rates are per second; the disk is the system's.
19  return [
20    k.share('cpu', '⚙', C.blue, pctOf(r.cpu), pctOf(r.cpuAll), hot(r.cpu, r.cpuAll)),
21    k.share('mem', '▦', C.purple, bytes(r.mem), bytes(r.memAll), hot(r.mem, r.memAll)),
22    k.share('net', '⇅', C.cyan, r.net === null ? '–' : bytes(r.net), bytes(r.netAll), r.net === null ? undefined : hot(r.net, r.netAll)),
23    k.share('disk', '⛁', C.orchid, bytes(r.diskAll), ''),
24    k.share('procs', '⑂', C.slate, String(r.procs), ''),
25  ]
26}
27
28/** The elements the app mode draws with: the desktop and VS Code have these. */
29export type AppElements = Pick<Elements['desktop'], 'Box' | 'Text' | 'Link' | 'Button' | 'Svg'>
30
31export type DesktopData = {
32  snap: Snapshot | null
33  limits: Limit[] | null
34  agents: Agent[]
35  extras: Record<string, AgentExtra>
36  git: GitInfo | GitNote | null
37  gh: GhInfo | null
38  activity: Activity
39  /** Section ids the person folded. */
40  collapsed: string[]
41  /** Folds or unfolds a section. */
42  toggle: (id: string) => void
43  /** The pane's width in cells. */
44  columns: number
45  /** The pane's height in rows, so the footer can sit on its bottom edge. */
46  rows?: number
47  /** The session's share of the machine, for the footer. */
48  resources: Resources | null
49  /** The branches the session started, newest first. */
50  branches: BranchView[]
51  /** The branch a PR is being created for, if any. */
52  creating: string | null
53  /** The ✕ on a branch. */
54  hideBranch: (branch: string) => void
55  /** Create PR on a branch without one. */
56  createPr: (branch: string) => void
57  /** Merge on an open PR. */
58  mergePr: (pr: GitPr) => void
59  /** What the mod cost, for the debug footer. */
60  perf: PerfSnapshot
61  /** The clock elapsed times and resets are drawn against. */
62  at: number
63}
64
65/** What the app mode borrows from register.tsx, passed in so this file has no cycle back. */
66export type Helpers = {
67  colorOf: (segments: Snapshot['segments'], i: number) => string | undefined
68  limitLabel: (kind: string) => string
69  resetIn: (resetsAt: string | undefined, at: number) => string
70  buildTree: (list: Agent[]) => Array<{ agent: Agent; depth: number }>
71}
72
73/**
74 * A context category's short name and colour. One cool family for what is
75 * fixed, amber for messages: the part that grows, and the one to watch.
76 */
77const CATEGORY: Record<string, { name: string; color: string }> = {
78  'System prompt': { name: 'System', color: C.slate },
79  'System tools': { name: 'Tools', color: C.blue },
80  'MCP tools': { name: 'MCP', color: C.cyan },
81  'MCP server instructions': { name: 'MCP notes', color: C.cyan },
82  'Custom agents': { name: 'Agents', color: C.purple },
83  'Memory files': { name: 'Memory', color: C.purple },
84  Skills: { name: 'Skills', color: C.orchid },
85  Messages: { name: 'Messages', color: C.warn },
86}
87
88/** Three letters for a context category: what the terminal layout's chips show. */
89export const tagOf = (name: string): string => {
90  const known: Record<string, string> = {
91    'System prompt': 'sys',
92    'System tools': 'tls',
93    'MCP tools': 'mcp',
94    'Custom agents': 'agt',
95    'Memory files': 'mem',
96    Skills: 'skl',
97    Messages: 'msg',
98  }
99  return known[name] ?? name.slice(0, 3).toLowerCase()
100}
101
102/** A status as the colour of its rail. */
103const statusColor = (status: string): string =>
104  status === 'running' || status === 'pending' ? C.warn : status === 'completed' ? C.ok : status === 'failed' ? C.bad : MUTED
105
106/** "2h18", "3d4h", from "resets in 2h 18m". */
107const compact = (s: string) => s.replace('resets in ', '').replace(/ /g, '')
108
109// A properties inspector for the session, drawn as an instrument: a header of
110// the session's totals, then one collapsible section per subject, its readings
111// in one column grid (label, gauge, value, unit). The context meter, with its
112// scale and the compaction mark, is the one bold thing; the rest stays quiet.
113export const renderDesktop = (els: AppElements, d: DesktopData, fx: Helpers): RenderElement => {
114  const { Box, Text } = els
115  const k = kit(els, 'desktop', d.columns)
116  const a = d.activity
117  const snap = d.snap
118  const fold = (id: string) => ({ collapsed: d.collapsed.includes(id), onToggle: () => d.toggle(id) })
119
120  // ---- the header: the session's totals ----
121  const toolCalls = Object.values(a.tools).reduce((n, t) => n + t.n, 0)
122  const toolErrors = Object.values(a.tools).reduce((n, t) => n + t.err, 0)
123  const running = d.agents.filter(x => x.status === 'running' || x.status === 'pending').length
124  const metric = (key: string, glyph: string, value: string, unit: string) => (
125    <Box key={key} flexDirection="row" gap={0.5} flexShrink={0}>
126      <Text dimColor>{glyph}</Text>
127      <Text>
128        {value}
129        <Text dimColor>{` ${unit}`}</Text>
130      </Text>
131    </Box>
132  )
133  const header = (
134    <Box flexDirection="column">
135      <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={1}>
136        <Text bold>{a.cost !== undefined ? money(a.cost) : '–'}</Text>
137        <Box flexDirection="row" gap={1.2}>
138          {running ? k.flag('running', C.warn, GLYPH.running, running) : null}
139          {toolErrors ? k.flag('failed', C.bad, GLYPH.failed, toolErrors) : null}
140        </Box>
141      </Box>
142      <Box flexDirection="row" justifyContent="space-between" gap={1} marginTop={0.2}>
143        {metric('time', GLYPH.time, a.startedAt ? short(d.at - a.startedAt) : '–', '')}
144        {metric('turns', GLYPH.turns, String(a.turns), 'turns')}
145        {metric('calls', GLYPH.tools, String(toolCalls), 'calls')}
146        {metric('files', GLYPH.files, String(a.files.length), 'files')}
147      </Box>
148    </Box>
149  )
150
151  // ---- context: the instrument's main meter ----
152  const parts = snap
153    ? snap.segments.flatMap(s => {
154        const c = CATEGORY[s.name]
155        return s.kind === 'used' && s.tokens > 0 ? [{ value: s.tokens, color: c?.color ?? C.slate, label: c?.name ?? s.name }] : []
156      })
157    : []
158  const context = k.section({
159    id: 'context',
160    icon: GLYPH.context,
161    color: snap ? levelColor(snap.percent) : MUTED,
162    title: 'Context',
163    right: snap ? (
164      <Text>
165        <Text bold color={levelColor(snap.percent)}>{`${snap.percent}%`}</Text>
166        <Text dimColor>{`  ${tokens(snap.total)} of ${tokens(snap.max)}`}</Text>
167      </Text>
168    ) : (
169      <Text dimColor>measuring…</Text>
170    ),
171    ...fold('context'),
172    children: snap
173      ? [
174          <Box key="meter" flexDirection="column" marginTop={0.2}>
175            {k.bar(parts, snap.max, { height: 0.5 })}
176            {k.scale(snap.compactAt ? snap.compactAt / snap.max : undefined, C.warn)}
177          </Box>,
178          <Box key="legend" flexDirection="row" flexWrap="wrap" marginTop={0.1}>
179            {parts.map(p => (
180              <Box key={`legend-${p.label}`} width={16} flexDirection="row" alignItems="center" gap={0.5}>
181                {k.swatch(p.color)}
182                <Box flexShrink={1}>
183                  <Text dimColor wrap="truncate">
184                    {p.label}
185                  </Text>
186                </Box>
187                <Box flexShrink={0}>
188                  <Text>{tokens(p.value)}</Text>
189                </Box>
190              </Box>
191            ))}
192          </Box>,
193          k.prop('model', 'Model', snap.model),
194          snap.compactAt ? k.prop('compact', 'Compacts', <Text color={C.warn}>{`at ${tokens(snap.compactAt)}`}</Text>) : null,
195        ]
196      : null,
197  })
198
199  // ---- limits ----
200  const windows = d.limits ?? []
201  const limits = windows.length
202    ? k.section({
203        id: 'limits',
204        icon: GLYPH.limits,
205        color: C.slate,
206        title: 'Limits',
207        ...fold('limits'),
208        children: windows.map(w =>
209          k.reading(`limit-${w.kind}`, {
210            label: fx.limitLabel(w.kind),
211            value: Math.min(100, Math.max(0, w.percent)),
212            max: 100,
213            color: levelColor(w.percent),
214            figure: `${Math.round(w.percent)}%`,
215            unit: w.resetsAt ? compact(fx.resetIn(w.resetsAt, d.at)) : undefined,
216          }),
217        ),
218      })
219    : null
220
221  // ---- turns ----
222  const cacheIn = a.turnLog.reduce((n, p) => n + p.cacheRead, 0)
223  const allIn = a.turnLog.reduce((n, p) => n + p.cacheRead + p.cacheWrite + p.input, 0)
224  const turns = a.turnLog.length
225    ? k.section({
226        id: 'turns',
227        icon: GLYPH.turns,
228        color: C.blue,
229        title: 'Turns',
230        right: (
231          <Box flexDirection="row" gap={1.5}>
232            <Text>
233              {`${allIn ? Math.round((cacheIn / allIn) * 100) : 0}%`}
234              <Text dimColor> cached</Text>
235            </Text>
236            <Text>
237              {tokens(a.turnLog.reduce((n, p) => n + p.output, 0))}
238              <Text dimColor> out</Text>
239            </Text>
240          </Box>
241        ),
242        ...fold('turns'),
243        children: k.columns(
244          a.turnLog.map(p => [
245            { value: p.output, color: C.ok },
246            { value: p.input, color: C.cyan },
247            { value: p.cacheWrite, color: C.purple },
248            { value: p.cacheRead, color: C.blue },
249          ]),
250          { slots: 24, height: 1.8 },
251        ),
252      })
253    : null
254
255  // ---- tools ----
256  const toolRows = Object.entries(a.tools)
257    .sort((x, y) => y[1].n - x[1].n)
258    .slice(0, 6)
259  const toolMax = Math.max(1, ...toolRows.map(([, t]) => t.n))
260  const tools = toolRows.length
261    ? k.section({
262        id: 'tools',
263        icon: GLYPH.tools,
264        color: C.blue,
265        title: 'Tools',
266        right: (
267          <Text>
268            {String(toolCalls)}
269            <Text dimColor> calls</Text>
270          </Text>
271        ),
272        ...fold('tools'),
273        children: toolRows.map(([name, t]) =>
274          k.reading(`tool-${name}`, {
275            label: name.startsWith('mcp__') ? (name.split('__').pop() ?? name) : name,
276            value: t.n,
277            max: toolMax,
278            color: C.blue,
279            figure: String(t.n),
280            unit: t.err ? `${t.err} ✕` : undefined,
281            unitColor: t.err ? C.bad : undefined,
282          }),
283        ),
284      })
285    : null
286
287  // ---- agents ----
288  const agents = d.agents.length
289    ? k.section({
290        id: 'agents',
291        icon: GLYPH.agents,
292        color: running ? C.warn : C.slate,
293        title: 'Agents',
294        right: (
295          <Text>
296            {String(d.agents.length)}
297            <Text dimColor> started</Text>
298          </Text>
299        ),
300        ...fold('agents'),
301        children: fx.buildTree(d.agents).map(({ agent, depth }) => {
302          const x = d.extras[agent.id]
303          const time =
304            agent.status === 'running' && x?.startedAt !== undefined
305              ? duration(d.at - x.startedAt)
306              : x?.durationMs !== undefined
307                ? duration(x.durationMs)
308                : ''
309          const figure = [x?.tokens !== undefined ? tokens(x.tokens) : '', time].filter(Boolean).join('  ')
310          return k.rail(agent.id, { color: statusColor(agent.status), text: agent.description || agent.type, note: agent.type, figure, indent: depth * 2 })
311        }),
312      })
313    : null
314
315  return (
316    k.pinned([header, context, limits, turns, tools, agents, ...renderBranches(els, k, d, fold)], k.perf(d.perf, { lead: machine(k, d.resources) }), d.rows)
317  )
318}
319
320/** A PR's state as one colour: merged purple, closed or draft grey, else by its checks. */
321const prColor = (pr: GitPr) =>
322  pr.state === 'MERGED' ? C.purple : pr.state === 'CLOSED' || pr.isDraft ? MUTED : pr.checks.failed ? C.bad : pr.checks.pending ? C.warn : C.ok
323
324/**
325 * One section per branch the session started, as the desktop app tracks them:
326 * the branch's changes against the default branch in the header, and below it
327 * where it lives, its uncommitted state, and its PR with CI; Create PR when it
328 * has none, ✕ to stop showing it.
329 */
330const renderBranches = (els: AppElements, k: ReturnType<typeof kit>, d: DesktopData, fold: (id: string) => { collapsed: boolean; onToggle: () => void }): RenderElement[] => {
331  const { Box, Text, Link, Button } = els
332  return d.branches.map(b => {
333    const pr = b.pr
334    const open = pr?.state === 'OPEN'
335    const color = pr ? prColor(pr) : C.slate
336    const id = `branch-${b.branch}`
337    // Git-prompt marks: ! conflicted, + staged, ~ modified, ? untracked, ↑ ahead, ↓ behind.
338    const state: [number, string, string][] = [
339      [b.conflicted, '!', C.bad],
340      [b.staged, '+', C.ok],
341      [b.modified, '~', C.warn],
342      [b.untracked, '?', C.cyan],
343      [b.ahead, '↑', C.ok],
344      [b.behind, '↓', C.warn],
345    ]
346    const creatable = !pr && b.exists
347    // One row, before and after the PR opens: on the left where the branch lives, or
348    // its PR once it has one; on the right its changes, then Create PR or the CI.
349    const checks = pr && open ? (
350      <Box flexDirection="row" gap={1}>
351        {/* Git-prompt style, like the changes: =passed, !failed, *pending. */}
352        {pr.checks.passed ? <Text key="ok" color={C.ok}>{`=${pr.checks.passed}`}</Text> : null}
353        {pr.checks.failed ? <Text key="bad" color={C.bad}>{`!${pr.checks.failed}`}</Text> : null}
354        {pr.checks.pending ? <Text key="wait" color={C.warn}>{`*${pr.checks.pending}`}</Text> : null}
355      </Box>
356    ) : (
357      <Text color={color}>{pr?.state === 'MERGED' ? 'merged' : pr?.state === 'CLOSED' ? 'closed' : ''}</Text>
358    )
359    const row = (
360      <Box key="row" flexDirection="row" justifyContent="space-between" gap={1}>
361        <Box flexDirection="row" gap={1} flexShrink={1}>
362          {pr ? (
363            <Link href={pr.url} label={`#${pr.number}`} />
364          ) : null}
365          <Text wrap="truncate" dimColor>
366            {pr ? pr.title : b.worktree ? `${b.place} (worktree)` : b.place}
367          </Text>
368          {!b.exists ? <Text dimColor>deleted</Text> : !pr && !b.pushed ? <Text dimColor>not pushed</Text> : null}
369        </Box>
370        <Box flexDirection="row" gap={1} alignItems="center">
371          {state.filter(([n]) => n > 0).map(([n, glyph, c]) => (
372            <Text key={`s-${glyph}`} color={c}>{`${glyph}${n}`}</Text>
373          ))}
374          {pr ? checks : null}
375          {pr && open && !pr.isDraft && Button ? <Button key={`merge-${pr.number}`} plain label="Merge" onPress={() => d.mergePr(pr)} /> : null}
376          {creatable && Button ? (
377            d.creating === b.branch ? (
378              <Text dimColor>creating PR…</Text>
379            ) : (
380              <Button key={`create-${b.branch}`} plain label="Create PR" onPress={() => d.createPr(b.branch)} />
381            )
382          ) : null}
383        </Box>
384      </Box>
385    )
386    const failing = pr && open && pr.failing.length ? (
387      <Text key="failing" color={C.bad} wrap="truncate">
388        {pr.failing.slice(0, 3).join(', ')}
389      </Text>
390    ) : null
391    return k.section({
392      id,
393      icon: GLYPH.branch,
394      color,
395      title: b.branch,
396      right: (
397        <Box flexDirection="row" gap={1} alignItems="center">
398          {b.insertions ? <Text color={C.ok}>{`+${b.insertions}`}</Text> : null}
399          {b.deletions ? <Text color={C.bad}>{`−${b.deletions}`}</Text> : null}
400          {Button ? <Button key={`hide-${b.branch}`} plain dimColor label="hide" onPress={() => d.hideBranch(b.branch)} /> : null}
401        </Box>
402      ),
403      ...fold(id),
404      children: [row, failing],
405    })
406  })
407}
408
409
hooks/resources.ts 130 lines
1import type { Resources } from '../types'
2
3/*
4 * The session's share of the machine, from one cheap shell command (ps, sysctl,
5 * ioreg, netstat; a few milliseconds): the session's Claude Code process (the
6 * nearest ancestor named `claude`), its whole process tree's CPU and memory,
7 * and the system's CPU, memory, disk and network. The tree's own network comes
8 * from `nettop`, which always takes 5 s (but almost no CPU), on its own. Rates
9 * are deltas between samples. macOS only. register.tsx runs the commands; this
10 * file parses.
11 */
12
13/**
14 * The sample command. Measured CPU per run: ps 0.02 s (0.04 s with names, so
15 * names only on the first run, to find the session's process), iostat, netstat
16 * and sysctl a few ms each.
17 */
18export const resourcesSh = () => [
19  'sh',
20  '-c',
21  `echo $PPID; ps -A -o pid=,ppid=,%cpu=,rss=${root === null ? ',comm=' : ''}; echo @; sysctl -n hw.ncpu hw.memsize; echo @; ` +
22    'iostat -Id -c 1; echo @; netstat -ib',
23]
24
25/** The session's Claude Code process, found once. */
26let root: number | null = null
27
28/** The tree's network, one nettop sample: per process, bytes in and out so far. */
29export const NETTOP = ['nettop', '-P', '-L', '1', '-x', '-J', 'bytes_in,bytes_out']
30
31/** Whether to read the tree's own network with nettop (about 0.015 s of CPU a run). */
32export const NET_PER_PROCESS = true
33
34type Counters = { pids: number[]; cores: number; cpu: number; cpuAll: number; mem: number; memAll: number; diskAll: number; netAll: number }
35
36const parse = (out: string): Counters => {
37  const [head = '', sys = '', io = '', nets = ''] = out.split('\n@\n')
38  const lines = head.split('\n')
39  const self = Number(lines[0])
40  const rows = new Map<number, { ppid: number; cpu: number; rss: number; name: string }>()
41  for (const line of lines.slice(1)) {
42    const m = /^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s*(.*)$/.exec(line)
43    if (m) rows.set(Number(m[1]), { ppid: Number(m[2]), cpu: Number(m[3]), rss: Number(m[4]), name: m[5]!.split('/').pop()! })
44  }
45  // The session's process: the nearest ancestor named `claude`, else the shell's parent.
46  if (root === null || !rows.has(root)) {
47    root = self
48    for (let pid = self; rows.has(pid); pid = rows.get(pid)!.ppid) {
49      if (rows.get(pid)!.name === 'claude') {
50        root = pid
51        break
52      }
53    }
54  }
55  const kids = new Map<number, number[]>()
56  for (const [pid, r] of rows) kids.set(r.ppid, [...(kids.get(r.ppid) ?? []), pid])
57  const pids: number[] = []
58  const todo = [root!]
59  while (todo.length) {
60    const pid = todo.pop()!
61    pids.push(pid)
62    todo.push(...(kids.get(pid) ?? []))
63  }
64  const [cores = 1, memAll = 0] = sys.trim().split('\n').map(Number)
65  let netAll = 0
66  const seen = new Set<string>()
67  for (const line of nets.split('\n').slice(1)) {
68    const p = line.trim().split(/\s+/)
69    if (p.length >= 10 && p[2]!.startsWith('<Link') && !p[0]!.startsWith('lo') && !seen.has(p[0]!)) {
70      seen.add(p[0]!)
71      netAll += Number(p[6]) + Number(p[9])
72    }
73  }
74  const sum = (f: (r: { cpu: number; rss: number }) => number, ids: Iterable<number>) => [...ids].reduce((n, id) => n + (rows.has(id) ? f(rows.get(id)!) : 0), 0)
75  return {
76    pids,
77    cores,
78    cpu: sum(r => r.cpu, pids),
79    cpuAll: sum(r => r.cpu, rows.keys()),
80    mem: sum(r => r.rss, pids) * 1024,
81    memAll,
82    // iostat -I: per disk, KB/t, transfers and MB so far; the MB is every third figure.
83    diskAll: (io.trim().split('\n').pop() ?? '')
84      .trim()
85      .split(/\s+/)
86      .filter((_, i) => i % 3 === 2)
87      .reduce((n, mb) => n + Number(mb) * 1024 * 1024, 0),
88    netAll,
89  }
90}
91
92let prev: { at: number; c: Counters } | null = null
93let netPrev: { at: number; bytes: number } | null = null
94let net: number | null = null
95let treePids: number[] = []
96
97/** Turns one sample into a Resources; null on the first. */
98export const toResources = (stdout: string, at: number): Resources | null => {
99  const c = parse(stdout)
100  treePids = c.pids
101  const before = prev
102  prev = { at, c }
103  if (!before) return null
104  const secs = Math.max(0.5, (at - before.at) / 1000)
105  const rate = (a: number, b: number) => Math.max(0, (a - b) / secs)
106  const allCores = c.cores * 100
107  return {
108    procs: c.pids.length,
109    cpu: (c.cpu / allCores) * 100,
110    cpuAll: Math.min(100, (c.cpuAll / allCores) * 100),
111    mem: c.mem,
112    memAll: c.memAll,
113    diskAll: rate(c.diskAll, before.c.diskAll),
114    net,
115    netAll: rate(c.netAll, before.c.netAll),
116  }
117}
118
119/** Folds one nettop sample into the tree's network rate. */
120export const takeNettop = (stdout: string, at: number) => {
121  const mine = new Set(treePids)
122  let bytes = 0
123  for (const line of stdout.split('\n')) {
124    const m = /\.(\d+),(\d+),(\d+),?$/.exec(line)
125    if (m && mine.has(Number(m[1]))) bytes += Number(m[2]) + Number(m[3])
126  }
127  if (netPrev) net = Math.max(0, (bytes - netPrev.bytes) / Math.max(0.5, (at - netPrev.at) / 1000))
128  netPrev = { at, bytes }
129}
130
kit/format.ts 47 lines
1const trim = (n: number) => (n >= 100 ? String(Math.round(n)) : n.toFixed(1).replace(/\.0$/, ''))
2
3/** Token counts: 950, 1.2k, 84k, 1M. */
4export const tokens = (n: number): string => {
5  if (n >= 999_500) return `${trim(n / 1_000_000)}M`
6  if (n >= 1_000) return `${trim(n / 1_000)}k`
7  return String(Math.round(n))
8}
9
10/** A duration to the second: 12s, 3m 05s, 2h 05m. */
11export const duration = (ms: number): string => {
12  const s = Math.max(0, Math.floor(ms / 1_000))
13  if (s < 60) return `${s}s`
14  const m = Math.floor(s / 60)
15  if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
16  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
17}
18
19/** A duration at a glance: 47m, 2h05, 3d4h. */
20export const short = (ms: number): string => {
21  const m = Math.max(0, Math.floor(ms / 60_000))
22  if (m < 60) return `${m}m`
23  const h = Math.floor(m / 60)
24  if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}`
25  return `${Math.floor(h / 24)}d${h % 24}h`
26}
27
28/** US dollars: $1.50, $123. */
29/** Bytes, short: 812, 41K, 3.2M, 16G. */
30export const bytes = (n: number): string => {
31  const units = ['', 'K', 'M', 'G', 'T']
32  let i = 0
33  while (n >= 1024 && i < units.length - 1) {
34    n /= 1024
35    i++
36  }
37  return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)}${units[i]}`
38}
39
40export const money =(usd: number) => (usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`)
41
42/** `value` as a whole percent of `max`, clamped: the desktop takes whole percentages only. */
43export const pct = (value: number, max: number) => `${Math.round(Math.max(0, Math.min(100, (value / Math.max(max, 1)) * 100)))}%`
44
45/** The last path segment. */
46export const basename = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
47
kit/tokens.ts 52 lines
1/**
2 * The instrument's colours: calibrated, not neon, readable on a light page, a dark
3 * page and a terminal. Status is green, amber, red; activity is cobalt; quiet
4 * structure is slate. Colour always means something.
5 */
6export const C = {
7  ok: '#5bc08a',
8  warn: '#e3a33b',
9  bad: '#e5636f',
10  blue: '#6c8ef5',
11  purple: '#9d85e8',
12  cyan: '#4fb3c8',
13  orange: '#e3a33b',
14  teal: '#4fb3c8',
15  slate: '#8c9bb5',
16  orchid: '#c58ad6',
17} as const
18
19/** Categorical hues, in order: one family of cool tones; the last in a set takes amber when it is the one to watch. */
20export const PALETTE = [C.slate, C.blue, C.cyan, C.purple, C.orchid, C.warn, C.ok, C.bad] as const
21
22/** A bar's empty part and other quiet fills: a grey that reads on light and dark. */
23export const TRACK = 'rgba(128,128,128,0.18)'
24
25/** Text and marks that should recede. */
26export const MUTED = '#8e8e93'
27
28/** Green under 75%, amber from 75%, red from 90%. */
29export const levelColor = (percent: number): string => (percent >= 90 ? C.bad : percent >= 75 ? C.warn : C.ok)
30
31/** One glyph per idea, shared so mods read alike. */
32export const GLYPH = {
33  context: '◕',
34  cost: '$',
35  time: '◷',
36  turns: '↻',
37  tools: '⚒',
38  files: '✎',
39  agents: '✦',
40  limits: '◔',
41  layers: '▤',
42  branch: '⎇',
43  pr: '⇡',
44  running: '◐',
45  done: '✓',
46  failed: '✕',
47  stopped: '■',
48  pending: '◌',
49  up: '↑',
50  down: '↓',
51} as const
52
kit/ui.tsx 562 lines
1import type { RenderElement, RenderSurface } from 'claude-code'
2
3import { pct, short } from './format'
4import { ms } from './perf'
5import type { PerfRow, PerfSnapshot } from './perf'
6import { C, MUTED, TRACK } from './tokens'
7
8/**
9 * The shared components. Each draws for the surface it is given: on the app
10 * surfaces (desktop, VS Code, mobile) as boxes laid out by flexbox, crisp at
11 * any width; in the terminal as cells, `columns` wide.
12 *
13 * What the desktop accepts (2.1.293): whole percentages only, `flexGrow` up to
14 * 10000, fractional heights and margins; no `hover`. Borders `round` (rounded
15 * corners), single, double, bold and classic draw; `dashed` doesn't. SVG is
16 * drawn as an image: crisp only at its exact pixel size, so it's kept to small
17 * fixed pieces like bar ends.
18 */
19
20/** The element constructors the kit draws with. `Button` is needed only for collapsible sections. */
21export type Els = {
22  Box: (props: any, ...children: any[]) => RenderElement
23  Text: (props: any, ...children: any[]) => RenderElement
24  Button?: (props: any, ...children: any[]) => RenderElement
25  /** Rounds the ends of bars where the surface has it. */
26  Svg?: (props: any, ...children: any[]) => RenderElement
27}
28
29/** A bar part: its value, its color, and the label its legend shows. */
30export type Part = { value: number; color: string; label?: string }
31
32const BAR = 0.45
33
34/** One row's height in CSS pixels on the desktop, measured: a 0.45-row bar is 8.5px tall. */
35const ROW_PX = 8.5 / 0.45
36
37/** The inspector's grid: the gutter the fold arrow sits in, and the label column. */
38const GUTTER = 2
39const LABEL = 10
40/** A band's padding (section headers, the footer), so every band has one height and feel. */
41const BAND_PAD_X = 0.8
42const BAND_PAD_Y = 0.2
43/** A section body's left indent on the desktop: about the width of a header's rounded corner. */
44const BODY_INDENT = 0.8
45
46/**
47 * A section header's band: a tonal step that marks where each section starts. Solid,
48 * not translucent, so its rounded border in the same colour doesn't show as a ring.
49 * Tuned for the desktop's dark pane.
50 */
51const HEAD = '#272727'
52
53
54/** The debug footer's glyph and hue for each job the mods time. */
55const PERF_LOOK: Record<string, [string, string]> = {
56  render: ['▣', C.purple],
57  tick: ['↻', C.blue],
58  resources: ['◎', C.slate],
59  usage: ['◕', C.cyan],
60  git: ['⎇', C.orchid],
61  gh: ['⇡', C.ok],
62}
63
64export const kit = (els: Els, surface: RenderSurface, columns = 40) => {
65  const { Box, Text, Button, Svg } = els
66  const terminal = surface === 'terminal'
67
68  /*
69   * Rounding on the desktop. Boxes have no corner radius; what rounds is:
70   *
71   * - `rounded`: a box a row or more tall. A `round` border in the fill's own colour,
72   *   which must be solid (a translucent one paints twice where border and fill
73   *   overlap and draws a ring). Not on thin boxes: the border takes room inside the
74   *   box, so a thin one has nothing left for its content.
75   * - `capped`: anything thin (bars, gauges). SVG end caps drawn at an exact pixel
76   *   size, so they stay crisp; the middle takes no height and stretches to the caps.
77   *
78   * What doesn't work, so nobody tries again: borders on thin bars (empty, or blobs),
79   * glyph ends (◖━◗, ╺━╸ don't draw), SVG slivers a few pixels wide (don't draw), an
80   * SVG stretched to fit (blurs), a circle drawn over a rect in one SVG (a translucent
81   * colour doubles where they overlap).
82   */
83
84  /**
85   * One rounded end: a square `px` tall, a half circle on the outside and a filled
86   * strip on the inside, as one shape.
87   */
88  const cap = (px: number, color: string, side: 'left' | 'right'): RenderElement | null => {
89    if (!Svg) return null
90    const r = px / 2
91    const d = side === 'left' ? `M${r} 0H${px}V${px}H${r}A${r} ${r} 0 0 1 ${r} 0Z` : `M0 0H${r}A${r} ${r} 0 0 1 ${r} ${px}H0Z`
92    const source =
93      `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 ${px} ${px}">` +
94      `<path d="${d}" fill="${color}"/></svg>`
95    return (
96      <Box flexShrink={0}>
97        <Svg source={source} alt="rounded end" width={px} height={px} />
98      </Box>
99    )
100  }
101
102  /**
103   * Rounds the ends of something thin: `middle` between a `left` and a `right` cap,
104   * `height` rows tall. Give `middle` no height of its own; it stretches to the caps,
105   * whose pixel size it can't drift from. Where the surface has no SVG, `middle` alone
106   * at `height`.
107   */
108  const capped = (height: number, left: string, right: string, middle: (height: number | undefined) => RenderElement): RenderElement => {
109    if (terminal || !Svg) return middle(height)
110    const px = Math.max(4, Math.round(height * ROW_PX))
111    return (
112      <Box flexDirection="row" flexGrow={1} alignItems="stretch">
113        {cap(px, left, 'left')}
114        {middle(undefined)}
115        {cap(px, right, 'right')}
116      </Box>
117    )
118  }
119
120  /**
121   * A filled box with rounded corners, a row or more tall. `color` must be solid
122   * (hex, not rgba): the border that rounds it is the same colour. In the terminal,
123   * the box without fill or border.
124   */
125  const rounded = (color: string, props: Record<string, unknown>, children: RenderElement | (RenderElement | null)[] | null) =>
126    terminal ? (
127      <Box {...props}>{children}</Box>
128    ) : (
129      <Box {...props} backgroundColor={color} borderStyle="round" borderColor={color}>
130        {children}
131      </Box>
132    )
133
134  /** A thin bar: parts left to right out of `max`, the rest as track. */
135  const bar = (parts: Part[], max: number, opts: { height?: number; width?: number } = {}): RenderElement => {
136    const total = Math.max(max, parts.reduce((n, p) => n + Math.max(0, p.value), 0), 1)
137    if (terminal) {
138      const w = opts.width ?? Math.max(4, columns - 20)
139      let used = 0
140      const cells = parts.map(p => {
141        const n = p.value > 0 ? Math.min(Math.max(1, Math.round((p.value / total) * w)), w - used) : 0
142        used += n
143        return <Text color={p.color}>{'█'.repeat(Math.max(0, n))}</Text>
144      })
145      return (
146        <Text>
147          {cells}
148          <Text dimColor>{'░'.repeat(Math.max(0, w - used))}</Text>
149        </Text>
150      )
151    }
152    // Grow factors out of 1000 (the desktop caps them at 10000): finer than a pixel at any width.
153    // Rounded by SVG end caps around a flat middle. Borders can't round it: on the desktop a
154    // border takes room inside the box, so a thin bordered track draws empty and bordered fills
155    // draw as blobs; glyph bars (◖━◗, ╺━╸) don't draw at all.
156    const height = opts.height ?? BAR
157    const shown = parts.filter(p => p.value > 0).map(p => ({ ...p, grow: (p.value / total) * 1000 }))
158    const rest = Math.max(0, 1000 - shown.reduce((n, p) => n + p.grow, 0))
159    // Not `h`: that's the JSX factory.
160    const flat = (rows: number | undefined) => (
161      <Box flexDirection="row" flexGrow={1} height={rows} backgroundColor={TRACK} overflow="hidden">
162        {/* An explicit full height: the desktop doesn't stretch a child to its parent's height. */}
163        {shown.map(p => (
164          <Box flexGrow={p.grow} flexShrink={0} width={0} height="100%" backgroundColor={p.color} />
165        ))}
166        {rest > 0 ? <Box flexGrow={rest} width={0} /> : null}
167      </Box>
168    )
169    // The caps sit inside the bar's width, so a scale under it still lines up with its ends.
170    const first = shown[0]?.color ?? TRACK
171    const last = rest > 0 ? TRACK : (shown[shown.length - 1]?.color ?? TRACK)
172    return capped(height, first, last, flat)
173  }
174
175  /**
176   * A card: a rounded border (`round`, which the desktop draws with rounded
177   * corners and the terminal with ╭╮╰╯), an optional tinted fill, and padding.
178   * Borders the desktop draws: round, single, double, bold, classic; not dashed.
179   */
180  const card = (key: string, children: RenderElement | (RenderElement | null)[], opts: { color?: string; fill?: string; border?: 'round' | 'single' | 'double' | 'bold' } = {}) => (
181    <Box key={key} flexDirection="column" borderStyle={opts.border ?? 'round'} borderColor={opts.color ?? TRACK} backgroundColor={opts.fill} paddingX={1}>
182      {children}
183    </Box>
184  )
185
186  /** A rounded pill: a short colored figure, like a count or a state. */
187  const pill = (key: string, color: string, text: string) =>
188    terminal ? (
189      <Text key={key} color={color}>{`(${text})`}</Text>
190    ) : (
191      <Box key={key} borderStyle="round" borderColor={color} paddingX={0.5}>
192        <Text color={color}>{text}</Text>
193      </Box>
194    )
195
196  /** A status flag: a glyph and a figure in one colour, the size of the text around it. */
197  const flag = (key: string, color: string, glyph: string, figure: string | number) => (
198    <Text key={key} color={color}>{`${glyph} ${figure}`}</Text>
199  )
200
201  /** A small round dot of colour, a legend's key. */
202  const swatch = (color: string) => <Text color={color}>●</Text>
203
204  /** A chip: a swatch, a figure, a dim tag. */
205  const chip = (key: string, color: string, figure: string, tag?: string) => (
206    <Box key={key} flexDirection="row" alignItems="center" gap={terminal ? 1 : 0.5}>
207      {swatch(color)}
208      <Text>
209        {figure}
210        {tag ? <Text dimColor>{` ${tag}`}</Text> : null}
211      </Text>
212    </Box>
213  )
214
215  /** Things that sit side by side and wrap. */
216  const flow = (items: RenderElement[], gap = 2) => (
217    <Box flexDirection="row" flexWrap="wrap" columnGap={gap}>
218      {items}
219    </Box>
220  )
221
222  /**
223   * A bar with parts and a legend under it: a chip per part, its figure from
224   * `format`, its label as the tag. The context window is one. The legend sits
225   * in cells of one width, so it reads as columns rather than a ragged wrap.
226   */
227  const legendBar = (parts: Part[], max: number, format: (n: number) => string = String, opts: { height?: number; cell?: number } = {}) => (
228    <Box flexDirection="column">
229      <Box flexDirection="row" marginTop={terminal ? 0 : 0.3}>
230        {bar(parts, max, { height: opts.height ?? 0.6 })}
231      </Box>
232      <Box flexDirection="row" flexWrap="wrap" marginTop={terminal ? 0 : 0.3}>
233        {parts
234          .filter(p => p.value > 0)
235          .map((p, i) => (
236            <Box key={`legend-${i}`} width={opts.cell ?? 15} flexDirection="row" alignItems="center" gap={terminal ? 1 : 0.5}>
237              {swatch(p.color)}
238              <Text dimColor wrap="truncate">
239                {p.label ?? ''}
240              </Text>
241              <Text>{format(p.value)}</Text>
242            </Box>
243          ))}
244      </Box>
245    </Box>
246  )
247
248  /**
249   * An instrument's scale under a bar: ticks at 0, 25, 50, 75 and 100%, and a
250   * marker where `mark` falls (a fraction of the scale), in `markColor`.
251   */
252  const scale = (mark?: number, markColor: string = C.warn) => {
253    if (terminal) return null
254    // Every tick at its place on the scale, the marker among them, gaps between sized by flex-grow.
255    const marks: Array<{ at: number; color: string; height: number }> = [0, 0.25, 0.5, 0.75, 1].map(at => ({ at, color: TRACK, height: 0.3 }))
256    if (mark !== undefined) marks.push({ at: Math.max(0, Math.min(1, mark)), color: markColor, height: 0.45 })
257    marks.sort((a, b) => a.at - b.at)
258    const row: RenderElement[] = []
259    marks.forEach((m, i) => {
260      if (i > 0) row.push(<Box key={`g${i}`} flexGrow={Math.max(0, (m.at - marks[i - 1]!.at) * 1000)} width={0} />)
261      row.push(<Box key={`m${i}`} width={0.12} height={m.height} backgroundColor={m.color} flexShrink={0} />)
262    })
263    return (
264      <Box flexDirection="row" alignItems="flex-start" height={0.45}>
265        {row}
266      </Box>
267    )
268  }
269
270  /**
271   * A labelled bar on one line: a fixed-width label, the bar, a figure on the
272   * right. Limits, tools, anything that is one value against a max.
273   */
274  const labelBar = (key: string, label: string, parts: Part[], max: number, figure: RenderElement, labelWidth = 10) => (
275    <Box key={key} flexDirection="row" alignItems="center" gap={1}>
276      <Box width={labelWidth} flexShrink={0}>
277        <Text wrap="truncate">{label}</Text>
278      </Box>
279      {bar(parts, max, { width: Math.max(4, columns - labelWidth - 10) })}
280      <Box minWidth={7} flexShrink={0} flexDirection="row" justifyContent="flex-end">
281        {figure}
282      </Box>
283    </Box>
284  )
285
286  /** A stat: a colored glyph, a bold figure, an optional dim suffix. */
287  const stat = (glyph: string, color: string, value: string, suffix?: string) => (
288    <Text>
289      <Text color={color}>{`${glyph} `}</Text>
290      <Text bold>{value}</Text>
291      {suffix ? <Text dimColor>{suffix}</Text> : null}
292    </Text>
293  )
294
295  /** A list line: something on the left that truncates, something on the right that doesn't, an optional indent. */
296  const line = (key: string, left: RenderElement, right?: RenderElement | null, indent = 0) => (
297    <Box key={key} flexDirection="row" justifyContent="space-between" gap={1} paddingLeft={indent}>
298      {left}
299      {right ?? null}
300    </Box>
301  )
302
303  /** A status line: a colored glyph, text that truncates, a dim figure on the right. */
304  const item = (key: string, glyph: string, color: string, text: string, figure?: string, indent = 0) =>
305    line(
306      key,
307      <Text wrap="truncate">
308        <Text color={color}>{`${glyph} `}</Text>
309        {text}
310      </Text>,
311      figure ? <Text dimColor>{figure}</Text> : null,
312      indent,
313    )
314
315  /**
316   * A section as a properties inspector draws one (Premiere's Effect Controls):
317   * a collapsible header row (▾ title, figures on the right) and its properties
318   * indented under the arrow. No panel, outline or tint: rows and indentation do
319   * the grouping. Collapsible when given `onToggle` (the mod keeps which are
320   * folded in its state); folded, only the header row shows.
321   */
322  const section = (s: {
323    id: string
324    title: string
325    /** A glyph before the title, in `color`. */
326    icon?: string
327    color?: string
328    right?: RenderElement | null
329    collapsed?: boolean
330    onToggle?: () => void
331    children: RenderElement | (RenderElement | null)[] | null
332  }) => {
333    const arrow = s.collapsed ? '▸' : '▾'
334    const toggle =
335      s.onToggle && Button ? (
336        <Button key={`toggle-${s.id}`} plain dimColor label={arrow} onPress={() => s.onToggle?.()} />
337      ) : (
338        <Text dimColor>{arrow}</Text>
339      )
340    // The arrow and the icon each take a fixed column, so every title starts at the same x.
341    // One standard row above each section, and no other margin: sections sit one row apart.
342    return (
343      <Box key={`section-${s.id}`} flexDirection="column" marginTop={1}>
344        {rounded(
345          HEAD,
346          {
347            flexDirection: 'row',
348            justifyContent: 'space-between',
349            alignItems: 'center',
350            gap: 1,
351            paddingRight: terminal ? 0 : BAND_PAD_X,
352            paddingY: terminal ? 0 : BAND_PAD_Y,
353          },
354          [
355            <Box key="title" flexDirection="row" alignItems="center" flexShrink={1}>
356              <Box width={GUTTER} flexShrink={0}>
357                {toggle}
358              </Box>
359              {s.icon ? (
360                <Box width={2} flexShrink={0}>
361                  <Text color={s.color}>{s.icon}</Text>
362                </Box>
363              ) : null}
364              <Text wrap="truncate" bold>
365                {s.title}
366              </Text>
367            </Box>,
368            s.right ?? null,
369          ],
370        )}
371        {/* A gentle indent: the body starts where the band's rounded corner turns straight. */}
372        {s.collapsed ? null : (
373          <Box flexDirection="column" paddingLeft={terminal ? GUTTER : BODY_INDENT} marginTop={terminal ? 0 : 0.25} rowGap={terminal ? 0 : 0.12}>
374            {s.children}
375          </Box>
376        )}
377      </Box>
378    )
379  }
380
381  /** A property row: a dim label in the label column, a value beside it. */
382  const prop = (key: string, label: string, value: RenderElement | string, labelWidth = LABEL) => (
383    <Box key={key} flexDirection="row" gap={1}>
384      <Box width={labelWidth} flexShrink={0}>
385        <Text dimColor wrap="truncate">
386          {label}
387        </Text>
388      </Box>
389      {typeof value === 'string' ? <Text wrap="truncate">{value}</Text> : value}
390    </Box>
391  )
392
393  /**
394   * Sections side by side when there is room: each at least `minWidth` cells,
395   * growing to share the row; one column when the pane is narrower than two.
396   */
397  const grid = (items: RenderElement[], minWidth = 34) => (
398    <Box flexDirection="row" flexWrap="wrap" columnGap={terminal ? 2 : 1} rowGap={terminal ? 0 : 0.6} marginTop={terminal ? 1 : 0.6}>
399      {items.map((it, i) => (
400        <Box key={`cell-${i}`} flexGrow={1} width={0} minWidth={minWidth} flexDirection="column">
401          {it}
402        </Box>
403      ))}
404    </Box>
405  )
406
407  /**
408   * A thin rounded gauge, `value` of `max`: a one-part bar.
409   */
410  const gauge = (value: number, max: number, color: string) => (terminal ? bar([{ value, color }], max) : bar([{ value, color }], max, { height: 0.3 }))
411
412  /**
413   * A reading, as an instrument shows one: a label, a thin gauge, a bold value
414   * and a dim unit or note, in fixed columns so readings line up.
415   */
416  const reading = (key: string, r: { label: string; value: number; max: number; color: string; figure: string; unit?: string; unitColor?: string; labelWidth?: number }) => (
417    <Box key={key} flexDirection="row" alignItems="center" gap={1}>
418      <Box width={r.labelWidth ?? LABEL} flexShrink={0}>
419        <Text dimColor wrap="truncate">
420          {r.label}
421        </Text>
422      </Box>
423      {gauge(r.value, r.max, r.color)}
424      <Box width={5} flexShrink={0} flexDirection="row" justifyContent="flex-end">
425        <Text bold>{r.figure}</Text>
426      </Box>
427      <Box width={6} flexShrink={0}>
428        <Text color={r.unitColor} dimColor={!r.unitColor}>
429          {r.unit ?? ''}
430        </Text>
431      </Box>
432    </Box>
433  )
434
435  /** A row with a slim coloured rail on the left: a name, a dim note, a figure on the right. */
436  const rail = (key: string, r: { color: string; text: string; note?: string; figure?: string; indent?: number }) => (
437    <Box key={key} flexDirection="row" gap={1} paddingLeft={r.indent ?? 0}>
438      {terminal ? <Text color={r.color}>▎</Text> : <Box width={0.25} backgroundColor={r.color} flexShrink={0} />}
439      <Box flexDirection="row" justifyContent="space-between" flexGrow={1} gap={1}>
440        <Text wrap="truncate">
441          {r.text}
442          {r.note ? <Text dimColor>{`  ${r.note}`}</Text> : null}
443        </Text>
444        {r.figure ? <Text dimColor>{r.figure}</Text> : null}
445      </Box>
446    </Box>
447  )
448
449  /** Stacked columns, one per item, scaled to the tallest; `slots` keeps a fixed column width. */
450  const columnsChart = (items: Part[][], opts: { height?: number; slots?: number } = {}) => {
451    const total = (s: Part[]) => s.reduce((n, p) => n + Math.max(0, p.value), 0)
452    const max = Math.max(1, ...items.map(total))
453    if (terminal) {
454      const ramp = ' ▁▂▃▄▅▆▇█'
455      return (
456        <Text>
457          {items.map(s => (
458            <Text color={s.find(p => p.value > 0)?.color ?? MUTED}>{ramp[Math.round((total(s) / max) * 8)]}</Text>
459          ))}
460        </Text>
461      )
462    }
463    const slots = Math.max(items.length, opts.slots ?? 0)
464    return (
465      <Box flexDirection="row" height={opts.height ?? 2.5} alignItems="flex-end" marginTop={0.3}>
466        {Array.from({ length: slots - items.length }, () => (
467          <Box flexGrow={1} width={0} />
468        ))}
469        {items.map(s => (
470          <Box flexGrow={1} width={0} height={pct(total(s), max)} flexDirection="row" justifyContent="center">
471            <Box width="70%" height="100%" flexDirection="column">
472              {s
473                .filter(p => p.value > 0)
474                .map(p => (
475                  <Box height={pct(p.value, total(s))} backgroundColor={p.color} />
476                ))}
477            </Box>
478          </Box>
479        ))}
480      </Box>
481    )
482  }
483
484  /**
485   * A debug footer: what the mod cost, from a `createPerf()` snapshot. One dim
486   * line per row's worth: the costliest things in the last minute first, each
487   * as its average time and how often it ran; amber past 1 s a minute or a
488   * render over 16 ms, red past 5 s a minute.
489   */
490  const perfFooter = (snap: PerfSnapshot, opts: { max?: number; lead?: RenderElement[] } = {}) => {
491    const rows = snap.rows.slice(0, opts.max ?? 6)
492    const tone = (r: PerfRow) =>
493      r.busyMs > 5_000 ? C.bad : r.busyMs > 1_000 || (r.name === 'render' && r.avgMs > 16) ? C.warn : undefined
494    // One glyph and hue per job, so a row reads at a glance; a slow job turns amber or red.
495    const look = (name: string): [string, string] => PERF_LOOK[name] ?? ['•', C.slate]
496    const items = [
497      <Text key="perf-since">
498        <Text color={C.slate}>⏱</Text>
499        {` ${short(snap.sinceMs)}`}
500      </Text>,
501      ...rows.map(r => {
502        const [glyph, hue] = look(r.name)
503        const color = tone(r)
504        return (
505          <Text key={`perf-${r.name}`}>
506            <Text color={color ?? hue}>{glyph}</Text>
507            <Text color={color} bold={!!color}>{r.avgMs > 0 ? ` ${ms(r.avgMs)}` : ''}</Text>
508            <Text dimColor>{` ${r.perMin}/m`}</Text>
509          </Text>
510        )
511      }),
512    ]
513    const line = (key: string, row: RenderElement[]) => (
514      <Box key={key} flexDirection="row" flexWrap="wrap" columnGap={terminal ? 2 : 1.5}>
515        {row}
516      </Box>
517    )
518    // With a lead, one row only: the lead and the uptime, no wrap; what doesn't fit is cut.
519    if (opts.lead?.length) {
520      const row = [...opts.lead, share('perf-since', '⏱', C.slate, short(snap.sinceMs), '')]
521      if (terminal) return <Box flexDirection="row" columnGap={2} marginTop={1} overflow="hidden">{row}</Box>
522      return rounded(HEAD, { flexDirection: 'row', flexWrap: 'nowrap', justifyContent: 'space-between', alignItems: 'center', columnGap: 1, paddingX: BAND_PAD_X, paddingY: BAND_PAD_Y, flexShrink: 0, marginTop: 0.6, overflow: 'hidden' }, row)
523    }
524    if (terminal) return <Box marginTop={1}>{line('jobs', items)}</Box>
525    return rounded(HEAD, { flexDirection: 'column', paddingX: BAND_PAD_X, paddingY: BAND_PAD_Y, flexShrink: 0, marginTop: 0.6 }, line('jobs', items))
526  }
527
528  /**
529   * One resource reading for a footer: a coloured glyph, our figure in bold, and
530   * the whole it's a share of, dim. `hot` tints our figure (amber, red) when it's a lot.
531   */
532  const share = (key: string, glyph: string, color: string, ours: string, whole: string, hot?: string) => (
533    // The glyph in its own box: the desktop drops a space at the start of a Text.
534    <Box key={key} flexDirection="row" gap={0.4} flexShrink={1}>
535      <Text color={color}>{glyph}</Text>
536      <Text wrap="truncate">
537        <Text bold color={hot}>{ours}</Text>
538        {whole ? <Text dimColor>{`/${whole}`}</Text> : null}
539      </Text>
540    </Box>
541  )
542
543  /**
544   * A pane whose footer stays on the bottom edge: `body` fills the height above it.
545   * For the debug footer, or anything that should sit under the content however short it is.
546   */
547  // `height="100%"` doesn't resolve in a pane, so the column takes the pane's height in
548  // rows (`e.props.scroll.bodyRows`) and a growing spacer pushes the footer down. Taller
549  // content than that just runs on, footer last.
550  const pinned = (body: RenderElement | (RenderElement | null)[], footer: RenderElement, rows?: number) => (
551    <Box flexDirection="column" minHeight={rows}>
552      {body}
553      <Box flexGrow={1} />
554      {footer}
555    </Box>
556  )
557
558  return { pinned, share, bar, capped, rounded, scale, gauge, reading, rail, prop, card, pill, flag, swatch, chip, flow, legendBar, labelBar, stat, line, item, section, grid, columns: columnsChart, perf: perfFooter }
559}
560
561export type Kit = ReturnType<typeof kit>
562
types/index.d.ts 239 lines
1/** One category of the window, as /context lists it; `kind` as the engine stamps it. */
2export type Segment = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }
3
4/** One line item under a category (a memory file, an MCP server, a skill, an agent). */
5export type Item = { label: string; tokens: number }
6
7/** The breakdown, slimmed to what the band draws. */
8export type Snapshot = {
9  model: string
10  total: number
11  max: number
12  percent: number
13  compactAt?: number
14  segments: Segment[]
15  memory: Item[]
16  mcp: Item[]
17  skills: Item[]
18  agents: Item[]
19}
20
21/** One subagent as `$.agent.list()` gives it, kept as plain data. */
22export type Agent = {
23  id: string
24  description: string
25  /** The agent definition (`general-purpose`, `Explore`, ...). */
26  type: string
27  /** `running`, `completed`, `failed`, `killed`, or another engine task status. */
28  status: string
29  /** The subagent that spawned it; absent when the main loop did. */
30  parentId?: string
31}
32
33/** What the spawn and turn hooks saw of an agent beyond the list. */
34export type AgentExtra = {
35  /** When `agent.spawn` started it (ms since epoch). */
36  startedAt?: number
37  /** Summed `durationMs` of its finished turns. */
38  durationMs?: number
39  /** Summed tokens of its finished turns (input, output and cache). */
40  tokens?: number
41  /** `agent.spawn`'s `parentAgentId`: the loop that spawned it, for when the list leaves `parentId` out. */
42  parentId?: string
43}
44
45/** One drawn row of the Tasks tree. */
46export type TreeRow = {
47  agent: Agent
48  depth: number
49  /** Last among its siblings: drawn with `└─` rather than `├─`. */
50  last: boolean
51  /** Per ancestor level 1..depth-1, whether that ancestor has later siblings (draw a `│` guide). */
52  guides: boolean[]
53  /** All its descendants, folded ones included. */
54  descendants: number
55  /** Of those, the ones still running or pending. */
56  running: number
57  /** Its finished subtree is folded into this row. */
58  folded: boolean
59}
60
61/** One changed path from `git status --porcelain=v2`. */
62export type GitFile = {
63  path: string
64  kind: 'conflict' | 'untracked' | 'added' | 'deleted' | 'renamed' | 'modified'
65  /** Only the index has the change (nothing left unstaged). */
66  staged: boolean
67}
68
69/** The session's repo as the git section draws it. */
70export type GitInfo = {
71  ok: true
72  /** `git rev-parse --show-toplevel` of the session cwd. */
73  root: string
74  repo: string
75  /** Branch name, or the short commit when detached. */
76  branch: string
77  detached: boolean
78  upstream?: string
79  ahead: number
80  behind: number
81  worktree: { name: string; path: string; linked: boolean; count: number }
82  staged: number
83  modified: number
84  untracked: number
85  conflicted: number
86  insertions: number
87  deletions: number
88  /** The first few changed paths. */
89  files: GitFile[]
90  /** Changed paths in all, beyond `files` too. */
91  fileCount: number
92}
93
94/** Why there is no git section: not a repo, git missing. */
95export type GitNote = { ok: false; note: string }
96
97/** One pull request for the git section. */
98export type GitPr = {
99  number: number
100  title: string
101  url: string
102  /** `OPEN`, `MERGED` or `CLOSED`. */
103  state: string
104  isDraft: boolean
105  /** `APPROVED`, `CHANGES_REQUESTED`, `REVIEW_REQUIRED` or empty. */
106  review: string
107  checks: { passed: number; failed: number; pending: number }
108  /** Names of the failing checks. */
109  failing: string[]
110  /** The PR's head is the current branch. */
111  current: boolean
112}
113
114/** The cached `gh` result, refreshed on a slower timer. */
115export type GhInfo = {
116  /** The repo root it was fetched for. */
117  root: string
118  prs: GitPr[]
119  /** Why there are no PRs to show: gh missing, unauthenticated, no remote. */
120  note?: string
121  at: number
122}
123
124/** One account rate-limit window as the API last reported it. */
125export type Limit = {
126  /** `five_hour`, `seven_day`, a gateway's `spend_limit`, or another window the API names. */
127  kind: string
128  /** 0 to 100; past 100 on an exceeded spend limit. */
129  percent: number
130  /** ISO 8601 time the window resets. */
131  resetsAt?: string
132}
133
134/** One tool's calls this session, and how many of them errored. */
135export type ToolCount = { n: number; err: number }
136
137/** One main-loop turn: how long it took and its tokens. */
138export type TurnPoint = { ms: number; input: number; output: number; cacheRead: number; cacheWrite: number }
139
140/** What the session has done, as the hooks saw it. */
141export type Activity = {
142  /** When the session's figures count from (ms since epoch); absent until the first usage read. */
143  startedAt?: number
144  /** US dollars so far; absent where the host keeps no cost ledger. */
145  cost?: number
146  /** Main-loop turns finished. */
147  turns: number
148  /** Calls per tool name, subagents' included. */
149  tools: Record<string, ToolCount>
150  /** Paths Edit, Write, MultiEdit and NotebookEdit touched, each once. */
151  files: string[]
152  /** The last main-loop turns, oldest first. */
153  turnLog: TurnPoint[]
154  /** The context window's percent at each measurement, oldest first. */
155  contextLog: number[]
156}
157
158/** A branch the session started (or began on), and the checkout it lives in. */
159export type Tracked = { branch: string; path: string; at: number }
160
161/** One tracked branch's state against the repo's default branch, and its PR. */
162export type BranchView = {
163  branch: string
164  /** The checkout (main or a worktree) the branch was started in. */
165  path: string
166  /** That checkout's folder name, and whether it's a linked worktree. */
167  place: string
168  worktree: boolean
169  /** The branch still exists locally. */
170  exists: boolean
171  /** It's what that checkout has checked out now, so the uncommitted counts are its. */
172  checkedOut: boolean
173  /** Commits ahead of and behind the default branch. */
174  ahead: number
175  behind: number
176  /** Lines added and removed against the default branch, committed and not. */
177  insertions: number
178  deletions: number
179  staged: number
180  modified: number
181  untracked: number
182  conflicted: number
183  /** Pushed: the remote has the branch. */
184  pushed: boolean
185  /** Its most recent PR, if any. */
186  pr: GitPr | null
187}
188
189/** This session's process tree against the whole machine, from the last sample. */
190export type Resources = {
191  /** Processes in the session's tree. */
192  procs: number
193  /** CPU: the tree's and the system's busy share of all cores, 0 to 100. */
194  cpu: number
195  cpuAll: number
196  /** Memory: the tree's resident bytes, and the machine's RAM. */
197  mem: number
198  memAll: number
199  /** Disk: the system's bytes per second, read plus written (macOS has no cheap per-process figure). */
200  diskAll: number
201  /** Network: bytes per second in plus out; the tree's is null until nettop's first sample. */
202  net: number | null
203  netAll: number
204}
205
206declare module 'claude-code' {
207  interface PluginState {
208    overview: {
209      /** The account's rate-limit windows from the last usage read; null until the first. */
210      limits: Limit[] | null
211      /** The session cwd's git state; null until the first read. */
212      git: GitInfo | GitNote | null
213      /** The last `gh` read for that repo; null until the first. */
214      gh: GhInfo | null
215      snapshot: Snapshot | null
216      /** The session's subagents from the last `$.agent.list()`. */
217      agents: Agent[]
218      /** Per agent id, what the spawn and turn hooks recorded. */
219      agentExtra: Record<string, AgentExtra>
220      /** The clock running agents' elapsed time is drawn against (ms since epoch). */
221      now: number
222      /** Cost, time, turns, tool calls and files: the session's activity. */
223      activity: Activity
224      /** The app mode's section ids the person folded. */
225      collapsed: string[]
226      /** The session's share of the machine; null until two samples give rates. */
227      resources: Resources | null
228      /** Branches the session started or began on, oldest first. */
229      tracked: Tracked[]
230      /** Branches the person closed with ✕. */
231      hidden: string[]
232      /** Each shown tracked branch's state, newest first. */
233      branches: BranchView[]
234      /** A branch a PR is being created for, while gh runs. */
235      creating: string | null
236    }
237  }
238}
239