Live dashboard of what Claude is doing: turn, plan, agents, sessions and health.

A Claude Code mod that shows what Claude is doing in one place: the current turn, the plan, subagents, your other sessions and the session's health. It works in the terminal, the Desktop app and VS Code.
npm test · 2:14 · ~1:46 left · 3/5 tasks/watch) with six tabs: Turn, Plan, Agents, Sessions, Health and Config~ with 3 or more samples, ? with fewer, up to when only a timeout bounds the callClaude Watcher only observes. Every hook passes the event on unchanged, and it never blocks or denies anything.

Requires Claude Code 2.1.304 or later.
At the prompt of a terminal session:
/plugin install watcher --marketplace 31mahadi/claude-watcher
From a shell:
claude plugin install watcher --marketplace 31mahadi/claude-watcher
To run it from a checkout:
claude --plugin-dir ./claude-watcher
Watcher checks GitHub for a new release once a day. When one is out, it shows a toast once with the version and the update commands. The check sends no data and stays quiet when you are offline.
At the prompt of a terminal session:
/plugin marketplace update claude-watcher
/plugin update watcher@claude-watcher
From a shell:
claude plugin marketplace update claude-watcher
claude plugin update watcher@claude-watcher
Restart Claude Code afterwards so the new version loads.
Auto-update is off by default for marketplaces outside Anthropic. To turn it on:
/plugin.claude-watcher.Claude Code then fetches new versions when it starts.
Run /plugin, open Installed and find watcher. The latest version is on the releases page.
Reinstall the plugin:
/plugin uninstall watcher@claude-watcher
/plugin install watcher --marketplace 31mahadi/claude-watcher
| Command | What it does | ||||||
|---|---|---|---|---|---|---|---|
/watch | Open or close the dashboard | ||||||
| `/watch turn\ | plan\ | agents\ | calls\ | sessions\ | health\ | config` | Open the dashboard or a detail view |
/watch config set <path> <value> | Change a setting, for example /watch config set barWidth 30 | ||||||
/watch config get <path> | Read a setting | ||||||
| `/watch reset [history\ | config\ | all]` | Clear duration history and/or settings | ||||
/watch export | Copy the last turn as JSON and print it | ||||||
/watch mute 30m / /watch unmute | Silence or restore alerts | ||||||
/watch checks / /watch changes | Every check run with failure output / the full change ledger | ||||||
| `/watch clear [history\ | plan\ | checks\ | all]` | Empty the history, dismiss the plan, empty the checks, or all three |
One pane you can leave open all day. It answers, at a glance: does anything need me, which step of the plan is Claude on, what has it changed, did its checks pass, and how long until context compaction.
╭ USAGE ──────────────────────────────────────────────╮
│ session ━━━━━────────── 24% resets in 2h 5m │
│ weekly ━━━━━━━━─────── 41% resets Sat │
╰─────────────────────────────────────────────────────╯
╭ STATUS ─────────────────────────────────────────────╮
│ ● Running Bash npm test · 0:41 │
│ ↳ Explore Read src/login.ts · 0:07 │
╰─────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────╮
│ PLAN CAM-77 · Migrate auth step 3 of 7 · ~8m │
│ ▰▰▰▱▱▱▱ 2 done │
│ ✓ Read existing auth code 1m │
│ ✓ Write the failing test 2m │
│ ▶ Rewrite login handler │
│ ○ Update docs │
│ NOW main ━━━━━━━━━━━━━ │
│ Explore ━━━━━ │
│ tests ✗ files changed +42 −7 context 57% │
╰─────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────╮
│ HISTORY ▾ hide ▂▅▃█ [▸ open a past turn… ▾] ✕ clear │
│ ✓ Turn 2 fix failing test 20:30 → 20:32 · 2m 11s │
╰─────────────────────────────────────────────────────╯
view ▾ history ▾ actions… ▾
/usage shows them: session (5-hour), weekly, and per-model weekly windows. Each has a bar, the percent and when it resets. Amber from 70%, red from 90%.+N −M, from git diff --numstat against the commit the session started from; your own edits counted separately.✗ ✗ ✓), kept across /clear and resume.health.compactAtPct, default 85).Idle and Now shows Idle · last turn took 2:11 · 16 calls · tests ✓ · 2 files.view switches between the dashboard and detail views, history filters history (all, turns, problems), actions… opens the plan file, the failing test at its line, or a changed file in VS Code (code -g; the path is copied when code isn't available), or copies the resume command; it also has clear plan and clear history.Watcher · 1 while something needs you.When every step of a plan is done it shows ✓ all N steps done, history records plan finished, and the plan clears itself when the next turn starts.
Highest priority first. A file-based plan survives turn end, compaction, /clear and resume.
| Source | Detected when | Steps | Done when |
|---|---|---|---|
| superpowers plan file | Claude reads or edits docs/superpowers/plans/*.md (or a file titled "… Implementation Plan") | ### Task N: headings, with their - [ ] steps | its ledger .superpowers/sdd/<plan>/progress.md says Task N: complete, or every checkbox is ticked |
| Plan mode | ExitPlanMode after plan mode named a plan file | headings or list items | checkboxes, or a matching todo |
| Todo list | TodoWrite | each todo | completed |
| Task tool | TaskCreated / TaskCompleted | each task | TaskCompleted |
| Prompt | every turn | none; the first line is the title | the turn ends |
With a plan file active, todos are matched to its tasks by title; extra todos become sub-steps of the running task. The ticket key (CAM-1234) comes from the plan title, the branch name or the prompt.
Every tool call, edited file, test result and subagent is credited to the step running at that moment.
start → end, prompt, duration, steps moved, calls, tests, files and agents. The latest 3 turns show; ↓ load 3 more adds older ones and ↑ show less goes back. ▾ hide / ▸ show N turns collapses it, ✕ clear empties it, and ▸ open a past turn… replays a past turn as the dashboard looked when it ended, with a ← dashboard button to return. Compactions, model switches, stop errors and plan loads appear between turns. The last 20 turns are kept per session and survive /clear and resume.Detail views: /watch plan (every step with its calls, files, tests and failure output), /watch agents, /watch calls, /watch history (every turn expanded), /watch sessions, /watch health, /watch config.
Settings merge in this order, later layers winning: built-in defaults, then <project>/.claude/watcher.json, then your own changes (stored in the mod's store). Unknown keys and invalid values are ignored. For every key, see DEFAULTS in hooks/config.ts.
// .claude/watcher.json
{
"autoOpen": "onLongTurn", // always | onLongTurn | never
"longTurnSeconds": 20,
"theme": "default", // default | minimal | dense
"barStyle": "blocks", // blocks | dots | ascii
"visuals": { "gantt": true, "sparkline": true },
"attention": { "desktopNotify": "whenUnfocused", "notifyOnTurnEndAfterSeconds": 300 },
"sessions": { "shareState": true, "showCwd": "basename" },
"redactCommands": false
}
desktopNotify: "whenUnfocused" behaves like "always".attention.sound is honoured only when that channel is the terminal bell.Run from a checkout with Claude Code 2.1.304 or later:
claude plugin validate .
claude plugin test .
MIT. See LICENSE.
hooks/register.tsx 1039 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { WatchCall, WatchNeedsYou, WatchPeer, WatchPlan, WatchSnapshot, WatchTab, WatchUi } from '../types'
5import { DEFAULTS, deepMergeRaw, getPath, mergeConfig, parseValue, patchFor } from './config'
6import { normalizeKey, remember, stepKind } from './estimate'
7import { parseDuration, time, toolLabel, truncate } from './format'
8import { classifyCommand, parseGitStatus, parseTestOutput } from './health'
9import { HISTORY_PAGE, needsYouText, Pane, shortModel, summary, TABS, type View } from './render/pane'
10import type { Kit } from './render/widgets'
11import { heartbeatOf } from './sessions'
12import { attribute, isPersistent, isPlanPath, ledgerPath, mergeTodos, parseLedger, parsePlanFile, planFromFile, promptPlan, settle, taskEvent } from './plan'
13import { changesFromGit, changesTotal } from './changes'
14import { checkKey, checkRows, recordCheck } from './checks'
15import * as H from './history'
16import { failingLocation, openOptions } from './openmenu'
17import * as S from './state'
18import * as U from './update'
19
20const snap = atom({ plugin: 'watcher', key: 'snap' } as const, S.EMPTY_SNAP)
21const config = atom({ plugin: 'watcher', key: 'config' } as const, DEFAULTS)
22const ui = atom({ plugin: 'watcher', key: 'ui' } as const, { tab: 'turn', scope: 'this', mutedUntil: 0, tick: 0, expanded: [], historyFilter: 'all' } as WatchUi)
23const peers = atom({ plugin: 'watcher', key: 'peers' } as const, [] as WatchPeer[])
24const estimates = atom({ plugin: 'watcher', key: 'estimates' } as const, {} as Record<string, number[]>)
25
26type $ = EngineInterface
27
28const TITLE = 'Watcher'
29
30// The user's sparse overrides live in $.store under `config`; project defaults
31// come from `<cwd>/.claude/watcher.json`. The merged result is mirrored in $.state.
32let projectLayer: unknown = undefined
33
34async function loadConfig($: $, cwd: string): Promise<void> {
35 projectLayer = undefined
36 try {
37 const raw = await $.fs.read(`${cwd.replace(/\/+$/, '')}/.claude/watcher.json`)
38 projectLayer = JSON.parse(raw)
39 } catch {
40 // No project file, or unreadable: defaults apply.
41 }
42 const user = await $.store.get('config')
43 await update($, config, () => mergeConfig(projectLayer, user))
44}
45
46async function setConfig($: $, path: string, value: unknown): Promise<void> {
47 const user = (await $.store.get('config')) ?? {}
48 const next = deepMergeRaw(user, patchFor(path, value))
49 await $.store.set('config', next)
50 await update($, config, () => mergeConfig(projectLayer, next))
51}
52
53async function resetConfig($: $): Promise<void> {
54 await $.store.delete('config')
55 await update($, config, () => mergeConfig(projectLayer))
56}
57
58async function loadHistory($: $): Promise<void> {
59 const stored = (await $.store.get('history')) as Record<string, number[]> | undefined
60 await update($, estimates, () => stored ?? {})
61}
62
63async function saveHistory($: $): Promise<void> {
64 await $.store.set('history', await read($, estimates))
65}
66
67async function resetHistory($: $): Promise<void> {
68 await $.store.delete('history')
69 await update($, estimates, () => ({}))
70}
71
72async function setTab($: $, tab: WatchTab): Promise<void> {
73 await update($, ui, u => ({ ...u, tab }))
74 if (tab === 'sessions') {
75 const list = await readPeers($, await $.clock.now())
76 await update($, peers, () => list)
77 }
78}
79
80const PREFIX = 'session:'
81const FORGET_AFTER_MS = 24 * 3_600_000
82
83async function writeBeat($: $, peer: WatchPeer): Promise<void> {
84 if (!peer.sessionId) return
85 await $.store.set(PREFIX + peer.sessionId, peer)
86}
87
88// Every session writes its own key; readers tolerate missing or malformed ones
89// and drop entries older than a day.
90async function readPeers($: $, now: number): Promise<WatchPeer[]> {
91 const keys = (await $.store.keys()).filter(k => k.startsWith(PREFIX))
92 const out: WatchPeer[] = []
93 for (const key of keys) {
94 const value = (await $.store.get(key)) as WatchPeer | undefined
95 if (!value || typeof value.beatAt !== 'number') continue
96 if (now - value.beatAt > FORGET_AFTER_MS) {
97 await $.store.delete(key)
98 continue
99 }
100 out.push(value)
101 }
102 return out.sort((a, b) => Number(!!b.needsYou) - Number(!!a.needsYou) || b.beatAt - a.beatAt)
103}
104
105// Timers belong to this module instance; a hot reload drops them and
106// session.start runs again to restart what is needed.
107let stopTick: Timer | undefined
108let stopBeat: Timer | undefined
109// Per-module bookkeeping; a reload resets it, which at worst repeats one alert.
110let notifiedFor = 0
111let autoOpenedFor = 0
112let lastBeatJson = ''
113let lastBeatWrite = 0
114const BEAT_REFRESH_MS = 5000
115const HISTORY_KEYS = 300
116// Calls that waited on a permission prompt: their duration is not a run time.
117const waitedCalls = new Set<string>()
118// When each of those calls started waiting, for the history's wait time.
119const waitStarts = new Map<string, number>()
120
121// ── Plan sources ──
122
123// Plan file and ledger text last parsed, by plan path, so unchanged files are skipped.
124const planTexts = new Map<string, string>()
125// The plan mode file named by the latest plan_mode attachment.
126let planModePath: string | undefined
127
128async function readOr($: $, path: string): Promise<string> {
129 try {
130 return await $.fs.read(path)
131 } catch {
132 return ''
133 }
134}
135
136// Parse a plan file (and its ledger) into the snapshot's plan. `force` re-parses unchanged text.
137async function loadPlanFile($: $, path: string, source: 'file' | 'planmode', force: boolean): Promise<void> {
138 const text = await readOr($, path)
139 if (!text) return
140 const ledger = source === 'file' ? await readOr($, ledgerPath(path)) : ''
141 const key = `${text}\u0000${ledger}`
142 const current = (await read($, snap)).plan
143 if (!force && current?.path === path && planTexts.get(path) === key) return
144 const parsed = parsePlanFile(text)
145 if (!parsed) return
146 planTexts.set(path, key)
147 const done = ledger ? parseLedger(ledger, path) : new Set<number>()
148 const now = await $.clock.now()
149 const wasPath = current?.path
150 await update($, snap, s => {
151 const prev = s.plan && s.plan.path === path ? s.plan : undefined
152 return { ...s, plan: settle(planFromFile(parsed, { path, source, done, now, prev, branch: s.branch, prompt: s.goal }), now) }
153 })
154 await savePlan($)
155 if (wasPath !== path) await logEvent($, 'plan', `plan loaded: ${parsed.title || 'plan'} (${parsed.steps.length} steps)`)
156 await notePlanFinished($)
157}
158
159async function refreshPlanFile($: $): Promise<void> {
160 const s = await read($, snap)
161 if (s.plan && isPersistent(s.plan) && s.plan.path) await loadPlanFile($, s.plan.path, s.plan.source as 'file' | 'planmode', false)
162}
163
164async function savePlan($: $): Promise<void> {
165 const s = await read($, snap)
166 if (s.sessionId && s.plan && isPersistent(s.plan)) await $.store.set(`plan:${s.sessionId}`, s.plan)
167}
168
169// ── Changes, checks, badge, open menu ──
170
171let editsSinceRefresh = false
172// The finished plan already logged, so its event is written once.
173let finishedLogged = ''
174
175async function notePlanFinished($: $): Promise<void> {
176 const plan = (await read($, snap)).plan
177 if (!plan || !plan.endedAt || plan.source === 'prompt' || plan.steps.length === 0) return
178 const id = `${plan.path ?? plan.title}:${plan.startedAt}`
179 if (finishedLogged === id) return
180 finishedLogged = id
181 await logEvent($, 'plan', `plan finished · ${plan.title} · ${plan.steps.length} steps · ${time(plan.endedAt - plan.startedAt, 'human')}`)
182}
183let lastBadge = -1
184
185async function setGitBase($: $): Promise<void> {
186 const cwd = (await read($, snap)).cwd || (await $.session.cwd())
187 const run = await $.process.run(['git', 'rev-parse', '--show-toplevel', 'HEAD'], { cwd, timeoutMs: 2000 }).catch(() => undefined)
188 const lines = run && run.exitCode === 0 ? run.stdout.split('\n').map(l => l.trim()).filter(Boolean) : []
189 const base = lines[lines.length - 1]
190 const root = lines.length > 1 ? lines[0] : cwd
191 await update($, snap, s => ({ ...s, health: { ...s.health, gitBase: base || undefined, gitRoot: base ? root : undefined, changes: base ? s.health.changes : [] } }))
192}
193
194// Diff against the session's starting commit; a run over 2s is dropped and retried later.
195async function refreshChanges($: $): Promise<void> {
196 const s = await read($, snap)
197 const base = s.health.gitBase
198 if (!base) return
199 const cwd = s.cwd || (await $.session.cwd())
200 const numstat = await $.process.run(['git', 'diff', '--numstat', base], { cwd, timeoutMs: 2000 }).catch(() => undefined)
201 const others = await $.process.run(['git', 'ls-files', '--others', '--exclude-standard', '--full-name'], { cwd, timeoutMs: 2000 }).catch(() => undefined)
202 if (!numstat || numstat.exitCode !== 0) return
203 const edited = new Map<string, number>()
204 let turn = 0
205 for (const e of s.history ?? []) {
206 if (e.kind !== 'turn') continue
207 turn += 1
208 for (const f of e.files) edited.set(f, turn)
209 }
210 // numstat and --full-name paths are relative to the repo's top, not the session's cwd.
211 const changes = changesFromGit(numstat.stdout, others && others.exitCode === 0 ? others.stdout : '', edited, s.health.gitRoot ?? cwd)
212 const now = await $.clock.now()
213 await update($, snap, x => ({ ...x, health: { ...x.health, changes, changesAt: now } }))
214 editsSinceRefresh = false
215}
216
217async function saveChecks($: $): Promise<void> {
218 const s = await read($, snap)
219 if (s.sessionId) await $.store.set(`checks:${s.sessionId}`, s.health.checks)
220}
221
222async function restoreChecks($: $, sessionId: string): Promise<void> {
223 const stored = (await $.store.get(`checks:${sessionId}`)) as WatchSnapshot['health']['checks'] | undefined
224 if (!stored || typeof stored !== 'object') return
225 await update($, snap, s => (Object.keys(s.health.checks ?? {}).length ? s : { ...s, health: { ...s.health, checks: stored } }))
226}
227
228// Things the attention card would show: needs-you, a failing test or a stop error this turn.
229function attentionCount(s: WatchSnapshot): number {
230 const since = s.turnStartedAt ?? Number.POSITIVE_INFINITY
231 if (s.health.needsYou) return 1
232 if (s.health.lastTest && !s.health.lastTest.ok && s.health.lastTest.at >= since) return 1
233 if (s.health.lastError && s.health.lastError.at >= since) return 1
234 return 0
235}
236
237// Pane tab title "Watcher · N" while something needs the person; only for an open pane, only on change.
238async function updateBadge($: $): Promise<void> {
239 const count = attentionCount(await read($, snap))
240 if (count === lastBadge) return
241 if (!(await isPaneOpen($))) return
242 lastBadge = count
243 await $.ui.open({ id: S.PANE, title: count ? `${TITLE} · ${count}` : TITLE })
244}
245
246async function openPick($: $, value: string, surface: string): Promise<void> {
247 if (value === 'clear-plan') return dismissPlan($)
248 if (value === 'clear-history') {
249 await update($, snap, x => ({ ...x, history: [] }))
250 return saveLog($)
251 }
252 const s = await read($, snap)
253 const cwd = s.cwd || (await $.session.cwd())
254 const failing = failingLocation(s.health.lastTest && !s.health.lastTest.ok ? s.health.lastTest.snippet : undefined, cwd)
255 const option = openOptions({ planPath: s.plan?.path, failing, changes: s.health.changes, sessionId: s.sessionId }).find(o => o.value === value)
256 if (!option) return
257 const copy = async (text: string, what: string) => {
258 const res = await $.ui.copy({ text, surface: surface as never }).catch(() => ({ isCopied: false }))
259 $.ui.toast(res.isCopied ? `Copied ${what}: ${text}` : text)
260 }
261 if (option.action.kind === 'copy') return copy(option.action.text, 'command')
262 const root = (s.health.gitRoot ?? cwd).replace(/\/+$/, '')
263 const path = option.action.path.startsWith('/') ? option.action.path : `${root}/${option.action.path}`
264 const target = option.action.line ? `${path}:${option.action.line}` : path
265 const run = await $.process.run(['code', '-g', target], { cwd, timeoutMs: 3000 }).catch(() => undefined)
266 if (!run || run.exitCode !== 0) await copy(path, 'path')
267}
268
269async function saveLog($: $): Promise<void> {
270 const s = await read($, snap)
271 if (s.sessionId) await $.store.set(`log:${s.sessionId}`, s.history)
272}
273
274async function restoreLog($: $, sessionId: string): Promise<void> {
275 const stored = (await $.store.get(`log:${sessionId}`)) as WatchSnapshot['history'] | undefined
276 if (!Array.isArray(stored)) return
277 await update($, snap, s => (s.history.length ? s : { ...s, history: stored }))
278}
279
280async function logEvent($: $, icon: 'compact' | 'model' | 'error' | 'plan', text: string): Promise<void> {
281 const now = await $.clock.now()
282 await update($, snap, s => ({ ...s, history: H.addEvent(s.history ?? [], now, icon, text) }))
283 await saveLog($)
284}
285
286// Drop the plan; reading or editing its file again brings it back.
287async function dismissPlan($: $): Promise<void> {
288 const s = await read($, snap)
289 if (s.plan?.path) planTexts.delete(s.plan.path)
290 await update($, snap, x => ({ ...x, plan: null }))
291 if (s.sessionId) await $.store.delete(`plan:${s.sessionId}`)
292}
293
294async function restorePlan($: $, sessionId: string): Promise<void> {
295 const stored = (await $.store.get(`plan:${sessionId}`)) as WatchPlan | undefined
296 if (!stored || !Array.isArray(stored.steps)) return
297 await update($, snap, s => (s.plan ? s : { ...s, plan: stored }))
298}
299
300async function guard(what: string, $: $, fn: () => Promise<unknown>) {
301 try {
302 await fn()
303 } catch (err) {
304 $.ui.log(`watcher: ${what} failed: ${err instanceof Error ? err.message : String(err)}`)
305 }
306}
307
308// Once a day, ask GitHub for the latest release; toast once per new version.
309async function checkUpdate($: $) {
310 const now = await $.clock.now()
311 const checkedAt = Number((await $.store.get('updateCheckedAt')) ?? 0)
312 let latest = (await $.store.get('updateLatest')) as string | undefined
313 if (now - checkedAt >= U.CHECK_EVERY_MS) {
314 await $.store.set('updateCheckedAt', now)
315 const res = await $.http.fetch(U.RELEASES_URL, { headers: { accept: 'application/vnd.github+json' } })
316 if (res.ok) latest = U.latestTag(res.text) ?? latest
317 if (latest) await $.store.set('updateLatest', latest)
318 }
319 if (!latest) return
320 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }
321 if (!manifest.version || !U.isNewer(latest, manifest.version)) return
322 if ((await $.store.get('updateNotified')) === latest) return
323 await $.store.set('updateNotified', latest)
324 $.ui.toast(`Claude Watcher ${latest} is out (you have ${manifest.version}): ${U.UPDATE_HINT}`)
325}
326
327async function isPaneOpen($: $) {
328 return (await $.ui.panes()).some(p => p.id === S.PANE)
329}
330
331async function tickOnce($: $) {
332 const s = await read($, snap)
333 const cfg = await read($, config)
334 const open = await isPaneOpen($)
335 await update($, ui, u => ({ ...u, tick: u.tick + 1 }))
336 const u = await read($, ui)
337 if (open && cfg.sessions.shareState && (u.tab === 'sessions' || u.tick % 5 === 0)) {
338 const now = await $.clock.now()
339 const list = await readPeers($, now)
340 await update($, peers, () => list)
341 }
342 const active = s.turnStartedAt !== null && s.turnEndedAt === null
343 const now = await $.clock.now()
344 const listed = await $.agent.list().catch(() => null)
345 const seen = (listed ?? []).map(a => ({ id: a.id, label: a.description || a.name || a.type, status: a.status }))
346 // An empty list is news too (every agent finished); a failed call is not.
347 if (listed && (seen.length || s.agents.some(a => a.phase === 'running'))) {
348 await update($, snap, x => {
349 const before = new Set(x.agents.map(a => a.id))
350 const y = S.syncAgents(x, seen, now)
351 let plan = y.plan
352 for (const a of y.agents) if (!before.has(a.id) && plan) plan = attribute(plan, { kind: 'agent', id: a.id })
353 return plan === y.plan ? y : { ...y, plan }
354 })
355 }
356 if (active && isPersistent(s.plan) && u.tick % 5 === 0) await refreshPlanFile($)
357 if (active && editsSinceRefresh && u.tick % 5 === 0) await refreshChanges($)
358 await updateBadge($)
359 const agentsRunning = seen.some(a => a.status === 'running' || a.status === 'pending' || a.status === 'waiting')
360 const waiting = s.health.needsYou
361 if (waiting && notifiedFor !== waiting.since && now - waiting.since >= cfg.attention.delaySeconds * 1000) {
362 notifiedFor = waiting.since
363 await alert($, needsYouText(s, now), true)
364 }
365 if (
366 active &&
367 !open &&
368 cfg.autoOpen === 'onLongTurn' &&
369 s.turnStartedAt !== null &&
370 autoOpenedFor !== s.turnStartedAt &&
371 now - s.turnStartedAt >= cfg.longTurnSeconds * 1000
372 ) {
373 autoOpenedFor = s.turnStartedAt
374 const opened = await openPane($)
375 if (!opened.isPlaced && !(await $.store.get('hintedNarrow'))) {
376 await $.store.set('hintedNarrow', true)
377 $.ui.toast('Claude Watcher: the terminal is too narrow to dock the pane. Type /watch to open it.')
378 }
379 }
380 if (!active && !open && !waiting && !agentsRunning && !cfg.idleTimer) stopTicker()
381}
382
383// One 1s timer, created in session.start: timers started inside a short
384// dispatch (turn.start, command.run) do not reliably outlive it. While idle
385// with the pane closed the timer does nothing, so no state is written.
386let isTicking = false
387
388function installTicker($: $) {
389 stopTick?.cancel()
390 stopTick = $.clock.every(1000, () => {
391 if (isTicking) void tickOnce($).catch(() => undefined)
392 })
393}
394
395function startTicker(_$: $) {
396 isTicking = true
397}
398
399function stopTicker() {
400 isTicking = false
401}
402
403async function beat($: $, endedAt?: number) {
404 const cfg = await read($, config)
405 if (!cfg.sessions.shareState) return
406 const s = await read($, snap)
407 const now = await $.clock.now()
408 const history = await read($, estimates)
409 const peer = heartbeatOf(s, now, summary({ s, cfg, history, now }) || undefined, endedAt)
410 // Write on change, and every few seconds so readers can tell the session is alive.
411 const { beatAt: _beatAt, ...body } = peer
412 const json = JSON.stringify(body)
413 if (!endedAt && json === lastBeatJson && now - lastBeatWrite < BEAT_REFRESH_MS) return
414 lastBeatJson = json
415 lastBeatWrite = now
416 await writeBeat($, peer)
417}
418
419async function startBeat($: $) {
420 const cfg = await read($, config)
421 if (stopBeat || !cfg.sessions.shareState) return
422 stopBeat = $.clock.every(Math.max(250, cfg.sessions.heartbeatMs), () => void beat($).catch(() => undefined))
423}
424
425function stopBeating() {
426 stopBeat?.cancel()
427 stopBeat = undefined
428}
429
430async function refreshGit($: $) {
431 const cfg = await read($, config)
432 if (!cfg.health.gitStatus) return
433 const cwd = (await read($, snap)).cwd || (await $.session.cwd())
434 const run = await $.process.run(['git', 'status', '--porcelain', '-b'], { cwd, timeoutMs: 5000 })
435 if (run.exitCode !== 0) return
436 const git = parseGitStatus(run.stdout)
437 await update($, snap, s => ({ ...s, branch: git.branch, health: { ...s.health, dirtyFiles: git.dirty } }))
438}
439
440async function openPane($: $, tab?: WatchTab) {
441 if (tab) await setTab($, tab)
442 const opened = await $.ui.open({ id: S.PANE, title: TITLE })
443 startTicker($)
444 // The open just reset the tab title; put the badge back.
445 lastBadge = -1
446 await updateBadge($)
447 return opened
448}
449
450async function setNeedsYou($: $, needsYou: WatchNeedsYou) {
451 await update($, snap, s => ({
452 ...s,
453 phase: needsYou.kind === 'permission' ? 'waiting_permission' : needsYou.kind === 'question' ? 'waiting_question' : s.phase,
454 health: { ...s.health, needsYou },
455 }))
456 const s = await read($, snap)
457 if (needsYou.kind === 'permission' && s.current) {
458 waitedCalls.add(s.current.id)
459 if (!waitStarts.has(s.current.id)) waitStarts.set(s.current.id, needsYou.since)
460 }
461 // The band shows at once; the toast and notification wait for attention.delaySeconds (see tickOnce).
462 if ((await read($, config)).attention.delaySeconds <= 0) {
463 notifiedFor = needsYou.since
464 await alert($, needsYouText(s, needsYou.since), true)
465 }
466 startTicker($)
467 await beat($)
468}
469
470async function alert($: $, text: string, urgent: boolean) {
471 const cfg = await read($, config)
472 const u = await read($, ui)
473 if ((await $.clock.now()) < u.mutedUntil) return
474 if (urgent && cfg.attention.toast) $.ui.toast(text)
475 // Focus is not exposed to mods, so `whenUnfocused` behaves like `always`.
476 if (cfg.attention.desktopNotify !== 'never') await $.ui.notify(text, { title: 'Claude Watcher' }).catch(() => undefined)
477}
478
479async function kitFor($: $, e: { surface: string; props: { bodyColumns: number; scroll?: { bodyRows?: number } } }, els: Kit['els']): Promise<Kit> {
480 const columns = e.props.bodyColumns
481 return { els, surface: e.surface, cfg: await read($, config), columns, rows: e.props.scroll?.bodyRows }
482}
483
484
485export const register: Register = on => {
486 on('session.start', async ($, e, next) => {
487 const result = await next(e)
488 await guard('session start', $, async () => {
489 const sessionId = await $.session.id()
490 const model = await $.session.model().catch(() => undefined)
491 await loadConfig($, e.cwd)
492 await loadHistory($)
493 const prev = await read($, snap)
494 // A hot reload keeps $.state: carry the snapshot on for the same session.
495 await update($, snap, () => (prev.sessionId === sessionId ? { ...S.normalizeSnap(prev), cwd: e.cwd, model } : { ...S.EMPTY_SNAP, sessionId, cwd: e.cwd, model }))
496 await restorePlan($, sessionId)
497 await restoreLog($, sessionId)
498 await restoreChecks($, sessionId)
499 await setGitBase($).catch(() => undefined)
500 const cfg = await read($, config)
501 await update($, ui, u => ({ ...u, expanded: u.expanded ?? [], historyFilter: u.historyFilter ?? 'all', tab: prev.sessionId === sessionId ? u.tab : cfg.defaultTab }))
502 await $.command.register({
503 name: 'watch',
504 description: 'Claude Watcher: toggle the dashboard, or /watch <tab|config|reset|export|mute|sessions>',
505 argumentHint: '[turn|plan|agents|calls|history|sessions|health|config|clear [history|plan|checks]|reset|export|mute <30m>]',
506 })
507 await refreshGit($).catch(() => undefined)
508 await startBeat($)
509 await beat($)
510 installTicker($)
511 if (await isPaneOpen($)) startTicker($)
512 await checkUpdate($).catch(() => undefined)
513 })
514 return result
515 })
516
517 on('session.end', async ($, e, next) => {
518 await guard('session end', $, async () => {
519 if (e.reason === 'clear') {
520 await update($, snap, s => S.resetTurn(s))
521 await beat($)
522 } else {
523 await beat($, await $.clock.now())
524 stopBeating()
525 stopTicker()
526 }
527 })
528 return next(e)
529 })
530
531 on('classic.SessionStart', async ($, e, next) => {
532 await guard('classic session start', $, async () => {
533 const source = (e as { source?: string }).source
534 if (source === 'clear' || source === 'resume' || source === 'compact') {
535 const sessionId = await $.session.id()
536 await update($, snap, s => ({ ...S.resetTurn(s), sessionId }))
537 if (source === 'clear') await setGitBase($).catch(() => undefined)
538 await loadHistory($)
539 await startBeat($)
540 }
541 })
542 return next(e)
543 })
544
545 on('turn.start', async ($, e, next) => {
546 if (e.agentId) return next(e)
547 await guard('turn start', $, async () => {
548 const now = await $.clock.now()
549 const goal = truncate(e.text.split('\n').find(l => l.trim()) ?? '', 80) || undefined
550 await update($, snap, s => {
551 const started = { ...S.startTurn(s, now, goal), history: H.startTurnEntry(s.history ?? [], e.turnId, now, e.text) }
552 // A finished plan gives way to the new turn; an unfinished file plan stays.
553 return isPersistent(s.plan) && !s.plan?.endedAt ? started : { ...started, plan: promptPlan(e.text, now, s.branch) }
554 })
555 const cfg = await read($, config)
556 startTicker($)
557 await startBeat($)
558 // `onLongTurn` is handled by the 1s ticker.
559 if (cfg.autoOpen === 'always') await openPane($)
560 await refreshGit($).catch(() => undefined)
561 })
562 return next(e)
563 })
564
565 on('turn.complete', async ($, e, next) => {
566 if (e.agentId) return next(e)
567 await guard('turn complete', $, async () => {
568 const now = await $.clock.now()
569 const failed = e.reason === 'error'
570 await update($, snap, s => {
571 const ended = S.endTurn(s, now, failed)
572 const pct = ended.health.contextPct
573 const contextHistory = pct === undefined ? ended.health.contextHistory : [...ended.health.contextHistory, Math.round(pct)].slice(-20)
574 const plan = ended.plan ? settle(ended.plan, now) : null
575 const since = s.turnStartedAt ?? now
576 let history = ended.history ?? []
577 // Only steps this turn started or finished; a step running across turns branches once.
578 if (plan) history = H.noteSteps(history, plan.steps.filter(x => (x.startedAt ?? -1) >= since || (x.endedAt ?? -1) >= since))
579 for (const a of ended.agents.filter(x => x.startedAt >= since)) history = H.addAgent(history, a.type, a.phase !== 'failed', (a.endedAt ?? now) - a.startedAt)
580 // Snapshot what the dashboard shows now, for replaying this turn later.
581 const testRuns = checkRows(ended.health.checks ?? {}).find(c => c.kind === 'test')
582 history = H.setSnapshot(history, {
583 plan: plan && plan.steps.length ? { title: plan.title, ticket: plan.ticket, steps: plan.steps.map(x => ({ title: x.title, status: x.status, ms: x.startedAt !== undefined ? (x.endedAt ?? now) - x.startedAt : undefined })) } : undefined,
584 contextPct: ended.health.contextPct,
585 changes: ended.health.gitBase ? changesTotal(ended.health.changes ?? []) : undefined,
586 tests: testRuns ? testRuns.runs.slice(-5).map(r => r.ok) : undefined,
587 })
588 history = H.endTurnEntry(history, now, failed)
589 return { ...ended, plan, history, health: { ...ended.health, contextHistory } }
590 })
591 await savePlan($)
592 await notePlanFinished($)
593 await saveLog($)
594 await refreshChanges($).catch(() => undefined)
595 await updateBadge($)
596 await saveHistory($)
597 await beat($)
598 const cfg = await read($, config)
599 if (e.durationMs >= cfg.attention.notifyOnTurnEndAfterSeconds * 1000) {
600 await alert($, `Claude finished after ${time(e.durationMs, cfg.timeFormat)}${failed ? ' (error)' : ''}`, false)
601 }
602 await refreshGit($).catch(() => undefined)
603 })
604 return next(e)
605 })
606
607 on('tool.call', async ($, e, next) => {
608 let call: WatchCall | undefined
609 await guard('tool start', $, async () => {
610 const cfg = await read($, config)
611 const s = await read($, snap)
612 const input = e as unknown as Record<string, unknown>
613 const now = await $.clock.now()
614 const key = normalizeKey(e.tool, input, s.cwd, cfg)
615 const timeout = typeof input.timeout === 'number' ? input.timeout : undefined
616 call = {
617 id: e.tool_use_id,
618 tool: e.tool,
619 label: toolLabel(e.tool, input, cfg),
620 key: key ?? '',
621 kindKey: key ? `${cfg.estimate.perProject ? `${s.cwd}::` : ''}kind:${stepKind(e.tool, input)}` : undefined,
622 startedAt: now,
623 agentId: e.agentId,
624 note: timeout ? `timeout=${timeout}` : undefined,
625 }
626 const started = call
627 await update($, snap, x => S.startCall(x, started))
628 if (e.tool === 'TodoWrite' && Array.isArray(input.todos)) {
629 const todos = input.todos as { content?: unknown; status?: unknown }[]
630 await update($, snap, x => ({ ...x, plan: mergeTodos(x.plan, todos, now, x.goal) }))
631 }
632 if (e.tool === 'AskUserQuestion' || e.tool === 'Agent') startTicker($)
633 })
634 const ran = await next(e)
635 await guard('tool end', $, async () => {
636 if (!call) return
637 const cfg = await read($, config)
638 const now = await $.clock.now()
639 const durationMs = now - call.startedAt
640 const ok = ran.deny === undefined && ran.isError !== true
641 const output = outputText(ran)
642 const exitCode = exitCodeOf(ran)
643 let note: string | undefined = ran.deny !== undefined ? 'denied' : undefined
644 const input = e as unknown as Record<string, unknown>
645 const patch: Partial<WatchSnapshot['health']> = {}
646 if (e.tool === 'Bash' && typeof input.command === 'string') {
647 const kind = classifyCommand(input.command)
648 if (kind === 'test' && cfg.health.parseTestOutput) {
649 const parsed = parseTestOutput(input.command, output, exitCode ?? (ok ? 0 : 1))
650 if (parsed) {
651 patch.lastTest = { ...parsed, at: now, command: input.command }
652 if (parsed.failed) note = `${parsed.failed} failed`
653 }
654 } else if (kind === 'lint') {
655 patch.lastLint = { ok, at: now, command: input.command }
656 } else if (kind === 'build') {
657 patch.lastBuild = { ok, at: now, command: input.command }
658 }
659 const key = kind ? checkKey(input.command, (await read($, snap)).cwd) : ''
660 if (key) {
661 const label = call.label
662 const t = patch.lastTest
663 const turns = ((await read($, snap)).history ?? []).filter(x => x.kind === 'turn').length
664 const runOk = t ? t.ok : ok
665 await update($, snap, x => ({ ...x, health: { ...x.health, checks: recordCheck(x.health.checks ?? {}, key, label, { ok: runOk, at: now, passed: t?.passed, failed: t?.failed, snippet: t?.snippet, turn: turns }) } }))
666 await saveChecks($)
667 }
668 }
669 const filePath = typeof input.file_path === 'string' ? input.file_path : undefined
670 const edited = ok && filePath && /^(Edit|Write|MultiEdit|NotebookEdit)$/.test(e.tool) ? filePath : undefined
671 const done: WatchCall = { ...call, durationMs, ok, note: note ?? (call.note?.startsWith('timeout=') ? undefined : call.note) }
672 await update($, snap, s => {
673 const ended = S.endCall(s, done, cfg.showRecentCalls)
674 return {
675 ...ended,
676 health: {
677 ...ended.health,
678 ...patch,
679 editedFiles: edited && !ended.health.editedFiles.includes(edited) ? [...ended.health.editedFiles, edited] : ended.health.editedFiles,
680 },
681 }
682 })
683 // Credit the call, its edit and its test to the step running now, and to the turn's history.
684 const testResult = patch.lastTest
685 const waitedFrom = waitStarts.get(call.id)
686 waitStarts.delete(call.id)
687 const mainLoop = !call.agentId
688 await update($, snap, s => {
689 let history = s.history ?? []
690 if (mainLoop) history = H.addCall(history, done)
691 if (edited) history = H.addFile(history, edited)
692 if (testResult) history = H.setTest(history, testResult)
693 if (waitedFrom !== undefined) history = H.addWait(history, now - waitedFrom)
694 return { ...s, history }
695 })
696 await update($, snap, s => {
697 if (!s.plan) return s
698 let plan = attribute(s.plan, { kind: 'call' })
699 if (edited) plan = attribute(plan, { kind: 'edit', file: edited })
700 if (testResult) plan = attribute(plan, { kind: 'test', result: testResult })
701 return { ...s, plan: settle(plan, now) }
702 })
703 if (edited) editsSinceRefresh = true
704 await notePlanFinished($)
705 await updateBadge($)
706 if (ok && filePath && /^(Read|Edit|Write|MultiEdit)$/.test(e.tool)) {
707 const looksLikePlan = isPlanPath(filePath) || (e.tool === 'Read' && /Implementation Plan\s*$/m.test(output.split('\n').slice(0, 3).join('\n')))
708 // Editing a plan makes it the plan. Reading one only does when no unfinished file plan is active,
709 // so a plan read for reference does not replace the one being executed.
710 const active = (await read($, snap)).plan
711 const takeOver = e.tool !== 'Read' || !isPersistent(active) || !!active?.endedAt || active?.path === filePath
712 if (looksLikePlan && takeOver) await loadPlanFile($, filePath, 'file', e.tool !== 'Read')
713 }
714 if (ok && e.tool === 'ExitPlanMode' && planModePath) await loadPlanFile($, planModePath, 'planmode', true)
715 if (call.key && ok && cfg.estimate.enabled) {
716 const key = call.key
717 if (!waitedCalls.has(call.id)) {
718 const kindKey = call.kindKey
719 await update($, estimates, h => {
720 const withKey = remember(h, key, durationMs, cfg.estimate.historySize, HISTORY_KEYS)
721 return kindKey ? remember(withKey, kindKey, durationMs, cfg.estimate.historySize, HISTORY_KEYS) : withKey
722 })
723 }
724 waitedCalls.delete(call.id)
725 }
726 })
727 return ran
728 })
729
730 on('classic.Notification', async ($, e, next) => {
731 await guard('notification', $, async () => {
732 const now = await $.clock.now()
733 const kind =
734 e.notification_type === 'permission_prompt'
735 ? 'permission'
736 : e.notification_type.startsWith('elicitation')
737 ? 'elicitation'
738 : e.notification_type === 'idle_prompt'
739 ? 'idle'
740 : undefined
741 if (kind) await setNeedsYou($, { kind, since: now, text: e.message })
742 await updateBadge($)
743 if (e.effort?.level) await update($, snap, s => ({ ...s, effort: e.effort?.level }))
744 })
745 return next(e)
746 })
747
748 on('prompt.submit', async ($, e, next) => {
749 await guard('prompt submit', $, async () => {
750 await update($, snap, s => (s.health.needsYou ? { ...s, health: { ...s.health, needsYou: null } } : s))
751 await updateBadge($)
752 })
753 return next(e)
754 })
755
756 on('classic.SubagentStart', async ($, e, next) => {
757 await guard('subagent start', $, async () => {
758 const now = await $.clock.now()
759 await update($, snap, s => S.agentStart(s, e.agent_id, e.agent_type || 'agent', now))
760 })
761 return next(e)
762 })
763
764 on('classic.SubagentStop', async ($, e, next) => {
765 await guard('subagent stop', $, async () => {
766 const now = await $.clock.now()
767 await update($, snap, s => S.agentStop(s, e.agent_id, 'done', now))
768 })
769 return next(e)
770 })
771
772 on('classic.TeammateIdle', async ($, e, next) => {
773 await guard('teammate idle', $, async () => {
774 const now = await $.clock.now()
775 const id = `teammate:${e.team_name}:${e.teammate_name}`
776 await update($, snap, s => {
777 const known = s.agents.some(a => a.id === id)
778 const withLane = known ? s : S.agentStart(s, id, e.teammate_name, now)
779 return S.agentStop(withLane, id, 'idle', now)
780 })
781 })
782 return next(e)
783 })
784
785 on('classic.TaskCreated', async ($, e, next) => {
786 await guard('task created', $, async () => {
787 const now = await $.clock.now()
788 await update($, snap, s => ({ ...s, plan: taskEvent(s.plan, { id: e.task_id, title: e.task_subject, done: false }, now) }))
789 })
790 return next(e)
791 })
792
793 on('classic.TaskCompleted', async ($, e, next) => {
794 await guard('task completed', $, async () => {
795 const now = await $.clock.now()
796 await update($, snap, s => ({ ...s, plan: taskEvent(s.plan, { id: e.task_id, title: e.task_subject, done: true }, now) }))
797 })
798 return next(e)
799 })
800
801 // Plan mode names its plan file in an attachment; ExitPlanMode (tool end) loads it.
802 on('prompt.attachment', { type: 'plan_mode' }, async ($, e, next) => {
803 const path = (e as { detail?: { planFilePath?: unknown } }).detail?.planFilePath
804 if (typeof path === 'string') planModePath = path
805 return next(e)
806 })
807
808 on('prompt.attachment', { type: 'plan_mode_reentry' }, async ($, e, next) => {
809 const path = (e as { detail?: { planFilePath?: unknown } }).detail?.planFilePath
810 if (typeof path === 'string') planModePath = path
811 return next(e)
812 })
813
814 on('classic.PreCompact', async ($, e, next) => {
815 await guard('pre compact', $, async () => {
816 const now = await $.clock.now()
817 await update($, snap, s => ({ ...s, health: { ...s.health, compactions: s.health.compactions + 1, lastCompactAt: now } }))
818 const pct = (await read($, snap)).health.contextPct
819 await logEvent($, 'compact', `compacted context${pct !== undefined ? ` at ${Math.round(pct)}%` : ''}`)
820 })
821 return next(e)
822 })
823
824 on('classic.StopFailure', async ($, e, next) => {
825 await guard('stop failure', $, async () => {
826 const now = await $.clock.now()
827 const kind = typeof e.error === 'string' ? e.error : 'error'
828 await update($, snap, s => ({ ...s, phase: 'error' as const, health: { ...s.health, lastError: { kind, text: e.error_details, at: now } } }))
829 await logEvent($, 'error', `Claude stopped: ${kind}`)
830 await alert($, `Claude stopped: ${kind}`, true)
831 })
832 return next(e)
833 })
834
835 on('classic.PostModelSwitch', async ($, e, next) => {
836 await guard('model switch', $, async () => {
837 await update($, snap, s => ({ ...s, model: e.to_model }))
838 await logEvent($, 'model', `model → ${shortModel(e.to_model)}`)
839 })
840 return next(e)
841 })
842
843 on('classic.CwdChanged', async ($, e, next) => {
844 await guard('cwd changed', $, async () => {
845 await update($, snap, s => ({ ...s, cwd: e.new_cwd }))
846 await refreshGit($)
847 })
848 return next(e)
849 })
850
851 on('session.measure', async ($, e, next) => {
852 await guard('measure', $, async () => {
853 const ctx = e.context
854 const pct = ctx.percent ?? (ctx.tokens !== undefined && ctx.window ? (ctx.tokens / ctx.window) * 100 : undefined)
855 const rate = e.rateLimits.length ? Math.max(...e.rateLimits.map(r => r.percentUsed)) : undefined
856 await update($, snap, s => ({
857 ...s,
858 health: { ...s.health, contextPct: pct, contextTokens: ctx.tokens, costUsd: e.cost?.usd ?? s.health.costUsd, rateLimitPct: rate, limits: e.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed, resetsAt: r.resetsAt ? Date.parse(r.resetsAt) || undefined : undefined })) },
859 }))
860 })
861 return next(e)
862 })
863
864 on('command.run', { command: 'watch' }, async ($, e) => {
865 const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
866 const arg = rest.join(' ')
867 if (verb === '') {
868 if (await isPaneOpen($)) {
869 await $.ui.close({ id: S.PANE })
870 return { text: 'Watcher closed.' }
871 }
872 const opened = await openPane($)
873 return { text: opened.isPlaced ? 'Watcher opened.' : 'Watcher will show once there is room for the pane.' }
874 }
875 if ((TABS as readonly string[]).includes(verb) && !(verb === 'config' && rest.length)) {
876 await openPane($, verb as WatchTab)
877 return { text: `Watcher: ${verb} tab.` }
878 }
879 if (verb === 'config') {
880 const [sub, path, ...valueParts] = rest
881 if (sub === 'set' && path) {
882 const before = getPath(await read($, config), path)
883 if (before === undefined) return { text: `Unknown setting: ${path}` }
884 await setConfig($, path, parseValue(valueParts.join(' ')))
885 const after = getPath(await read($, config), path)
886 return { text: JSON.stringify(after) === JSON.stringify(before) && valueParts.join(' ') !== JSON.stringify(before) ? `Rejected value for ${path}; kept ${JSON.stringify(before)}.` : `${path} = ${JSON.stringify(after)}` }
887 }
888 if (sub === 'get' && path) return { text: `${path} = ${JSON.stringify(getPath(await read($, config), path))}` }
889 return { text: 'Usage: /watch config [set <path> <value> | get <path>]' }
890 }
891 if (verb === 'reset') {
892 const what = arg || 'all'
893 if (what === 'history' || what === 'all') await resetHistory($)
894 if (what === 'config' || what === 'all') await resetConfig($)
895 return { text: `Watcher: reset ${what}.` }
896 }
897 if (verb === 'export') {
898 const s = await read($, snap)
899 const json = JSON.stringify(
900 { turnStartedAt: s.turnStartedAt, turnEndedAt: s.turnEndedAt, phase: s.phase, toolCalls: s.toolCalls, recent: s.recent, agents: s.agents, health: s.health, plan: s.plan },
901 null,
902 2,
903 )
904 const copied = await $.ui.copy({ text: json }).catch(() => ({ isCopied: false }))
905 return { text: `${copied.isCopied ? 'Copied to clipboard.\n' : ''}\`\`\`json\n${json}\n\`\`\`` }
906 }
907 if (verb === 'clear') {
908 const what = arg || 'all'
909 if (what === 'history' || what === 'all') {
910 await update($, snap, s => ({ ...s, history: [] }))
911 await saveLog($)
912 }
913 if (what === 'plan' || what === 'all') await dismissPlan($)
914 if (what === 'checks' || what === 'all') {
915 await update($, snap, s => ({ ...s, health: { ...s.health, checks: {} } }))
916 await saveChecks($)
917 }
918 return { text: `Watcher: ${what === 'all' ? 'history, plan and checks' : what} cleared.` }
919 }
920 if (verb === 'mute') {
921 const ms = parseDuration(arg || '30m')
922 if (ms === undefined) return { text: 'Usage: /watch mute 30m' }
923 const until = (await $.clock.now()) + ms
924 await update($, ui, u => ({ ...u, mutedUntil: until }))
925 return { text: `Watcher alerts muted for ${time(ms, 'human')}.` }
926 }
927 if (verb === 'unmute') {
928 await update($, ui, u => ({ ...u, mutedUntil: 0 }))
929 return { text: 'Watcher alerts on.' }
930 }
931 return { text: 'Usage: /watch [turn|plan|agents|sessions|health|config|reset [history|config|all]|export|mute 30m|unmute]' }
932 })
933
934 on('ui.render', { component: 'Pane', requestId: 'watcher' }, async ($, e) => {
935 const els = $.ui.resolve(e) as unknown as Kit['els']
936 const k = await kitFor($, e, els)
937 const view: View = {
938 k,
939 s: S.normalizeSnap(await read($, snap)),
940 ui: await read($, ui),
941 peers: await read($, peers),
942 history: await read($, estimates),
943 now: await $.clock.now(),
944 actions: {
945 tab: tab => {
946 void (async () => {
947 await setTab($, tab)
948 })()
949 },
950 scope: scope => void update($, ui, u => ({ ...u, scope })),
951 toggle: id =>
952 void update($, ui, u => {
953 const open = u.expanded ?? []
954 return { ...u, expanded: open.includes(id) ? open.filter(x => x !== id) : [...open, id].slice(-50) }
955 }),
956 clearHistory: () =>
957 void (async () => {
958 await update($, snap, s => ({ ...s, history: [] }))
959 await saveLog($)
960 })(),
961 dismissPlan: () => void dismissPlan($),
962 pick: value => void openPick($, value, e.surface),
963 toggleHistory: () => void update($, ui, (u): WatchUi => ({ ...u, historyCollapsed: !u.historyCollapsed })),
964 turnPick: value =>
965 void update($, ui, (u): WatchUi =>
966 value === 'more'
967 ? { ...u, historyLimit: (u.historyLimit ?? HISTORY_PAGE) + HISTORY_PAGE }
968 : value === 'less'
969 ? { ...u, historyLimit: HISTORY_PAGE }
970 : value === 'collapse' || value === 'expand'
971 ? { ...u, historyCollapsed: value === 'collapse' }
972 : value
973 ? { ...u, replay: value, tab: 'replay' }
974 : u,
975 ),
976 filter: value => void update($, ui, (u): WatchUi => ({ ...u, historyFilter: value === 'turns' || value === 'problems' ? value : 'all' })),
977 cycleFilter: () =>
978 void update($, ui, (u): WatchUi => ({ ...u, historyFilter: u.historyFilter === 'all' ? 'turns' : u.historyFilter === 'turns' ? 'problems' : 'all' })),
979 set: (path, value) => void setConfig($, path, value),
980 copy: text => {
981 void (async () => {
982 const res = await $.ui.copy({ text, surface: e.surface }).catch(() => ({ isCopied: false }))
983 $.ui.toast(res.isCopied ? `Copied: ${text}` : text)
984 })()
985 },
986 },
987 }
988 return Pane(view)
989 })
990
991 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
992 const cfg = await read($, config)
993 if (!cfg.spinnerSuffix) return next(e)
994 const s = await read($, snap)
995 // Subscribe to the tick so elapsed time advances.
996 await read($, ui)
997 const now = await $.clock.now()
998 const line = summary({ s, cfg, history: await read($, estimates), now, short: true })
999 if (!line) return next(e)
1000 const suffix = e.props.suffix ? `${e.props.suffix} · ${line}` : line
1001 return next({ ...e, props: { ...e.props, suffix } })
1002 })
1003
1004 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1005 const cfg = await read($, config)
1006 const s = await read($, snap)
1007 const u = await read($, ui)
1008 const now = await $.clock.now()
1009 if (!cfg.attention.band || e.props.hasSurvey || !s.health.needsYou || now < u.mutedUntil) return next(e)
1010 const { Box, Text } = $.ui.resolve(e)
1011 const hint = s.health.needsYou.kind === 'permission' ? ' — ↑ to review' : ''
1012 return (
1013 <Box>
1014 <Text color={cfg.colors.warn || 'warning'} bold>
1015 ⏸ {needsYouText(s, now)}
1016 {hint}
1017 </Text>
1018 </Box>
1019 )
1020 })
1021}
1022
1023function outputText(ran: { text?: string; result?: unknown }): string {
1024 if (typeof ran.text === 'string') return ran.text
1025 const r = ran.result as { stdout?: unknown; stderr?: unknown } | undefined
1026 if (r && typeof r === 'object') return [r.stdout, r.stderr].filter(x => typeof x === 'string').join('\n')
1027 return typeof ran.result === 'string' ? ran.result : ''
1028}
1029
1030function exitCodeOf(ran: { result?: unknown; text?: string }): number | undefined {
1031 const r = ran.result as { exitCode?: unknown; returnCode?: unknown } | undefined
1032 if (r && typeof r === 'object') {
1033 if (typeof r.exitCode === 'number') return r.exitCode
1034 if (typeof r.returnCode === 'number') return r.returnCode
1035 }
1036 const m = typeof ran.text === 'string' ? /exit code (\d+)/i.exec(ran.text) : null
1037 return m ? Number(m[1]) : undefined
1038}
1039hooks/config.ts 148 lines1import type { WatchConfig } from '../types'
2
3export const DEFAULTS: WatchConfig = {
4 autoOpen: 'onLongTurn',
5 longTurnSeconds: 20,
6 defaultTab: 'turn',
7 spinnerSuffix: true,
8 spinnerMaxChars: 80,
9 theme: 'default',
10 compact: false,
11 barWidth: 20,
12 barStyle: 'blocks',
13 useSvg: true,
14 useRaster: true,
15 animations: true,
16 visuals: {
17 cards: true,
18 pills: true,
19 gantt: true,
20 sparkline: true,
21 contextDonut: true,
22 heatStrip: true,
23 failedTestSnippet: true,
24 ganttWindowMinutes: 10,
25 },
26 showRecentCalls: 8,
27 timeFormat: 'mmss',
28 // Empty = follow the theme. Set a colour name or #rrggbb to override.
29 colors: { ok: '', warn: '', attention: '', accent: '', dim: '' },
30 estimate: {
31 enabled: true,
32 historySize: 10,
33 minSamples: 3,
34 perProject: true,
35 ignorePatterns: ['sleep *', 'echo *'],
36 },
37 attention: {
38 band: true,
39 toast: true,
40 desktopNotify: 'whenUnfocused',
41 notifyOnTurnEndAfterSeconds: 300,
42 // Toast + desktop notification only when a wait lasts this long; the band shows at once.
43 delaySeconds: 15,
44 sound: false,
45 },
46 sessions: {
47 shareState: true,
48 heartbeatMs: 1000,
49 staleAfterSeconds: 10,
50 hideEndedAfterMinutes: 5,
51 showCwd: 'basename',
52 },
53 health: {
54 contextWarnPct: 70,
55 parseTestOutput: true,
56 gitStatus: true,
57 showCost: true,
58 compactAtPct: 85,
59 },
60 showToolBadge: false,
61 redactCommands: false,
62 maxCommandChars: 120,
63 idleTimer: false,
64}
65
66// Enum settings: path → allowed values. Used by validation and the config tab.
67export const CHOICES: Record<string, readonly string[]> = {
68 autoOpen: ['always', 'onLongTurn', 'never'],
69 defaultTab: ['turn', 'plan', 'agents', 'sessions', 'health'],
70 theme: ['default', 'minimal', 'dense'],
71 barStyle: ['blocks', 'dots', 'ascii'],
72 timeFormat: ['mmss', 'human'],
73 'attention.desktopNotify': ['always', 'whenUnfocused', 'never'],
74 'sessions.showCwd': ['full', 'basename', 'hidden'],
75}
76
77const isObject = (v: unknown): v is Record<string, unknown> =>
78 typeof v === 'object' && v !== null && !Array.isArray(v)
79
80// Deep merge `over` onto `base`, keeping only keys and value types `base` knows.
81function mergeKnown(base: unknown, over: unknown, path: string): unknown {
82 if (isObject(base)) {
83 if (!isObject(over)) return base
84 const out: Record<string, unknown> = {}
85 for (const key of Object.keys(base)) {
86 const child = path ? `${path}.${key}` : key
87 out[key] = key in over ? mergeKnown(base[key], over[key], child) : base[key]
88 }
89 return out
90 }
91 if (Array.isArray(base)) {
92 return Array.isArray(over) && over.every(x => typeof x === 'string') ? over : base
93 }
94 if (typeof over !== typeof base) return base
95 if (typeof over === 'number' && !Number.isFinite(over)) return base
96 const allowed = CHOICES[path]
97 if (allowed && !allowed.includes(over as string)) return base
98 return over
99}
100
101// Precedence: defaults < project file < user store.
102export function mergeConfig(...layers: unknown[]): WatchConfig {
103 let out: unknown = DEFAULTS
104 for (const layer of layers) out = mergeKnown(out, layer, '')
105 return out as WatchConfig
106}
107
108export function getPath(obj: unknown, path: string): unknown {
109 let cur: unknown = obj
110 for (const part of path.split('.')) {
111 if (!isObject(cur)) return undefined
112 cur = cur[part]
113 }
114 return cur
115}
116
117// Returns a sparse override object holding only `path = value`.
118export function patchFor(path: string, value: unknown): Record<string, unknown> {
119 const parts = path.split('.')
120 const root: Record<string, unknown> = {}
121 let cur = root
122 parts.forEach((part, i) => {
123 if (i === parts.length - 1) cur[part] = value
124 else cur = cur[part] = {} as Record<string, unknown>
125 })
126 return root
127}
128
129export function deepMergeRaw(a: unknown, b: unknown): unknown {
130 if (!isObject(a) || !isObject(b)) return b
131 const out: Record<string, unknown> = { ...a }
132 for (const [k, v] of Object.entries(b)) out[k] = deepMergeRaw(a[k], v)
133 return out
134}
135
136// Parse a `/watch config set <path> <value>` value string.
137export function parseValue(raw: string): unknown {
138 const t = raw.trim()
139 if (t === 'true') return true
140 if (t === 'false') return false
141 if (t !== '' && !Number.isNaN(Number(t))) return Number(t)
142 try {
143 return JSON.parse(t)
144 } catch {
145 return t
146 }
147}
148hooks/estimate.ts 102 lines1import type { WatchConfig } from '../types'
2
3// Strip volatile parts (paths after flags, numbers, quoted strings) so repeat runs share a key.
4export function normalizeKey(tool: string, input: Record<string, unknown>, cwd: string, cfg: WatchConfig): string | undefined {
5 let body: string
6 if (tool === 'Bash' && typeof input.command === 'string') {
7 body = input.command
8 .trim()
9 .replace(/\s+/g, ' ')
10 .replace(/(["']).*?\1/g, '"…"')
11 .replace(/\b\d+\b/g, 'N')
12 .split(' ')
13 .slice(0, 4)
14 .join(' ')
15 if (cfg.estimate.ignorePatterns.some(p => globMatch(p, input.command as string))) return undefined
16 } else {
17 body = tool
18 }
19 return cfg.estimate.perProject ? `${cwd}::${body}` : body
20}
21
22// Coarse kind of step, so a new command still gets a typical duration.
23export function stepKind(tool: string, input: Record<string, unknown>): string {
24 if (tool === 'Bash' && typeof input.command === 'string') {
25 const c = input.command.toLowerCase()
26 if (/\b(test|jest|vitest|pytest|mocha|rspec|phpunit)\b/.test(c)) return 'bash:test'
27 if (/\b(lint|eslint|ruff|flake8|clippy|golangci|prettier --check|biome check)\b/.test(c)) return 'bash:lint'
28 if (/\b(npm|pnpm|yarn|bun|pip|poetry|cargo|go) (install|add|i|ci|sync|get)\b/.test(c)) return 'bash:install'
29 if (/\b(build|tsc|webpack|vite build|cargo build|go build|make)\b/.test(c)) return 'bash:build'
30 if (/^\s*git\b/.test(c)) return 'bash:git'
31 return 'bash:other'
32 }
33 if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return 'edit'
34 if (/^(Read|Grep|Glob|LS)$/.test(tool)) return 'read'
35 return tool
36}
37
38export function globMatch(pattern: string, text: string): boolean {
39 const re = new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`)
40 return re.test(text.trim())
41}
42
43export function record(history: number[] | undefined, durationMs: number, size: number): number[] {
44 return [...(history ?? []), Math.round(durationMs)].slice(-Math.max(1, size))
45}
46
47// Add a sample under `key` and keep the store bounded: the key moves to the end
48// (most recent), and the oldest keys beyond `maxKeys` are dropped.
49export function remember(
50 history: Record<string, number[]>,
51 key: string,
52 durationMs: number,
53 size: number,
54 maxKeys: number,
55): Record<string, number[]> {
56 const { [key]: previous, ...rest } = history
57 const keys = Object.keys(rest)
58 const keep = keys.slice(Math.max(0, keys.length - (maxKeys - 1)))
59 const out: Record<string, number[]> = {}
60 for (const k of keep) out[k] = rest[k]!
61 out[key] = record(previous, durationMs, size)
62 return out
63}
64
65export function median(values: readonly number[]): number {
66 if (values.length === 0) return 0
67 const sorted = [...values].sort((a, b) => a - b)
68 const mid = Math.floor(sorted.length / 2)
69 return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2
70}
71
72export type Estimate = { remainingMs: number; totalMs: number; confidence: '~' | '?' | 'max'; fraction: number }
73
74// Time-left for a running call. Confidence: `~` with ≥ minSamples, `?` with fewer,
75// `max` when only a timeout bounds it. Never negative; past the median, "overdue" by
76// stretching total so the bar never sits at 100% while still running.
77export function estimateMs(
78 history: readonly number[] | undefined,
79 elapsedMs: number,
80 minSamples: number,
81 timeoutMs?: number,
82): Estimate | undefined {
83 const samples = history ?? []
84 if (samples.length === 0) {
85 if (!timeoutMs) return undefined
86 return {
87 remainingMs: Math.max(0, timeoutMs - elapsedMs),
88 totalMs: timeoutMs,
89 confidence: 'max',
90 fraction: Math.min(0.99, elapsedMs / timeoutMs),
91 }
92 }
93 const typical = median(samples)
94 const totalMs = Math.max(typical, elapsedMs * 1.1, 1)
95 return {
96 remainingMs: Math.max(0, typical - elapsedMs),
97 totalMs,
98 confidence: samples.length >= minSamples ? '~' : '?',
99 fraction: Math.min(0.99, elapsedMs / totalMs),
100 }
101}
102hooks/format.ts 68 lines1import type { WatchConfig } from '../types'
2
3export function time(ms: number, style: WatchConfig['timeFormat'] = 'mmss'): string {
4 const total = Math.max(0, Math.round(ms / 1000))
5 const h = Math.floor(total / 3600)
6 const m = Math.floor((total % 3600) / 60)
7 const s = total % 60
8 if (style === 'human') {
9 if (h > 0) return `${h}h ${m}m`
10 if (m > 0) return `${m}m ${s}s`
11 return `${s}s`
12 }
13 const ss = String(s).padStart(2, '0')
14 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
15}
16
17export function seconds(ms: number): string {
18 return ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 1000)}s`
19}
20
21export function truncate(text: string, max: number): string {
22 if (max <= 0) return ''
23 const flat = text.replace(/\s+/g, ' ').trim()
24 return flat.length <= max ? flat : `${flat.slice(0, Math.max(0, max - 1))}…`
25}
26
27export function basename(path: string): string {
28 const parts = path.replace(/\/+$/, '').split('/')
29 return parts[parts.length - 1] || path
30}
31
32export function pct(value: number): string {
33 return `${Math.round(value)}%`
34}
35
36export function ago(ms: number): string {
37 if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s ago`
38 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`
39 return `${Math.round(ms / 3_600_000)}h ago`
40}
41
42// "30m", "2h", "90s", "45" (minutes) → ms
43export function parseDuration(raw: string): number | undefined {
44 const m = /^(\d+)\s*(s|m|h)?$/.exec(raw.trim())
45 if (!m) return undefined
46 const n = Number(m[1])
47 const unit = m[2] ?? 'm'
48 return n * (unit === 's' ? 1000 : unit === 'h' ? 3_600_000 : 60_000)
49}
50
51// Text label for a tool call, honouring redaction and length limits.
52export function toolLabel(tool: string, input: Record<string, unknown>, cfg: WatchConfig): string {
53 if (cfg.redactCommands) return tool
54 const pick = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : undefined)
55 // A Bash call's own description reads better than a raw shell line.
56 const detail =
57 (tool === 'Bash' ? pick('description') : undefined) ??
58 pick('command') ??
59 pick('description') ??
60 (pick('file_path') ? basename(pick('file_path')!) : undefined) ??
61 pick('pattern') ??
62 pick('url') ??
63 pick('subject') ??
64 pick('prompt')
65 const text = detail ? (tool === 'Bash' ? detail : `${tool} ${detail}`) : tool
66 return truncate(text, cfg.maxCommandChars)
67}
68hooks/health.ts 106 lines1import type { WatchTestResult } from '../types'
2
3type Parsed = Omit<WatchTestResult, 'at' | 'command'>
4
5// An absent group ("Tests: 11 passed" has no failed count) is 0, not NaN.
6const num = (m: RegExpMatchArray | null, i = 1) => (m && m[i] !== undefined ? Number(m[i]) : 0)
7
8// Best-effort parse of common test runners' summaries. Returns undefined when the
9// command does not look like a test run.
10export function parseTestOutput(command: string, output: string, exitCode: number | undefined): Parsed | undefined {
11 const cmd = command.toLowerCase()
12 const looksLikeTest = /\b(test|jest|vitest|pytest|cargo test|go test|mocha|rspec|phpunit)\b/.test(cmd)
13 if (!looksLikeTest) return undefined
14 const text = output.slice(-20_000)
15 const failedByExit = exitCode !== undefined && exitCode !== 0
16
17 // jest: "Tests: 3 failed, 39 passed, 42 total"
18 let m = text.match(/Tests:\s+(?:(\d+) failed,\s*)?(?:\d+ skipped,\s*)?(?:(\d+) passed)?/)
19 if (m && (m[1] || m[2])) return done('jest', num(m, 2), num(m, 1), text)
20
21 // vitest: "Tests 3 failed | 39 passed (42)"
22 m = text.match(/Tests\s+(?:(\d+) failed\s*\|\s*)?(\d+) passed/)
23 if (m) return done('vitest', num(m, 2), num(m, 1), text)
24
25 // pytest: "=== 3 failed, 39 passed in 1.2s ==="
26 if (/pytest|py\.test/.test(cmd) || /=+ .*(passed|failed).* in [\d.]+s/.test(text)) {
27 const passed = num(text.match(/(\d+) passed/))
28 const failed = num(text.match(/(\d+) failed/)) + num(text.match(/(\d+) error/))
29 if (passed || failed) return done('pytest', passed, failed, text)
30 }
31
32 // bun / claude plugin test: " 29 pass" / " 0 fail" on their own lines
33 m = text.match(/^\s*(\d+) pass\s*$/m)
34 if (m) return done('bun', num(m), num(text.match(/^\s*(\d+) fail\s*$/m)), text)
35
36 // cargo: "test result: FAILED. 39 passed; 3 failed;"
37 m = text.match(/test result: \w+\. (\d+) passed; (\d+) failed/)
38 if (m) return done('cargo', num(m, 1), num(m, 2), text)
39
40 // go test: count "--- PASS" / "--- FAIL" lines, or "ok"/"FAIL" package lines
41 if (/go test/.test(cmd)) {
42 const passed = (text.match(/^--- PASS/gm) ?? []).length || (text.match(/^ok\s/gm) ?? []).length
43 const failed = (text.match(/^--- FAIL/gm) ?? []).length || (text.match(/^FAIL\s/gm) ?? []).length
44 return done('go', passed, failed, text)
45 }
46
47 // Unknown runner: exit code only.
48 return { runner: 'test', passed: 0, failed: failedByExit ? 1 : 0, ok: !failedByExit }
49
50 function done(runner: string, passed: number, failed: number, all: string): Parsed {
51 const ok = failed === 0 && !failedByExit
52 return { runner, passed, failed, ok, snippet: ok ? undefined : failureSnippet(all) }
53 }
54}
55
56export function failureSnippet(text: string, maxLines = 12): string {
57 const lines = text.split('\n')
58 const idx = lines.findIndex(l => /(FAIL|✕|×|● |FAILED|Error:|AssertionError|panicked)/.test(l))
59 const from = idx >= 0 ? idx : Math.max(0, lines.length - maxLines)
60 return lines.slice(from, from + maxLines).join('\n').trim()
61}
62
63const PM = '(?:npm|pnpm|yarn|bun)(?: run)?'
64const TEST = new RegExp(`^(?:(?:npx|bunx|pnpm|yarn|bun)\\s+)?(?:jest|vitest|mocha|pytest|py\\.test|rspec|phpunit)\\b|^${PM} test\\b|^(?:go|cargo|deno|make|bun|claude plugin) test\\b`)
65const LINT = new RegExp(`^${PM} lint\\b|^(?:(?:npx|pnpm|bunx)\\s+)?(?:eslint|ruff|flake8|golangci-lint|biome check|prettier --check)\\b|^cargo clippy\\b|^(?:npx\\s+)?tsc\\b.*--noemit`)
66const BUILD = new RegExp(`^${PM} build\\b|^(?:cargo|go) build\\b|^(?:npx\\s+)?(?:tsc|webpack|vite build|next build)\\b|^make\\b`)
67
68// What kind of check a shell line runs, judged by each command's own words:
69// quoted text, comments, `cd` and env assignments do not count.
70// Drop heredoc bodies: `cmd <<'EOF'` … `EOF` lines are data, not commands.
71function withoutHeredocs(command: string): string {
72 const out: string[] = []
73 let end: string | undefined
74 for (const line of command.split('\n')) {
75 if (end !== undefined) {
76 if (line.trim() === end) end = undefined
77 continue
78 }
79 out.push(line)
80 const m = /<<-?\s*(['"]?)(\w+)\1/.exec(line)
81 if (m) end = m[2]
82 }
83 return out.join('\n')
84}
85
86export function classifyCommand(command: string): 'test' | 'lint' | 'build' | undefined {
87 const bare = withoutHeredocs(command)
88 .toLowerCase()
89 .replace(/(["'])(?:\\.|(?!\1).)*\1/g, ' ')
90 .replace(/(^|\s)#.*$/gm, ' ')
91 const segments = bare.split(/&&|\|\||;|\||\n/).map(x => x.trim().replace(/^(?:\w+=\S*\s+)+/, ''))
92 const runs = segments.filter(x => x && !/^cd\b/.test(x))
93 if (runs.some(x => TEST.test(x))) return 'test'
94 if (runs.some(x => LINT.test(x))) return 'lint'
95 if (runs.some(x => BUILD.test(x))) return 'build'
96 return undefined
97}
98
99// `git status --porcelain -b` → branch + dirty count
100export function parseGitStatus(out: string): { branch?: string; dirty: number } {
101 const lines = out.split('\n').filter(Boolean)
102 const head = lines[0]?.startsWith('## ') ? lines.shift()! : undefined
103 const branch = head ? head.slice(3).split('...')[0]?.replace(/^No commits yet on /, '') : undefined
104 return { branch, dirty: lines.length }
105}
106hooks/render/pane.tsx 1469 lines1import type { WatchCall, WatchChange, WatchConfig, WatchHistoryEvent, WatchHistoryTurn, WatchPeer, WatchPlan, WatchPlanStep, WatchSnapshot, WatchTab, WatchUi } from '../../types'
2import { changesTotal } from '../changes'
3import { checkRows } from '../checks'
4import { forecast, forecastText } from '../forecast'
5import { filterHistory, isProblem, lastCompactIndex } from '../history'
6import { failingLocation, openOptions } from '../openmenu'
7import { CHOICES, getPath } from '../config'
8import { estimateMs } from '../estimate'
9import { isPersistent, planProgress } from '../plan'
10import { ago, basename, pct, seconds, time, truncate } from '../format'
11import { resolve, STATE_HEX, TINT, type Semantic, type Tint } from './colors'
12import { Bar, Card, Donut, Gantt, inner, Mark, narrow, MiniBar, Pulse, Row, Badge, Section, Segments, TurnStrip, type GanttLane, type Kit } from './widgets'
13
14export type View = {
15 k: Kit
16 s: WatchSnapshot
17 ui: WatchUi
18 peers: WatchPeer[]
19 history: Record<string, number[]>
20 now: number
21 actions: {
22 tab: (tab: WatchTab) => void
23 scope: (scope: 'this' | 'all') => void
24 set: (path: string, value: unknown) => void
25 copy: (text: string) => void
26 toggle: (id: string) => void
27 cycleFilter: () => void
28 clearHistory: () => void
29 dismissPlan: () => void
30 pick: (value: string) => void
31 turnPick: (value: string) => void
32 toggleHistory: () => void
33 filter: (value: string) => void
34 }
35}
36
37// 'turn' is the adaptive dashboard; the others are detail views reached by /watch <tab>.
38export const TABS: readonly WatchTab[] = ['turn', 'plan', 'agents', 'calls', 'history', 'checks', 'changes', 'sessions', 'health', 'config']
39
40// The VS Code side panel is ~45-55 columns; only truly tiny panes go compact, on any surface.
41const COMPACT_BELOW = 36
42
43const PHASE_LABEL: Record<WatchSnapshot['phase'], string> = {
44 idle: 'Idle',
45 thinking: 'Thinking',
46 tool: 'Running',
47 waiting_permission: 'Needs you',
48 waiting_question: 'Needs you',
49 subagent: 'Agents working',
50 error: 'Error',
51 done: 'Done',
52}
53
54export function phaseSem(phase: WatchSnapshot['phase']): Semantic {
55 if (phase === 'error') return 'attention'
56 if (phase === 'waiting_permission' || phase === 'waiting_question') return 'warn'
57 if (phase === 'done') return 'ok'
58 if (phase === 'idle') return 'dim'
59 return 'accent'
60}
61
62export const isActive = (s: WatchSnapshot) => s.turnStartedAt !== null && s.turnEndedAt === null
63
64export function turnElapsed(s: WatchSnapshot, now: number): number {
65 if (s.turnStartedAt === null) return 0
66 return (s.turnEndedAt ?? now) - s.turnStartedAt
67}
68
69export function currentEta(s: WatchSnapshot, history: Record<string, number[]>, cfg: WatchConfig, now: number) {
70 const c = s.current
71 if (!c || !cfg.estimate.enabled) return undefined
72 const m = /timeout=(\d+)/.exec(c.note ?? '')
73 const exact = history[c.key]
74 const samples = exact && exact.length > 0 ? exact : c.kindKey ? history[c.kindKey] : undefined
75 return estimateMs(samples, now - c.startedAt, cfg.estimate.minSamples, m ? Number(m[1]) : undefined)
76}
77
78export function etaText(eta: ReturnType<typeof currentEta>, cfg: WatchConfig): string | undefined {
79 if (!eta) return undefined
80 if (eta.confidence === 'max') return `up to ${time(eta.remainingMs, cfg.timeFormat)}`
81 if (eta.remainingMs === 0) return 'overdue'
82 return `${eta.confidence}${time(eta.remainingMs, cfg.timeFormat)} left`
83}
84
85// One-line summary for the spinner suffix and the sessions board.
86// `short` drops the command and elapsed time, which Claude Code's own spinner already shows.
87export function summary(v: Pick<View, 's' | 'history' | 'now'> & { cfg: WatchConfig; short?: boolean }): string {
88 const { s, cfg, now } = v
89 const parts: string[] = []
90 if (s.current && !v.short) parts.push(s.current.label, time(now - s.current.startedAt, cfg.timeFormat))
91 const eta = etaText(currentEta(s, v.history, cfg, now), cfg)
92 if (eta) parts.push(eta)
93 const p = s.plan ? planProgress(s.plan) : undefined
94 if (p && p.total > 0) parts.push(`${p.done}/${p.total} tasks`)
95 if (s.health.needsYou) parts.push('needs you')
96 return truncate(parts.join(' · '), cfg.spinnerMaxChars)
97}
98
99export function needsYouText(s: WatchSnapshot, now: number): string {
100 const n = s.health.needsYou
101 if (!n) return ''
102 const what =
103 n.kind === 'permission'
104 ? 'Claude is waiting for your permission'
105 : n.kind === 'question'
106 ? 'Claude asked you a question'
107 : n.kind === 'elicitation'
108 ? 'An MCP server needs your input'
109 : 'Claude is waiting for your input'
110 return `${what} (${time(now - n.since)})`
111}
112
113// five_hour → session, seven_day → weekly, seven_day_fable → fable.
114export function limitLabel(kind: string): string {
115 if (kind === 'five_hour') return 'session'
116 if (kind === 'seven_day') return 'weekly'
117 if (kind === 'spend_limit') return 'spend'
118 return kind.replace(/^(five_hour|seven_day)_/, '').replace(/_/g, ' ')
119}
120
121function resetText(at: number, now: number): string {
122 const ms = at - now
123 if (ms <= 0) return 'resetting'
124 if (ms < 86_400_000) return `resets in ${time(ms, 'human').replace(/ \d+s$/, '')}`
125 return `resets ${new Date(at).toLocaleDateString('en-US', { weekday: 'short' })}`
126}
127
128// USAGE: what /usage shows — session, weekly and per-model windows — always on top.
129function UsageBar(v: View) {
130 const { k, s, now } = v
131 const { Box, Text } = k.els
132 const limits = s.health.limits ?? []
133 if (limits.length === 0) return Section(k, 'usage', 'USAGE', [<Text key="usage-wait" dimColor>waiting for the first usage update…</Text>])
134 const order = (kind: string) => (kind === 'five_hour' ? 0 : kind === 'seven_day' ? 1 : 2)
135 const rows = [...limits]
136 .sort((a, b) => order(a.kind) - order(b.kind))
137 .map(l => {
138 const sem: Semantic = l.pct >= 90 ? 'attention' : l.pct >= 70 ? 'warn' : 'accent'
139 return (
140 <Box key={`usage-${l.kind}`} flexDirection="row" gap={1} alignItems="center">
141 <Box width={8} flexShrink={0}>
142 <Text dimColor>{limitLabel(l.kind)}</Text>
143 </Box>
144 {MiniBar(k, `usage-bar-${l.kind}`, l.pct / 100, sem, undefined, narrow(k) ? 8 : 16)}
145 <Text color={resolve(sem, k.cfg)}>{pct(l.pct)}</Text>
146 {l.resetsAt && !narrow(k) ? <Text dimColor wrap="truncate-end">{resetText(l.resetsAt, now)}</Text> : null}
147 </Box>
148 )
149 })
150 return Section(k, 'usage', 'USAGE', rows)
151}
152
153// STATUS: one glance at what Claude is doing right now, then each running agent.
154function StatusBlock(v: View) {
155 const { k, s, now } = v
156 const { Box, Text } = k.els
157 const sem = phaseSem(s.phase)
158 const active = isActive(s)
159 const what =
160 s.phase === 'tool' && s.current
161 ? `${s.current.label} · ${span(now - s.current.startedAt)}`
162 : s.phase === 'thinking' && s.turnStartedAt !== null
163 ? `${span(now - s.turnStartedAt)} into the turn`
164 : s.health.needsYou
165 ? needsYouText(s, now)
166 : undefined
167 const rows: unknown[] = [
168 <Box key="status-line" flexDirection="row" gap={1}>
169 {active ? Pulse(k, sem, 'status-pulse') : Mark(k, '●', sem)}
170 <Text key="status-phase" bold color={resolve(sem, k.cfg)}>{PHASE_LABEL[s.phase]}</Text>
171 {what ? <Text dimColor wrap="truncate-end">{what}</Text> : null}
172 </Box>,
173 ]
174 for (const a of s.agents.filter(x => x.phase === 'running')) {
175 rows.push(
176 <Box key={`status-agent-${a.id}`} flexDirection="row" gap={1}>
177 <Text color={resolve('accent', k.cfg)}>{' ↳'}</Text>
178 <Text key={`status-agent-type-${a.id}`} bold>{a.type}</Text>
179 <Text dimColor wrap="truncate-end">{`${a.current ?? 'thinking'} · ${span(now - a.startedAt)}`}</Text>
180 </Box>,
181 )
182 }
183 return (
184 <Box key="status" flexDirection="column" borderStyle="round" borderColor={STATE_HEX[sem]} paddingX={1}>
185 {rows}
186 </Box>
187 )
188}
189
190function UsageBlock(v: View) {
191 const { Box } = v.k.els
192 return (
193 <Box key="usage-block" flexDirection="column" borderStyle="round" borderColor={resolve('dim', v.k.cfg)} paddingX={1}>
194 {UsageBar(v)}
195 </Box>
196 )
197}
198
199export function Pane(v: View) {
200 const { Box } = v.k.els
201 const gap = v.k.cfg.theme === 'dense' ? 0 : 1
202 if (v.ui.tab === 'turn') {
203 // Two blocks: what is happening now on top, history pinned to the bottom with the
204 // controls as its last row. The spacer between them takes the pane's spare height.
205 const compact = v.k.cfg.compact || v.k.columns < COMPACT_BELOW
206 return (
207 <Box flexDirection="column" height={v.k.rows}>
208 {UsageBlock(v)}
209 {StatusBlock(v)}
210 <Box key="now" flexDirection="column" gap={gap} borderStyle="round" borderColor={STATE_HEX[nowSem(v)]} paddingX={1}>
211 {NowBlock(v, compact)}
212 </Box>
213 <Box key="spacer" flexGrow={1} />
214 <Box key="history-block" flexDirection="column" gap={gap} borderStyle="round" borderColor={resolve('dim', v.k.cfg)} paddingX={1}>
215 {compact ? null : HistoryGlance(v)}
216 {Footer(v)}
217 </Box>
218 </Box>
219 )
220 }
221 const detail =
222 v.ui.tab === 'plan'
223 ? PlanDetail(v)
224 : v.ui.tab === 'agents'
225 ? AgentsDetail(v)
226 : v.ui.tab === 'calls'
227 ? CallsDetail(v)
228 : v.ui.tab === 'history'
229 ? HistorySection(v, 'full')
230 : v.ui.tab === 'checks'
231 ? ChecksDetail(v)
232 : v.ui.tab === 'changes'
233 ? ChangesDetail(v)
234 : v.ui.tab === 'replay'
235 ? ReplayView(v)
236 : v.ui.tab === 'sessions'
237 ? SessionsDetail(v)
238 : v.ui.tab === 'health'
239 ? HealthDetail(v)
240 : ConfigDetail(v)
241 return (
242 <Box flexDirection="column" gap={gap}>
243 {UsageBlock(v)}
244 {detail}
245 {Footer(v)}
246 </Box>
247 )
248}
249
250// Only the terminal measures real cells; elsewhere the surface cuts text itself.
251function fit(k: Kit, text: string, width: number): string {
252 return k.surface === 'terminal' ? truncate(text, width) : text
253}
254
255const VIEWS: readonly { value: WatchTab; label: string }[] = [
256 { value: 'turn', label: 'dashboard' },
257 { value: 'plan', label: 'plan' },
258 { value: 'history', label: 'history' },
259 { value: 'changes', label: 'changes' },
260 { value: 'checks', label: 'checks' },
261 { value: 'calls', label: 'calls' },
262 { value: 'agents', label: 'agents' },
263 { value: 'sessions', label: 'sessions' },
264 { value: 'health', label: 'health' },
265 { value: 'config', label: 'settings' },
266]
267
268// The one row of controls: view, history filter, open….
269function Footer(v: View) {
270 const { k, s } = v
271 const { Box, Text, Select } = k.els
272 const rule = <Text dimColor wrap="truncate-end">{'─'.repeat(k.surface === 'terminal' ? k.columns : 200)}</Text>
273 if (!Select) {
274 return (
275 <Box key="footer" flexDirection="column">
276 {rule}
277 <Text dimColor>/watch history · changes · checks · plan · clear</Text>
278 </Box>
279 )
280 }
281 const cwd = s.cwd
282 const failing = failingLocation(s.health.lastTest && !s.health.lastTest.ok ? s.health.lastTest.snippet : undefined, cwd)
283 const open = openOptions({ planPath: s.plan?.path, failing, changes: s.health.changes ?? [], sessionId: s.sessionId })
284 const actions = [
285 ...open.map(o => ({ value: o.value, label: o.label })),
286 ...(s.plan && s.plan.source !== 'prompt' ? [{ value: 'clear-plan', label: 'clear plan' }] : []),
287 ...((s.history ?? []).length ? [{ value: 'clear-history', label: 'clear history' }] : []),
288 ]
289 return (
290 <Box key="footer" flexDirection="column">
291 {rule}
292 <Box flexDirection="row" gap={2} flexWrap="wrap">
293 <Select
294 key="view"
295 label="view"
296 value={v.ui.tab}
297 // The menu must list what it shows: in a replay, so "dashboard" is a real change.
298 options={[...VIEWS.map(x => ({ value: x.value, label: x.label })), ...(v.ui.tab === 'replay' ? [{ value: 'replay', label: 'turn replay' }] : [])]}
299 onSelect={(value: string) => v.actions.tab(value as WatchTab)}
300 />
301 <Select
302 key="filter"
303 label="history"
304 value={v.ui.historyFilter ?? 'all'}
305 options={[
306 { value: 'all', label: 'all' },
307 { value: 'turns', label: 'turns' },
308 { value: 'problems', label: 'problems' },
309 ]}
310 onSelect={(value: string) => v.actions.filter(value)}
311 />
312 {actions.length ? (
313 // A blank first choice: a Select shows its value, and re-picking the shown one fires nothing.
314 <Select
315 key="open"
316 label="actions…"
317 value=""
318 options={[{ value: '', label: 'choose…' }, ...actions]}
319 onSelect={(value: string) => (value ? v.actions.pick(value) : undefined)}
320 />
321 ) : null}
322 </Box>
323 </Box>
324 )
325}
326
327// ── Visual dashboard: picture first, one line of text per idea ──
328
329function stepSem(st: WatchPlanStep['status']): Semantic {
330 return st === 'done' ? 'ok' : st === 'failed' ? 'attention' : st === 'running' ? 'accent' : 'dim'
331}
332
333// The NOW block's border colour: the state at a glance.
334function nowSem(v: View): Semantic {
335 const s = v.s
336 if (s.health.needsYou) return 'warn'
337 const since = s.turnStartedAt ?? Number.POSITIVE_INFINITY
338 if ((s.health.lastTest && !s.health.lastTest.ok && s.health.lastTest.at >= since) || (s.health.lastError && s.health.lastError.at >= since)) return 'attention'
339 if (isActive(s)) return 'accent'
340 if (s.plan?.endedAt) return 'ok'
341 return 'dim'
342}
343
344// Step list colours: done soft green, running bright blue on a faint band, waiting grey.
345const STEP_COLOR = { doneMark: '#3fb950', done: '#7ee2a0', run: '#79c0ff', runBand: '#111d2e', wait: '#6e7681' }
346const STEPS_SHOWN = 20
347
348// Steps in execution order, one line each. Past STEPS_SHOWN, done steps fold into one line.
349function StepList(v: View, plan: WatchPlan, live = true): unknown[] {
350 const { k, s, now } = v
351 const { Box, Text } = k.els
352 const steps = plan.steps
353 const doneIdx = steps.map((x, i) => (x.status === 'done' ? i : -1)).filter(i => i >= 0)
354 const fold = steps.length > STEPS_SHOWN && doneIdx.length > 1 ? new Set(doneIdx.slice(0, -1)) : new Set<number>()
355 const rows: unknown[] = []
356 if (fold.size) {
357 rows.push(
358 <Text key="steps-folded">
359 <Text color={STEP_COLOR.doneMark}>✓</Text>
360 <Text color={STEP_COLOR.done}>{` ${fold.size} done`}</Text>
361 </Text>,
362 )
363 }
364 steps.forEach((step, i) => {
365 if (fold.has(i)) return
366 const running = step.status === 'running'
367 // Between turns nothing runs: the step that is up next keeps its colour but not the play mark.
368 const mark = step.status === 'done' ? '✓' : step.status === 'failed' ? '✗' : running && isActive(s) ? '▶' : '○'
369 const markColor = step.status === 'done' ? STEP_COLOR.doneMark : step.status === 'failed' ? resolve('attention', k.cfg) : running ? STEP_COLOR.run : STEP_COLOR.wait
370 const textColor = step.status === 'done' ? STEP_COLOR.done : step.status === 'failed' ? resolve('attention', k.cfg) : running ? STEP_COLOR.run : STEP_COLOR.wait
371 // Steps marked done without being watched (ledger on load) have no real duration.
372 const ms = step.startedAt !== undefined ? (step.endedAt ?? now) - step.startedAt : 0
373 const dur = step.startedAt !== undefined && ms >= 1000 ? span(ms) : ''
374 rows.push(
375 <Box key={`step-${step.id}`} flexDirection="row" justifyContent="space-between" gap={1}>
376 <Box flexDirection="row" gap={1} flexShrink={1}>
377 <Text color={markColor}>{mark}</Text>
378 <Text color={textColor} bold={running ? true : undefined} backgroundColor={running ? STEP_COLOR.runBand : undefined} wrap="truncate-end">
379 {step.title}
380 </Text>
381 </Box>
382 <Text dimColor>{dur}</Text>
383 </Box>,
384 )
385 if (live && running && isActive(s)) {
386 const call = s.current ? `${s.current.label} ${span(now - s.current.startedAt)}` : 'thinking'
387 rows.push(
388 <Text key="step-live" color={TINT.teal.fg} wrap="truncate-end">
389 {` ${call}`}
390 </Text>,
391 )
392 }
393 })
394 return rows
395}
396
397// "41s", "21m", "2h 5m": a length of time that never reads like a clock.
398function span(ms: number): string {
399 const sec = Math.max(0, Math.round(ms / 1000))
400 if (sec < 60) return `${sec}s`
401 const m = Math.floor(sec / 60)
402 if (m < 60) return `${m}m`
403 return `${Math.floor(m / 60)}h ${m % 60}m`
404}
405
406// The NOW block: attention, then labelled rows — PLAN (goal, step progress) and NOW (live line, agents, signals).
407function NowBlock(v: View, compact: boolean): unknown[] {
408 const { k, s, now } = v
409 const { Box, Text } = k.els
410 const cfg = k.cfg
411 const out: unknown[] = []
412 const attention = AttentionCard(v)
413 if (attention) out.push(attention)
414 const plan = s.plan
415 const active = isActive(s)
416
417 // A prompt-only plan says nothing the idle line doesn't; show it while a turn runs.
418 if (plan && (plan.steps.length > 0 || active)) {
419 const p = planProgress(plan)
420 const ticket = plan.ticket && !plan.title.includes(plan.ticket) ? `${plan.ticket} · ` : ''
421 const end = plan.endedAt ?? (active ? undefined : (s.turnEndedAt ?? undefined))
422 const elapsed = `${clock(plan.startedAt)} → ${end ? clock(end) : 'now'} · ${span((end ?? now) - plan.startedAt)}`
423 const position = p.total ? (p.done === p.total ? `all ${p.total} steps done` : `step ${p.current ? plan.steps.indexOf(p.current) + 1 : Math.min(p.total, p.done + 1)} of ${p.total}`) : undefined
424 const rows: unknown[] = [
425 <Box key="goal" flexDirection="row" justifyContent="space-between" gap={1}>
426 <Box flexDirection="row" gap={1} flexShrink={1}>
427 {active ? Pulse(k, 'accent') : Mark(k, plan.endedAt ? '✓' : '○', plan.endedAt ? 'ok' : 'dim')}
428 <Text bold wrap="truncate-end">
429 {`${ticket}${plan.title || 'Working'}`}
430 </Text>
431 </Box>
432 <Text dimColor>{[position, elapsed].filter(Boolean).join(' · ')}</Text>
433 </Box>,
434 ]
435 if (p.total) {
436 const left = p.done === p.total ? '' : p.remainingMs ? ` · ~${span(p.remainingMs)} left` : ''
437 rows.push(
438 <Box key="progress" flexDirection="row" justifyContent="space-between" gap={1} alignItems="center">
439 {Segments(k, 'plan-segments', plan.steps.map(x => stepSem(x.status)))}
440 <Text dimColor>{`${p.done} done${left}`}</Text>
441 </Box>,
442 )
443 if (!compact) rows.push(...StepList(v, plan))
444 }
445 out.push(Section(k, 'plan', 'PLAN', rows))
446 }
447 if (compact) {
448 out.push(SignalChips(v))
449 return out.filter(Boolean)
450 }
451
452 const nowRows: unknown[] = []
453 const listed = !!plan && plan.steps.length > 0 && !!planProgress(plan).current
454 if (active && listed) {
455 // The step list already shows the running step and its live command.
456 } else if (active) {
457 const step = plan ? planProgress(plan).current : undefined
458 const call = s.current ? `${s.current.label} ${span(now - s.current.startedAt)}` : 'thinking'
459 nowRows.push(
460 <Text key="now-line" wrap="truncate-end">
461 {Mark(k, '▶', 'accent')}
462 {step ? <Text bold>{` ${step.title}`}</Text> : null}
463 <Text color={TINT.teal.fg}>{` ${call}`}</Text>
464 </Text>,
465 )
466 } else {
467 nowRows.push(IdleLine(v))
468 }
469 if (s.agents.length) {
470 const chart = Gantt(k, 'timeline', lanes(v), ganttFrom(v), ganttTo(v))
471 nowRows.push(chart ?? <Box key="agents" flexDirection="column">{s.agents.slice(-4).map(a => agentRow(v, a))}</Box>)
472 }
473 nowRows.push(...LiveTree(v))
474 const chips = SignalChips(v)
475 if (chips) nowRows.push(chips)
476 out.push(Section(k, 'now-rows', 'NOW', nowRows))
477 return out.filter(Boolean)
478}
479
480// tests ✗✗✓ changes +63 −8 ctx ◔ 57% ~6t — tinted badges.
481function SignalChips(v: View) {
482 const { k, s } = v
483 const { Box, Text } = k.els
484 const cfg = k.cfg
485 const hl = s.health
486 const chips: unknown[] = []
487 const checks = checkRows(hl.checks ?? {})
488 const test = checks.find(c => c.kind === 'test') ?? checks[0]
489 if (test) {
490 const last = test.runs[test.runs.length - 1]!
491 const tint: Tint = last.ok ? 'ok' : 'attention'
492 chips.push(Badge(k, tint, 'tests', test.runs.slice(-5).map(r => (
493 <Text color={r.ok ? TINT.ok.fg : TINT.attention.fg}>{r.ok ? '✓' : '✗'}</Text>
494 ))))
495 }
496 if (hl.gitBase) {
497 const t = changesTotal(hl.changes ?? [])
498 const lines = t.added || t.removed ? `+${t.added} −${t.removed}` : ''
499 const created = t.created ? `${t.created} new` : ''
500 chips.push(Badge(k, t.files ? 'violet' : 'slate', 'files changed', t.files ? [lines, created].filter(Boolean).join(' · ') : 'none'))
501 }
502 if (hl.contextPct !== undefined) {
503 const tint: Tint = hl.contextPct >= 90 ? 'attention' : hl.contextPct >= cfg.health.contextWarnPct ? 'warn' : 'teal'
504 const ring = '○◔◑◕●'[Math.min(4, Math.round((hl.contextPct / 100) * 4))]
505 const f = forecast(hl.contextHistory ?? [], lastCompactIndex(s.history ?? []), hl.contextPct, cfg.health.compactAtPct ?? 85)
506 const tail = f.kind === 'turns' ? ` · ~${f.turns} turns left` : f.kind === 'next' ? ' · compacts next turn' : ''
507 chips.push(Badge(k, tint, 'context', `${ring} ${pct(hl.contextPct)}${tail}`))
508 }
509 if (chips.length === 0) return null
510 return (
511 <Box key="signals" flexDirection="row" columnGap={1} rowGap={0} flexWrap="wrap">
512 {chips}
513 </Box>
514 )
515}
516
517// HISTORY block glance: a turn strip, then turns newest first (10, more on request),
518// each with start → end and length; the history filter applies.
519function turnTimes(t: WatchHistoryTurn): string {
520 return `${clock(t.at)} → ${t.ended && !t.interrupted ? clock(t.at + (t.durationMs ?? 0)) : t.ended ? '?' : 'now'}`
521}
522
523const BRANCHES_SHOWN = 4
524
525// A command as one short line: its first line, cut to `max`.
526function oneLine(text: string, max = 60): string {
527 return truncate((text.split('\n')[0] ?? '').trim(), max)
528}
529
530type Branch = { glyph: string; color: string; label: string; value: string }
531
532// Branches of a turn: steps it moved, its test result, agents, files.
533function branchItems(t: WatchHistoryTurn, steps: readonly { title: string; status: string }[], agents: readonly { label: string; ok: boolean; durationMs: number; running?: boolean }[]): Branch[] {
534 const items: Branch[] = []
535 for (const st of steps) {
536 const [glyph, color] =
537 st.status === 'done' ? ['✓', STEP_COLOR.doneMark] : st.status === 'failed' ? ['✗', TINT.attention.fg] : st.status === 'running' ? ['▶', STEP_COLOR.run] : ['○', STEP_COLOR.wait]
538 items.push({ glyph, color, label: 'step', value: st.title })
539 }
540 if (t.tests) {
541 items.push(
542 t.tests.ok
543 ? { glyph: '✓', color: TINT.ok.fg, label: 'tests', value: `${t.tests.passed ? `${t.tests.passed} passed · ` : ''}${oneLine(t.tests.command)}` }
544 : { glyph: '✗', color: TINT.attention.fg, label: 'tests', value: `${t.tests.failed} failed · ${oneLine(t.tests.command)}` },
545 )
546 }
547 for (const a of agents) items.push({ glyph: a.running ? '▶' : '⚙', color: a.running ? STEP_COLOR.run : a.ok ? TINT.ok.fg : TINT.attention.fg, label: 'agent', value: `${a.label}${a.running ? '' : ` ${time(a.durationMs, 'human')} ${a.ok ? '✓' : '✗'}`}` })
548 if (t.files.length) items.push({ glyph: '✎', color: TINT.violet.fg, label: 'files', value: t.files.map(basename).join(', ') })
549 return items
550}
551
552function renderBranches(k: Kit, key: string, items: readonly Branch[], indent = ' ') {
553 const { Text } = k.els
554 const shown = items.slice(0, BRANCHES_SHOWN)
555 const extra = items.length - shown.length
556 return shown
557 .map((it, i) => {
558 const last = i === shown.length - 1 && extra === 0
559 return (
560 <Text key={`br-${key}-${i}`} wrap="truncate-end">
561 <Text dimColor>{`${indent}${last ? '└─' : '├─'} `}</Text>
562 <Text color={it.color}>{it.glyph}</Text>
563 <Text dimColor>{` ${it.label.padEnd(5)} `}</Text>
564 {fit(k, it.value, inner(k) - 18)}
565 </Text>
566 )
567 })
568 .concat(extra > 0 ? [<Text key={`br-${key}-more`} dimColor>{`${indent}└─ … +${extra}`}</Text>] : [])
569}
570
571// The running turn's work so far, as a tree under NOW.
572function LiveTree(v: View): unknown[] {
573 const { k, s } = v
574 const turns = (s.history ?? []).filter((e): e is WatchHistoryTurn => e.kind === 'turn')
575 const t = turns[turns.length - 1]
576 if (!t || t.ended || !isActive(s)) return []
577 const since = s.turnStartedAt ?? 0
578 const steps = (s.plan?.steps ?? []).filter(x => (x.startedAt ?? -1) >= since || (x.endedAt ?? -1) >= since).filter(x => x.status !== 'running')
579 const agents = s.agents.filter(a => a.startedAt >= since).map(a => ({ label: a.type, ok: a.phase !== 'failed', durationMs: (a.endedAt ?? v.now) - a.startedAt, running: a.phase === 'running' }))
580 return renderBranches(k, `live-${t.id}`, branchItems(t, steps, agents))
581}
582
583
584// One past turn as a small card: outcome, prompt, start → end · length; its plan bar; badges.
585function TurnCard(v: View, t: WatchHistoryTurn, n: number) {
586 const { k } = v
587 const { Box, Text } = k.els
588 const problem = isProblem(t)
589 const sem: Semantic = !t.ended ? 'accent' : problem ? 'attention' : 'ok'
590 const len = t.interrupted ? 'interrupted' : t.ended ? span(t.durationMs ?? 0) : 'running'
591 const plan = t.snapshot?.plan
592 const facts = [
593 t.tests ? (t.tests.ok ? 'tests ✓' : `tests ✗ ${t.tests.failed}`) : undefined,
594 t.files.length ? `${t.files.length} file${t.files.length === 1 ? '' : 's'}` : undefined,
595 `${t.calls.length + t.moreCalls} calls`,
596 t.agents.length ? `${t.agents.length} agent${t.agents.length === 1 ? '' : 's'}` : undefined,
597 ].filter(Boolean)
598 return (
599 <Box key={`card-${t.id}`} flexDirection="column" borderStyle="round" borderColor={STATE_HEX[sem === 'ok' ? 'dim' : sem]} paddingX={1}>
600 <Box flexDirection="row" justifyContent="space-between" gap={1}>
601 <Box flexDirection="row" gap={1} flexShrink={1}>
602 {Mark(k, !t.ended ? '▶' : problem ? '✗' : '✓', sem)}
603 <Text dimColor>{`Turn ${n}`}</Text>
604 <Text wrap="truncate-end">{t.title || '(no prompt)'}</Text>
605 </Box>
606 <Text dimColor>{`${turnTimes(t)} · ${len}`}</Text>
607 </Box>
608 {plan ? (
609 <Box flexDirection="row" gap={1} alignItems="center">
610 {Segments(k, `card-seg-${t.id}`, plan.steps.map(x => stepSem(x.status as WatchPlanStep['status'])))}
611 <Text dimColor>{`${plan.steps.filter(x => x.status === 'done').length}/${plan.steps.length} · ${plan.title}`}</Text>
612 </Box>
613 ) : null}
614 <Text dimColor>{facts.join(' · ')}</Text>
615 </Box>
616 )
617}
618
619// Turn cards shown at first and added per "load more".
620export const HISTORY_PAGE = 3
621
622function HistoryGlance(v: View) {
623 const { k, s } = v
624 const { Box, Text, Select, Button } = k.els
625 const cfg = k.cfg
626 const all = (s.history ?? []).filter((e): e is WatchHistoryTurn => e.kind === 'turn')
627 if (all.length === 0) return null
628 const filter = v.ui.historyFilter ?? 'all'
629 const turns = filterHistory(all, filter).filter((e): e is WatchHistoryTurn => e.kind === 'turn')
630 const limit = v.ui.historyLimit ?? HISTORY_PAGE
631 const shown = turns.slice().reverse().slice(0, limit)
632 const number = new Map(all.map((t, i) => [t.id, i + 1]))
633 const ended = all.filter(t => t.ended).slice(-30)
634 const strip = TurnStrip(
635 k,
636 'turn-strip',
637 ended.map(t => ({ ms: t.durationMs ?? 0, sem: isProblem(t) ? 'attention' : 'ok' })),
638 )
639 const rows = shown.map(t => TurnCard(v, t, number.get(t.id) ?? 0))
640 const more = turns.length > shown.length
641 const collapsed = v.ui.historyCollapsed === true
642 const picker = Select ? (
643 <Select
644 key="turn-pick"
645 value=""
646 options={[
647 { value: '', label: '▸ open a past turn…' },
648 collapsed ? { value: 'expand', label: 'expand history' } : { value: 'collapse', label: 'collapse history' },
649 ...shown.map(t => ({ value: t.id, label: `Turn ${number.get(t.id)} · ${t.title.slice(0, 40)}` })),
650 ...(more ? [{ value: 'more', label: `load ${Math.min(HISTORY_PAGE, turns.length - shown.length)} more (${turns.length - shown.length} older)` }] : []),
651 ]}
652 onSelect={(value: string) => v.actions.turnPick(value)}
653 />
654 ) : null
655 // Its own accent-bordered box so the picker reads as a control, not a label.
656 const pickerBox = picker ? (
657 <Box key="turn-pick-box" borderStyle="round" borderColor={resolve('accent', k.cfg)} paddingX={1} width={30} flexShrink={1}>
658 {picker}
659 </Box>
660 ) : null
661 return (
662 <Box key="history" flexDirection="column">
663 <Box flexDirection="row" gap={2} alignItems="center" flexWrap="wrap">
664 <Text dimColor bold>HISTORY</Text>
665 <Button key="history-toggle" plain label={collapsed ? `▸ show ${all.length} turn${all.length === 1 ? '' : 's'}` : '▾ hide'} onPress={() => v.actions.toggleHistory()} />
666 {collapsed ? null : strip}
667 {pickerBox}
668 <Box flexGrow={1} />
669 <Button key="history-clear" plain label="✕ clear" onPress={() => v.actions.clearHistory()} />
670 </Box>
671 {collapsed ? null : (
672 <>
673 <Box flexDirection="column" gap={k.cfg.theme === 'dense' ? 0 : 1}>
674 {rows}
675 </Box>
676 {rows.length === 0 ? <Text dimColor>{filter === 'problems' ? 'No problem turns.' : 'Nothing yet.'}</Text> : null}
677 {more || shown.length > HISTORY_PAGE ? (
678 <Box key="history-paging" flexDirection="row" gap={2}>
679 {more && Button ? <Button key="history-more" plain label={`↓ load ${Math.min(HISTORY_PAGE, turns.length - shown.length)} more · ${turns.length - shown.length} older`} onPress={() => v.actions.turnPick('more')} /> : null}
680 {shown.length > HISTORY_PAGE && Button ? <Button key="history-less" plain label="↑ show less" onPress={() => v.actions.turnPick('less')} /> : null}
681 {more && !Button ? <Text dimColor>{`+${turns.length - shown.length} older · /watch history`}</Text> : null}
682 </Box>
683 ) : null}
684 </>
685 )}
686 </Box>
687 )
688}
689
690// A past turn, drawn like the dashboard was when it ended.
691function ReplayView(v: View) {
692 const { k, s } = v
693 const { Box, Text, Button } = k.els
694 const back = Button ? <Button key="replay-back" plain label="← dashboard" onPress={() => v.actions.tab('turn')} /> : <Text dimColor>/watch to go back</Text>
695 const all = (s.history ?? []).filter((e): e is WatchHistoryTurn => e.kind === 'turn')
696 const t = all.find(x => x.id === v.ui.replay)
697 if (!t) return Section(k, 'turn', 'TURN', [back, <Text dimColor>That turn is no longer in history.</Text>])
698 const n = all.indexOf(t) + 1
699 const out: unknown[] = [
700 Section(k, 'turn', 'TURN', [
701 back,
702 <Box key="replay-head" flexDirection="row" justifyContent="space-between" gap={1}>
703 <Text bold wrap="truncate-end">{`Turn ${n} · ${t.title || '(no prompt)'}`}</Text>
704 <Text dimColor>{`${turnTimes(t)} · ${t.interrupted ? 'interrupted' : time(t.durationMs ?? 0, 'human')}`}</Text>
705 </Box>,
706 ]),
707 ]
708 const snap = t.snapshot
709 if (snap?.plan) {
710 const steps = snap.plan.steps.map((x, i) => ({
711 id: `r${i}`,
712 title: x.title,
713 status: x.status as WatchPlanStep['status'],
714 calls: 0,
715 editedFiles: [],
716 agentIds: [],
717 startedAt: x.ms !== undefined ? 0 : undefined,
718 endedAt: x.ms,
719 }))
720 const plan: WatchPlan = { source: 'file', title: snap.plan.title, ticket: snap.plan.ticket, steps, startedAt: 0 }
721 const p = planProgress(plan)
722 out.push(
723 Section(k, 'plan', 'PLAN', [
724 <Box key="goal" flexDirection="row" justifyContent="space-between" gap={1}>
725 <Text bold wrap="truncate-end">{snap.plan.title}</Text>
726 <Text dimColor>{p.done === p.total ? `all ${p.total} steps done` : `step ${Math.min(p.total, p.done + 1)} of ${p.total}`}</Text>
727 </Box>,
728 Segments(k, 'replay-segments', steps.map(x => stepSem(x.status))),
729 ...StepList(v, plan, false),
730 ]),
731 )
732 }
733 const chips: unknown[] = []
734 if (snap?.tests?.length) chips.push(Badge(k, snap.tests[snap.tests.length - 1] ? 'ok' : 'attention', 'tests', snap.tests.map(ok => <Text color={ok ? TINT.ok.fg : TINT.attention.fg}>{ok ? '✓' : '✗'}</Text>)))
735 if (snap?.changes) chips.push(Badge(k, snap.changes.files ? 'violet' : 'slate', 'files changed', snap.changes.files ? `+${snap.changes.added} −${snap.changes.removed}` : 'none'))
736 if (snap?.contextPct !== undefined) chips.push(Badge(k, snap.contextPct >= 90 ? 'attention' : snap.contextPct >= k.cfg.health.contextWarnPct ? 'warn' : 'teal', 'context', pct(snap.contextPct)))
737 const detail = turnDetail(v, t, true)
738 out.push(
739 Section(k, 'replay-now', 'THEN', [
740 chips.length ? <Box key="chips" flexDirection="row" columnGap={1} flexWrap="wrap">{chips}</Box> : null,
741 ...detail,
742 ]),
743 )
744 return out
745}
746
747// ── Dashboard: does anything need me, which step is it on, is it stuck ──
748
749const STALL_MS = 90_000
750const DONE_SHOWN = 6
751const PENDING_SHOWN = 2
752
753function Dashboard(v: View) {
754 const compact = v.k.cfg.compact || v.k.columns < COMPACT_BELOW
755 const attention = AttentionCard(v)
756 // Idle: nothing running and nothing to act on → one line, then history.
757 if (!isActive(v.s) && !attention && !compact) {
758 // What changed and how checks fared matter most right after a turn ends.
759 return [IdleLine(v), IdlePlan(v), ChangesSection(v), ChecksSection(v)].filter(Boolean)
760 }
761 if (compact) return [attention, PlanSection(v, true), ContextSection(v)].filter(Boolean)
762 return [attention, PlanSection(v, false), ChangesSection(v), ChecksSection(v), ContextSection(v), TimelineSection(v)].filter(Boolean)
763}
764
765// "Idle · last turn 2:11 · 16 calls · tests ✓ · 2 files" … "main · ctx 57%"
766function IdleLine(v: View) {
767 const { k, s } = v
768 const { Box, Text } = k.els
769 const cfg = k.cfg
770 const turns = (s.history ?? []).filter((e): e is WatchHistoryTurn => e.kind === 'turn')
771 const last = turns[turns.length - 1]
772 const left = last
773 ? [
774 `Idle · last turn ${last.interrupted ? 'interrupted' : 'took ' + time(last.durationMs ?? 0, cfg.timeFormat)}`,
775 `${last.calls.length + last.moreCalls} calls`,
776 last.tests ? (last.tests.ok ? 'tests ✓' : 'tests ✗') : undefined,
777 last.files.length ? `${last.files.length} file${last.files.length === 1 ? '' : 's'}` : undefined,
778 ]
779 .filter(Boolean)
780 .join(' · ')
781 : 'Idle · waiting for a prompt'
782 const right = [s.branch, s.health.contextPct !== undefined ? `ctx ${pct(s.health.contextPct)}` : undefined].filter(Boolean).join(' · ')
783 return (
784 <Box key="idle" flexDirection="row" justifyContent="space-between">
785 <Text wrap="truncate-end">{left}</Text>
786 {right ? <Text dimColor>{right}</Text> : null}
787 </Box>
788 )
789}
790
791// A file plan that is not finished stays visible while idle: header and bar only.
792function IdlePlan(v: View) {
793 const plan = v.s.plan
794 if (!plan || !isPersistent(plan) || plan.steps.length === 0) return null
795 const { Text } = v.k.els
796 const p = planProgress(plan)
797 if (plan.endedAt) return Section(v.k, 'plan', 'PLAN', [<Text>{Mark(v.k, '✓', 'ok')}{` ${plan.title} · all ${p.total} steps done`}</Text>])
798 return Section(v.k, 'plan', 'PLAN', [planHeaderRow(v, plan), Bar(v.k, 'plan-bar', p.done / p.total, 'accent', `${p.done} / ${p.total}`)])
799}
800
801// Usual duration of the running call and how far past it we are.
802function callTiming(v: View): { elapsed: number; usual?: number } | undefined {
803 const { s, now, history, k } = v
804 if (!s.current) return undefined
805 const elapsed = now - s.current.startedAt
806 const eta = currentEta(s, history, k.cfg, now)
807 const usual = eta && eta.confidence !== 'max' ? Math.max(eta.remainingMs + elapsed, 1000) : undefined
808 return { elapsed, usual }
809}
810
811function stallText(v: View): string | undefined {
812 const { s, now, k } = v
813 if (!isActive(s)) return undefined
814 const t = callTiming(v)
815 if (t?.usual && t.elapsed > 2 * t.usual && t.elapsed > 10_000) return `⏳ ${truncate(s.current!.label, 40)} running ${(t.elapsed / t.usual).toFixed(1)}× its usual time`
816 const quiet = s.lastEventAt ? now - s.lastEventAt : 0
817 if (!s.current && quiet > STALL_MS) return `⏳ no activity for ${time(quiet, k.cfg.timeFormat)}`
818 return undefined
819}
820
821// The only red/amber card: needs-you, failing tests, stop errors, stalls.
822function AttentionCard(v: View) {
823 const { s, k, now } = v
824 const { Text, Code } = k.els
825 if (s.health.needsYou) {
826 const hint = s.health.needsYou.kind === 'permission' ? 'Review the prompt below to continue.' : 'Answer in the prompt below.'
827 return Card(k, 'attention', <Text bold>{`⏸ ${needsYouText(s, now)}`}</Text>, undefined, [<Text dimColor>{hint}</Text>], 'warn')
828 }
829 const t = s.health.lastTest
830 if (t && !t.ok && (s.turnStartedAt === null || t.at >= s.turnStartedAt)) {
831 const step = s.plan?.steps.find(x => x.lastTest === t || (x.lastTest && x.lastTest.at === t.at))
832 const body: unknown[] = [<Text dimColor>{truncate(`${step ? `${step.title} · ` : ''}${t.command}`, inner(k))}</Text>]
833 if (t.snippet && k.cfg.visuals.failedTestSnippet) body.push(Code ? <Code source={t.snippet} /> : <Text>{t.snippet}</Text>)
834 return Card(k, 'attention', <Text bold>{`✗ ${t.failed} test${t.failed === 1 ? '' : 's'} failing`}</Text>, ago(now - t.at), body, 'attention')
835 }
836 const err = s.health.lastError
837 if (err && s.turnStartedAt !== null && err.at >= s.turnStartedAt) {
838 return Card(k, 'attention', <Text bold>{`✗ Claude stopped: ${err.kind}`}</Text>, ago(now - err.at), err.text ? [<Text dimColor>{truncate(err.text, inner(k) * 2)}</Text>] : [], 'attention')
839 }
840 const stall = stallText(v)
841 if (stall) return Card(k, 'attention', <Text bold>{stall}</Text>, undefined, [], 'warn')
842 return null
843}
844
845function planSem(v: View, plan: WatchPlan): Semantic {
846 if (v.s.health.needsYou) return 'warn'
847 if (plan.steps.some(x => x.status === 'failed')) return 'attention'
848 if (plan.endedAt) return 'ok'
849 return isActive(v.s) ? 'accent' : 'dim'
850}
851
852// "CAM-77 · Migrate auth" "3 / 13 · ~8m 12:40"
853function planHeaderRow(v: View, plan: WatchPlan) {
854 const { k, s, now } = v
855 const { Box, Text } = k.els
856 const cfg = k.cfg
857 const active = isActive(s)
858 const ticket = plan.ticket && !plan.title.includes(plan.ticket) ? plan.ticket : undefined
859 const p = planProgress(plan)
860 const end = plan.endedAt ?? (active ? now : (s.turnEndedAt ?? now))
861 const right = [p.total ? `${p.done} / ${p.total}${p.remainingMs ? ` · ~${time(p.remainingMs, 'human')}` : ''}` : undefined, time(end - plan.startedAt, cfg.timeFormat)]
862 .filter(Boolean)
863 .join(' ')
864 return (
865 <Box key="plan-head" flexDirection="row" justifyContent="space-between" gap={1}>
866 <Box flexDirection="row" gap={1} flexShrink={1}>
867 {active && !plan.endedAt ? Pulse(k, 'accent') : Mark(k, plan.endedAt ? '✓' : '○', plan.endedAt ? 'ok' : 'dim')}
868 <Text wrap="truncate-end">
869 {ticket ? <Text color={resolve('accent', cfg)}>{`${ticket} · `}</Text> : null}
870 <Text bold>{plan.title || 'Working'}</Text>
871 </Text>
872 </Box>
873 <Text dimColor>{right}</Text>
874 </Box>
875 )
876}
877
878// What the turn is doing right now, or its receipt once it ended.
879function liveLine(v: View, indent: string) {
880 const { s, k } = v
881 const { Text } = k.els
882 const cfg = k.cfg
883 if (!isActive(s)) {
884 // No turn since /clear: nothing to account for.
885 if (s.turnStartedAt === null) return null
886 const turnStart = s.turnStartedAt
887 const steps = s.plan?.steps.filter(x => (x.status === 'done' || x.status === 'failed') && (x.endedAt ?? 0) >= turnStart).length ?? 0
888 const t = s.health.lastTest && s.health.lastTest.at >= turnStart ? s.health.lastTest : undefined
889 const facts = [`turn done · ${s.toolCalls} calls`, steps ? `${steps} steps` : undefined, t ? (t.ok ? 'tests ✓' : 'tests ✗') : undefined, s.health.editedFiles.length ? `${s.health.editedFiles.length} files` : undefined]
890 return <Text dimColor>{`${indent}${facts.filter(Boolean).join(' · ')}`}</Text>
891 }
892 const timing = callTiming(v)
893 if (s.current && timing) {
894 const usual = timing.usual ? `usually ~${time(timing.usual, cfg.timeFormat)}` : 'first run'
895 return Row(
896 k,
897 <Text wrap="truncate-end">
898 {indent}
899 {Mark(k, '▶', 'accent')} {truncate(s.current.label, inner(k) - indent.length - 22)}
900 <Text dimColor>{` ${usual}`}</Text>
901 </Text>,
902 time(timing.elapsed, cfg.timeFormat),
903 )
904 }
905 return <Text dimColor>{`${indent}thinking${s.toolCalls ? ` · ${s.toolCalls} tool calls` : ''}`}</Text>
906}
907
908function stepGlyph(step: WatchPlanStep): [string, Semantic] {
909 switch (step.status) {
910 case 'done':
911 return ['✓', 'ok']
912 case 'failed':
913 return ['✗', 'attention']
914 case 'running':
915 return ['▶', 'accent']
916 default:
917 return ['○', 'dim']
918 }
919}
920
921function stepRow(v: View, step: WatchPlanStep) {
922 const { k, now } = v
923 const { Text } = k.els
924 const [glyph, sem] = stepGlyph(step)
925 const dur = step.startedAt !== undefined ? time((step.endedAt ?? now) - step.startedAt, k.cfg.timeFormat) : ''
926 return Row(
927 k,
928 <Text wrap="truncate-end">
929 {Mark(k, glyph, sem)} {step.status === 'pending' ? <Text dimColor>{step.title}</Text> : step.title}
930 </Text>,
931 dur,
932 )
933}
934
935// A labelled detail row under a step: " running npm test · 0:41 · usually ~1:30".
936function detailRow(k: Kit, label: string, value: unknown, sem?: Semantic) {
937 const { Box, Text } = k.els
938 return (
939 <Box flexDirection="row">
940 <Text dimColor>{` ${label.padEnd(9)}`}</Text>
941 {typeof value === 'string' ? <Text wrap="truncate-end" color={sem ? resolve(sem, k.cfg) : undefined}>{value}</Text> : value}
942 </Box>
943 )
944}
945
946// The running step's detail: live call, next sub-step, tests, files.
947function stepDetail(v: View, step: WatchPlanStep, full: boolean): unknown[] {
948 const { k, s } = v
949 const { Text, Code } = k.els
950 const cfg = k.cfg
951 const lines: unknown[] = []
952 if (step.status === 'running' && isActive(s)) {
953 const timing = callTiming(v)
954 if (s.current && timing) {
955 const usual = timing.usual ? `usually ~${time(timing.usual, cfg.timeFormat)}` : 'first run'
956 lines.push(detailRow(k, 'running', `${s.current.label} · ${time(timing.elapsed, cfg.timeFormat)} · ${usual}`))
957 } else {
958 lines.push(detailRow(k, 'running', 'thinking'))
959 }
960 }
961 if (step.substeps?.length) {
962 const done = step.substeps.filter(x => x.done).length
963 if (full) for (const x of step.substeps) lines.push(detailRow(k, '', `${x.done ? '✓' : '○'} ${x.title}`))
964 else {
965 const next = step.substeps.find(x => !x.done)
966 lines.push(detailRow(k, 'next', next ? `${next.title} · ${done} of ${step.substeps.length} done` : `all ${step.substeps.length} done`))
967 }
968 }
969 if (step.lastTest) {
970 const t = step.lastTest
971 lines.push(detailRow(k, 'tests', t.ok ? `✓${t.passed ? ` ${t.passed} passed` : ''}` : `✗ ${t.failed} failed`, t.ok ? undefined : 'attention'))
972 if (full && !t.ok && t.snippet && Code) lines.push(<Code source={t.snippet} />)
973 }
974 if (step.editedFiles.length || step.calls) {
975 const files = step.editedFiles.length ? `${step.editedFiles.map(basename).join(', ')} · ` : ''
976 lines.push(detailRow(k, 'files', `${files}${step.calls} calls`))
977 }
978 return lines
979}
980
981// PLAN: header, bar, done steps (collapsed past six), the running step in detail, two pending.
982function PlanSection(v: View, compact: boolean) {
983 const { s, k, now } = v
984 const { Text } = k.els
985 const cfg = k.cfg
986 const plan = s.plan
987 if (!plan) return null
988 if (compact) return Section(k, 'plan', 'PLAN', [planHeaderRow(v, plan)])
989 const rows: unknown[] = [planHeaderRow(v, plan)]
990 const p = planProgress(plan)
991 if (p.total === 0) {
992 rows.push(liveLine(v, ''))
993 return Section(k, 'plan', 'PLAN', rows)
994 }
995 rows.push(Bar(k, 'plan-bar', p.done / p.total, p.done === p.total ? 'ok' : 'accent', p.done === p.total ? 'all done' : `${Math.round((p.done / p.total) * 100)}%`))
996 const finished = plan.steps.filter(x => x.status === 'done' || x.status === 'failed')
997 const doneOnly = finished.filter(x => x.status === 'done')
998 const collapsed = doneOnly.length > DONE_SHOWN ? new Set(doneOnly.slice(0, -2).map(x => x.id)) : new Set<string>()
999 let collapsedShown = false
1000 for (const step of plan.steps) {
1001 if (step.status === 'pending') continue
1002 if (collapsed.has(step.id)) {
1003 if (!collapsedShown) {
1004 const sum = doneOnly.slice(0, -2).reduce((t, x) => t + ((x.endedAt ?? 0) - (x.startedAt ?? x.endedAt ?? 0)), 0)
1005 rows.push(Row(k, <Text>{Mark(k, '✓', 'ok')}{` ${collapsed.size} steps done`}</Text>, time(sum, cfg.timeFormat)))
1006 collapsedShown = true
1007 }
1008 continue
1009 }
1010 rows.push(stepRow(v, step))
1011 if (step.status === 'running') rows.push(...stepDetail(v, step, false))
1012 }
1013 if (!p.current) rows.push(liveLine(v, ''))
1014 const pending = plan.steps.filter(x => x.status === 'pending')
1015 for (const step of pending.slice(0, PENDING_SHOWN)) rows.push(stepRow(v, step))
1016 if (pending.length > PENDING_SHOWN) rows.push(<Text dimColor>{`+${pending.length - PENDING_SHOWN} more`}</Text>)
1017 void now
1018 return Section(k, 'plan', 'PLAN', rows)
1019}
1020
1021// CHANGES: Claude's edits with +N −M and the turn; yours dim below; a total.
1022function ChangesSection(v: View) {
1023 const { k, s } = v
1024 const { Text } = k.els
1025 const rows0 = s.health.changes ?? []
1026 if (rows0.length === 0) return null
1027 const mine = rows0.filter(r => r.byClaude)
1028 const yours = rows0.filter(r => !r.byClaude)
1029 const rows: unknown[] = []
1030 for (const r of mine.slice(0, 6)) rows.push(changeRow(k, r, false))
1031 if (mine.length > 6) rows.push(<Text dimColor>{`+${mine.length - 6} more`}</Text>)
1032 if (yours.length) rows.push(<Text dimColor>{`also changed (yours): ${yours.length} file${yours.length === 1 ? '' : 's'}`}</Text>)
1033 const t = changesTotal(rows0)
1034 if (t.files) rows.push(<Text dimColor>{`${t.files} files · +${t.added} −${t.removed}`}</Text>)
1035 return Section(k, 'changes', 'CHANGES', rows)
1036}
1037
1038function changeRow(k: Kit, r: WatchChange, dim: boolean) {
1039 const { Box, Text } = k.els
1040 const counts = r.binary ? 'bin' : r.added < 0 ? 'new' : `+${r.added} −${r.removed}`
1041 return (
1042 <Box flexDirection="row">
1043 <Box flexGrow={1} flexShrink={1}>
1044 <Text wrap="truncate-end" dimColor={dim}>{fit(k, r.path, inner(k) - (narrow(k) ? 12 : 20))}</Text>
1045 </Box>
1046 <Box width={11} flexShrink={0} justifyContent="flex-end">
1047 <Text dimColor={dim}>{counts}</Text>
1048 </Box>
1049 {narrow(k) ? null : (
1050 <Box width={8} flexShrink={0} justifyContent="flex-end">
1051 <Text dimColor>{r.lastTurn ? `turn ${r.lastTurn}` : ''}</Text>
1052 </Box>
1053 )}
1054 </Box>
1055 )
1056}
1057
1058// CHECKS: each test/lint/build command, its last five outcomes, when and how.
1059function ChecksSection(v: View, all = false) {
1060 const { k, s, now } = v
1061 const { Box, Text, Code } = k.els
1062 const list = checkRows(s.health.checks ?? {})
1063 if (list.length === 0) return null
1064 const rows = (all ? list : list.slice(0, 4)).flatMap(c => {
1065 const last = c.runs[c.runs.length - 1]!
1066 const outcome = last.failed ? `${last.failed} failed` : last.passed ? `${last.passed} passed` : last.ok ? 'passed' : 'failed'
1067 const row = (
1068 <Box flexDirection="row" gap={1}>
1069 <Box flexGrow={1} flexShrink={1}>
1070 <Text wrap="truncate-end">{fit(k, c.command, inner(k) - (narrow(k) ? 22 : 30))}</Text>
1071 </Box>
1072 <Text wrap="truncate-end">
1073 {c.runs.slice(-5).map((r, i) => (
1074 <Text color={resolve(r.ok ? 'ok' : 'attention', k.cfg)}>{`${i ? ' ' : ''}${r.ok ? '✓' : '✗'}`}</Text>
1075 ))}
1076 </Text>
1077 <Box width={narrow(k) ? 10 : 18} flexShrink={0} justifyContent="flex-end">
1078 <Text dimColor wrap="truncate-end">{narrow(k) ? outcome : `${ago(now - last.at)} · ${outcome}`}</Text>
1079 </Box>
1080 </Box>
1081 )
1082 return all && !last.ok && last.snippet && Code ? [row, <Code source={last.snippet} />] : [row]
1083 })
1084 return Section(k, 'checks', 'CHECKS', rows)
1085}
1086
1087// CONTEXT: % with a gauge, and turns left before compaction at the current burn.
1088function ContextSection(v: View) {
1089 const { k, s } = v
1090 const { Box, Text } = k.els
1091 const hl = s.health
1092 if (hl.contextPct === undefined) return null
1093 const sem: Semantic | undefined = hl.contextPct >= 90 ? 'attention' : hl.contextPct >= k.cfg.health.contextWarnPct ? 'warn' : undefined
1094 const f = forecast(hl.contextHistory ?? [], lastCompactIndex(s.history ?? []), hl.contextPct, k.cfg.health.compactAtPct ?? 85)
1095 const row = (
1096 <Box flexDirection="row" gap={1} alignItems="center">
1097 <Text color={sem ? resolve(sem, k.cfg) : undefined}>{pct(hl.contextPct)}</Text>
1098 {MiniBar(k, 'ctx-bar', hl.contextPct / 100, sem ?? 'accent', k.cfg.health.contextWarnPct / 100, 12)}
1099 {(hl.contextHistory ?? []).length >= 1 ? <Text dimColor>{forecastText(f)}</Text> : null}
1100 </Box>
1101 )
1102 return Section(k, 'context', 'CONTEXT', [row])
1103}
1104
1105function agentSem(a: WatchSnapshot['agents'][number]): Semantic {
1106 return a.phase === 'running' ? 'accent' : a.phase === 'done' ? 'ok' : a.phase === 'failed' ? 'attention' : 'dim'
1107}
1108
1109function agentRow(v: View, a: WatchSnapshot['agents'][number]) {
1110 const { k, now } = v
1111 const { Text } = k.els
1112 const glyph = a.phase === 'running' ? '▶' : a.phase === 'done' ? '✓' : a.phase === 'failed' ? '✗' : '○'
1113 return Row(
1114 k,
1115 <Text wrap="truncate-end">
1116 {Mark(k, glyph, agentSem(a))} {a.type}
1117 <Text dimColor>{a.current ? ` ${truncate(a.current, 30)}` : ''}</Text>
1118 </Text>,
1119 time((a.endedAt ?? now) - a.startedAt, k.cfg.timeFormat),
1120 )
1121}
1122
1123// The chart's right edge: now while a turn runs, frozen at the last end once it stops.
1124function ganttTo(v: View): number {
1125 const { s, now } = v
1126 if (isActive(s) || s.agents.some(a => a.endedAt === undefined)) return now
1127 return Math.max(s.turnEndedAt ?? 0, ...s.agents.map(a => a.endedAt ?? 0)) || now
1128}
1129
1130function lanes(v: View): GanttLane[] {
1131 const { s } = v
1132 const now = ganttTo(v)
1133 return [
1134 ...(s.turnStartedAt !== null ? [{ label: 'main', start: s.turnStartedAt, end: s.turnEndedAt ?? now, sem: phaseSem(s.phase) }] : []),
1135 ...s.agents.map(a => ({ label: a.type, start: a.startedAt, end: a.endedAt ?? now, sem: agentSem(a), failed: a.phase === 'failed' })),
1136 ]
1137}
1138
1139function ganttFrom(v: View): number {
1140 const { s, k } = v
1141 const now = ganttTo(v)
1142 return Math.max(now - k.cfg.visuals.ganttWindowMinutes * 60_000, Math.min(s.turnStartedAt ?? now, ...s.agents.map(a => a.startedAt)))
1143}
1144
1145// AGENTS: one swimlane chart for main and every agent; rows for running and failed ones.
1146function TimelineSection(v: View) {
1147 const { s, k, now } = v
1148 if (s.agents.length === 0) return null
1149 const running = s.agents.filter(a => a.phase === 'running')
1150 const failed = s.agents.filter(a => a.phase === 'failed')
1151 const chart = Gantt(k, 'timeline', lanes(v), ganttFrom(v), ganttTo(v))
1152 const rows = chart ? [chart, ...[...running, ...failed].slice(0, 4).map(a => agentRow(v, a))] : s.agents.slice(-4).map(a => agentRow(v, a))
1153 return Section(k, 'agents', 'AGENTS', rows)
1154}
1155
1156// "claude-opus-5-5" → "opus 5.5"; other ids pass through.
1157export function shortModel(id: string): string {
1158 const m = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id)
1159 return m ? `${m[1]} ${m[2]}.${m[3]}` : id.replace(/^claude-/, '')
1160}
1161
1162// ── History: every turn and session event, collapsed rows that expand on press ──
1163
1164const HISTORY_ROWS = 12
1165const CALLS_SHOWN = 10
1166const EVENT_ICON: Record<WatchHistoryEvent['icon'], string> = { compact: '⇣', model: '⚙', error: '✗', plan: '☰' }
1167
1168function clock(at: number): string {
1169 const d = new Date(at)
1170 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
1171}
1172
1173function turnSummary(t: WatchHistoryTurn, n: number, cfg: WatchConfig): string {
1174 return [
1175 `Turn ${n} · ${t.title || '(no prompt)'}`,
1176 t.interrupted ? 'interrupted' : t.ended ? time(t.durationMs ?? 0, cfg.timeFormat) : 'running',
1177 `${t.calls.length + t.moreCalls} calls`,
1178 t.tests ? (t.tests.ok ? 'tests ✓' : 'tests ✗') : undefined,
1179 t.files.length ? `${t.files.length} file${t.files.length === 1 ? '' : 's'}` : undefined,
1180 t.waitMs ? `waited ${time(t.waitMs, cfg.timeFormat)}` : undefined,
1181 ]
1182 .filter(Boolean)
1183 .join(' · ')
1184}
1185
1186function turnDetail(v: View, t: WatchHistoryTurn, full: boolean): unknown[] {
1187 const { k } = v
1188 const { Text, Code } = k.els
1189 const cfg = k.cfg
1190 const pad = ' '
1191 const lines: unknown[] = []
1192 for (const st of t.steps) {
1193 const [glyph, sem] = st.status === 'done' ? ['✓', 'ok'] : st.status === 'failed' ? ['✗', 'attention'] : st.status === 'running' ? ['▶', 'accent'] : ['○', 'dim']
1194 lines.push(<Text>{pad}{Mark(k, glyph, sem as Semantic)}{` step: ${st.title}`}</Text>)
1195 }
1196 const calls = full ? t.calls : t.calls.slice(-CALLS_SHOWN)
1197 const hidden = t.moreCalls + (t.calls.length - calls.length)
1198 if (hidden > 0) lines.push(<Text dimColor>{`${pad}+${hidden} earlier calls`}</Text>)
1199 for (const c of calls) {
1200 const ok = c.ok !== falsehooks/render/widgets.tsx 339 lines1import type { WatchConfig } from '../../types'
2import { css, hex, resolve, TINT, TRACK_CSS, TRACK_HEX, type Semantic, type Tint } from './colors'
3import * as raster from './raster'
4import * as svg from './svg'
5
6// Element constructors differ per surface; widgets check what exists and fall back to text.
7// eslint-disable-next-line @typescript-eslint/no-explicit-any
8export type Els = Record<string, any>
9
10export type Kit = {
11 els: Els
12 surface: string
13 cfg: WatchConfig
14 columns: number
15 // Pane body height in rows, when the surface says; the blocks fill it.
16 rows?: number
17}
18
19// An Svg at its markup's own pixel size: surfaces stretch an unsized Svg to the
20// slot, which scales its text too.
21export function svgEl(k: Kit, source: string, alt: string) {
22 const { Svg } = k.els
23 const w = Number(/width="([\d.]+)"/.exec(source)?.[1] ?? 0) || undefined
24 const hh = Number(/height="([\d.]+)"/.exec(source)?.[1] ?? 0) || undefined
25 return <Svg source={source} alt={alt} width={w} height={hh} />
26}
27
28// One bar thickness everywhere: progress, gauge and agent lanes.
29export const BAR_PX = 4
30
31export const hasSvg = (k: Kit) => k.cfg.useSvg && 'Svg' in k.els
32export const hasRaster = (k: Kit) => k.cfg.useRaster && 'Raster' in k.els
33
34// Width available inside a card (border + padding take 4 columns).
35export const inner = (k: Kit) => Math.max(10, k.columns - (cardsOn(k) ? 4 : 0))
36// Svg pixels per pane column, so images track the pane's width.
37export const PX = 7
38export const maxPx = (k: Kit) => inner(k) * PX
39// Below this many inner columns, rows drop their secondary columns.
40export const NARROW = 40
41export const narrow = (k: Kit) => inner(k) < NARROW
42const cardsOn = (k: Kit) => k.cfg.visuals.cards && k.cfg.theme !== 'minimal' && !k.cfg.compact
43
44const GLYPHS: Record<WatchConfig['barStyle'], [string, string]> = {
45 blocks: ['━', '─'],
46 dots: ['●', '○'],
47 ascii: ['#', '-'],
48}
49
50export function textBar(fraction: number, width: number, style: WatchConfig['barStyle']): string {
51 const [on, off] = GLYPHS[style]
52 const filled = Math.round(Math.max(0, Math.min(1, fraction)) * width)
53 return on.repeat(filled) + off.repeat(Math.max(0, width - filled))
54}
55
56// Progress bar that fills the card, with a right-aligned dim label.
57export function Bar(k: Kit, key: string, fraction: number, sem: Semantic, label: string) {
58 const { Box, Text, Svg, Raster } = k.els
59 const width = Math.max(6, inner(k) - label.length - 1)
60 let bar
61 if (hasSvg(k)) {
62 bar = svgEl(k, svg.bar(fraction, css(sem, k.cfg), TRACK_CSS, width * PX, BAR_PX), textBar(fraction, width, 'ascii'))
63 } else if (hasRaster(k)) {
64 const g = raster.bar(fraction, width, hex(sem, k.cfg), TRACK_HEX)
65 bar = <Raster key={key} columns={g.columns} rows={1} cells={g.cells} />
66 } else {
67 bar = <Text color={resolve(sem, k.cfg)}>{textBar(fraction, width, k.cfg.barStyle)}</Text>
68 }
69 return (
70 <Box flexDirection="row" gap={1}>
71 {bar}
72 <Text dimColor>{label}</Text>
73 </Box>
74 )
75}
76
77// Short bold chip. Colour only when it carries state.
78export function Pill(k: Kit, text: string, sem: Semantic | undefined) {
79 const { Text } = k.els
80 if (!sem || sem === 'dim') return <Text dimColor>{text}</Text>
81 if (!k.cfg.visuals.pills) return <Text color={resolve(sem, k.cfg)}>{text}</Text>
82 return (
83 <Text bold inverse color={resolve(sem, k.cfg)}>
84 {` ${text} `}
85 </Text>
86 )
87}
88
89// Same card everywhere: rounded border, 1-cell padding, bold header, body. No gaps inside.
90export function Card(k: Kit, key: string, title: unknown, right: string | undefined, body: unknown[], sem?: Semantic) {
91 const { Box, Text } = k.els
92 const header = (
93 <Box flexDirection="row" justifyContent="space-between">
94 {typeof title === 'string' ? <Text bold>{title}</Text> : title}
95 {right ? <Text dimColor>{right}</Text> : null}
96 </Box>
97 )
98 if (!cardsOn(k)) {
99 return (
100 <Box key={key} flexDirection="column">
101 {header}
102 {body}
103 </Box>
104 )
105 }
106 return (
107 <Box key={key} flexDirection="column" borderStyle="round" borderColor={resolve(sem ?? 'dim', k.cfg)} paddingX={1}>
108 {header}
109 {body}
110 </Box>
111 )
112}
113
114// A label on the left, a fixed-width right-aligned value column.
115export function Row(k: Kit, left: unknown, right: string, valueWidth = 7) {
116 const { Box, Text } = k.els
117 return (
118 <Box flexDirection="row">
119 <Box flexGrow={1} flexShrink={1}>
120 {left}
121 </Box>
122 <Box width={valueWidth} justifyContent="flex-end">
123 <Text dimColor>{right}</Text>
124 </Box>
125 </Box>
126 )
127}
128
129// A labelled section: dim uppercase label column, rows in the value column. No border.
130export function Section(k: Kit, key: string, label: string, rows: unknown[]) {
131 const { Box, Text } = k.els
132 return (
133 <Box key={key} flexDirection="row">
134 <Box width={9} flexShrink={0}>
135 <Text dimColor bold>
136 {label}
137 </Text>
138 </Box>
139 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
140 {rows}
141 </Box>
142 </Box>
143 )
144}
145
146// Small section heading: uppercase, dim, bold.
147export function SectionLabel(k: Kit, text: string) {
148 const { Text } = k.els
149 return (
150 <Text dimColor bold>
151 {text.toUpperCase()}
152 </Text>
153 )
154}
155
156// A fixed-width gauge with an optional tick at `mark` (0..1), e.g. a warning level.
157export function MiniBar(k: Kit, key: string, fraction: number, sem: Semantic, mark?: number, cells = 10) {
158 const { Text, Svg, Raster } = k.els
159 const f = Math.max(0, Math.min(1, fraction))
160 cells = Math.min(cells, inner(k))
161 if (hasSvg(k)) {
162 const w = cells * PX
163 const tick = mark !== undefined ? `<line x1="${Math.round(mark * w)}" y1="0" x2="${Math.round(mark * w)}" y2="${BAR_PX}" stroke="#8b949e" stroke-width="1"/>` : ''
164 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${BAR_PX}" viewBox="0 0 ${w} ${BAR_PX}"><rect width="${w}" height="${BAR_PX}" rx="${BAR_PX / 2}" fill="${TRACK_CSS}"/><rect width="${Math.round(f * w)}" height="${BAR_PX}" rx="${BAR_PX / 2}" fill="${css(sem, k.cfg)}"/>${tick}</svg>`
165 return svgEl(k, source, `${Math.round(f * 100)}%`)
166 }
167 if (hasRaster(k)) {
168 const g = raster.bar(f, cells, hex(sem, k.cfg), TRACK_HEX)
169 return <Raster key={key} columns={g.columns} rows={1} cells={g.cells} />
170 }
171 return <Text color={resolve(sem, k.cfg)}>{textBar(f, cells, k.cfg.barStyle)}</Text>
172}
173
174// One block per step, coloured by status; 4px like every bar.
175export function Segments(k: Kit, key: string, sems: readonly Semantic[]) {
176 const { Box, Text, Svg, Raster } = k.els
177 const n = Math.max(1, sems.length)
178 if (hasSvg(k)) {
179 const total = maxPx(k)
180 const gap = n > 40 ? 1 : 2
181 const w = (total - gap * (n - 1)) / n
182 const fill = (s: Semantic) => (s === 'dim' ? '#2f3a4a' : css(s, k.cfg))
183 const rects = sems.map((s, i) => `<rect x="${(i * (w + gap)).toFixed(1)}" y="0" width="${w.toFixed(1)}" height="${BAR_PX}" rx="1" fill="${fill(s)}"/>`)
184 return (
185 <Box key={key}>
186 {svgEl(k, `<svg xmlns="http://www.w3.org/2000/svg" data-kind="segments" width="${total}" height="${BAR_PX}" viewBox="0 0 ${total} ${BAR_PX}">${rects.join('')}</svg>`, sems.map(s => (s === 'ok' ? '▰' : '▱')).join(''))}
187 </Box>
188 )
189 }
190 if (hasRaster(k)) {
191 const cells = raster.cellsOf(sems.map(s => ({ ch: '▬', fg: hex(s, k.cfg) })))
192 return (
193 <Box key={key}>
194 <Raster key={`${key}-r`} columns={n} rows={1} cells={cells} />
195 </Box>
196 )
197 }
198 return (
199 <Box key={key}>
200 <Text>
201 {sems.map(s => (
202 <Text color={resolve(s, k.cfg)}>{s === 'dim' ? '▱' : '▰'}</Text>
203 ))}
204 </Text>
205 </Box>
206 )
207}
208
209// One bar per turn: height = duration, colour = outcome.
210export function TurnStrip(k: Kit, key: string, turns: readonly { ms: number; sem: Semantic }[]) {
211 const { Box, Text, Svg } = k.els
212 if (turns.length === 0) return null
213 turns = turns.slice(-Math.max(1, Math.floor(maxPx(k) / (BAR_PX + 2))))
214 const max = Math.max(...turns.map(t => t.ms), 1)
215 if (hasSvg(k)) {
216 const tall = 14
217 const rects = turns.map((t, i) => {
218 const bh = Math.max(2, Math.round((t.ms / max) * tall))
219 return `<rect x="${i * (BAR_PX + 2)}" y="${tall - bh}" width="${BAR_PX}" height="${bh}" rx="1" fill="${css(t.sem, k.cfg)}"/>`
220 })
221 const w = turns.length * (BAR_PX + 2)
222 return (
223 <Box key={key}>
224 {svgEl(k, `<svg xmlns="http://www.w3.org/2000/svg" data-kind="turns" width="${w}" height="${tall}" viewBox="0 0 ${w} ${tall}">${rects.join('')}</svg>`, `${turns.length} turns`)}
225 </Box>
226 )
227 }
228 const SPARK = '▁▂▃▄▅▆▇█'
229 return (
230 <Box key={key}>
231 <Text>
232 {turns.map(t => (
233 <Text color={resolve(t.sem, k.cfg)}>{SPARK[Math.min(7, Math.floor((t.ms / max) * 7.999))]}</Text>
234 ))}
235 </Text>
236 </Box>
237 )
238}
239
240// A tinted badge: " label value ".
241export function Badge(k: Kit, tint: Tint, label: string, value: unknown) {
242 const { Text } = k.els
243 const t = TINT[tint]
244 return (
245 <Text backgroundColor={t.bg} color={t.fg}>
246 {` ${label} `}
247 {value}
248 {' '}
249 </Text>
250 )
251}
252
253export function Mark(k: Kit, glyph: string, sem: Semantic) {
254 const { Text } = k.els
255 return <Text color={resolve(sem, k.cfg)}>{glyph}</Text>
256}
257
258// The one motion: a Client pulse where the surface has Client, a still dot elsewhere.
259export function Pulse(k: Kit, sem: Semantic, key = 'pulse') {
260 const { Client, Text } = k.els
261 // Client runs on the terminal and Desktop only; VS Code gets a still dot.
262 if (k.cfg.animations && Client && (k.surface === 'terminal' || k.surface === 'desktop')) return <Client key={key} module="../client/pulse.ts" props={{ color: resolve(sem, k.cfg) }} width={1} />
263 return <Text color={resolve(sem, k.cfg)}>●</Text>
264}
265
266// A small trend line. `cells` bounds its width; `max` fixes the scale (100 for a percentage).
267export function Sparkline(k: Kit, key: string, values: readonly number[], opts: { sem?: Semantic; cells?: number; max?: number; alt?: string } = {}) {
268 const { Text, Svg, Raster } = k.els
269 if (!k.cfg.visuals.sparkline || values.length < 2) return null
270 const sem = opts.sem ?? 'accent'
271 const cells = Math.min(inner(k), opts.cells ?? 40)
272 const shown = values.slice(-cells)
273 if (hasSvg(k)) return svgEl(k, svg.sparkline(shown, css(sem, k.cfg), Math.max(24, shown.length * 5), 12, opts.max), opts.alt ?? 'trend')
274 if (hasRaster(k)) {
275 const g = raster.sparkline(shown, hex(sem, k.cfg), opts.max)
276 return <Raster key={key} columns={g.columns} rows={1} cells={g.cells} />
277 }
278 const max = opts.max ?? Math.max(...shown, 1)
279 return <Text dimColor>{shown.map(v => '▁▂▃▄▅▆▇█'[Math.min(7, Math.floor((v / max) * 7.999))]).join('')}</Text>
280}
281
282export function Donut(k: Kit, fraction: number, sem: Semantic) {
283 const { Text, Svg } = k.els
284 if (k.cfg.visuals.contextDonut && hasSvg(k)) {
285 return svgEl(k, svg.donut(fraction, css(sem, k.cfg), TRACK_CSS), `${Math.round(fraction * 100)}%`)
286 }
287 return <Text color={resolve(sem, k.cfg)}>{textBar(fraction, 10, k.cfg.barStyle)}</Text>
288}
289
290export type GanttLane = { label: string; start: number; end: number; sem: Semantic; failed?: boolean }
291
292export function Gantt(k: Kit, key: string, lanes: readonly GanttLane[], from: number, to: number) {
293 const { Box, Text, Svg, Raster } = k.els
294 if (!k.cfg.visuals.gantt || lanes.length < 2) return null
295 const width = inner(k)
296 if (hasSvg(k)) {
297 // One row per lane: the label is pane text (never scaled), the bar a small image.
298 const labelW = narrow(k) ? 8 : 15
299 const plot = Math.max(PX * 4, (inner(k) - labelW - 1) * PX)
300 return (
301 <Box key={key} flexDirection="column">
302 {lanes.map((l, i) => (
303 <Box key={`lane-${i}`} flexDirection="row" gap={1} alignItems="center">
304 <Box width={labelW} flexShrink={0}>
305 <Text dimColor wrap="truncate-end">
306 {l.label}
307 </Text>
308 </Box>
309 {svgEl(k, svg.gantt([{ label: l.label, start: l.start, end: l.end, color: css(l.sem, k.cfg), outline: l.failed ? css('attention', k.cfg) : undefined }], from, to, plot, 0), l.label)}
310 </Box>
311 ))}
312 </Box>
313 )
314 }
315 if (hasRaster(k)) {
316 const labelW = narrow(k) ? 6 : 10
317 const g = raster.swimlanes(
318 lanes.map(l => ({ start: l.start, end: l.end, fg: hex(l.sem, k.cfg) })),
319 from,
320 to,
321 Math.max(10, width - labelW - 1),
322 TRACK_HEX,
323 )
324 return (
325 <Box flexDirection="row" gap={1}>
326 <Box flexDirection="column" width={labelW}>
327 {lanes.map(l => (
328 <Text dimColor wrap="truncate-end">
329 {l.label}
330 </Text>
331 ))}
332 </Box>
333 <Raster key={key} columns={g.columns} rows={g.rows} cells={g.cells} />
334 </Box>
335 )
336 }
337 return null
338}
339hooks/sessions.ts 21 lines1import type { WatchPeer, WatchSnapshot } from '../types'
2import { basename } from './format'
3
4export function heartbeatOf(s: WatchSnapshot, now: number, label: string | undefined, endedAt?: number): WatchPeer {
5 return {
6 sessionId: s.sessionId,
7 project: basename(s.cwd),
8 cwd: s.cwd,
9 branch: s.branch,
10 phase: s.phase,
11 label,
12 turnStartedAt: s.turnStartedAt,
13 tasksDone: s.plan?.steps.filter(t => t.status === 'done' || t.status === 'failed').length ?? 0,
14 tasksTotal: s.plan?.steps.length ?? 0,
15 needsYou: s.health.needsYou?.kind,
16 beatAt: now,
17 endedAt,
18 }
19}
20
21hooks/plan.ts 305 lines1// The plan model: parse plan files and ledgers, merge todo/task events, and
2// attribute what happens to the step that is running. Pure; no `$`.
3
4import type { WatchPlan, WatchPlanStep, WatchTestResult } from '../types'
5
6export type ParsedStep = { id: string; title: string; substeps: { title: string; done: boolean }[] }
7export type ParsedPlan = { title: string; steps: ParsedStep[] }
8
9const PLANS_DIR = '/docs/superpowers/plans/'
10const TASK_HEADING = /^###\s+Task\s+(\d+):\s*(.+?)\s*$/
11const CHECKBOX = /^\s*[-*]\s+\[( |x|X)\]\s+(.+?)\s*$/
12const TICKET = /\b[A-Z][A-Z0-9]+-\d+\b/
13
14// "**Step 3: Add the fields** in `x`" → "Add the fields"
15function substepTitle(raw: string): string {
16 let t = raw
17 const bold = /^\*\*(.+?)\*\*/.exec(t)
18 if (bold) t = bold[1]!
19 return t.replace(/^Step\s+\d+:\s*/i, '').trim()
20}
21
22export function parsePlanFile(text: string): ParsedPlan | undefined {
23 const lines = text.split('\n')
24 const titleLine = lines.find(l => /^#\s+/.test(l))
25 const title = (titleLine ?? '').replace(/^#\s+/, '').replace(/\s+Implementation Plan\s*$/i, '').trim()
26
27 // superpowers format: ### Task N: <title>, checkbox substeps under each
28 const steps: ParsedStep[] = []
29 for (const line of lines) {
30 const task = TASK_HEADING.exec(line)
31 if (task) {
32 steps.push({ id: `task-${task[1]}`, title: task[2]!, substeps: [] })
33 continue
34 }
35 const box = CHECKBOX.exec(line)
36 if (box && steps.length) steps[steps.length - 1]!.substeps.push({ title: substepTitle(box[2]!), done: box[1] !== ' ' })
37 }
38 if (steps.length) return { title, steps }
39
40 // Fallback (plan mode and free-form plans): ##/### headings, else top-level list items.
41 const headings = lines.filter(l => /^#{2,3}\s+/.test(l)).map(l => l.replace(/^#{2,3}\s+/, '').trim())
42 const items = lines.filter(l => /^(\d+[.)]|[-*])\s+/.test(l)).map(l => l.replace(/^(\d+[.)]|[-*])\s+(\[[ xX]\]\s+)?/, '').trim())
43 const pick = headings.length >= 2 ? headings : items.length >= 2 ? items : []
44 if (pick.length < 2) return undefined
45 return { title, steps: pick.map((t, i) => ({ id: `item-${i + 1}`, title: t, substeps: [] })) }
46}
47
48function repoRoot(planPath: string): string | undefined {
49 const i = planPath.indexOf(PLANS_DIR)
50 return i >= 0 ? planPath.slice(0, i) : undefined
51}
52
53export function isPlanPath(path: string): boolean {
54 return path.includes(PLANS_DIR) && path.endsWith('.md')
55}
56
57export function ledgerPath(planPath: string): string {
58 const root = repoRoot(planPath) ?? planPath.replace(/\/[^/]*$/, '')
59 const base = planPath.replace(/^.*\//, '').replace(/\.md$/, '')
60 return `${root}/.superpowers/sdd/${base}/progress.md`
61}
62
63// Task numbers the ledger marks complete — only when its first line names this plan.
64export function parseLedger(text: string, planPath: string): Set<number> {
65 const done = new Set<number>()
66 const [first = '', ...rest] = text.split('\n')
67 const root = repoRoot(planPath)
68 const relative = root !== undefined ? planPath.slice(root.length + 1) : planPath
69 const named = first.replace(/^.*plan:\s*/, '').trim()
70 if (!named || !(named === relative || named === planPath || planPath.endsWith(`/${named}`))) return done
71 for (const line of rest) {
72 const m = /^Task\s+(\d+):\s*complete/.exec(line.trim())
73 if (m) done.add(Number(m[1]))
74 }
75 return done
76}
77
78export function ticketKey(...candidates: (string | undefined)[]): string | undefined {
79 for (const c of candidates) {
80 const m = c ? TICKET.exec(c) : null
81 if (m) return m[0]
82 }
83 return undefined
84}
85
86// ── Plan model ──
87
88type Status = WatchPlanStep['status']
89type Todo = { content?: unknown; status?: unknown }
90
91function blank(id: string, title: string): WatchPlanStep {
92 return { id, title, status: 'pending', calls: 0, editedFiles: [], agentIds: [] }
93}
94
95// Status changes stamp startedAt on entering running and endedAt on finishing.
96function withStatus(step: WatchPlanStep, status: Status, now: number): WatchPlanStep {
97 if (step.status === status) return step
98 return {
99 ...step,
100 status,
101 startedAt: status === 'running' || status === 'done' || status === 'failed' ? (step.startedAt ?? now) : step.startedAt,
102 endedAt: status === 'done' || status === 'failed' ? (step.endedAt ?? now) : undefined,
103 }
104}
105
106const finished = (s: WatchPlanStep) => s.status === 'done' || s.status === 'failed'
107
108// With nothing running, the first unfinished step becomes the running one.
109function advance(steps: WatchPlanStep[], now: number): WatchPlanStep[] {
110 if (steps.some(s => s.status === 'running')) return steps
111 const i = steps.findIndex(s => s.status === 'pending')
112 return i < 0 ? steps : steps.map((s, j) => (j === i ? withStatus(s, 'running', now) : s))
113}
114
115// Exactly one running step: entering running sends any other running step back to pending.
116function setRunning(steps: WatchPlanStep[], i: number, now: number): WatchPlanStep[] {
117 return steps.map((s, j) => (j === i ? withStatus(s, 'running', now) : s.status === 'running' ? { ...s, status: 'pending' as const } : s))
118}
119
120const normal = (t: string) => t.replace(/^Task\s+\d+:\s*/i, '').trim().toLowerCase()
121
122// A todo or task naming several steps ("Implement Tasks 1+2", "Tasks 1-3", "Task 1 and 2")
123// stands for each of them; otherwise it names one step as below.
124function matchSteps(steps: readonly WatchPlanStep[], text: string): number[] {
125 const list = /\bTasks?\s+(\d+(?:\s*(?:[+,&\-–]|and)\s*\d+)+)\b/i.exec(text)?.[1]
126 if (list) {
127 const nums = list.split(/\s*(?:[+,&]|and)\s*/i).flatMap(part => {
128 const [a, b] = part.split(/\s*[\-–]\s*/).map(Number)
129 return b !== undefined && b >= a! && b - a! < 50 ? Array.from({ length: b - a! + 1 }, (_, i) => a! + i) : [a!]
130 })
131 const found = nums.map(n => steps.findIndex(s => s.id === `task-${n}`)).filter(i => i >= 0)
132 if (found.length) return [...new Set(found)]
133 }
134 const i = matchStep(steps, text)
135 return i < 0 ? [] : [i]
136}
137
138// A todo or task names a step by "Task N:", by its exact title, or by a close prefix:
139// the shorter title is at least 60% of the longer and ends on a word boundary.
140function matchStep(steps: readonly WatchPlanStep[], text: string): number {
141 const num = /^\s*Task\s+(\d+):/i.exec(text)?.[1]
142 if (num) {
143 const i = steps.findIndex(s => s.id === `task-${num}`)
144 if (i >= 0) return i
145 }
146 const t = normal(text)
147 if (!t) return -1
148 const exact = steps.findIndex(s => s.title.toLowerCase() === t)
149 if (exact >= 0) return exact
150 return steps.findIndex(s => {
151 const title = s.title.toLowerCase()
152 const [short, long] = title.length <= t.length ? [title, t] : [t, title]
153 return short.length >= 0.6 * long.length && long.startsWith(short) && /^\W|^$/.test(long.slice(short.length))
154 })
155}
156
157function todoStatus(status: unknown): Status | undefined {
158 return status === 'completed' ? 'done' : status === 'in_progress' ? 'running' : status === 'pending' ? 'pending' : undefined
159}
160
161export function isPersistent(plan: WatchPlan | null): boolean {
162 return plan?.source === 'file' || plan?.source === 'planmode'
163}
164
165export function planFromFile(
166 parsed: ParsedPlan,
167 opts: { path: string; source: 'file' | 'planmode'; done: Set<number>; now: number; prev?: WatchPlan; branch?: string; prompt?: string },
168): WatchPlan {
169 const { now, prev } = opts
170 const known = new Map((prev?.path === opts.path ? prev.steps : []).map(s => [s.id, s]))
171 let steps = parsed.steps.map(p => {
172 const before = known.get(p.id)
173 const extras = (before?.substeps ?? []).filter(x => !p.substeps.some(y => y.title === x.title))
174 const base: WatchPlanStep = { ...(before ?? blank(p.id, p.title)), title: p.title, substeps: [...p.substeps, ...extras] }
175 const num = Number(/^task-(\d+)$/.exec(p.id)?.[1])
176 const allChecked = p.substeps.length > 0 && p.substeps.every(x => x.done)
177 if (opts.done.has(num) || allChecked) return withStatus(base, 'done', now)
178 return base
179 })
180 // The first unfinished step runs from the moment the plan is loaded, so early work is credited.
181 steps = advance(steps, now)
182 const title = parsed.title || prev?.title || 'Plan'
183 return {
184 source: opts.source,
185 title,
186 ticket: ticketKey(title, opts.branch, opts.prompt),
187 path: opts.path,
188 steps,
189 startedAt: prev?.path === opts.path ? prev.startedAt : now,
190 endedAt: undefined,
191 }
192}
193
194export function mergeTodos(plan: WatchPlan | null, todos: readonly Todo[], now: number, prompt?: string): WatchPlan {
195 const items = todos.filter((t): t is { content: string; status?: unknown } => typeof t.content === 'string')
196 if (plan && isPersistent(plan)) {
197 let steps = plan.steps
198 const extras: { title: string; done: boolean }[] = []
199 for (const todo of items) {
200 const idx = matchSteps(steps, todo.content)
201 const status = todoStatus(todo.status)
202 if (!idx.length) {
203 extras.push({ title: todo.content, done: status === 'done' })
204 continue
205 }
206 if (status === 'running') steps = setRunning(steps, idx[0]!, now)
207 else if (status === 'done') steps = steps.map((s, j) => (idx.includes(j) ? withStatus(s, 'done', now) : s))
208 }
209 if (steps.some(finished)) steps = advance(steps, now)
210 const r = steps.findIndex(s => s.status === 'running')
211 if (r >= 0 && extras.length) {
212 steps = steps.map((s, j) => {
213 if (j !== r) return s
214 const subs = [...(s.substeps ?? [])]
215 for (const x of extras) {
216 const k = subs.findIndex(y => y.title === x.title)
217 if (k >= 0) subs[k] = x
218 else subs.push(x)
219 }
220 return { ...s, substeps: subs }
221 })
222 }
223 return { ...plan, steps }
224 }
225 const known = new Map((plan?.steps ?? []).map(s => [s.title, s]))
226 const steps = items.map(t => withStatus(known.get(t.content) ?? blank(`todo-${t.content}`, t.content), todoStatus(t.status) ?? 'pending', now))
227 return {
228 source: 'todo',
229 title: plan?.title ?? (prompt ? firstLine(prompt) : 'Todo list'),
230 ticket: plan?.ticket ?? ticketKey(prompt),
231 steps,
232 startedAt: plan?.startedAt ?? now,
233 }
234}
235
236export function taskEvent(plan: WatchPlan | null, ev: { id: string; title: string; done: boolean }, now: number): WatchPlan {
237 if (plan && isPersistent(plan)) {
238 const idx = matchSteps(plan.steps, ev.title)
239 if (!idx.length) return plan
240 if (!ev.done) return { ...plan, steps: setRunning(plan.steps, idx[0]!, now) }
241 return { ...plan, steps: advance(plan.steps.map((s, j) => (idx.includes(j) ? withStatus(s, 'done', now) : s)), now) }
242 }
243 const base: WatchPlan = plan && plan.source === 'task' ? plan : { source: 'task', title: plan?.title ?? 'Tasks', ticket: plan?.ticket, steps: [], startedAt: plan?.startedAt ?? now }
244 const id = `task:${ev.id}`
245 let steps = base.steps.some(s => s.id === id) ? base.steps : [...base.steps, blank(id, ev.title)]
246 if (ev.done) steps = steps.map(s => (s.id === id ? withStatus(s, 'done', now) : s))
247 return { ...base, steps: advance(steps, now) }
248}
249
250function firstLine(text: string): string {
251 const line = text.split('\n').find(l => l.trim()) ?? ''
252 const t = line.trim()
253 return t.length > 80 ? `${t.slice(0, 79)}…` : t
254}
255
256export function promptPlan(prompt: string, now: number, branch?: string): WatchPlan {
257 const title = firstLine(prompt)
258 return { source: 'prompt', title, ticket: ticketKey(title, branch, prompt), steps: [], startedAt: now }
259}
260
261export type PlanEvent =
262 | { kind: 'call' }
263 | { kind: 'edit'; file: string }
264 | { kind: 'test'; result: WatchTestResult }
265 | { kind: 'agent'; id: string }
266
267export function attribute(plan: WatchPlan, ev: PlanEvent): WatchPlan {
268 const i = plan.steps.findIndex(s => s.status === 'running')
269 if (i < 0) return plan
270 const steps = plan.steps.map((s, j) => {
271 if (j !== i) return s
272 switch (ev.kind) {
273 case 'call':
274 return { ...s, calls: s.calls + 1 }
275 case 'edit':
276 return s.editedFiles.includes(ev.file) ? s : { ...s, editedFiles: [...s.editedFiles, ev.file] }
277 case 'test':
278 return { ...s, lastTest: ev.result }
279 case 'agent':
280 return s.agentIds.includes(ev.id) ? s : { ...s, agentIds: [...s.agentIds, ev.id] }
281 }
282 })
283 return { ...plan, steps }
284}
285
286// A step Claude moved past with red tests is failed; a plan with nothing left is ended.
287export function settle(plan: WatchPlan, now: number): WatchPlan {
288 const steps = plan.steps.map(s => (s.status === 'done' && s.lastTest && !s.lastTest.ok ? { ...s, status: 'failed' as const } : s))
289 const over = steps.length > 0 && steps.every(finished)
290 return { ...plan, steps, endedAt: over ? (plan.endedAt ?? now) : undefined }
291}
292
293export function planProgress(plan: WatchPlan): { done: number; total: number; remainingMs?: number; current?: WatchPlanStep } {
294 const total = plan.steps.length
295 const done = plan.steps.filter(finished).length
296 const current = plan.steps.find(s => s.status === 'running')
297 // Steps marked done without being watched (ledger on load) have no real duration.
298 const durations = plan.steps.filter(s => finished(s) && s.startedAt !== undefined && s.endedAt !== undefined).map(s => s.endedAt! - s.startedAt!).filter(d => d > 0)
299 if (durations.length === 0 || done === total) return { done, total, current }
300 const sorted = [...durations].sort((a, b) => a - b)
301 const mid = Math.floor(sorted.length / 2)
302 const typical = sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2
303 return { done, total, remainingMs: (total - done) * typical, current }
304}
305hooks/changes.ts 56 lines1// What changed on disk this session, from git, split into Claude's edits and yours. Pure; no `$`.
2
3import type { WatchChange } from '../types'
4
5type Numstat = { path: string; added: number; removed: number; binary: boolean }
6
7// "src/{a => b}.ts" and "old => new" are renames; keep the new path.
8function renamed(path: string): string {
9 const brace = /^(.*)\{[^}]* => ([^}]*)\}(.*)$/.exec(path)
10 if (brace) return `${brace[1]}${brace[2]}${brace[3]}`.replace(/\/\//g, '/')
11 const plain = /^.* => (.*)$/.exec(path)
12 return plain ? plain[1]! : path
13}
14
15export function parseNumstat(text: string): Numstat[] {
16 const rows: Numstat[] = []
17 for (const line of text.split('\n')) {
18 const m = /^(-|\d+)\t(-|\d+)\t(.+)$/.exec(line)
19 if (!m) continue
20 const binary = m[1] === '-' && m[2] === '-'
21 rows.push({ path: renamed(m[3]!), added: binary ? 0 : Number(m[1]), removed: binary ? 0 : Number(m[2]), binary })
22 }
23 return rows
24}
25
26export function parseUntracked(text: string): string[] {
27 return text.split('\n').map(l => l.trim()).filter(Boolean)
28}
29
30// `edited` maps absolute path → the turn that last edited it.
31export function changesFromGit(numstat: string, untracked: string, edited: Map<string, number>, cwd: string): WatchChange[] {
32 const root = cwd.replace(/\/+$/, '')
33 const turnOf = (path: string) => edited.get(`${root}/${path}`)
34 const rows: WatchChange[] = parseNumstat(numstat).map(r => ({ ...r, lastTurn: turnOf(r.path), byClaude: turnOf(r.path) !== undefined }))
35 for (const path of parseUntracked(untracked)) {
36 if (rows.some(r => r.path === path)) continue
37 const turn = turnOf(path)
38 // A new file Claude wrote: no line counts against the base; -1 marks it "new".
39 rows.push({ path, added: turn !== undefined ? -1 : 0, removed: 0, binary: false, lastTurn: turn, byClaude: turn !== undefined })
40 }
41 const mine = rows.filter(r => r.byClaude).sort((a, b) => (b.lastTurn ?? 0) - (a.lastTurn ?? 0) || a.path.localeCompare(b.path))
42 const yours = rows.filter(r => !r.byClaude).sort((a, b) => a.path.localeCompare(b.path))
43 return [...mine, ...yours]
44}
45
46// Totals over Claude's rows only; "new" files count as a file, not as lines.
47export function changesTotal(rows: readonly WatchChange[]): { files: number; added: number; removed: number; created: number } {
48 const mine = rows.filter(r => r.byClaude)
49 return {
50 files: mine.length,
51 added: mine.reduce((t, r) => t + Math.max(0, r.added), 0),
52 removed: mine.reduce((t, r) => t + r.removed, 0),
53 created: mine.filter(r => r.added < 0).length,
54 }
55}
56hooks/checks.ts 40 lines1// How each test, lint and build command fared across turns. Pure; no `$`.
2
3import type { WatchCheck, WatchCheckRun } from '../types'
4import { DEFAULTS } from './config'
5import { normalizeKey } from './estimate'
6import { classifyCommand } from './health'
7
8const MAX_RUNS = 10
9const KEY_CFG = { ...DEFAULTS, estimate: { ...DEFAULTS.estimate, perProject: false, ignorePatterns: [] } }
10
11// One key per command shape: `npm test -- --shard=1` and `=2` are the same check.
12export function checkKey(command: string, cwd: string): string {
13 const kind = classifyCommand(command)
14 if (!kind) return ''
15 return `${kind}:${normalizeKey('Bash', { command }, cwd, KEY_CFG) ?? command}`
16}
17
18export function recordCheck(checks: Record<string, WatchCheck>, key: string, command: string, run: WatchCheckRun): Record<string, WatchCheck> {
19 const kind = (key.split(':')[0] as WatchCheck['kind']) || 'test'
20 const prev = checks[key]
21 const runs = [...(prev?.runs ?? []), run].slice(-MAX_RUNS)
22 return { ...checks, [key]: { command, kind, runs } }
23}
24
25const last = (c: WatchCheck) => c.runs[c.runs.length - 1]
26
27// Failing first, then most recent.
28export function checkRows(checks: Record<string, WatchCheck>): WatchCheck[] {
29 return Object.values(checks)
30 .filter(c => c.runs.length > 0)
31 .sort((a, b) => Number(last(a)!.ok) - Number(last(b)!.ok) || last(b)!.at - last(a)!.at)
32}
33
34export function trendGlyphs(c: WatchCheck): string {
35 return c.runs
36 .slice(-5)
37 .map(r => (r.ok ? '✓' : '✗'))
38 .join(' ')
39}
40hooks/history.ts 107 lines1// The session's history: one entry per turn, plus session events between turns. Pure; no `$`.
2
3import type { WatchCall, WatchHistoryEntry, WatchHistoryEvent, WatchHistoryTurn, WatchTestResult, WatchTurnSnapshot } from '../types'
4
5export const MAX_TURNS = 20
6export const MAX_CALLS = 40
7
8type History = readonly WatchHistoryEntry[]
9
10// Keep the last MAX_TURNS turns and the events from the oldest kept turn on.
11function cap(h: WatchHistoryEntry[]): WatchHistoryEntry[] {
12 const turns = h.filter(e => e.kind === 'turn')
13 if (turns.length <= MAX_TURNS) return h
14 const firstKept = turns[turns.length - MAX_TURNS]!
15 return h.slice(h.indexOf(firstKept))
16}
17
18// Apply `fn` to the latest turn while it is still running.
19function onOpenTurn(h: History, fn: (t: WatchHistoryTurn) => WatchHistoryTurn): WatchHistoryEntry[] {
20 for (let i = h.length - 1; i >= 0; i--) {
21 const e = h[i]!
22 if (e.kind !== 'turn') continue
23 if (e.ended) return [...h]
24 const out = [...h]
25 out[i] = fn(e)
26 return out
27 }
28 return [...h]
29}
30
31export function startTurnEntry(h: History, id: string, at: number, prompt: string): WatchHistoryEntry[] {
32 const line = (prompt.split('\n').find(l => l.trim()) ?? '').trim()
33 const title = line.length > 80 ? `${line.slice(0, 79)}…` : line
34 const turn: WatchHistoryTurn = { kind: 'turn', id, at, title, ended: false, failed: false, calls: [], moreCalls: 0, files: [], steps: [], agents: [], waitMs: 0 }
35 // A turn that never saw its end (interrupted, or the mod reloaded) is closed now.
36 const closed = h.map(e => {
37 if (e.kind !== 'turn' || e.ended) return e
38 return { ...e, ended: true, interrupted: true, durationMs: undefined }
39 })
40 return cap([...closed, turn])
41}
42
43export function addCall(h: History, call: WatchCall): WatchHistoryEntry[] {
44 return onOpenTurn(h, t => {
45 const calls = [...t.calls, { label: call.label, ok: call.ok, durationMs: call.durationMs, note: call.note }]
46 const over = Math.max(0, calls.length - MAX_CALLS)
47 return { ...t, calls: calls.slice(over), moreCalls: t.moreCalls + over }
48 })
49}
50
51export function addFile(h: History, file: string): WatchHistoryEntry[] {
52 return onOpenTurn(h, t => (t.files.includes(file) ? t : { ...t, files: [...t.files, file] }))
53}
54
55export function setTest(h: History, r: WatchTestResult): WatchHistoryEntry[] {
56 return onOpenTurn(h, t => ({ ...t, tests: { ok: r.ok, passed: r.passed, failed: r.failed, command: r.command, snippet: r.snippet } }))
57}
58
59export function addWait(h: History, ms: number): WatchHistoryEntry[] {
60 return onOpenTurn(h, t => ({ ...t, waitMs: t.waitMs + Math.max(0, ms) }))
61}
62
63export function noteSteps(h: History, steps: readonly { title: string; status: string }[]): WatchHistoryEntry[] {
64 return onOpenTurn(h, t => ({ ...t, steps: steps.map(s => ({ title: s.title, status: s.status })) }))
65}
66
67export function addAgent(h: History, label: string, ok: boolean, durationMs: number): WatchHistoryEntry[] {
68 return onOpenTurn(h, t => ({ ...t, agents: [...t.agents.filter(a => a.label !== label), { label, ok, durationMs }] }))
69}
70
71export function endTurnEntry(h: History, at: number, failed: boolean): WatchHistoryEntry[] {
72 return onOpenTurn(h, t => ({ ...t, ended: true, failed, durationMs: at - t.at }))
73}
74
75export function addEvent(h: History, at: number, icon: WatchHistoryEvent['icon'], text: string): WatchHistoryEntry[] {
76 return cap([...h, { kind: 'event', id: `ev-${at}-${icon}`, at, icon, text }])
77}
78
79export function isProblem(e: WatchHistoryEntry): boolean {
80 if (e.kind === 'event') return e.icon === 'error'
81 return e.failed || e.waitMs > 0 || (e.tests !== undefined && !e.tests.ok) || e.calls.some(c => c.ok === false) || e.agents.some(a => !a.ok)
82}
83
84export function filterHistory(h: History, filter: 'all' | 'turns' | 'problems'): WatchHistoryEntry[] {
85 if (filter === 'turns') return h.filter(e => e.kind === 'turn')
86 if (filter === 'problems') return h.filter(isProblem)
87 return [...h]
88}
89
90// How many turns had ended before the last compaction: the forecast's start in contextHistory.
91export function lastCompactIndex(h: History): number {
92 let at = -1
93 for (let i = h.length - 1; i >= 0; i--) {
94 const e = h[i]!
95 if (e.kind === 'event' && e.icon === 'compact') {
96 at = i
97 break
98 }
99 }
100 if (at < 0) return 0
101 return h.slice(0, at).filter(e => e.kind === 'turn' && e.ended).length
102}
103
104export function setSnapshot(h: History, snapshot: WatchTurnSnapshot): WatchHistoryEntry[] {
105 return onOpenTurn(h, t => ({ ...t, snapshot }))
106}
107