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

Ryan's Claude Code setup: mods, the kit they're built from, and the skills Claude uses to make more of them.
| Folder | What 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 |
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.
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.
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.
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.
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.
hooks/register.tsx 1309 lines1import { 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 lines1/*
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}
38hooks/desktop.tsx 409 lines1import 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
409hooks/resources.ts 130 lines1import 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}
130types/index.d.ts 239 lines1/** 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