Live plan progress bars above the Claude Code prompt: stages, steps, a pixel fill and soft sounds for decision, error and done

<h1 align="center">Awesome Claude Code Mods</h1>
<a id="showcase"></a>
<table> <tr><td width="33%" align="center" valign="top"><p><a href="https://github.com/therahul-yo/clawdman"><img src="assets/showcase-01.gif" width="100%" alt="Clawdman: An animated companion that reacts to your coding session. Demo · 10×."></a></p><p><strong><a href="https://github.com/therahul-yo/clawdman">Clawdman</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/pi-agent/"><img src="assets/showcase-02.gif" width="100%" alt="Pi Agent: Run pi-powered models as native Claude Code subagents. Demo · 10×."></a></p><p><strong><a href="AgentMods/pi-agent/">Pi Agent</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="FocusMods/breathing-exercises/"><img src="assets/showcase-03.gif" width="100%" alt="Breathing Exercises: A guided breathing animation while Claude works."></a></p><p><strong><a href="FocusMods/breathing-exercises/">Breathing Exercises</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="sources/creative-toolkit/"><img src="assets/showcase-04.png" width="100%" alt="Creative Toolkit: A collection of live panels, session tools and playful mods. Code Pet screenshot."></a></p><p><strong><a href="sources/creative-toolkit/">Creative Toolkit</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/agent-cartoons/"><img src="assets/showcase-05.gif" width="100%" alt="Agent Cartoons: Coding activity becomes a cartoon in a choice of visual styles."></a></p><p><strong><a href="GameMods/agent-cartoons/">Agent Cartoons</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="https://github.com/adamholter/claude-subway-surfers"><img src="assets/showcase-06.gif" width="100%" alt="Subway Surfers Desktop: Gameplay beside Claude Desktop while it works. Desktop demo · 10×."></a></p><p><strong><a href="https://github.com/adamholter/claude-subway-surfers">Subway Surfers Desktop</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="GameMods/dino-game/"><img src="assets/showcase-07.gif" width="100%" alt="Dino Game: Play the T-rex runner above your prompt."></a></p><p><strong><a href="GameMods/dino-game/">Dino Game</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/spell-bar/"><img src="assets/showcase-08.gif" width="100%" alt="Spell Bar: An animated spell bar for your selected effort level."></a></p><p><strong><a href="GameMods/spell-bar/">Spell Bar</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/agent-dashboard/"><img src="assets/showcase-09.gif" width="100%" alt="Agent Dashboard: A live dashboard for context, costs, permissions and agents."></a></p><p><strong><a href="AgentMods/agent-dashboard/">Agent Dashboard</a></strong></p></td></tr> </table>
<a id="browse-by-category"></a>
<h2>Browse by category <img src="assets/category-magnifier.gif" width="52" height="40" alt="Clawd with a magnifying glass"></h2>
Usage Mods · Agent Mods · Planning Mods · File Mods · Git Mods · Safety Mods · Memory Mods · Interface Mods · Prompt Mods · Testing Mods · Web Mods · Focus Mods · Game Mods · Integration Mods
Source packages available here: 82 mods from 38 repositories, with their complete tracked files and notices. Local package links open inside this collection. Other entries currently lead to upstream references while the wider source import is reviewed.
Entries are grouped by their main purpose. Collections can contain several kinds of mods; hosted mod pages are categorized individually.
<a id="usage"></a>
Track model usage, costs, effort and quotas.
<a id="agents"></a>
Coordinate agents and follow their actual work.
hooks/register.tsx 1713 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Plan, PlanStage, PlanState, PlanStep, StepStatus } from '../types'
5
6const TOOL = 'mcp__plan-progress__plan_progress'
7const plans = atom({ plugin: 'plan-progress', key: 'plans' } as const, [])
8const MAX_BARS = 3
9// a space as wide as a digit, so ' 0%' and '100%' take the same room
10const FIGURE_SPACE = String.fromCharCode(0x2007)
11const isOpen = atom({ plugin: 'plan-progress', key: 'isOpen' } as const, true)
12const tick = atom({ plugin: 'plan-progress', key: 'tick' } as const, 0)
13const STRIP_H = 16
14const STRIP_GAP = 2
15// rows of strips per bar, the "+N more" row included: the band above the prompt has room for about 300 px
16const stripBudget = (bars: number) => (bars >= 3 ? 3 : bars === 2 ? 4 : 5)
17const FOLD_MS = 5000 // finished strips stay this long, failed ones stay until the bar closes
18
19// one lightness for every state (OKLCH L .55, hues of the desktop's violet, amber, red and green), so no state
20// shouts louder than another, and white on each reads at 4.5:1 or better
21const STATE_COLOR: Record<PlanState, string> = { running: '#7858CA', needs_input: '#AD6400', error: '#C5353E', done: '#18883A' }
22const INK = '#FFFFFF'
23const STATE_GLYPH: Record<PlanState, string> = { running: '●', needs_input: '?', error: '!', done: '✓' }
24const STATUSES: StepStatus[] = ['pending', 'active', 'done', 'error', 'skipped']
25const TRACK_H = 22
26const NARROW = 360
27
28const RULES = `# Progress bars
29Tasks needing more than ~3 edits or commands get a bar via ${TOOL}: create it once with the full breakdown (2-7 stages of steps {title}, or kind "todo" for one flat list; titles of at most 4 words, in the user's language; the first open step becomes active), then move it with short calls: {id, next:true} when the active step is finished, or {id, done:[...], active:"..."}, {id, failed:"...", note}. When the plan changes, resend stages under the same id; steps sent without a status keep their done by title. Send state "needs_input" with a note before asking the user to decide. Never describe the bars to the user.`
30
31type Raw = Record<string, unknown>
32const str = (v: unknown, max = 120) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
33const status = (v: unknown): StepStatus => (STATUSES.includes(v as StepStatus) ? (v as StepStatus) : 'pending')
34const list = (v: unknown): Raw[] => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object') : []) as Raw[]
35const isFinished = (s: StepStatus) => s === 'done' || s === 'skipped'
36
37const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
38
39// short updates: {next:true}, {done:[titles]}, {active:title}, {failed:title} against the stored plan;
40// titles it cannot find come back in missing, so the call is refused instead of passing as a success
41function applyOps(stages: PlanStage[], input: Raw, now: number): { stages: PlanStage[]; missing: string[] } {
42 const next = stages.map(s => ({ ...s, steps: s.steps.map(st => ({ ...st })) }))
43 const steps = next.flatMap(s => s.steps)
44 const missing: string[] = []
45 // a step finishing now remembers when, for the time its checkpoint shows
46 const finish = (st: PlanStep) => {
47 if (!isFinished(st.status)) Object.assign(st, { status: 'done', doneAt: now })
48 }
49 // a title used twice means the one still open
50 const find = (title: string) => {
51 const found = steps.find(st => same(st.title, title) && !isFinished(st.status)) ?? steps.find(st => same(st.title, title))
52 if (!found) missing.push(title)
53 return found
54 }
55 if (input.next === true) {
56 const at = steps.findIndex(st => st.status === 'active') >= 0 ? steps.findIndex(st => st.status === 'active') : steps.findIndex(st => !isFinished(st.status))
57 const cur = steps[at]
58 if (cur) finish(cur)
59 // past the last step, work goes back to one left open earlier
60 const following = steps.slice(at + 1).find(st => st.status === 'pending') ?? steps.find(st => st.status === 'pending')
61 if (following) following.status = 'active'
62 }
63 let lastDone = -1
64 for (const t of Array.isArray(input.done) ? input.done : []) {
65 const st = typeof t === 'string' ? find(t) : undefined
66 if (st?.status === 'active') lastDone = steps.indexOf(st)
67 if (st) finish(st)
68 }
69 // finishing the step in progress moves on, as next does, unless the call names the new one itself
70 if (lastDone >= 0 && typeof input.active !== 'string' && !steps.some(st => st.status === 'active')) {
71 const following = steps.slice(lastDone + 1).find(st => st.status === 'pending') ?? steps.find(st => st.status === 'pending')
72 if (following) following.status = 'active'
73 }
74 const active = typeof input.active === 'string' ? find(input.active) : undefined
75 if (active) {
76 const at = steps.indexOf(active)
77 steps.forEach((st, i) => {
78 if (st.status === 'active' && i !== at) {
79 if (i < at) finish(st)
80 else st.status = 'pending'
81 }
82 })
83 active.status = 'active'
84 }
85 const failed = typeof input.failed === 'string' ? find(input.failed) : undefined
86 if (failed) failed.status = 'error'
87
88 return { stages: next, missing }
89}
90
91// the new bar, or the refusal for a call that names steps the bar does not have
92function normalize(input: Raw, prev: Plan | null, now: number, id: string): Plan | string {
93 const sent = list(input.stages)
94 .map(s => ({
95 name: str(s.name, 80) || 'Stage',
96 steps: list(s.steps).map(st => ({
97 title: str(st.title) || 'Step',
98 status: status(st.status),
99 substeps: list(st.substeps).map(sub => ({ title: str(sub.title) || '…', status: status(sub.status) })),
100 })),
101 }))
102 .filter(s => s.steps.length > 0) as PlanStage[]
103 const isPartial = sent.length === 0 && prev !== null
104 // a resent plan keeps what was finished; short ops sent along with it apply on top
105 const base = isPartial ? prev.stages : pointAt(prev ? carryDone(sent, prev.stages) : sent)
106 const { stages, missing } = applyOps(base, input, now)
107 if (missing.length > 0 && base.length > 0) {
108 const titles = stages.flatMap(s => s.steps.map(st => st.title)).join(', ')
109 return `plan_progress: "${id}" has no step ${missing.map(t => `"${str(t, 60)}"`).join(', ')}. Its steps: ${titles.slice(0, 400)}`
110 }
111 const title = str(input.title, 80) || prev?.title || 'Plan'
112 const steps = stages.flatMap(s => s.steps)
113 const isAllDone = steps.length > 0 && steps.every(s => isFinished(s.status))
114 const asked = input.state as PlanState
115 const failedNow = typeof input.failed === 'string'
116 const state: PlanState = ['running', 'needs_input', 'error', 'done'].includes(asked) ? asked : isAllDone ? 'done' : failedNow ? 'error' : 'running'
117
118 return {
119 id,
120 title,
121 kind: input.kind === 'todo' || (isPartial && prev?.kind === 'todo') ? 'todo' : 'plan',
122 stages,
123 state,
124 note: str(input.note, 160) || null,
125 startedAt: prev ? prev.startedAt : now,
126 endedAt: state === 'done' ? (prev?.endedAt ?? now) : null,
127 }
128}
129
130// a resent plan keeps what is finished: a step sent as pending under a title that was done stays done
131function carryDone(stages: PlanStage[], before: PlanStage[]): PlanStage[] {
132 const finished = new Map(before.flatMap(s => s.steps).filter(st => isFinished(st.status)).map(st => [st.title.trim().toLowerCase(), st]))
133 return stages.map(s => ({
134 ...s,
135 steps: s.steps.map(st => {
136 const was = finished.get(st.title.trim().toLowerCase())
137 return was && (st.status === 'pending' || st.status === was.status) ? { ...st, status: was.status, doneAt: was.doneAt } : st
138 }),
139 }))
140}
141
142// with nothing in progress, the first open step is the current one
143function pointAt(stages: PlanStage[]): PlanStage[] {
144 const steps = stages.flatMap(s => s.steps)
145 if (steps.some(st => st.status === 'active' || st.status === 'error')) return stages
146 const first = steps.find(st => st.status === 'pending')
147 return stages.map(s => ({ ...s, steps: s.steps.map(st => (st === first ? { ...st, status: 'active' as const } : st)) }))
148}
149
150// the bar an approved plan-mode plan lands on; a revised plan updates it instead of opening a second one
151const PLAN_ID = 'plan'
152
153const clean = (s: string) =>
154 s
155 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
156 .replace(/[*_`]/g, '')
157 .replace(/^\s*(\d+[.)]|[-*+]|\[[ xX]\])\s+/, '')
158 .replace(/^(\d+[.)]|\[[ xX]\])\s+/, '')
159 .trim()
160
161function parsePlan(markdown: string, now: number): Plan | null {
162 let title = ''
163 const headed: PlanStage[] = []
164 const items: { depth: number; text: string; status: StepStatus }[] = []
165 for (const line of markdown.split(/\r?\n/)) {
166 const h = line.match(/^(#{1,4})\s+(.*)$/)
167 if (h) {
168 const text = clean(h[2] ?? '')
169 if (h[1] === '#' && !title) title = text
170 else headed.push({ name: text, steps: [] })
171 continue
172 }
173 const li = line.match(/^(\s*)(\d+[.)]|[-*+])\s+(.*)$/)
174 if (!li) continue
175 const depth = Math.floor((li[1] ?? '').replace(/\t/g, ' ').length / 2)
176 const text = clean(li[3] ?? '').slice(0, 120)
177 if (!text) continue
178 // a ticked box is a step the plan already counts as done
179 const status: StepStatus = /^\[[xX]\]\s/.test(li[3] ?? '') ? 'done' : 'pending'
180 items.push({ depth, text, status })
181 const stage = headed[headed.length - 1]
182 if (!stage) continue
183 const step = stage.steps[stage.steps.length - 1]
184 if (depth === 0 || !step) stage.steps.push({ title: text, status, substeps: [] })
185 else step.substeps.push({ title: text, status })
186 }
187 let stages = headed.filter(s => s.steps.length > 0)
188 if (stages.length === 0) {
189 if (items.some(i => i.depth > 0)) {
190 for (const item of items) {
191 const stage = stages[stages.length - 1]
192 if (item.depth === 0 || !stage) stages.push({ name: item.text, steps: [] })
193 else stage.steps.push({ title: item.text, status: item.status, substeps: [] })
194 }
195 stages = stages.map(s => (s.steps.length ? s : { ...s, steps: [{ title: s.name, status: 'pending', substeps: [] }] }))
196 } else if (items.length > 0) {
197 stages = [{ name: 'Tasks', steps: items.map(i => ({ title: i.text, status: i.status, substeps: [] })) }]
198 }
199 }
200 if (stages.length === 0) return null
201
202 return { id: PLAN_ID, title: title || 'Plan', kind: stages.length === 1 ? 'todo' : 'plan', stages, state: 'running', note: null, startedAt: now }
203}
204
205function st(title: string, s: StepStatus): PlanStep {
206 return { title, status: s, substeps: [] }
207}
208
209// ---------- drawing ----------
210
211type Where = { pos: number; total: number; stage: number; step: number; stageSize: number }
212
213// pos counts the finished steps wherever they are; the current step is the active one, else the first still open
214function where(p: Plan): Where {
215 const steps = p.stages.flatMap((s, i) => s.steps.map((step, j) => ({ i, j, step })))
216 const pos = p.state === 'done' ? steps.length : steps.filter(x => isFinished(x.step.status)).length
217 const cur = p.state === 'done' ? undefined : (steps.find(x => x.step.status === 'active') ?? steps.find(x => !isFinished(x.step.status)))
218 const stage = (cur ?? steps[steps.length - 1])?.i ?? 0
219
220 return { pos, total: steps.length, stage, step: cur ? cur.j + 1 : (p.stages[stage]?.steps.length ?? 0), stageSize: p.stages[stage]?.steps.length ?? 0 }
221}
222
223const hex = (h: string) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16))
224const mix = (a: number[], b: number[], m: number) => a.map((v, i) => Math.round(v + ((b[i] ?? 0) - v) * m))
225const rgb = (c: number[]) => `rgb(${c.join(',')})`
226const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
227const hash = (a: number, b: number, k: number) => {
228 const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
229 return x - Math.floor(x)
230}
231const textWidth = (s: string, px = 6.7) => [...s].reduce((w, ch) => w + (/[ -鿿]/.test(ch) ? 12 : /[ilI.,:;'|!]/.test(ch) ? 3.4 : /[mwMWШЩЖМ]/.test(ch) ? 9.5 : px), 0)
232
233const ICON_PATH: Partial<Record<PlanState, string>> = {
234 needs_input: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01',
235 error: 'M18 6 6 18M6 6l12 12',
236 done: 'M20 6 9 17l-5-5',
237}
238
239// how long each finished step took (from the previous finish, or the plan's start) and each finished stage
240function stepTimes(p: Plan): { steps: Map<PlanStep, number>; stages: (number | undefined)[] } {
241 const ends = p.stages.flatMap(s => s.steps).flatMap(st => (st.doneAt === undefined ? [] : [st.doneAt])).sort((a, b) => a - b)
242 const startOf = (at: number) => Math.max(p.startedAt, ...ends.filter(t => t < at))
243 const steps = new Map<PlanStep, number>()
244 const stages = p.stages.map(s => {
245 for (const st of s.steps) if (st.doneAt !== undefined) steps.set(st, st.doneAt - startOf(st.doneAt))
246 const times = s.steps.map(st => st.doneAt)
247 if (times.some(t => t === undefined)) return undefined
248 const done = times as number[]
249 return Math.max(...done) - Math.min(...done.map(startOf))
250 })
251 return { steps, stages }
252}
253
254// the 2 px dots of one look go into one path: a fraction of the markup of a rect each, and one node instead of thousands
255function addDot(dots: Map<string, string>, cls: string, x: number, y: number) {
256 dots.set(cls, `${dots.get(cls) ?? ''}M${x} ${y}h2v2h-2z`)
257}
258
259// last drawn head position per plan, so a redraw glides from where the bar was
260const lastHead = new Map<string, number>()
261
262// the track draws in a sandboxed frame (for hover); its page must stay see-through in either theme
263const SEE_THROUGH = '<style>:root,html,body{background:transparent!important;color-scheme:light dark;margin:0;overflow:hidden}svg{display:block}</style>'
264
265// a clock that counts in the frame by itself, so the drawing never has to be redrawn each second (a redraw
266// reloads the frame and everything in it blinks): each digit is a reel of its figures behind a one-line window,
267// stepped by a CSS animation whose negative delay is the time already run. Plain SVG, since the host's frame
268// drops foreignObject. {{T:start}} becomes those seconds only when a bar's markup really changes (liveSource)
269const CLOCK_W = 48 // "59m 59s"
270const LINE = 16
271const CLOCK_CSS = `.ckt{font-variant-numeric:tabular-nums}
272.rs1{animation:r10 10s steps(10) var(--d) infinite}.rs10{animation:r6 60s steps(6) var(--d) infinite}
273.cc{animation:cc 600s linear var(--d) both}@keyframes cc{0%,9.99%{transform:translateX(-13.5px)}10%,99.99%{transform:translateX(-3.25px)}100%{transform:none}}
274.rm1{animation:r10 600s steps(10) var(--d) infinite}.rm10{animation:r10 6000s steps(10) var(--d) infinite}.rmm{animation:hm 60s steps(1,end) var(--d) both}
275@keyframes r10{to{transform:translateY(-${LINE * 10}px)}}@keyframes r6{to{transform:translateY(-${LINE * 6}px)}}@keyframes hm{from{opacity:0}to{opacity:1}}`
276
277// x is the clock's left edge, top the window's top; the minutes part stays hidden for the first minute
278function liveClock(x: number, top: number, start: number, cls: string, textCls: string, isCentered = false): string {
279 const base = top + 12
280 const reel = (cx: number, figures: string[], reelCls: string) =>
281 `<g class="${reelCls}"><text class="${textCls} ckt" text-anchor="middle">${figures
282 .map((f, i) => `<tspan x="${cx.toFixed(1)}" y="${base + i * LINE}">${f}</tspan>`)
283 .join('')}</text></g>`
284 const digits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
285 const id = `ck${start}x${Math.round(x)}y${Math.round(top)}`
286 return (
287 `<style>.${id}{--d:-{{T:${start}}}s}</style><g class="${cls} ${id}"><clipPath id="${id}"><rect x="${(x - 2).toFixed(1)}" y="${top}" width="${CLOCK_W + 4}" height="${LINE}"/></clipPath>` +
288 `<g clip-path="url(#${id})"${isCentered ? ' class="cc"' : ''}><g class="rmm">${reel(x + 3.5, ['', ...digits.slice(1)], 'rm10')}${reel(x + 10.5, digits, 'rm1')}` +
289 `<text x="${(x + 14).toFixed(1)}" y="${base}" class="${textCls}">m</text></g>` +
290 `${reel(x + 31, digits.slice(0, 6), 'rs10')}${reel(x + 38, digits, 'rs1')}<text x="${(x + 41.5).toFixed(1)}" y="${base}" class="${textCls}">s</text></g></g>`
291 )
292}
293
294// a bar's frame reloads whenever its markup changes; markup that differs only in when it was drawn keeps the
295// source already shown, so its clocks run on instead of the frame blinking
296const lastSource = new Map<string, { template: string; source: string }>()
297function liveSource(id: string, template: string, now: number): string {
298 const last = lastSource.get(id)
299 if (last?.template === template) return last.source
300 const source = template.replace(/\{\{T:(\d+)\}\}/g, (_, t: string) => Math.max(0, (now - Number(t)) / 1000).toFixed(1))
301 lastSource.set(id, { template, source })
302 return source
303}
304
305// a bar is drawn twice: the track itself as a plain picture, which the desktop keeps steady whatever else redraws,
306// and a see-through layer on top for the hover parts (checkpoint times, the pill's clock). That layer needs an
307// interactive frame, and the desktop rebuilds such frames on every redraw of the band; empty until hovered, the
308// rebuild is invisible. A plan is immutable, so both drawings at one width are reused until the plan changes
309type Track = { base: string; overlay: string }
310const drawn = new WeakMap<Plan, { W: number; track: Track }>()
311
312function trackSvg(p: Plan, W: number): Track {
313 const cached = drawn.get(p)
314 if (cached?.W === W) return cached.track
315 const track = drawTrack(p, W)
316 drawn.set(p, { W, track })
317 return track
318}
319
320function drawTrack(p: Plan, W: number): Track {
321 const H = TRACK_H
322 const w = where(p)
323 const done = p.state === 'done'
324 // the fill is exactly the finished share: a fresh plan starts empty
325 const frac = done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))
326 const fx = frac * W
327 const key = p.id
328 const from = lastHead.get(key) ?? fx
329 lastHead.set(key, fx)
330
331 const acc = hex(STATE_COLOR[p.state])
332 const light = mix(acc, [255, 255, 255], 0.32)
333 const grey = [132, 130, 138]
334 const ease = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
335 const glide = Math.abs(from - fx) > 0.5
336
337 const bounds: number[] = []
338 let acc2 = 0
339 p.stages.forEach((s, i) => {
340 acc2 += s.steps.length
341 if (i < p.stages.length - 1) bounds.push((acc2 / w.total) * W)
342 })
343
344 // pixels: 3px grid, 7 rows, denser towards the head, twinkling and warming from grey to the state colour
345 const buckets = [0, 1, 2, 3, 4].map(b => {
346 const m = b / 4
347 const dense = 0.22 + 0.78 * Math.pow(m, 1.5)
348 return { color: rgb(mix(grey, light, m)), opacity: (0.35 + 0.65 * dense).toFixed(2) }
349 })
350 const dots = new Map<string, string>()
351 for (let col = 0; col * 3 < fx; col++) {
352 const x = col * 3
353 const u = Math.min(1, (x + 1.5) / fx)
354 const dense = 0.22 + 0.78 * Math.pow(u, 1.5)
355 const bucket = Math.min(4, Math.floor(Math.min(1, Math.pow(u, 0.9) * 1.1) * 4.99))
356 for (let r = 0; r < 7; r++) {
357 if (hash(col, r, 1) > dense + 0.1) continue
358 addDot(dots, `b${bucket} t${Math.floor(hash(col, r, 2) * 4)}`, x, 1 + r * 3)
359 }
360 }
361 const px = [...dots].map(([cls, d]) => `<path class="${cls}" d="${d}"/>`).join('')
362
363 const took = stepTimes(p)
364 const tipRules: string[] = []
365 let marks = ''
366 let hits = ''
367 let tips = ''
368 let k = 0
369 p.stages.forEach((s, i) => {
370 s.steps.forEach((_, j) => {
371 if (k > 0) {
372 const x = (k / w.total) * W
373 const isStage = j === 0
374 // a stage boundary is a short capsule, a step a dot; bright once passed
375 const passed = x < fx - 1
376 const fill = passed ? rgb(mix(light, [255, 255, 255], 0.45)) : '#A8A69E'
377 const opacity = passed ? (isStage ? 0.95 : 0.8) : isStage ? 0.75 : 0.6
378 marks += isStage
379 ? `<rect x="${(x - 1.5).toFixed(1)}" y="${(H - 10) / 2}" width="3" height="10" rx="1.5" fill="${fill}" opacity="${opacity}"/>`
380 : `<circle cx="${x.toFixed(1)}" cy="${H / 2}" r="1.4" fill="${fill}" opacity="${opacity}"/>`
381 const before = p.stages[isStage ? i - 1 : i]
382 const ended = isStage ? before?.steps[before.steps.length - 1] : s.steps[j - 1]
383 const label = isStage ? (before?.name ?? '') : (ended?.title ?? '')
384 const ms = isStage ? took.stages[i - 1] : took.steps.get(ended as PlanStep)
385 const text = ms === undefined ? label : `${label} · ${elapsed(ms)}`
386 const tw = textWidth(text, 6.2) + 16
387 const tx = Math.max(0, Math.min(W - tw, x - tw / 2))
388 hits += `<rect class="h${k}" x="${(x - 5).toFixed(1)}" width="10" height="${H}" fill="#000" fill-opacity="0"/>`
389 tips += `<g class="tp p${k}"><rect x="${tx.toFixed(1)}" y="2" width="${tw.toFixed(1)}" height="${H - 4}" rx="${(H - 4) / 2}" fill="#1F1E1D" fill-opacity=".94"/><text x="${(tx + 8).toFixed(1)}" y="${H / 2 + 3.8}" class="tt">${esc(text)}</text></g>`
390 tipRules.push(`.h${k}:hover~.p${k}`)
391 }
392 k++
393 })
394 void i
395 })
396
397 // knob: a pill with stage and count, or a round dot with the stage number when narrow
398 const isNarrow = W < NARROW
399 const color = STATE_COLOR[p.state]
400 const icon = ICON_PATH[p.state]
401 const single = p.stages.length === 1
402 const number = single ? w.step : w.stage + 1
403 let knob = ''
404 let timePill = ''
405 let kw = H
406 if (isNarrow) {
407 const label = done ? '' : String(number)
408 knob = `<circle cx="0" cy="${H / 2}" r="${H / 2}" fill="${color}"/>${
409 done ? `<path d="${ICON_PATH.done}" transform="translate(-6 5) scale(.5)" fill="none" stroke="${INK}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>` : `<text x="0" y="${H / 2 + 4.2}" text-anchor="middle" class="kt">${label}</text>`
410 }`
411 } else {
412 const name = done ? (p.endedAt ? elapsed(p.endedAt - p.startedAt) : 'Done') : single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
413 // the pill carries the stage name alone; the fill and the percent already say how far along it is
414 const count = ''
415 const iconW = icon ? 16 : 0
416 const countW = count ? textWidth(count, 6.5) : -6
417 const maxW = Math.max(80, W * 0.55)
418 let shown = name
419 while (shown.length > 3 && 20 + iconW + textWidth(shown) + 6 + countW > maxW) shown = shown.slice(0, -1)
420 if (shown !== name) shown = shown.trimEnd() + '…'
421 // a running pill is wide enough for its clock too, so the hover swap does not change its size
422 const textW = done ? textWidth(shown) : Math.max(textWidth(shown), CLOCK_W)
423 kw = Math.round(20 + iconW + textW + 6 + countW)
424 const left = -(iconW + textW) / 2
425 const mid = left + iconW + textW / 2
426 knob = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>`
427 if (icon) knob += `<path d="${icon}" transform="translate(${left.toFixed(1)} 5) scale(.5)" fill="none" stroke="${INK}" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>`
428 knob += `<text x="${mid.toFixed(1)}" y="${H / 2 + 4.2}" text-anchor="middle" class="kt">${esc(shown)}${count ? `<tspan class="kc" dx="6">${count}</tspan>` : ''}</text>`
429 // hovering the pill lays a copy of it over the stage name, carrying the time the plan has run so far
430 if (!done) {
431 const face = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>${
432 icon ? `<path d="${icon}" transform="translate(${left.toFixed(1)} 5) scale(.5)" fill="none" stroke="${INK}" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>` : ''
433 }`
434 timePill = `<g class="kb"><rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" fill="#000" fill-opacity="0"/><g class="kv">${face}${liveClock(mid - CLOCK_W / 2, 3, p.startedAt, 'kc0', 'kt', true)}</g></g>`
435 }
436 }
437 const clampX = (x: number) => Math.max(kw / 2, Math.min(W - kw / 2, x))
438 const kx = clampX(fx)
439 const kFrom = clampX(from)
440
441 const style = `<style>
442.b0{fill:${buckets[0]?.color};fill-opacity:${buckets[0]?.opacity}}.b1{fill:${buckets[1]?.color};fill-opacity:${buckets[1]?.opacity}}
443.b2{fill:${buckets[2]?.color};fill-opacity:${buckets[2]?.opacity}}.b3{fill:${buckets[3]?.color};fill-opacity:${buckets[3]?.opacity}}
444.b4{fill:${buckets[4]?.color};fill-opacity:${buckets[4]?.opacity}}
445.t0,.t1,.t2,.t3{animation:tw 2.2s ease-in-out infinite}
446.t1{animation-duration:2.8s;animation-delay:-.7s}.t2{animation-duration:1.9s;animation-delay:-1.3s}.t3{animation-duration:3.3s;animation-delay:-.4s}
447@keyframes tw{0%,100%{opacity:1}50%{opacity:.45}}
448.kt{font:500 12px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:${INK}}
449.kc{font-weight:400;fill-opacity:.75}
450@media (prefers-reduced-motion:reduce){.t0,.t1,.t2,.t3{animation:none}}
451</style>`
452 const hoverStyle = `<style>
453.tp{opacity:0;transition:opacity .12s;pointer-events:none}${tipRules.length ? `${tipRules.join(',')}{opacity:1}` : ''}
454.tt{font:400 11px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#F0EEFC}
455.kt{font:500 12px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:${INK}}
456.kv{opacity:0;filter:blur(3px);transition:opacity .2s,filter .2s}.kb:hover .kv{opacity:1;filter:none}
457.kb,rect[class^="h"]{cursor:pointer}
458${CLOCK_CSS}
459</style>`
460 const glideFill = glide ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${ease} fill="freeze"/>` : ''
461 const glideKnob = glide ? `<animateTransform attributeName="transform" type="translate" from="${kFrom.toFixed(1)} 0" to="${kx.toFixed(1)} 0" dur=".45s" ${ease} fill="freeze"/>` : ''
462
463 const open = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
464 const base = `${open}${style}
465<defs><clipPath id="pill"><rect width="${W}" height="${H}" rx="${H / 2}"/></clipPath><clipPath id="fill"><rect width="${fx.toFixed(1)}" height="${H}">${glideFill}</rect></clipPath>
466<linearGradient id="base" x1="0" x2="${fx.toFixed(1)}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${rgb(acc)}" stop-opacity=".05"/><stop offset="1" stop-color="${rgb(acc)}" stop-opacity=".33"/></linearGradient></defs>
467<g clip-path="url(#pill)"><rect width="${W}" height="${H}" fill="#808080" fill-opacity=".16"/>
468<g clip-path="url(#fill)"><rect width="${fx.toFixed(1)}" height="${H}" fill="url(#base)"/>${px}</g>${marks}</g>
469<g transform="translate(${kx.toFixed(1)} 0)">${glideKnob}${knob}</g></svg>`
470 // the hover layer: checkpoint areas under the pill's copy, so the pill wins where they meet; tips on top
471 const overlay = `${open}${SEE_THROUGH}${hoverStyle}${hits}<g transform="translate(${kx.toFixed(1)} 0)">${timePill}</g>${tips}</svg>`
472
473 return { base, overlay }
474}
475
476const AGENT_COLOR: Record<AgentRun['state'], string> = {
477 running: STATE_COLOR.running,
478 waiting: STATE_COLOR.needs_input,
479 done: STATE_COLOR.done,
480 error: STATE_COLOR.error,
481}
482
483// the desktop drops an Svg whose alt is empty, so every drawing says what it shows
484function stripAlt(p: Plan, key: string): string {
485 const a = (p.agents ?? []).find(x => x.id === key)
486 return a ? `agent ${a.title}: ${a.state}, ${a.tool}` : 'more agents'
487}
488
489const elapsed = (ms: number) => {
490 const sec = Math.max(0, Math.round(ms / 1000))
491 if (sec < 60) return `${sec}s`
492 return sec < 3600 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
493}
494
495// which strips show: all of a small batch; in a big one the unfinished first, the rest folded into one line
496function visibleAgents(p: Plan, now: number, max: number): { shown: AgentRun[]; hidden: AgentRun[] } | null {
497 const list = p.agents ?? []
498 if (list.length === 0) return null
499 const hasError = list.some(a => a.state === 'error')
500 if (p.agentsDoneAt && now - p.agentsDoneAt > FOLD_MS && !hasError) return null
501 if (list.length <= max) return { shown: list, hidden: [] }
502 const keep = new Set(list.filter(a => a.state !== 'done').slice(0, max - 1).map(a => a.id))
503 for (const a of [...list].reverse()) {
504 if (keep.size >= max - 1) break
505 keep.add(a.id)
506 }
507 return { shown: list.filter(a => keep.has(a.id)), hidden: list.filter(a => !keep.has(a.id)) }
508}
509
510// what each strip showed last time it was drawn, so a change morphs from the old status instead of jumping
511const lastStrip = new Map<string, { tool: string; color: string }>()
512const MORPH = '.2s'
513
514// a strip's markup per agent object: drawn once per change, so its morph plays once and later redraws match
515const drawnRows = new WeakMap<AgentRun, { key: string; html: string }>()
516
517
518// one tinted strip per agent: state colour, name, what it does now and for how long; not a progress bar
519// Lucide "bot", drawn at 12 px in the gutter before each strip
520const BOT = '<rect width="16" height="12" x="4" y="8" rx="2"/><path d="M12 8V4H8M2 14h2M20 14h2M15 13v2M9 13v2"/>'
521const GUTTER = 36 // icon and agent number, left of the strip
522
523// claude-haiku-4-5-20251001 -> haiku 4.5; an alias stays as given
524const modelName = (m: string) => {
525 const r = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(m)
526 return r ? `${r[1]} ${r[2]}.${r[3]}` : m
527}
528
529// the agent's name, its model and effort in a dimmer parenthesis
530const nameMarkup = (name: string) => {
531 const at = name.indexOf(' (')
532 return at > 0 ? `${esc(name.slice(0, at))}<tspan class="st">${esc(name.slice(at))}</tspan>` : esc(name)
533}
534
535// one tinted strip per agent behind a bot icon and its number: the colour says how it went,
536// the word says what it does now (only while it runs or waits), the time how long it took
537type StripRow = { key: string; html: string; height: number }
538
539// each strip is its own drawing, so a change to one agent redraws that strip alone, never the bar or the others
540function stripsSvg(v: { shown: AgentRun[]; hidden: AgentRun[] }, all: AgentRun[], W: number): StripRow[] {
541 const isNarrow = W < NARROW
542 const SW = W - GUTTER
543 const rows: StripRow[] = []
544 const gutter = (y: number, label: string, color: string) =>
545 `<g transform="translate(1 ${y + 2}) scale(.5)" fill="none" stroke="${color}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">${BOT}</g>` +
546 `<text x="16" y="${y + 11.5}" class="sn sg" style="fill:${color}">${label}</text>`
547 v.shown.forEach((a, i) => {
548 // the first strip keeps a little room from the track above it
549 const y = i === 0 ? 5 : STRIP_GAP
550 const rowKey = `${W}|${y}|${all.indexOf(a)}`
551 const cachedRow = drawnRows.get(a)
552 if (cachedRow?.key === rowKey) {
553 rows.push({ key: a.id, html: cachedRow.html, height: y + STRIP_H })
554 return
555 }
556 const c = AGENT_COLOR[a.state]
557 const indent = a.depth > 0 ? 10 : 0
558 const word = a.state === 'running' || a.state === 'waiting' ? a.tool : ''
559 const dots = new Map<string, string>()
560 if (a.state === 'running') {
561 for (let col = 0; col * 3 < SW; col++) {
562 for (let r = 0; r < 4; r++) {
563 if (hash(col + i * 41, r, 5) > 0.2) continue
564 addDot(dots, `t${Math.floor(hash(col, r, 6) * 4)}`, GUTTER + col * 3, Math.round((y + 2.5 + r * 3.2) * 10) / 10)
565 }
566 }
567 }
568 const px = [...dots].map(([cls, d]) => `<path class="${cls}" fill="${c}" fill-opacity=".32" d="${d}"/>`).join('')
569 // a status change: the old word blurs out while the new one blurs in, and the tint flows to the new colour
570 const was = lastStrip.get(a.id)
571 lastStrip.set(a.id, { tool: word, color: c })
572 const isWordChanged = was !== undefined && was.tool !== word
573 const flow = (attr: string) => (was && was.color !== c ? `<animate attributeName="${attr}" from="${was.color}" to="${c}" dur="${MORPH}" fill="freeze"/>` : '')
574 // the tool word sits at the right, just before the clock, so the name and its model get the rest of the row
575 const toolEnd = W - 9 - CLOCK_W - 10
576 const wordW = Math.max(word ? textWidth(word, 6.2) : 0, isWordChanged && was.tool ? textWidth(was.tool, 6.2) : 0)
577 const nameX = GUTTER + 19 + indent
578 const nameRoom = isNarrow ? SW - 24 - indent : toolEnd - (wordW > 0 ? wordW + 12 : 0) - nameX
579 const spec = [a.model ? modelName(a.model) : '', a.effort ?? ''].filter(Boolean).join(' · ')
580 const full = (a.depth > 0 ? '↳ ' : '') + a.title + (spec ? ` (${spec})` : '')
581 let name = full
582 while (name.length > 4 && textWidth(name, 6.2) > nameRoom) name = name.slice(0, -1)
583 if (name !== full) name = name.trimEnd() + '…'
584 const time =
585 a.endedAt === null
586 ? liveClock(W - 9 - CLOCK_W, y, a.startedAt, 'sc', 'sn st')
587 : `<text x="${W - 9}" y="${y + 11.5}" text-anchor="end" class="sn st">${elapsed(a.endedAt - a.startedAt)}</text>`
588 const tool = isNarrow
589 ? ''
590 : (isWordChanged && was.tool ? `<text x="${toolEnd}" y="${y + 11.5}" text-anchor="end" class="sn mo" style="fill:${was.color}">${esc(was.tool)}</text>` : '') +
591 (word ? `<text x="${toolEnd}" y="${y + 11.5}" text-anchor="end" class="sn${isWordChanged ? ' mi' : ''}" style="fill:${c}">${esc(word)}</text>` : '') +
592 time
593 const html =
594 gutter(y, String(all.indexOf(a) + 1), '#A8A69E') +
595 `<rect x="${GUTTER}" y="${y}" width="${SW}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="${c}" fill-opacity=".15">${flow('fill')}</rect>${px}` +
596 `<circle cx="${GUTTER + 10 + indent}" cy="${y + STRIP_H / 2}" r="3" fill="${c}"${a.state === 'running' ? ' class="sd"' : ''}>${flow('fill')}</circle>` +
597 `<text x="${nameX}" y="${y + 11.5}" class="sn">${nameMarkup(name)}</text>` +
598 tool
599 drawnRows.set(a, { key: rowKey, html })
600 rows.push({ key: a.id, html, height: y + STRIP_H })
601 })
602 if (v.hidden.length > 0) {
603 const y = v.shown.length === 0 ? 5 : STRIP_GAP
604 const doneCount = v.hidden.filter(a => a.state === 'done').length
605 rows.push({
606 key: '+',
607 height: y + STRIP_H,
608 html:
609 gutter(y, `+${v.hidden.length}`, '#8A8984') +
610 `<rect x="${GUTTER}" y="${y}" width="${SW}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="#808080" fill-opacity=".14"/>` +
611 `<text x="${GUTTER + 10}" y="${y + 11.5}" class="sn st">${plural(v.hidden.length, 'more agent')} · ${doneCount} done</text>`,
612 })
613 }
614 return rows
615}
616
617const STRIP_STYLE = `<style>.sn{font:400 11.5px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#F0EEFC}.st{fill-opacity:.65}.sg{font-weight:500;font-variant-numeric:tabular-nums}
618.sd{animation:sp 1.1s ease-in-out infinite}@keyframes sp{50%{opacity:.3}}
619.mi{animation:mi ${MORPH} ease-out both}@keyframes mi{from{opacity:0;filter:blur(3px)}}
620.mo{animation:mo ${MORPH} ease-in both}@keyframes mo{to{opacity:0;filter:blur(3px)}}
621.t0,.t1,.t2,.t3{animation:tw 2.2s ease-in-out infinite}.t1{animation-duration:2.8s;animation-delay:-.7s}.t2{animation-duration:1.9s;animation-delay:-1.3s}.t3{animation-duration:3.3s;animation-delay:-.4s}
622@keyframes tw{0%,100%{opacity:1}50%{opacity:.45}}
623${CLOCK_CSS}
624@media (prefers-reduced-motion:reduce){.sd,.mi,.mo,.t0,.t1,.t2,.t3{animation:none}.mo{opacity:0}}</style>`
625
626function plural(n: number, word: string) {
627 return `${n} ${word}${n === 1 ? '' : 's'}`
628}
629
630const DEFAULT = 0x01000000
631let isLight = false
632const termBg = () => (isLight ? [255, 255, 255] : [24, 24, 27])
633const termFg = () => (isLight ? [34, 34, 38] : [240, 238, 252])
634const pack = (c: number[]) => ((c[0] ?? 0) << 16) | ((c[1] ?? 0) << 8) | (c[2] ?? 0)
635const isWide = (cp: number) => cp > 0xffff || (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6)
636const cellText = (s: string) => [...s].map(ch => (isWide(ch.codePointAt(0) ?? 63) || (ch.codePointAt(0) ?? 0) < 32 ? '·' : ch)).join('')
637const cellsOf = (s: string) => [...s].reduce((w, ch) => w + (isWide(ch.codePointAt(0) ?? 0) ? 2 : 1), 0)
638const fit = (s: string, w: number) => {
639 const chars = [...cellText(s)]
640 if (chars.length <= w) return chars.join('')
641 return w <= 1 ? '…'.slice(0, w) : chars.slice(0, w - 1).join('').trimEnd() + '…'
642}
643
644type Cell = [number, number, number]
645
646class Grid {
647 cells: Cell[]
648 constructor(
649 readonly columns: number,
650 readonly rows: number,
651 ) {
652 this.cells = Array.from({ length: columns * rows }, () => [32, DEFAULT, DEFAULT] as Cell)
653 }
654 set(x: number, y: number, ch: string | number, fg: number, bg: number) {
655 if (x < 0 || x >= this.columns || y < 0 || y >= this.rows) return
656 this.cells[y * this.columns + x] = [typeof ch === 'number' ? ch : (ch.codePointAt(0) ?? 32), fg, bg]
657 }
658 bg(x: number, y: number) {
659 return this.cells[y * this.columns + x]?.[2] ?? DEFAULT
660 }
661 text(x: number, y: number, s: string, fg: number, bg?: number) {
662 let i = 0
663 for (const ch of cellText(s)) {
664 this.set(x + i, y, ch, fg, bg ?? this.bg(x + i, y))
665 i++
666 }
667 return i
668 }
669 encode() {
670 const words = new Uint32Array(this.cells.length * 3)
671 this.cells.forEach((c, i) => words.set(c, i * 3))
672 return (new Uint8Array(words.buffer) as Uint8Array & { toBase64: () => string }).toBase64()
673 }
674}
675
676const BRAILLE_BITS = [
677 [0x01, 0x08],
678 [0x02, 0x10],
679 [0x04, 0x20],
680 [0x40, 0x80],
681]
682
683function pill(g: Grid, y: number, from: number, to: number, bgAt: (x: number) => number[]) {
684 for (let x = from; x < to; x++) g.set(x, y, ' ', DEFAULT, pack(bgAt(x)))
685}
686
687const LEVELS = 8
688const q = (m: number) => Math.round(Math.max(0, Math.min(1, m)) * LEVELS) / LEVELS
689const wave = (t: number, periodMs: number, offset = 0) => 0.5 + 0.5 * Math.sin(((t / periodMs) + offset) * Math.PI * 2)
690const easeOut = (x: number) => 1 - Math.pow(1 - Math.max(0, Math.min(1, x)), 4)
691
692const glide = new Map<string, { from: number; to: number; at: number }>()
693const GLIDE_MS = 450
694
695function headAt(id: string, target: number, t: number): number {
696 const g = glide.get(id)
697 if (!g) {
698 glide.set(id, { from: target, to: target, at: t })
699 return target
700 }
701 const cur = g.from + (g.to - g.from) * easeOut((t - g.at) / GLIDE_MS)
702 if (Math.abs(g.to - target) > 0.01) {
703 glide.set(id, { from: cur, to: target, at: t })
704 return cur
705 }
706 return cur
707}
708
709
710function trackCells(p: Plan, W: number, t: number): string {
711 const g = new Grid(W, 1)
712 const w = where(p)
713 const done = p.state === 'done'
714 const target = (done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))) * W
715 const fx = headAt(p.id, target, t)
716 const back = termBg()
717 const acc = hex(STATE_COLOR[p.state])
718 const light = mix(acc, [255, 255, 255], 0.35)
719 const grey = [120, 118, 128]
720 const track = mix(back, [128, 128, 128], isLight ? 0.14 : 0.18)
721 const fill = mix(track, acc, done ? 0.3 : 0.17)
722 const under = (x: number) => (x + 0.5 < fx ? fill : track)
723 pill(g, 0, 0, W, under)
724
725 const TWINKLE = done ? [3200, 3800, 4400, 3500] : [2200, 2800, 1900, 3300]
726 const DELAY = [0, 700, 1300, 400]
727 const dim = done ? 0.2 : 0.55
728 for (let col = 0; col < Math.min(W, Math.ceil(fx)); col++) {
729 const u = Math.min(1, (col + 0.5) / Math.max(1, fx))
730 const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(u, 1.5)
731 let bits = 0
732 for (let r = 0; r < 4; r++) {
733 for (let c = 0; c < 2; c++) {
734 const sx = col * 2 + c
735 if (sx / 2 >= fx || hash(sx, r, 1) > dense * 0.6) continue
736 bits |= BRAILLE_BITS[r]?.[c] ?? 0
737 }
738 }
739 if (bits === 0) continue
740 const cls = Math.floor(hash(col, 0, 2) * 4)
741 const period = TWINKLE[cls] ?? 2200
742 const blink = 1 - dim * wave(t + (DELAY[cls] ?? 0), period, 0.25)
743 const bucket = done ? 1 : q(Math.min(1, Math.pow(u, 0.9) * 1.1))
744 const tone = mix(grey, light, bucket)
745 const opacity = (0.35 + 0.65 * dense) * blink
746 g.set(col, 0, 0x2800 + bits, pack(mix(fill, tone, q(opacity))), pack(fill))
747 }
748
749 let k = 0
750 p.stages.forEach(s => {
751 if (k > 0) {
752 const x = Math.round((k / w.total) * W)
753 if (x > 0 && x < W - 1) {
754 const passed = x < fx - 0.5
755 g.set(x, 0, '│', pack(passed ? mix(light, [255, 255, 255], 0.5) : mix(track, isLight ? [0, 0, 0] : [255, 255, 255], 0.3)), pack(under(x)))
756 }
757 }
758 k += s.steps.length
759 })
760
761 const base = hex(STATE_COLOR[p.state])
762 const color = base
763 const single = p.stages.length === 1
764 const number = single ? Math.min(w.total, w.pos + 1) : w.stage + 1
765 const icon = p.state === 'done' ? '✓' : p.state === 'error' ? '✕' : p.state === 'needs_input' ? '?' : ''
766 let name = ''
767 let count = ''
768 if (W < 28) {
769 name = icon || String(number)
770 } else {
771 const agents = p.agents ?? []
772 name = done ? 'Done' : single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
773 const baseCount = p.id === AGENTS ? `${w.pos}/${w.total}` : done ? `${w.total}/${w.total}` : single ? `${number}/${w.total}` : `${w.step}/${w.stageSize}`
774 count = baseCount + (agents.length > 0 && p.id !== AGENTS ? ` · ${agents.filter(a => a.state === 'done').length}/${agents.length}` : '')
775 }
776 const lead = icon && W >= 28 ? `${icon} ` : ''
777 const maxName = Math.max(3, Math.floor(W * (W < 60 ? 0.75 : 0.55)) - cellsOf(lead) - cellsOf(count) - 5)
778 const shown = lead + fit(name, maxName)
779 const kw = cellsOf(shown) + (count ? count.length + 1 : 0) + 2
780 const kx = Math.round(Math.max(0, Math.min(W - kw, fx - kw / 2)))
781 const white = pack([255, 255, 255])
782 pill(g, 0, kx, kx + kw, () => color)
783 let at = kx + 1
784 at += g.text(at, 0, shown, white, pack(color))
785 if (count) g.text(at + 1, 0, count, pack(mix([255, 255, 255], color, 0.3)), pack(color))
786 return g.encode()
787}
788
789function stripCells(v: { shown: AgentRun[]; hidden: AgentRun[] }, W: number, now: number): { cells: string; rows: number } {
790 const rows = v.shown.length + (v.hidden.length > 0 ? 1 : 0)
791 const g = new Grid(W, rows)
792 const back = termBg()
793 const text = pack(termFg())
794 v.shown.forEach((a, y) => {
795 const c = hex(AGENT_COLOR[a.state])
796 const tint = mix(back, c, 0.18)
797 pill(g, y, 0, W, () => tint)
798 const running = a.state === 'running' || a.state === 'waiting'
799 if (running) {
800 for (let col = 1; col < W - 1; col++) {
801 let bits = 0
802 let glow = 0
803 for (let r = 0; r < 4; r++) {
804 for (let cc = 0; cc < 2; cc++) {
805 const sx = col * 2 + cc + y * 83
806 if (hash(sx, r, 5) >= 0.08) continue
807 const b = 1 - 0.55 * wave(now, 1900 + hash(sx, r, 6) * 1400, hash(sx, r, 7))
808 bits |= BRAILLE_BITS[r]?.[cc] ?? 0
809 glow = Math.max(glow, b)
810 }
811 }
812 if (bits) g.set(col, y, 0x2800 + bits, pack(mix(tint, c, 0.2 + 0.4 * q(glow))), pack(tint))
813 }
814 }
815 const indent = a.depth > 0 ? 2 : 0
816 const dotColor = running ? mix(tint, c, 0.3 + 0.7 * q(wave(now, 1100))) : c
817 g.text(2 + indent, y, '●', pack(dotColor), pack(tint))
818 const time = elapsed((a.endedAt ?? now) - a.startedAt)
819 const narrow = W < 30
820 const tx = W - 2 - time.length
821 // the tool word sits at the right, just before the time, so the name and its model get the rest of the row
822 const word = narrow || !running ? '' : fit(a.tool, Math.max(0, Math.floor(W * 0.25)))
823 const toolAt = tx - 1 - word.length
824 let at = 4 + indent
825 const spec = [a.model ? modelName(a.model) : '', a.effort ?? ''].filter(Boolean).join(' · ')
826 const full = (a.depth > 0 ? '↳ ' : '') + a.title + (spec ? ` (${spec})` : '')
827 const name = fit(full, Math.max(3, (narrow ? W - 2 : word ? toolAt - 1 : tx - 1) - at))
828 // the model and effort are drawn dimmer than the name
829 const cut = spec ? name.indexOf(' (') : -1
830 const head = cut > 0 ? name.slice(0, cut) : name
831 for (let i = -1; i < name.length + 1 && at + i < W - 1; i++) g.set(at + i, y, ' ', DEFAULT, pack(tint))
832 at += g.text(at, y, head, text, pack(tint))
833 if (head !== name) g.text(at, y, name.slice(head.length), pack(mix(termFg(), tint, 0.4)), pack(tint))
834 if (narrow) return
835 if (word) {
836 for (let i = -1; i <= word.length; i++) g.set(toolAt + i, y, ' ', DEFAULT, pack(tint))
837 g.text(toolAt, y, word, pack(c), pack(tint))
838 }
839 for (let i = -1; i < time.length; i++) g.set(tx + i, y, ' ', DEFAULT, pack(tint))
840 g.text(tx, y, time, pack(mix(termFg(), tint, 0.35)), pack(tint))
841 })
842 if (v.hidden.length > 0) {
843 const y = v.shown.length
844 const tint = mix(back, [128, 128, 128], 0.16)
845 pill(g, y, 0, W, () => tint)
846 const doneCount = v.hidden.filter(a => a.state === 'done').length
847 g.text(2, y, fit(`+${plural(v.hidden.length, 'more agent')} · ${doneCount} done`, W - 4), pack(mix(termFg(), tint, 0.35)), pack(tint))
848 }
849 return { cells: g.encode(), rows }
850}
851
852type Band = { requestId: string; W: number; list: readonly Plan[] }
853let band: Band | null = null
854let isFrameBusy = false
855// a bar twinkles only while Claude works on it; one waiting on the person or left open after the turn stands still
856let isTurnLive = false
857let frames: { cancel: () => void } | null = null
858
859const hasRunningAgents = (p: Plan) => (p.agents ?? []).some(a => a.state === 'running' || a.state === 'waiting')
860const isGliding = (p: Plan, t: number) => {
861 const g = glide.get(p.id)
862 return g !== undefined && g.from !== g.to && t - g.at < GLIDE_MS + 100
863}
864const isAnimated = (p: Plan, t: number) => (isTurnLive && p.state === 'running') || hasRunningAgents(p) || isGliding(p, t)
865
866// the 30 fps clock runs only while a terminal band has something moving; the second timer starts and stops it
867function syncFrames($: EngineInterface, now: number) {
868 const isWanted = band !== null && band.list.some(p => isAnimated(p, now))
869 if (isWanted && !frames) frames = $.clock.every(33, () => void animate($))
870 if (!isWanted && frames) {
871 frames.cancel()
872 frames = null
873 }
874}
875
876// ---------- terminal tint ----------
877// the track's light or dark tint: a theme the person picked wins; "auto", and the default "dark" inside Terminal.app
878// (whose stock profiles follow macOS), take the macOS appearance. The appearance is read only while a terminal bar
879// is on screen, at most every APPEARANCE_MS, and never again once the command turns out to be missing (not a Mac)
880const APPEARANCE_MS = 5000
881let themeSetting = 'dark'
882let mac: 'unknown' | 'yes' | 'no' = 'unknown'
883let termProgram = ''
884let isSystemDark: boolean | null = null
885let appearanceAt = -Infinity
886let isProbing = false
887
888const followsSystem = () => mac !== 'no' && (themeSetting === 'auto' || (themeSetting === 'dark' && (mac === 'unknown' || termProgram === 'Apple_Terminal')))
889const tintIsLight = () => (followsSystem() && isSystemDark !== null ? !isSystemDark : /light/i.test(themeSetting))
890
891async function probeAppearance($: EngineInterface, now: number) {
892 if (isProbing || !followsSystem() || now - appearanceAt < APPEARANCE_MS) return
893 isProbing = true
894 appearanceAt = now
895 try {
896 const r = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: 2000 }).catch(() => null)
897 if (!r) {
898 // no such command: not a Mac, so the theme setting alone decides from here on
899 mac = 'no'
900 return
901 }
902 if (mac === 'unknown') {
903 const t = await $.process.run(['/bin/sh', '-c', 'printf %s "$TERM_PROGRAM"'], { timeoutMs: 2000 }).catch(() => null)
904 termProgram = t?.stdout.trim() ?? ''
905 mac = 'yes'
906 }
907 // the key is missing in light mode, so anything but "Dark" reads as light
908 isSystemDark = /dark/i.test(r.stdout)
909 } finally {
910 isProbing = false
911 }
912}
913
914// reads the appearance when due and repaints the bars at once if the tint changed
915async function syncTint($: EngineInterface, now: number) {
916 if (band) await probeAppearance($, now)
917 const next = tintIsLight()
918 if (next === isLight) return
919 isLight = next
920 const b = band
921 if (b) await blitPlans($, b, b.list, now)
922}
923
924function blitPlans($: EngineInterface, b: Band, list: readonly Plan[], now: number) {
925 return Promise.all(
926 list.flatMap(p => {
927 const v = visibleAgents(p, now, stripBudget(b.list.length))
928 const strips = v ? stripCells(v, b.W, now) : null
929 const calls = [$.ui.blit({ requestId: b.requestId, key: `track-${p.id}`, cells: trackCells(p, b.W, now) })]
930 if (strips) calls.push($.ui.blit({ requestId: b.requestId, key: `strips-${p.id}`, cells: strips.cells }))
931 return calls.map(c => c.catch(() => undefined))
932 }),
933 )
934}
935
936async function animate($: EngineInterface) {
937 const b = band
938 if (!b || isFrameBusy) return
939 const now = await $.clock.now()
940 const live = b.list.filter(p => isAnimated(p, now))
941 if (live.length === 0) return
942 isFrameBusy = true
943 try {
944 await blitPlans($, b, live, now)
945 } finally {
946 isFrameBusy = false
947 }
948}
949
950
951// ---------- engine glue ----------
952
953// the engine's player first (afplay on macOS); PowerShell where it cannot play
954// set while a demo reel records: every sound with its moment, so a video can carry them
955let soundLog: { at: number; name: string }[] | null = null
956
957function play($: EngineInterface, name: 'decision' | 'error' | 'done') {
958 if (soundLog) void $.clock.now().then(at => soundLog?.push({ at, name }))
959 const file = `${$.plugin.root}/sounds/${name}.wav`.replace(/\//g, '\\')
960 const viaPowerShell = () =>
961 $.process
962 .run(['powershell', '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `(New-Object Media.SoundPlayer '${file}').PlaySync()`], { timeoutMs: 5000 })
963 .catch(() => undefined)
964 // on Windows the engine's player can report success and stay silent, so the system player plays it directly
965 if (/^[A-Za-z]:/.test($.plugin.root)) {
966 void viaPowerShell()
967 return
968 }
969 void $.audio.play({ asset: `sounds/${name}.wav` }).catch(viaPowerShell)
970}
971
972// the agents bar is the mod's own; the model never owes it an update
973const AGENTS = 'agents:auto' // slug() never yields ':', so no model id can take it
974const isOpenPlan = (p: Plan) => p.id !== AGENTS && p.state === 'running' && !p.stages.flatMap(s => s.steps).every(s => isFinished(s.status))
975
976const slug = (s: string) =>
977 s
978 .toLowerCase()
979 .replace(/[^\p{L}\p{N}]+/gu, '-')
980 .replace(/^-|-$/g, '')
981 .slice(0, 40) || 'plan'
982
983// adds or replaces one bar by id; keeps at most MAX_BARS, dropping finished ones first
984// computed inside update() from the latest list, so concurrent writers (parallel agents) do not drop each other
985function placeBar(list: readonly Plan[], next: Plan): Plan[] {
986 const prev = list.find(p => p.id === next.id)
987 // an update keeps its row and, unless it brings its own, the agent strips already on it; a new bar goes to the bottom
988 const kept = prev && !('agents' in next) ? { ...next, agents: prev.agents, agentsDoneAt: prev.agentsDoneAt } : next
989 const rest = prev ? list.map(p => (p.id === next.id ? kept : p)) : [...list, next]
990 while (rest.length > MAX_BARS) {
991 const doneAt = rest.findIndex(p => p.state === 'done')
992 rest.splice(doneAt >= 0 ? doneAt : 0, 1)
993 }
994 return rest
995}
996
997function chime($: EngineInterface, prev: PlanState | undefined, next: PlanState) {
998 if (next === prev) return
999 if (next === 'needs_input') play($, 'decision')
1000 if (next === 'error') play($, 'error')
1001 if (next === 'done') play($, 'done')
1002}
1003
1004async function putPlan($: EngineInterface, next: Plan) {
1005 await editPlan($, next.id, () => next)
1006}
1007
1008// builds a bar from the latest stored one inside update(), so back-to-back calls never work from a stale copy;
1009// make returns a string to refuse, and the list stays as it was
1010async function editPlan($: EngineInterface, id: string, make: (prev: Plan | null) => Plan | string, isQuiet = false): Promise<Plan | string> {
1011 let prev: Plan | undefined
1012 let made = '' as Plan | string
1013 await update($, plans, list => {
1014 prev = list.find(p => p.id === id)
1015 made = make(prev ?? null)
1016 return typeof made === 'string' ? [...list] : placeBar(list, made)
1017 })
1018 if (typeof made === 'string') return made
1019 if (!isQuiet) chime($, prev?.state, made.state)
1020 if (!prev) await update($, isOpen, () => true)
1021 return made
1022}
1023
1024// ---------- agents: drawn from engine events alone, no model calls ----------
1025// each subagent lives on a bar as one state strip: the open task bar it was started under,
1026// the bar of its parent agent, or the mod's own "Agents" bar when no task is open.
1027// Module maps: a reload forgets running agents, whose strips then stay until the bar is closed.
1028const agentHome = new Map<string, string>() // agentId -> bar id
1029const toolUses = new Map<string, string>() // tool_use_id -> agentId, to find who waits on a permission
1030const waiting = new Set<string>()
1031let foldUntil = 0 // keep ticking until finished strips have folded
1032
1033// the mod's own bar mirrors its agents as steps, finished first, so percent and count read done/total
1034function syncAuto(p: Plan, now: number): Plan {
1035 const agents = p.agents ?? []
1036 const isOver = agents.length > 0 && agents.every(a => a.state === 'done' || a.state === 'error')
1037 const agentsDoneAt = isOver ? (p.agentsDoneAt ?? now) : null
1038 if (p.id !== AGENTS) return { ...p, agentsDoneAt }
1039 const rank = (a: AgentRun) => (a.state === 'done' ? 0 : a.state === 'error' ? 1 : 2)
1040 const steps: PlanStep[] = [...agents]
1041 .sort((a, b) => rank(a) - rank(b))
1042 .map(a => ({ title: a.title, status: a.state === 'done' ? 'done' : a.state === 'error' ? 'error' : 'active', substeps: [] }))
1043 const state: PlanState = isOver
1044 ? agents.some(a => a.state === 'error') ? 'error' : 'done'
1045 : agents.some(a => a.state === 'waiting') ? 'needs_input' : 'running'
1046 return { ...p, agentsDoneAt, stages: [{ name: 'Agents', steps }], state }
1047}
1048
1049function addRun(p: Plan, run: AgentRun, parentId: string | undefined, now: number): Plan {
1050 // a batch that has finished makes room for the next one
1051 const list = p.agentsDoneAt ? [] : [...(p.agents ?? [])]
1052 let at = list.length
1053 const parentAt = parentId ? list.findIndex(a => a.id === parentId) : -1
1054 if (parentAt >= 0) {
1055 at = parentAt + 1
1056 while (at < list.length && (list[at]?.depth ?? 0) > 0) at++
1057 }
1058 list.splice(at, 0, run)
1059 return syncAuto({ ...p, agents: list, agentsDoneAt: null }, now)
1060}
1061
1062// changes one agent's strip inside the latest list; silent, since a subagent answers to Claude, not to the person
1063async function editAgent($: EngineInterface, agentId: string, change: (a: AgentRun) => AgentRun) {
1064 const home = agentHome.get(agentId)
1065 if (!home) return
1066 const now = await $.clock.now()
1067 let isFolding = false
1068 await update($, plans, list =>
1069 list.map(p => {
1070 if (p.id !== home || !p.agents?.some(a => a.id === agentId)) return p
1071 const next = syncAuto({ ...p, agents: p.agents.map(a => (a.id === agentId ? change(a) : a)) }, now)
1072 isFolding = !p.agentsDoneAt && next.agentsDoneAt !== null
1073 return next
1074 }),
1075 )
1076 if (isFolding) foldUntil = now + FOLD_MS + 200
1077}
1078
1079// module maps outlive the bars they describe: a bar pushed out past MAX_BARS, a cleared list, an agent
1080// whose finish never arrived (killed, or started before a reload); drop what no bar holds any more
1081function forgetGone(list: readonly Plan[]) {
1082 const bars = new Set(list.map(p => p.id))
1083 const live = new Set(list.flatMap(p => (p.agents ?? []).filter(a => a.state === 'running' || a.state === 'waiting').map(a => a.id)))
1084 const shown = new Set(list.flatMap(p => (p.agents ?? []).map(a => a.id)))
1085 for (const id of lastHead.keys()) if (!bars.has(id)) lastHead.delete(id)
1086 for (const id of glide.keys()) if (!bars.has(id)) glide.delete(id)
1087 for (const id of lastSource.keys()) {
1088 const [bar, strip] = id.split('/')
1089 if (!bars.has(bar ?? '') || (strip !== undefined && strip !== '+' && !shown.has(strip))) lastSource.delete(id)
1090 }
1091 for (const id of lastStrip.keys()) if (!shown.has(id)) lastStrip.delete(id)
1092 for (const [id, home] of agentHome) {
1093 if (bars.has(home) && live.has(id)) continue
1094 agentHome.delete(id)
1095 waiting.delete(id)
1096 }
1097}
1098
1099async function dropPlan($: EngineInterface, id: string) {
1100 lastHead.delete(id)
1101 glide.delete(id)
1102 for (const p of await read($, plans)) if (p.id === id) for (const a of p.agents ?? []) lastStrip.delete(a.id)
1103 await update($, plans, list => list.filter(p => p.id !== id))
1104}
1105
1106// substeps are left out: the bar never draws them and short ops cannot reach them
1107const STEP_SCHEMA = {
1108 type: 'object',
1109 required: ['title'],
1110 properties: {
1111 title: { type: 'string' },
1112 status: { enum: STATUSES, description: 'Default pending' },
1113 },
1114}
1115
1116// edits are the work the enforcement below counts. Shell calls are never refused or counted: the host marks only
1117// plain reads (ls) read-only, so a compound read (cd x && git log) would look like work and be refused
1118const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
1119const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
1120const WORK_BEFORE_PLAN = 3 // the 4th edit without a plan is refused once
1121const CALLS_BEFORE_NUDGE = 6 // edits without a plan update before a reminder
1122
1123
1124
1125// ---------- demo reel: two pipelines and their agents, ~13 s, for screen recordings ----------
1126const run = (id: string, title: string, tool: string, at: number, isSonnet: boolean): AgentRun => ({
1127 id, title, state: 'running', tool, startedAt: at, endedAt: null, depth: 0,
1128 ...(isSonnet ? { model: 'claude-sonnet-5-5', effort: 'medium' } : { model: 'claude-haiku-4-5' }),
1129})
1130
1131// $.state lives as long as the process, so the bars are kept per session in the plugin's store as well
1132const SAVED = 'plans:'
1133const KEEP_SESSIONS = 20
1134let lastSaved: Plan[] | null = null
1135
1136async function savePlans($: EngineInterface, list: Plan[]) {
1137 lastSaved = list
1138 const key = SAVED + (await $.session.id())
1139 if (list.length === 0) {
1140 await $.store.delete(key)
1141 return
1142 }
1143 await $.store.set(key, list)
1144 const keys = (await $.store.keys()).filter(k => k.startsWith(SAVED))
1145 for (const old of keys.slice(0, Math.max(0, keys.length - KEEP_SESSIONS))) await $.store.delete(old)
1146}
1147
1148// agents do not outlive the process that ran them, so a restored bar comes back without strips
1149async function restorePlans($: EngineInterface) {
1150 const saved = await $.store.get(SAVED + (await $.session.id()))
1151 if (!Array.isArray(saved) || saved.length === 0) return
1152 const list = (saved as Plan[]).map(p => ({ ...p, agents: [], agentsDoneAt: null }))
1153 await update($, plans, () => list)
1154 lastSaved = list
1155}
1156
1157async function runReel($: EngineInterface, logPath: string) {
1158 const t0 = await $.clock.now()
1159 soundLog = []
1160 foldUntil = t0 + 20_000
1161 await update($, plans, () => [])
1162 const A = 'reel-checkout'
1163 const B = 'reel-release'
1164 const steps = (names: string[], active: number) => names.map((n, i) => st(n, i < active ? 'done' : i === active ? 'active' : 'pending'))
1165 // mid-run the checkout plan is rewritten: a Harden stage lands between Build and Verify
1166 let isReplanned = false
1167 const planA = (k: number, state: PlanState = 'running'): Plan => {
1168 const all = isReplanned
1169 ? ['Routes', 'Cart store', 'Payment API', 'Webhooks', 'Checkout UI', 'Rate limits', 'Security review', 'Tests', 'Build']
1170 : ['Routes', 'Cart store', 'Payment API', 'Webhooks', 'Checkout UI', 'Tests', 'Build']
1171 const s2 = steps(all, k)
1172 const stages = isReplanned
1173 ? [{ name: 'Explore', steps: s2.slice(0, 2) }, { name: 'Build', steps: s2.slice(2, 5) }, { name: 'Harden', steps: s2.slice(5, 7) }, { name: 'Verify', steps: s2.slice(7) }]
1174 : [{ name: 'Explore', steps: s2.slice(0, 2) }, { name: 'Build', steps: s2.slice(2, 5) }, { name: 'Verify', steps: s2.slice(5) }]
1175 return { id: A, title: 'Checkout flow', kind: 'plan', state, note: null, startedAt: t0, stages }
1176 }
1177 const planB = (k: number, state: PlanState = 'running'): Plan =>
1178 ({ id: B, title: 'Release notes', kind: 'todo', state, note: null, startedAt: t0, stages: [{ name: 'Release', steps: steps(['Collect PRs', 'Group changes', 'Draft notes', 'Publish'], k) }] })
1179 const setAgents = async (id: string, f: (a: AgentRun[]) => AgentRun[]) => {
1180 const now = await $.clock.now()
1181 await update($, plans, list => list.map(p => (p.id === id ? syncAuto({ ...p, agents: f(p.agents ?? []) }, now) : p)))
1182 }
1183 const edit = (id: string, agentId: string, ch: Partial<AgentRun>, at: number) =>
1184 setAgents(id, list => list.map(a => (a.id === agentId ? { ...a, ...ch, ...(ch.state === 'done' || ch.state === 'error' ? { endedAt: at } : {}) } : a)))
1185 const keep = async (id: string, f: (p: Plan) => Plan) => {
1186 let before: PlanState | undefined
1187 let after: PlanState | undefined
1188 const now = await $.clock.now()
1189 await update($, plans, list =>
1190 list.map(p => {
1191 if (p.id !== id) return p
1192 before = p.state
1193 const made = f(p)
1194 // a finished bar shows its time in the pill, so it needs the moment it ended
1195 const next = { ...made, endedAt: made.state === 'done' ? now : null, agents: p.agents, agentsDoneAt: p.agentsDoneAt }
1196 after = next.state
1197 return next
1198 }),
1199 )
1200 if (after) chime($, before, after)types/index.d.ts 45 lines1export type StepStatus = 'pending' | 'active' | 'done' | 'error' | 'skipped'
2export type PlanSubstep = { title: string; status: StepStatus }
3// doneAt: when the step was finished, so a checkpoint can tell how long it took
4export type PlanStep = { title: string; status: StepStatus; substeps: PlanSubstep[]; doneAt?: number }
5export type PlanStage = { name: string; steps: PlanStep[] }
6export type PlanState = 'running' | 'needs_input' | 'error' | 'done'
7// one subagent shown as a state strip under a bar; depth 1 sits under its parent agent
8export type AgentRun = {
9 id: string
10 title: string
11 state: 'running' | 'waiting' | 'done' | 'error'
12 tool: string
13 startedAt: number
14 endedAt: number | null
15 depth: number
16 // the model it runs on and its effort, as the engine resolved them
17 model?: string
18 effort?: string
19}
20export type Plan = {
21 id: string
22 title: string
23 kind: 'plan' | 'todo'
24 stages: PlanStage[]
25 state: PlanState
26 note: string | null
27 startedAt: number
28 // when the plan was finished; the pill then shows the time it took
29 endedAt?: number | null
30 agents?: AgentRun[]
31 // when the current batch of agents all finished; their strips fold a few seconds later
32 agentsDoneAt?: number | null
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 'plan-progress': {
38 plans: Plan[]
39 isOpen: boolean
40 // bumped every second while agents run, so elapsed times and folding redraw
41 tick: number
42 }
43 }
44}
45