SLOPSHOPPER

watcher

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

newpanebandspinnerguardcommand
★ 2v0.12.2MITupdated 2026-10-2131mahadi/claude-watcher
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · watcher
│ ┃ Watcher ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ USAGE session █████░░░░░░░░░░░ 31% │ ● watcher: watcher: turn start failed: undefined is not an object (ev │ ┃ ╰──────────────────────────────────────────╯ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ ● Done │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ╭─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ │ ╭──────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ │ ✗ 1 test failing 0s ago │ │ ┃ │ │ bun test │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ src/auth.test.ts: │ │ ┃ │ │ ✓ refreshes expired token │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ ✓ rejects a bad signature │ │ ┃ │ │ ✓ issues a new token │ › /watch │ ┃ │ │ ✗ revokes on logout │ ⎿ watcher: Watcher opened. │ ┃ │ │ error: expected 401, got 200 │ │ ┃ │ │ │ │ ┃ │ │ 3 pass │ │ ┃ │ │ 1 fail │ │ ┃ │ ╰──────────────────────────────────────╯ │ ┃ │ │ ┃ │ NOW Idle · waiting for a prompt │ ┃ │ tests ✗ files changed none c │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Watcher
╭──────────────────────────────────────────────────────────╮ │ USAGE session █████░░░░░░░░░░░ 31% │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ● Done │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ ╭──────────────────────────────────────────────────────╮ │ │ ✗ 1 test failing 0s ago │ │ │ bun test │ │ │ src/auth.test.ts: │ │ │ ✓ refreshes expired token │ │ │ ✓ rejects a bad signature │ │ │ ✓ issues a new token │ │ │ ✗ revokes on logout │ │ │ error: expected 401, got 200 │ │ │ │ │ │ 3 pass │ │ │ 1 fail │ │ ╰──────────────────────────────────────────────────────╯ │ │ NOW Idle · waiting for a prompt │ tests ✗ files changed none context ◑ 49% ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ ──────────────────────────────────────────────────────── │ │ view: dashboard ▾ history: all ▾ actions…: choose… ▾ │ ╰──────────────────────────────────────────────────────────╯
README

Claude Watcher

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.

  • Spinner suffix: npm test · 2:14 · ~1:46 left · 3/5 tasks
  • Dashboard pane (/watch) with six tabs: Turn, Plan, Agents, Sessions, Health and Config
  • Attention band above the prompt when Claude needs you, plus an optional toast and desktop notification
  • Visuals: Svg bars, Gantt, sparkline and donut on Desktop and VS Code; Raster bars, swimlanes and a heat strip in the terminal; a text fallback everywhere
  • Time-left estimates learned from your own command history: ~ with 3 or more samples, ? with fewer, up to when only a timeout bounds the call

Claude Watcher only observes. Every hook passes the event on unchanged, and it never blocks or denies anything.

Watcher pane in VS Code: usage, status, now and history

Install

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

Updating

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.

Update by hand

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.

Update automatically

Auto-update is off by default for marketplaces outside Anthropic. To turn it on:

  1. Run /plugin.
  2. Open Marketplaces and select claude-watcher.
  3. Turn on auto-update.

Claude Code then fetches new versions when it starts.

Check your version

Run /plugin, open Installed and find watcher. The latest version is on the releases page.

If the update does not take

Reinstall the plugin:

/plugin uninstall watcher@claude-watcher
/plugin install watcher --marketplace 31mahadi/claude-watcher

Commands

CommandWhat it does
/watchOpen 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 exportCopy the last turn as JSON and print it
/watch mute 30m / /watch unmuteSilence or restore alerts
/watch checks / /watch changesEvery 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

Dashboard

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 (always on top): every usage window the session reports, as /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%.
  • Status: what Claude is doing right now (Thinking, Running with the current command and its timer, Needs you, Idle), then one line per running subagent with what it is doing.
  • Attention (only when needed): a permission prompt or question, failing tests with the failure output and their step, a stop error, or a stall.
  • Plan: ticket and title, progress and time left, then every step in order: done ✓, running ▶ with its live command, waiting ○. Only plans of more than 20 steps fold their done steps.
  • Now: one timeline for the main loop and every subagent, frozen once the turn ends; a live tree of the current turn; chips for tests, changed files and context.
  • Changes: every file Claude edited this session with +N −M, from git diff --numstat against the commit the session started from; your own edits counted separately.
  • Checks: each test, lint and build command with its last five outcomes (✗ ✗ ✓), kept across /clear and resume.
  • Context: usage with a gauge and a forecast of turns left before compaction (health.compactAtPct, default 85).
  • Idle: when nothing runs, the status reads Idle and Now shows Idle · last turn took 2:11 · 16 calls · tests ✓ · 2 files.
  • Footer: 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.
  • Badge: the pane's tab reads Watcher · 1 while something needs you.
  • Any width: the pane fits the terminal, the Desktop app and the VS Code side panel. Charts size to the pane; narrow panes drop secondary columns, and very narrow ones go compact.

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.

Where the plan comes from

Highest priority first. A file-based plan survives turn end, compaction, /clear and resume.

SourceDetected whenStepsDone when
superpowers plan fileClaude reads or edits docs/superpowers/plans/*.md (or a file titled "… Implementation Plan")### Task N: headings, with their - [ ] stepsits ledger .superpowers/sdd/<plan>/progress.md says Task N: complete, or every checkbox is ticked
Plan modeExitPlanMode after plan mode named a plan fileheadings or list itemscheckboxes, or a matching todo
Todo listTodoWriteeach todocompleted
Task toolTaskCreated / TaskCompletedeach taskTaskCompleted
Promptevery turnnone; the first line is the titlethe 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.

  • History (pinned at the bottom): every turn of the session, newest first, as a card: time 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.

Configuration

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
}

Limits

  • Mods cannot read window focus, so desktopNotify: "whenUnfocused" behaves like "always".
  • Desktop notifications go through your own Claude Code notification channel. attention.sound is honoured only when that channel is the terminal bell.
  • The running indicator pulses on the terminal and the Desktop app; VS Code shows a still dot.
  • Test parsing is best effort and is labelled "parsed" in the Health tab.

Development

Run from a checkout with Claude Code 2.1.304 or later:

claude plugin validate .
claude plugin test .

License

MIT. See LICENSE.

Source 21 files
hooks/register.tsx 1039 lines
1import { 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}
1039
hooks/config.ts 148 lines
1import 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}
148
hooks/estimate.ts 102 lines
1import 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}
102
hooks/format.ts 68 lines
1import 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}
68
hooks/health.ts 106 lines
1import 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}
106
hooks/render/pane.tsx 1469 lines
1import 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 !== false
hooks/render/widgets.tsx 339 lines
1import 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}
339
hooks/sessions.ts 21 lines
1import 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
21
hooks/plan.ts 305 lines
1// 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}
305
hooks/changes.ts 56 lines
1// 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}
56
hooks/checks.ts 40 lines
1// 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}
40
hooks/history.ts 107 lines
1// 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