Live plan progress bars right above the prompt (stages, steps, a pixel fill, agent strips, sounds). A fork of zycck's plan-progress that works without a…

Live progress bars right above the prompt for Claude's multi-step work: stages and steps with a pixel fill, a strip under the bar for each subagent, a red bar for a failed step and an amber one while Claude waits for you, and soft sounds for decisions, errors and finishing.
A fork of plan-progress by Kirill Serditov (MIT), with the same bars, commands and sounds. Three things are different:
: progress with the original's JSON op in a heredoc. The mod answers that call itself, so nothing runs and no permission is asked. In some setups Claude Code never connects the original's plan_progress tool (it reports CLIENT_HTTP_NOT_IMPLEMENTED and then asks to authenticate), so the bars there never move. `` : progress <<'EOF' {"id":"plan","next":true} EOF ``plan, and its Context, Background, Why, Goals, Notes and Decisions sections add no stages./progress shows or hides the bars./progress-clear removes them./progress-agents folds or shows the agent strips./plan-progress-autoclose turns on or off a finished bar leaving after doneBarSeconds (20 s by default; /plugin → plan-line → configure)./plugin marketplace add didac-alonso/didac-mods
/plugin install plan-line@didac-mods
Don't enable it alongside plan-progress, or both will draw bars.
claude plugin validate .
cd tests && node compile.cjs ../hooks/register.tsx register.mjs && node regress.mjs
compile.cjs needs TypeScript 5's JS API (the TYPESCRIPT environment variable can point at it).
MIT, see LICENSE. Original code © 2026 Kirill Serditov.
hooks/register.tsx 2127 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
6// how the model moves a bar: a shell no-op the mod answers itself, so no tool needs to connect.
7// The heredoc holds the same JSON the plan_progress tool took; with the mod off, `:` runs and does nothing
8const TOOL = "a Bash call `: progress <<'EOF'` + one JSON op + `EOF`"
9const OP_LINE = /^\s*:\s+progress\b/
10const plans = atom({ plugin: 'plan-line', key: 'plans' } as const, [])
11const MAX_BARS = 3
12// a space as wide as a digit, so ' 0%' and '100%' take the same room
13const FIGURE_SPACE = String.fromCharCode(0x2007)
14const isOpen = atom({ plugin: 'plan-line', key: 'isOpen' } as const, true)
15const tick = atom({ plugin: 'plan-line', key: 'tick' } as const, 0)
16const STRIP_H = 16
17const STRIP_GAP = 2
18// rows of strips per bar, the "+N more" row included: the band above the prompt has room for about 300 px
19const stripBudget = (bars: number) => (bars >= 3 ? 3 : bars === 2 ? 4 : 5)
20const FOLD_MS = 5000 // finished strips stay this long, failed ones stay until the bar closes
21
22// one lightness for every state (OKLCH L .55, hues of the desktop's violet, amber, red and green), so no state
23// shouts louder than another, and white on each reads at 4.5:1 or better
24const STATE_COLOR: Record<PlanState, string> = { running: '#7858CA', needs_input: '#AD6400', error: '#C5353E', done: '#18883A' }
25const INK = '#FFFFFF'
26
27// the Omarchy theme in use, read from its colors.toml: a running bar takes its accent, the terminal track its
28// background and foreground, and its mode says light or dark. Null anywhere else, where the defaults above stay
29type Omarchy = { accent: string; background: number[]; foreground: number[]; isLight: boolean }
30let omarchy: Omarchy | null = null
31const stateColor = (s: PlanState) => (s === 'running' && omarchy ? omarchy.accent : STATE_COLOR[s])
32
33function parseOmarchy(toml: string): Omarchy | null {
34 const color = (key: string) => new RegExp(`^\\s*${key}\\s*=\\s*["'](#[0-9a-fA-F]{6})["']`, 'm').exec(toml)?.[1]?.toLowerCase()
35 const accent = color('accent')
36 if (!accent) return null
37 const background = hex(color('background') ?? '#1b1b1b')
38 const foreground = hex(color('foreground') ?? '#e0e0e0')
39 const mode = /^\s*mode\s*=\s*["'](light|dark)["']/m.exec(toml)?.[1]
40 return { accent, background, foreground, isLight: mode ? mode === 'light' : luminance(background) > 0.5 }
41}
42
43const luminance = (c: number[]) => (0.2126 * (c[0] ?? 0) + 0.7152 * (c[1] ?? 0) + 0.0722 * (c[2] ?? 0)) / 255
44
45// WCAG 2.x contrast, measured on the 4-bit colour the engine paints a Raster cell in
46const q17 = (c: number[]) => c.map(v => Math.round(v / 17) * 17)
47const relLum = (c: number[]) => {
48 const l = q17(c).map(v => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4))
49 return 0.2126 * (l[0] ?? 0) + 0.7152 * (l[1] ?? 0) + 0.0722 * (l[2] ?? 0)
50}
51const contrast = (a: number[], b: number[]) => (Math.max(relLum(a), relLum(b)) + 0.05) / (Math.min(relLum(a), relLum(b)) + 0.05)
52// text over a theme's colours: the first candidate that reads at AA (4.5:1) on bg, else the one that reads best. The
53// theme's own colours are never changed; the text only picks among them
54function readable(bg: number[], ...candidates: number[][]): number[] {
55 const ok = candidates.find(c => contrast(c, bg) >= 4.5)
56 return ok ?? candidates.reduce((a, b) => (contrast(b, bg) > contrast(a, bg) ? b : a))
57}
58// text on a pill: white, else the theme's background, else its foreground (a yellow or a pastel accent)
59const inkOn = (c: number[]) => (omarchy ? readable(c, [255, 255, 255], omarchy.background, omarchy.foreground) : [255, 255, 255])
60// a word in a colour of its own on a strip: that colour, else it mixed toward the theme's text, else the text
61const wordOn = (bg: number[], c: number[]) =>
62 omarchy ? readable(bg, c, mix(c, omarchy.foreground, 0.3), mix(c, omarchy.foreground, 0.6), omarchy.foreground) : c
63// the model and the time: the theme's text dimmed toward the strip as far as AA allows
64const dimOn = (bg: number[], c: number[]) =>
65 omarchy ? readable(bg, mix(omarchy.foreground, bg, 0.4), mix(omarchy.foreground, bg, 0.25), omarchy.foreground) : c
66const STATE_GLYPH: Record<PlanState, string> = { running: '●', needs_input: '?', error: '!', done: '✓' }
67const STATUSES: StepStatus[] = ['pending', 'active', 'done', 'error', 'skipped']
68const TRACK_H = 22
69const NARROW = 360
70
71const RULES = `# Progress bars
72Tasks 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.
73Each call is one Bash command, the op as JSON in a heredoc; it runs nothing and needs no permission:
74: progress <<'EOF'
75{"id":"fix-auth","next":true}
76EOF`
77
78type Raw = Record<string, unknown>
79
80// what a `: progress` call reads back, in the Bash tool's own shape
81const shell = (stdout: string) => ({ result: { stdout, stderr: '', interrupted: false } })
82
83// the op of a `: progress` call: the JSON in its heredoc, or written after the word inline
84function opFrom(command: string): Raw | string {
85 const doc = /<<-?\s*(['"]?)(\w+)\1[^\n]*\n([\s\S]*?)\n\s*\2\s*$/.exec(command)
86 const text = (doc ? (doc[3] ?? '') : command.replace(OP_LINE, '').replace(/^\s*'([\s\S]*)'\s*$/, '$1')).trim()
87 try {
88 const op = JSON.parse(text) as unknown
89 if (op && typeof op === 'object' && !Array.isArray(op)) return op as Raw
90 } catch {
91 // falls through to the refusal
92 }
93 return `plan-line: \`: progress\` needs one JSON op in a heredoc, e.g. : progress <<'EOF'\n{"id":"plan","next":true}\nEOF`
94}
95// the cut drops half a character left at the edge (an emoji is two UTF-16 units)
96const str = (v: unknown, max = 120) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max).replace(/[\uD800-\uDBFF]$/, '').trimEnd() : '')
97const status = (v: unknown): StepStatus => (STATUSES.includes(v as StepStatus) ? (v as StepStatus) : 'pending')
98const list = (v: unknown): Raw[] => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object') : []) as Raw[]
99const isFinished = (s: StepStatus) => s === 'done' || s === 'skipped'
100
101const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
102
103// short updates: {next:true}, {done:[titles]}, {active:title}, {failed:title} against the stored plan;
104// titles it cannot find come back in missing, so the call is refused instead of passing as a success
105function applyOps(stages: PlanStage[], input: Raw, now: number): { stages: PlanStage[]; missing: string[] } {
106 const next = stages.map(s => ({ ...s, steps: s.steps.map(st => ({ ...st })) }))
107 const steps = next.flatMap(s => s.steps)
108 const missing: string[] = []
109 // a step finishing now remembers when, for the time its checkpoint shows
110 const finish = (st: PlanStep) => {
111 if (!isFinished(st.status)) Object.assign(st, { status: 'done', doneAt: now })
112 }
113 // a step opened again forgets when it was finished, so its checkpoint shows no stale time
114 const reopen = (st: PlanStep, to: StepStatus) => {
115 st.status = to
116 delete st.doneAt
117 }
118 // a title used twice means the one still open
119 const find = (title: string) => {
120 const found = steps.find(st => same(st.title, title) && !isFinished(st.status)) ?? steps.find(st => same(st.title, title))
121 if (!found) missing.push(title)
122 return found
123 }
124 if (input.next === true) {
125 const at = steps.findIndex(st => st.status === 'active') >= 0 ? steps.findIndex(st => st.status === 'active') : steps.findIndex(st => !isFinished(st.status))
126 const cur = steps[at]
127 if (cur) finish(cur)
128 // past the last step, work goes back to one left open earlier
129 const following = steps.slice(at + 1).find(st => st.status === 'pending') ?? steps.find(st => st.status === 'pending')
130 if (following) following.status = 'active'
131 }
132 let lastDone = -1
133 for (const t of Array.isArray(input.done) ? input.done : []) {
134 const st = typeof t === 'string' ? find(t) : undefined
135 if (st?.status === 'active') lastDone = steps.indexOf(st)
136 if (st) finish(st)
137 }
138 // finishing the step in progress moves on, as next does, unless the call names the new one itself
139 if (lastDone >= 0 && typeof input.active !== 'string' && !steps.some(st => st.status === 'active')) {
140 const following = steps.slice(lastDone + 1).find(st => st.status === 'pending') ?? steps.find(st => st.status === 'pending')
141 if (following) following.status = 'active'
142 }
143 const active = typeof input.active === 'string' ? find(input.active) : undefined
144 if (active) {
145 const at = steps.indexOf(active)
146 steps.forEach((st, i) => {
147 if (st.status === 'active' && i !== at) {
148 if (i < at) finish(st)
149 else st.status = 'pending'
150 }
151 })
152 reopen(active, 'active')
153 }
154 const failed = typeof input.failed === 'string' ? find(input.failed) : undefined
155 if (failed) reopen(failed, 'error')
156
157 return { stages: next, missing }
158}
159
160// the new bar, or the refusal for a call that names steps the bar does not have
161function normalize(input: Raw, prev: Plan | null, now: number, id: string): Plan | string {
162 const sent = list(input.stages)
163 .map(s => ({
164 name: str(s.name, 80) || 'Stage',
165 steps: list(s.steps).map(st => ({
166 title: str(st.title) || 'Step',
167 status: status(st.status),
168 substeps: list(st.substeps).map(sub => ({ title: str(sub.title) || '…', status: status(sub.status) })),
169 })),
170 }))
171 .filter(s => s.steps.length > 0) as PlanStage[]
172 const isPartial = sent.length === 0 && prev !== null
173 // a resent plan keeps what was finished; short ops sent along with it apply on top
174 const base = isPartial ? prev.stages : pointAt(prev ? carryDone(sent, prev.stages) : sent)
175 const { stages, missing } = applyOps(base, input, now)
176 if (missing.length > 0 && base.length > 0) {
177 const titles = stages.flatMap(s => s.steps.map(st => st.title)).join(', ')
178 return `plan-line: "${id}" has no step ${missing.map(t => `"${str(t, 60)}"`).join(', ')}. Its steps: ${titles.slice(0, 400)}`
179 }
180 const title = str(input.title, 80) || prev?.title || 'Plan'
181 const steps = stages.flatMap(s => s.steps)
182 const isAllDone = steps.length > 0 && steps.every(s => isFinished(s.status))
183 const asked = input.state as PlanState
184 const failedNow = typeof input.failed === 'string'
185 const state: PlanState = ['running', 'needs_input', 'error', 'done'].includes(asked) ? asked : isAllDone ? 'done' : failedNow ? 'error' : 'running'
186
187 return {
188 id,
189 title,
190 kind: input.kind === 'todo' || (isPartial && prev?.kind === 'todo') ? 'todo' : 'plan',
191 stages,
192 state,
193 note: str(input.note, 160) || null,
194 startedAt: prev ? prev.startedAt : now,
195 endedAt: state === 'done' ? (prev?.endedAt ?? now) : null,
196 }
197}
198
199// a resent plan keeps what is finished: a step sent as pending under a title that was done stays done
200// a title used twice: each finished step carries over to one resent step of that title, in order
201function carryDone(stages: PlanStage[], before: PlanStage[]): PlanStage[] {
202 const finished = new Map<string, PlanStep[]>()
203 for (const st of before.flatMap(s => s.steps).filter(st => isFinished(st.status))) {
204 const key = st.title.trim().toLowerCase()
205 finished.set(key, [...(finished.get(key) ?? []), st])
206 }
207 return stages.map(s => ({
208 ...s,
209 steps: s.steps.map(st => {
210 const queue = finished.get(st.title.trim().toLowerCase())
211 const was = queue?.[0]
212 if (!queue || !was || !(st.status === 'pending' || st.status === was.status)) return st
213 queue.shift()
214 return { ...st, status: was.status, doneAt: was.doneAt }
215 }),
216 }))
217}
218
219// with nothing in progress, the first open step is the current one
220function pointAt(stages: PlanStage[]): PlanStage[] {
221 const steps = stages.flatMap(s => s.steps)
222 if (steps.some(st => st.status === 'active' || st.status === 'error')) return stages
223 const first = steps.find(st => st.status === 'pending')
224 return stages.map(s => ({ ...s, steps: s.steps.map(st => (st === first ? { ...st, status: 'active' as const } : st)) }))
225}
226
227// the bar an approved plan-mode plan lands on; a revised plan updates it instead of opening a second one
228const PLAN_ID = 'plan'
229
230const clean = (s: string) =>
231 s
232 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
233 .replace(/[*_`]/g, '')
234 .replace(/^\s*(\d+[.)]|[-*+]|\[[ xX]\])\s+/, '')
235 .replace(/^(\d+[.)]|\[[ xX]\])\s+/, '')
236 .trim()
237
238const NOT_WORK = /^(context|background|why|goals?|notes?|decisions?)\b/i
239
240function parsePlan(markdown: string, now: number): Plan | null {
241 let title = ''
242 const headed: PlanStage[] = []
243 const items: { depth: number; text: string; status: StepStatus }[] = []
244 // a section that explains rather than lists work (plan mode asks for a Context one first) adds no stage, and
245 // its bullets belong to no other stage
246 let isSkipping = false
247 for (const line of markdown.split(/\r?\n/)) {
248 const h = line.match(/^(#{1,4})\s+(.*)$/)
249 if (h) {
250 const text = clean(h[2] ?? '')
251 isSkipping = false
252 if (h[1] === '#' && !title) title = text
253 else if (NOT_WORK.test(text)) isSkipping = true
254 else headed.push({ name: text, steps: [] })
255 continue
256 }
257 if (isSkipping) continue
258 const li = line.match(/^(\s*)(\d+[.)]|[-*+])\s+(.*)$/)
259 if (!li) continue
260 const depth = Math.floor((li[1] ?? '').replace(/\t/g, ' ').length / 2)
261 const text = clean(li[3] ?? '').slice(0, 120)
262 if (!text) continue
263 // a ticked box is a step the plan already counts as done
264 const status: StepStatus = /^\[[xX]\]\s/.test(li[3] ?? '') ? 'done' : 'pending'
265 items.push({ depth, text, status })
266 const stage = headed[headed.length - 1]
267 if (!stage) continue
268 const step = stage.steps[stage.steps.length - 1]
269 if (depth === 0 || !step) stage.steps.push({ title: text, status, substeps: [] })
270 else step.substeps.push({ title: text, status })
271 }
272 let stages = headed.filter(s => s.steps.length > 0)
273 if (stages.length === 0) {
274 if (items.some(i => i.depth > 0)) {
275 for (const item of items) {
276 const stage = stages[stages.length - 1]
277 if (item.depth === 0 || !stage) stages.push({ name: item.text, steps: [] })
278 else stage.steps.push({ title: item.text, status: item.status, substeps: [] })
279 }
280 stages = stages.map(s => (s.steps.length ? s : { ...s, steps: [{ title: s.name, status: 'pending', substeps: [] }] }))
281 } else if (items.length > 0) {
282 stages = [{ name: 'Tasks', steps: items.map(i => ({ title: i.text, status: i.status, substeps: [] })) }]
283 }
284 }
285 if (stages.length === 0) return null
286
287 return { id: PLAN_ID, title: title || 'Plan', kind: stages.length === 1 ? 'todo' : 'plan', stages, state: 'running', note: null, startedAt: now }
288}
289
290function st(title: string, s: StepStatus): PlanStep {
291 return { title, status: s, substeps: [] }
292}
293
294// ---------- drawing ----------
295
296type Where = { pos: number; total: number; stage: number; step: number; stageSize: number }
297
298// pos counts the finished steps wherever they are; the current step is the active one, else the first still open
299function where(p: Plan): Where {
300 const steps = p.stages.flatMap((s, i) => s.steps.map((step, j) => ({ i, j, step })))
301 const pos = p.state === 'done' ? steps.length : steps.filter(x => isFinished(x.step.status)).length
302 const cur = p.state === 'done' ? undefined : (steps.find(x => x.step.status === 'active') ?? steps.find(x => !isFinished(x.step.status)))
303 const stage = (cur ?? steps[steps.length - 1])?.i ?? 0
304
305 return { pos, total: steps.length, stage, step: cur ? cur.j + 1 : (p.stages[stage]?.steps.length ?? 0), stageSize: p.stages[stage]?.steps.length ?? 0 }
306}
307
308const hex = (h: string) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16))
309const mix = (a: number[], b: number[], m: number) => a.map((v, i) => Math.round(v + ((b[i] ?? 0) - v) * m))
310const rgb = (c: number[]) => `rgb(${c.join(',')})`
311const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
312const hash = (a: number, b: number, k: number) => {
313 const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
314 return x - Math.floor(x)
315}
316const 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)
317
318const ICON_PATH: Partial<Record<PlanState, string>> = {
319 needs_input: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01',
320 error: 'M18 6 6 18M6 6l12 12',
321 done: 'M20 6 9 17l-5-5',
322}
323
324// how long each finished step took (from the previous finish, or the plan's start) and each finished stage
325function stepTimes(p: Plan): { steps: Map<PlanStep, number>; stages: (number | undefined)[] } {
326 const ends = p.stages.flatMap(s => s.steps).flatMap(st => (st.doneAt === undefined ? [] : [st.doneAt])).sort((a, b) => a - b)
327 const startOf = (at: number) => Math.max(p.startedAt, ...ends.filter(t => t < at))
328 const steps = new Map<PlanStep, number>()
329 const stages = p.stages.map(s => {
330 for (const st of s.steps) if (st.doneAt !== undefined) steps.set(st, st.doneAt - startOf(st.doneAt))
331 const times = s.steps.map(st => st.doneAt)
332 if (times.some(t => t === undefined)) return undefined
333 const done = times as number[]
334 return Math.max(...done) - Math.min(...done.map(startOf))
335 })
336 return { steps, stages }
337}
338
339// 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
340function addDot(dots: Map<string, string>, cls: string, x: number, y: number) {
341 dots.set(cls, `${dots.get(cls) ?? ''}M${x} ${y}h2v2h-2z`)
342}
343
344// last drawn head position per plan, so a redraw glides from where the bar was
345const lastHead = new Map<string, { W: number; x: number }>()
346
347// the track draws in a sandboxed frame (for hover); its page must stay see-through in either theme
348const SEE_THROUGH = '<style>:root,html,body{background:transparent!important;color-scheme:light dark;margin:0;overflow:hidden}svg{display:block}</style>'
349
350// a clock that counts in the frame by itself, so the drawing never has to be redrawn each second (a redraw
351// reloads the frame and everything in it blinks): each digit is a reel of its figures behind a one-line window,
352// stepped by a CSS animation whose negative delay is the time already run. Plain SVG, since the host's frame
353// drops foreignObject. {{T:start}} becomes those seconds on every draw (liveSource)
354const CLOCK_W = 48 // "59m 59s"
355const LINE = 16
356const CLOCK_CSS = `.ckt{font-variant-numeric:tabular-nums}
357.rs1{animation:r10 10s steps(10) var(--d) infinite}.rs10{animation:r6 60s steps(6) var(--d) infinite}
358.cc{animation:cc 36000s linear var(--d) both}@keyframes cc{0%,.1666%{transform:translateX(-13.5px)}.1667%,1.6666%{transform:translateX(-3.25px)}1.6667%,9.9999%{transform:none}10%,99.9999%{transform:translateX(-3.25px)}100%{transform:none}}
359.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}
360.rm6{animation:r6 3600s steps(6) var(--d) infinite}.rh1{animation:r10 36000s steps(10) var(--d) infinite}.rh10{animation:r10 360000s steps(10) var(--d) infinite}
361.ph1{animation:ho 3600s steps(1,end) var(--d) both}.ph2{animation:hm 3600s steps(1,end) var(--d) both}
362@keyframes r10{to{transform:translateY(-${LINE * 10}px)}}@keyframes r6{to{transform:translateY(-${LINE * 6}px)}}@keyframes hm{from{opacity:0}to{opacity:1}}@keyframes ho{from{opacity:1}to{opacity:0}}`
363
364// x is the clock's left edge, top the window's top. Under an hour it reads "12m 05s", the minutes part hidden for
365// the first minute; from an hour on "1h 05m", as elapsed() writes a finished time, up to 99h
366function liveClock(x: number, top: number, start: number, cls: string, textCls: string, isCentered = false): string {
367 const base = top + 12
368 const reel = (cx: number, figures: string[], reelCls: string) =>
369 `<g class="${reelCls}"><text class="${textCls} ckt" text-anchor="middle">${figures
370 .map((f, i) => `<tspan x="${cx.toFixed(1)}" y="${base + i * LINE}">${f}</tspan>`)
371 .join('')}</text></g>`
372 const unit = (ux: number, label: string) => `<text x="${ux.toFixed(1)}" y="${base}" class="${textCls}">${label}</text>`
373 const digits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
374 const tens = ['', ...digits.slice(1)]
375 const id = `ck${start}x${Math.round(x)}y${Math.round(top)}`
376 const minutes =
377 `<g class="ph1"><g class="rmm">${reel(x + 3.5, tens, 'rm10')}${reel(x + 10.5, digits, 'rm1')}${unit(x + 14, 'm')}</g>` +
378 `${reel(x + 31, digits.slice(0, 6), 'rs10')}${reel(x + 38, digits, 'rs1')}${unit(x + 41.5, 's')}</g>`
379 const hours =
380 `<g class="ph2">${reel(x + 3.5, tens, 'rh10')}${reel(x + 10.5, digits, 'rh1')}${unit(x + 14, 'h')}` +
381 `${reel(x + 31, digits.slice(0, 6), 'rm6')}${reel(x + 38, digits, 'rm1')}${unit(x + 41.5, 'm')}</g>`
382 return (
383 `<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>` +
384 `<g clip-path="url(#${id})"${isCentered ? ' class="cc"' : ''}>${minutes}${hours}</g></g>`
385 )
386}
387
388// the desktop rebuilds a strip's picture on every redraw of the band, another plugin's invalidate included, so a
389// source reused from an earlier draw would restart its clocks at that draw's offset (issue #6): the offset is
390// taken from now on every draw
391function liveSource(template: string, now: number): string {
392 return template.replace(/\{\{T:(\d+)\}\}/g, (_, t: string) => Math.max(0, (now - Number(t)) / 1000).toFixed(1))
393}
394
395// a bar is drawn twice: the track itself as a plain picture, which the desktop keeps steady whatever else redraws,
396// and a see-through layer on top for the hover parts (checkpoint times, the pill's clock). That layer needs an
397// interactive frame, and the desktop rebuilds such frames on every redraw of the band; empty until hovered, the
398// rebuild is invisible. A plan is immutable, so both drawings at one width are reused until the plan changes
399type Track = { base: string; overlay: string }
400const drawn = new WeakMap<Plan, { W: number; color: string; track: Track }>()
401
402function trackSvg(p: Plan, W: number): Track {
403 const cached = drawn.get(p)
404 const color = stateColor(p.state)
405 if (cached?.W === W && cached.color === color) return cached.track
406 const track = drawTrack(p, W)
407 drawn.set(p, { W, color, track })
408 return track
409}
410
411function drawTrack(p: Plan, W: number): Track {
412 const H = TRACK_H
413 const w = where(p)
414 const done = p.state === 'done'
415 // the fill is exactly the finished share: a fresh plan starts empty
416 const frac = done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))
417 const fx = frac * W
418 const key = p.id
419 // a resize redraws at once: a head drawn at another width would glide in from the wrong place
420 const last = lastHead.get(key)
421 const from = last?.W === W ? last.x : fx
422 lastHead.set(key, { W, x: fx })
423
424 const acc = hex(stateColor(p.state))
425 const light = mix(acc, [255, 255, 255], 0.32)
426 const grey = [132, 130, 138]
427 const ease = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
428 const glide = Math.abs(from - fx) > 0.5
429
430 const bounds: number[] = []
431 let acc2 = 0
432 p.stages.forEach((s, i) => {
433 acc2 += s.steps.length
434 if (i < p.stages.length - 1) bounds.push((acc2 / w.total) * W)
435 })
436
437 // pixels: 3px grid, 7 rows, denser towards the head, twinkling and warming from grey to the state colour
438 const buckets = [0, 1, 2, 3, 4].map(b => {
439 const m = b / 4
440 const dense = 0.22 + 0.78 * Math.pow(m, 1.5)
441 return { color: rgb(mix(grey, light, m)), opacity: (0.35 + 0.65 * dense).toFixed(2) }
442 })
443 const dots = new Map<string, string>()
444 for (let col = 0; col * 3 < fx; col++) {
445 const x = col * 3
446 const u = Math.min(1, (x + 1.5) / fx)
447 const dense = 0.22 + 0.78 * Math.pow(u, 1.5)
448 const bucket = Math.min(4, Math.floor(Math.min(1, Math.pow(u, 0.9) * 1.1) * 4.99))
449 for (let r = 0; r < 7; r++) {
450 if (hash(col, r, 1) > dense + 0.1) continue
451 addDot(dots, `b${bucket} t${Math.floor(hash(col, r, 2) * 4)}`, x, 1 + r * 3)
452 }
453 }
454 const px = [...dots].map(([cls, d]) => `<path class="${cls}" d="${d}"/>`).join('')
455
456 const took = stepTimes(p)
457 const tipRules: string[] = []
458 let marks = ''
459 let hits = ''
460 let tips = ''
461 let k = 0
462 p.stages.forEach((s, i) => {
463 s.steps.forEach((_, j) => {
464 if (k > 0) {
465 const x = (k / w.total) * W
466 const isStage = j === 0
467 // a stage boundary is a short capsule, a step a dot; bright once passed
468 const passed = x < fx - 1
469 const fill = passed ? rgb(mix(light, [255, 255, 255], 0.45)) : '#A8A69E'
470 const opacity = passed ? (isStage ? 0.95 : 0.8) : isStage ? 0.75 : 0.6
471 marks += isStage
472 ? `<rect x="${(x - 1.5).toFixed(1)}" y="${(H - 10) / 2}" width="3" height="10" rx="1.5" fill="${fill}" opacity="${opacity}"/>`
473 : `<circle cx="${x.toFixed(1)}" cy="${H / 2}" r="1.4" fill="${fill}" opacity="${opacity}"/>`
474 const before = p.stages[isStage ? i - 1 : i]
475 const ended = isStage ? before?.steps[before.steps.length - 1] : s.steps[j - 1]
476 const label = isStage ? (before?.name ?? '') : (ended?.title ?? '')
477 const ms = isStage ? took.stages[i - 1] : took.steps.get(ended as PlanStep)
478 const text = ms === undefined ? label : `${label} · ${elapsed(ms)}`
479 const tw = textWidth(text, 6.2) + 16
480 const tx = Math.max(0, Math.min(W - tw, x - tw / 2))
481 hits += `<rect class="h${k}" x="${(x - 5).toFixed(1)}" width="10" height="${H}" fill="#000" fill-opacity="0"/>`
482 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>`
483 tipRules.push(`.h${k}:hover~.p${k}`)
484 }
485 k++
486 })
487 void i
488 })
489
490 // knob: a pill with stage and count, or a round dot with the stage number when narrow
491 const isNarrow = W < NARROW
492 const color = stateColor(p.state)
493 const icon = ICON_PATH[p.state]
494 const single = p.stages.length === 1
495 const number = single ? w.step : w.stage + 1
496 let knob = ''
497 let timePill = ''
498 let kw = H
499 if (isNarrow) {
500 const label = done ? '' : String(number)
501 knob = `<circle cx="0" cy="${H / 2}" r="${H / 2}" fill="${color}"/>${
502 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>`
503 }`
504 } else {
505 const name = done ? (p.endedAt ? elapsed(p.endedAt - p.startedAt) : 'Done') : single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
506 // the pill carries the stage name alone (the fill and the percent already say how far along it is), and, while
507 // the person has folded the bar's agent strips away, how many of its agents are still at work
508 const agents = p.isFolded && p.id !== AGENTS ? (p.agents ?? []) : []
509 const running = agents.filter(a => a.state === 'running' || a.state === 'waiting').length
510 const count = agents.length === 0 ? '' : running > 0 ? `${running} running` : `${agents.length} done`
511 const iconW = icon ? 16 : 0
512 const countW = count ? textWidth(count, 6.5) : -6
513 const maxW = Math.max(80, W * 0.55)
514 let shown = name
515 while (shown.length > 3 && 20 + iconW + textWidth(shown) + 6 + countW > maxW) shown = shown.slice(0, -1)
516 if (shown !== name) shown = shown.trimEnd() + '…'
517 // a running pill is wide enough for its clock too, so the hover swap does not change its size
518 const textW = done ? textWidth(shown) : Math.max(textWidth(shown), CLOCK_W)
519 kw = Math.round(20 + iconW + textW + 6 + countW)
520 const left = -(iconW + textW) / 2
521 const mid = left + iconW + textW / 2
522 knob = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>`
523 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"/>`
524 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>`
525 // hovering the pill lays a copy of it over the stage name, carrying the time the plan has run so far
526 if (!done) {
527 const face = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>${
528 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"/>` : ''
529 }`
530 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>`
531 }
532 }
533 const clampX = (x: number) => Math.max(kw / 2, Math.min(W - kw / 2, x))
534 const kx = clampX(fx)
535 const kFrom = clampX(from)
536
537 const style = `<style>
538.b0{fill:${buckets[0]?.color};fill-opacity:${buckets[0]?.opacity}}.b1{fill:${buckets[1]?.color};fill-opacity:${buckets[1]?.opacity}}
539.b2{fill:${buckets[2]?.color};fill-opacity:${buckets[2]?.opacity}}.b3{fill:${buckets[3]?.color};fill-opacity:${buckets[3]?.opacity}}
540.b4{fill:${buckets[4]?.color};fill-opacity:${buckets[4]?.opacity}}
541.t0,.t1,.t2,.t3{animation:tw 2.2s ease-in-out infinite}
542.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}
543@keyframes tw{0%,100%{opacity:1}50%{opacity:.45}}
544.kt{font:500 12px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:${INK}}
545.kc{font-weight:400}
546@media (prefers-reduced-motion:reduce){.t0,.t1,.t2,.t3{animation:none}}
547</style>`
548 const hoverStyle = `<style>
549.tp{opacity:0;transition:opacity .12s;pointer-events:none}${tipRules.length ? `${tipRules.join(',')}{opacity:1}` : ''}
550.tt{font:400 11px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#F0EEFC}
551.kt{font:500 12px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:${INK}}
552.kv{opacity:0;filter:blur(3px);transition:opacity .2s,filter .2s}.kb:hover .kv{opacity:1;filter:none}
553.kb,rect[class^="h"]{cursor:pointer}
554${CLOCK_CSS}
555</style>`
556 const glideFill = glide ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${ease} fill="freeze"/>` : ''
557 const glideKnob = glide ? `<animateTransform attributeName="transform" type="translate" from="${kFrom.toFixed(1)} 0" to="${kx.toFixed(1)} 0" dur=".45s" ${ease} fill="freeze"/>` : ''
558
559 const open = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
560 const base = `${open}${style}
561<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>
562<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>
563<g clip-path="url(#pill)"><rect width="${W}" height="${H}" fill="#808080" fill-opacity=".16"/>
564<g clip-path="url(#fill)"><rect width="${fx.toFixed(1)}" height="${H}" fill="url(#base)"/>${px}</g>${marks}</g>
565<g transform="translate(${kx.toFixed(1)} 0)">${glideKnob}${knob}</g></svg>`
566 // the hover layer: checkpoint areas under the pill's copy, so the pill wins where they meet; tips on top
567 const overlay = `${open}${SEE_THROUGH}${hoverStyle}${hits}<g transform="translate(${kx.toFixed(1)} 0)">${timePill}</g>${tips}</svg>`
568
569 return { base, overlay }
570}
571
572const AGENT_COLOR: Record<AgentRun['state'], string> = {
573 running: STATE_COLOR.running,
574 waiting: STATE_COLOR.needs_input,
575 done: STATE_COLOR.done,
576 error: STATE_COLOR.error,
577}
578const agentColor = (s: AgentRun['state']) => (s === 'running' ? stateColor('running') : AGENT_COLOR[s])
579
580// strip text that reads at WCAG AA (4.5:1) on its tint, in a dark and a light scheme (issue #13). The desktop draws
581// a strip as a picture over the app's background, which no event tells the plugin: the picture carries both sets,
582// its media query picks one, and it lays its own opaque page under the tint, so a wrong guess still pairs the text
583// with its backing. The terminal picks by isLight
584type Scheme = { page: string; ink: string; dim: string; gutter: string; more: string; word: Record<AgentRun['state'], string> }
585const SCHEMES: Record<'dark' | 'light', Scheme> = {
586 dark: { page: '#1F1E1D', ink: '#F0EEFC', dim: '#A7A5AE', gutter: '#A8A69E', more: '#9A9993', word: { running: '#9C85D8', waiting: '#C0883B', done: '#4FA569', error: '#D67278' } },
587 light: { page: '#FFFFFF', ink: '#222226', dim: '#5B5B5E', gutter: '#5F5E59', more: '#6F6D66', word: { running: '#6A4DB2', waiting: '#905300', done: '#157632', error: '#B13038' } },
588}
589
590// the desktop drops an Svg whose alt is empty, so every drawing says what it shows
591function stripAlt(p: Plan, key: string): string {
592 const a = (p.agents ?? []).find(x => x.id === key)
593 return a ? `agent ${a.title}: ${a.state}, ${a.tool}` : 'more agents'
594}
595
596// an unfinished bar stops at 99%: 199 of 200 steps would otherwise round up to 100%
597function percent(p: Plan, w: { pos: number; total: number }): number {
598 if (p.state === 'done') return 100
599 return Math.min(99, Math.round((Math.min(w.pos, w.total) / Math.max(1, w.total)) * 100))
600}
601
602const elapsed = (ms: number) => {
603 const sec = Math.max(0, Math.round(ms / 1000))
604 if (sec < 60) return `${sec}s`
605 return sec < 3600 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
606}
607
608// which strips show: all of a small batch; in a big one the unfinished first, the rest folded into one line
609function visibleAgents(p: Plan, now: number, max: number): { shown: AgentRun[]; hidden: AgentRun[] } | null {
610 const list = p.agents ?? []
611 if (list.length === 0) return null
612 const hasError = list.some(a => a.state === 'error')
613 if (p.agentsDoneAt && now - p.agentsDoneAt > FOLD_MS && !hasError) return null
614 if (list.length <= max) return { shown: list, hidden: [] }
615 const keep = new Set(list.filter(a => a.state !== 'done').slice(0, max - 1).map(a => a.id))
616 for (const a of [...list].reverse()) {
617 if (keep.size >= max - 1) break
618 keep.add(a.id)
619 }
620 return { shown: list.filter(a => keep.has(a.id)), hidden: list.filter(a => !keep.has(a.id)) }
621}
622
623// the strips drawn under a bar: none while the person has folded them away (the bar's pill still counts its agents)
624const shownAgents = (p: Plan, now: number, max: number) => (p.isFolded ? null : visibleAgents(p, now, max))
625// a bar gets a fold button while it has strips to show or fold; folded with none left (the batch over, or after a
626// restart) it keeps the choice for its next agents but shows no button that would do nothing
627const canFold = (p: Plan, now: number, max: number) => visibleAgents(p, now, max) !== null
628
629// what each strip showed last time it was drawn, so a change morphs from the old status instead of jumping
630const lastStrip = new Map<string, { tool: string; color: string; state: AgentRun['state'] }>()
631const MORPH = '.2s'
632
633// a strip's markup per agent object: drawn once per change, so its morph plays once and later redraws match
634const drawnRows = new WeakMap<AgentRun, { key: string; html: string }>()
635
636
637// one tinted strip per agent: state colour, name, what it does now and for how long; not a progress bar
638// Lucide "bot", drawn at 12 px in the gutter before each strip
639const BOT = '<rect width="16" height="12" x="4" y="8" rx="2"/><path d="M12 8V4H8M2 14h2M20 14h2M15 13v2M9 13v2"/>'
640
641// the desktop's fold control: Lucide "chevron-up" (fold) or "chevron-down" (show); a Button's label is text only, so
642// the picture is centred in the cell and a blank Button over it, centred the same way, takes the presses and draws its
643// own hover and focus ring around it (a picture laid over the Button would take the pointer)
644const FOLD_W = 16
645const FOLD_H = 16
646const foldChip = (isFolded: boolean) =>
647 `<svg xmlns="http://www.w3.org/2000/svg" width="${FOLD_W}" height="${FOLD_H}" viewBox="0 0 ${FOLD_W} ${FOLD_H}"><style>.fc{stroke:#C2C0B6}` +
648 `@media (prefers-color-scheme:light){.fc{stroke:#3D3D3A}}</style>` +
649 `<g transform="translate(${(FOLD_W - 16) / 2} ${(FOLD_H - 16) / 2}) scale(${16 / 24})" fill="none" class="fc" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">` +
650 `<path d="${isFolded ? 'm6 9 6 6 6-6' : 'm18 15-6-6-6 6'}"/></g></svg>`
651const GUTTER = 36 // icon and agent number, left of the strip
652
653// claude-haiku-4-5-20251001 -> haiku 4.5; an alias stays as given
654const modelName = (m: string) => {
655 const r = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(m)
656 return r ? `${r[1]} ${r[2]}.${r[3]}` : m
657}
658
659// the agent's name, its model and effort in a dimmer parenthesis
660const nameMarkup = (name: string) => {
661 const at = name.indexOf(' (')
662 return at > 0 ? `${esc(name.slice(0, at))}<tspan class="st">${esc(name.slice(at))}</tspan>` : esc(name)
663}
664
665// one tinted strip per agent behind a bot icon and its number: the colour says how it went,
666// the word says what it does now (only while it runs or waits), the time how long it took
667type StripRow = { key: string; html: string; height: number }
668
669// each strip is its own drawing, so a change to one agent redraws that strip alone, never the bar or the others
670function stripsSvg(v: { shown: AgentRun[]; hidden: AgentRun[] }, all: AgentRun[], W: number): StripRow[] {
671 const isNarrow = W < NARROW
672 const SW = W - GUTTER
673 const rows: StripRow[] = []
674 // the icon and number take their colour from the scheme's class (gu for an agent, gm for the more line)
675 const gutter = (y: number, label: string, cls: string) =>
676 `<g class="${cls}"><g transform="translate(1 ${y + 2}) scale(.5)" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">${BOT}</g>` +
677 `<text x="16" y="${y + 11.5}" class="sn sg">${label}</text></g>`
678 // the scheme's page under a tint, so the tint and the text over it look the same on any background; drawn first, so
679 // a "+N" wider than the gutter still shows whole
680 const backing = (y: number) => `<rect class="sb" x="${GUTTER}" y="${y}" width="${SW}" height="${STRIP_H}" rx="${STRIP_H / 2}"/>`
681 v.shown.forEach((a, i) => {
682 // the first strip keeps a little room from the track above it
683 const y = i === 0 ? 5 : STRIP_GAP
684 const rowKey = `${W}|${y}|${all.indexOf(a)}`
685 const cachedRow = drawnRows.get(a)
686 if (cachedRow?.key === rowKey) {
687 rows.push({ key: a.id, html: cachedRow.html, height: y + STRIP_H })
688 return
689 }
690 const c = agentColor(a.state)
691 const indent = a.depth > 0 ? 10 : 0
692 const word = a.state === 'running' || a.state === 'waiting' ? a.tool : ''
693 const dots = new Map<string, string>()
694 if (a.state === 'running') {
695 for (let col = 0; col * 3 < SW; col++) {
696 for (let r = 0; r < 4; r++) {
697 if (hash(col + i * 41, r, 5) > 0.2) continue
698 addDot(dots, `t${Math.floor(hash(col, r, 6) * 4)}`, GUTTER + col * 3, Math.round((y + 2.5 + r * 3.2) * 10) / 10)
699 }
700 }
701 }
702 const px = [...dots].map(([cls, d]) => `<path class="${cls}" fill="${c}" fill-opacity=".32" d="${d}"/>`).join('')
703 // a status change: the old word blurs out while the new one blurs in, and the tint flows to the new colour
704 const was = lastStrip.get(a.id)
705 lastStrip.set(a.id, { tool: word, color: c, state: a.state })
706 const isWordChanged = was !== undefined && was.tool !== word
707 const flow = (attr: string) => (was && was.color !== c ? `<animate attributeName="${attr}" from="${was.color}" to="${c}" dur="${MORPH}" fill="freeze"/>` : '')
708 // the tool word sits at the right, just before the clock, so the name and its model get the rest of the row
709 const toolEnd = W - 9 - CLOCK_W - 10
710 const wordW = Math.max(word ? textWidth(word, 6.2) : 0, isWordChanged && was.tool ? textWidth(was.tool, 6.2) : 0)
711 const nameX = GUTTER + 19 + indent
712 const nameRoom = isNarrow ? SW - 24 - indent : toolEnd - (wordW > 0 ? wordW + 12 : 0) - nameX
713 const spec = [a.model ? modelName(a.model) : '', a.effort ?? ''].filter(Boolean).join(' · ')
714 const full = (a.depth > 0 ? '↳ ' : '') + a.title + (spec ? ` (${spec})` : '')
715 let name = full
716 while (name.length > 4 && textWidth(name, 6.2) > nameRoom) name = name.slice(0, -1)
717 if (name !== full) name = name.trimEnd() + '…'
718 const time =
719 a.endedAt === null
720 ? liveClock(W - 9 - CLOCK_W, y, a.startedAt, 'sc', 'sn st')
721 : `<text x="${W - 9}" y="${y + 11.5}" text-anchor="end" class="sn st">${elapsed(a.endedAt - a.startedAt)}</text>`
722 const tool = isNarrow
723 ? ''
724 : (isWordChanged && was.tool ? `<text x="${toolEnd}" y="${y + 11.5}" text-anchor="end" class="sn mo w-${was.state}">${esc(was.tool)}</text>` : '') +
725 (word ? `<text x="${toolEnd}" y="${y + 11.5}" text-anchor="end" class="sn w-${a.state}${isWordChanged ? ' mi' : ''}">${esc(word)}</text>` : '') +
726 time
727 const html =
728 backing(y) +
729 gutter(y, String(all.indexOf(a) + 1), 'gu') +
730 `<rect x="${GUTTER}" y="${y}" width="${SW}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="${c}" fill-opacity=".15">${flow('fill')}</rect>${px}` +
731 `<circle cx="${GUTTER + 10 + indent}" cy="${y + STRIP_H / 2}" r="3" fill="${c}"${a.state === 'running' ? ' class="sd"' : ''}>${flow('fill')}</circle>` +
732 `<text x="${nameX}" y="${y + 11.5}" class="sn">${nameMarkup(name)}</text>` +
733 tool
734 drawnRows.set(a, { key: rowKey, html })
735 rows.push({ key: a.id, html, height: y + STRIP_H })
736 })
737 if (v.hidden.length > 0) {
738 const y = v.shown.length === 0 ? 5 : STRIP_GAP
739 const doneCount = v.hidden.filter(a => a.state === 'done').length
740 rows.push({
741 key: '+',
742 height: y + STRIP_H,
743 html:
744 backing(y) +
745 gutter(y, `+${v.hidden.length}`, 'gm') +
746 `<rect x="${GUTTER}" y="${y}" width="${SW}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="#808080" fill-opacity=".14"/>` +
747 `<text x="${GUTTER + 10}" y="${y + 11.5}" class="sn st">${plural(v.hidden.length, 'more agent')} · ${doneCount} done</text>`,
748 })
749 }
750 return rows
751}
752
753// a scheme's colours as the strip's classes; in the stylesheet the light set follows the dark one, so it wins where
754// its query holds, and the dim and tool word follow the name they colour over
755const schemeCss = (s: Scheme) =>
756 `.sb{fill:${s.page}}.sn{fill:${s.ink}}.st{fill:${s.dim}}.gu{color:${s.gutter}}.gm{color:${s.more}}` +
757 Object.entries(s.word).map(([state, c]) => `.w-${state}{fill:${c}}`).join('')
758const STRIP_STYLE = `<style>.sn{font:400 11.5px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif}.sg{font-weight:500;font-variant-numeric:tabular-nums}
759${schemeCss(SCHEMES.dark)}@media (prefers-color-scheme:light){${schemeCss(SCHEMES.light)}}.gu .sn,.gm .sn{fill:currentColor}
760.sd{animation:sp 1.1s ease-in-out infinite}@keyframes sp{50%{opacity:.3}}
761.mi{animation:mi ${MORPH} ease-out both}@keyframes mi{from{opacity:0;filter:blur(3px)}}
762.mo{animation:mo ${MORPH} ease-in both}@keyframes mo{to{opacity:0;filter:blur(3px)}}
763.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}
764@keyframes tw{0%,100%{opacity:1}50%{opacity:.45}}
765${CLOCK_CSS}
766@media (prefers-reduced-motion:reduce){.sd,.mi,.mo,.t0,.t1,.t2,.t3{animation:none}.mo{opacity:0}}</style>`
767
768function plural(n: number, word: string) {
769 return `${n} ${word}${n === 1 ? '' : 's'}`
770}
771
772const DEFAULT = 0x01000000
773let isLight = false
774const termBg = () => omarchy?.background ?? (isLight ? [255, 255, 255] : [24, 24, 27])
775const termFg = () => omarchy?.foreground ?? (isLight ? [34, 34, 38] : [240, 238, 252])
776const scheme = () => SCHEMES[isLight ? 'light' : 'dark']
777const pack = (c: number[]) => ((c[0] ?? 0) << 16) | ((c[1] ?? 0) << 8) | (c[2] ?? 0)
778// a Raster cell takes one printable BMP character exactly one column wide, as the engine measures it
779// (Bun.stringWidth, ambiguous narrow); any other character refuses the whole tree and every bar vanishes.
780// NOT_ONE lists the printable BMP characters whose width is not one, as base-36 "start-extra" ranges
781const NOT_ONE = '4t,lc-33,w3-6,13l-18,14v,14x-1,150-1,153,16o-5,174-a,17g,18r-k,19s,1cm-7,1cv-5,1d3-1,1d6-3,1e7,1e9,1f4-q,1ie-a,1kb-8,1kt,1li-3,1ln-8,1lx-2,1m1-4,1nd-2,1p3-8,1qi-1k,1tm-2,1tq-f,1u9-6,1uq-1,1vk-2,1x6-2,1xa-f,1xt-6,1ya-1,1z2,1z4-2,20q-2,20u-f,21d-6,21u-1,228-1,22d,22o-2,24a-2,24e-f,24x-6,25e-1,262-8,27u-2,27y-f,28h-6,28y-1,29s-2,2be-2,2bi-f,2c1-6,2ci-1,2dc-2,2dg,2ey-2,2f2-f,2fl-6,2g2-1,2gw-2,2ii-2,2im-f,2j5-6,2jm-1,2kg-2,2m2-2,2m6-f,2n6-1,2o1,2q2,2qa-2,2qe,2sx,2t0-6,2tj-7,2wh,2wk-8,2x4-6,2zc-1,305,307,309,31t-d,328-4,32e-1,32l-a,32x-z,346,371-3,376-5,37d-1,37h-1,388-1,38e-2,38x-3,39e,39h-1,39p,3a5,3cw-73,3tp-2,4k2-2,4ky-1,4lu-1,4mq-1,4ok-1,4on-6,4p2,4p5-a,4pp,4qz-4,4ud-1,4vd,4yo-2,4yv-1,4z6,4zd-2,55j-1,55n,57a,57c-6,57k,57m,57p-7,583-9,58f,59s-2b,5dg,5di-4,5do,5du,5ez-8,5fk-1,5gi-3,5go-1,5gr-2,5ie,5ig-1,5il,5in-2,5kc-7,5km-1,5ow-2,5p0-c,5pe-6,5pp,5pw,5q0-1,5vk-1r,6bv-4,6cq-4,6e8-f,6hc-1b,6xm-1,6y1-1,73d-3,73k,73n,7i5-1,7is-1,7jk-7,7k8-b,7lr,7m2-5,7mb,7mp,7my-1,7nh-1,7no-1,7ny,7o4,7oq,7oy-1,7p1,7p6,7p9,7ph,7pm-1,7qg,7rg,7ri,7rn-2,7rr,7th-2,7u8,7un,8ij-1,8k0,8k5,8vj-2,8zj,928-v,96o-p,97f-2g,9a8-5x,9gw-26,9j5-2d,9ll-2u,9ol-16,9pt-2l,9sg-2d,9v3-1b,9wg-13,9xs-mkc,wi8-1i,wvj-3,wvo-9,wwu-1,wz4-1,x6q,x6u,x6z,x7p-1,x7w,xc4-1,xcw-h,xdr,xeu-7,xfr-a,xgg-s,xhc-2,xir,xiu-3,xj0-1,xk5,xm1-5,xm9-1,xmd-1,xmr,xn0,xoc,xps,xpu-2,xpz-1,xq6-1,xq9,xrg-1,xrq,xyd,xyg,xyl,xz4-8mb,16ls-27,1d6o-e7,1dlq,1e68-p,1e74-1e,1e8k-i,1e94-3,1edb,1edd-2n,1ejk-6'
782const notOne = NOT_ONE.split(',').map(r => {
783 const [a = '0', n] = r.split('-')
784 const from = parseInt(a, 36)
785 return [from, from + (n ? parseInt(n, 36) : 0)] as const
786})
787function isNotOne(cp: number) {
788 let lo = 0
789 let hi = notOne.length - 1
790 while (lo <= hi) {
791 const mid = (lo + hi) >> 1
792 const [from, to] = notOne[mid] ?? [0, 0]
793 if (cp < from) hi = mid - 1
794 else if (cp > to) lo = mid + 1
795 else return true
796 }
797 return false
798}
799// marks and format characters (accents written apart, variation selectors, joiners, soft hyphens) take no cell
800// of their own and go; anything else that cannot fill exactly one cell is drawn as a middle dot
801const cellChar = (ch: string) => {
802 if (/\p{M}|\p{Cf}/u.test(ch)) return ''
803 const cp = ch.codePointAt(0) ?? 63
804 const isControl = cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)
805 return isControl || cp > 0xffff || (cp >= 0xd800 && cp <= 0xdfff) || isNotOne(cp) ? '·' : ch
806}
807// an emoji sequence (a variation selector, a keycap, joined people, a skin tone, a flag) is one picture two columns
808// wide, so it is found a grapheme cluster at a time; everything else is measured a character at a time, as the
809// engine measures it, since a cluster of letters (a Hindi conjunct, Thai SARA AM) is several columns
810const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null
811const clusters = (s: string): string[] => (segmenter ? Array.from(segmenter.segment(s), x => x.segment) : [...s])
812const isEmojiCluster = (c: string) =>
813 /\u20E3|\p{Regional_Indicator}/u.test(c) ||
814 (/\p{Extended_Pictographic}/u.test(c) && (/[\uFE0F\u200D\u{1F3FB}-\u{1F3FF}]/u.test(c) || (c.codePointAt(0) ?? 0) > 0xffff))
815// the cells a string takes inside a Raster: a dot for an emoji sequence, each other character as cellChar draws it
816const cellText = (s: string) => clusters(s).map(c => (isEmojiCluster(c) ? '·' : [...c].map(cellChar).join(''))).join('')
817const cellsOf = (s: string) => [...cellText(s)].length
818// the columns one character takes as plain Text: none for marks, format and control characters and the vowel and
819// final jamo of a Hangul syllable written apart; two for wide ones, and for any character beyond the basic plane
820// but mathematical letters (a guess too wide only pads the title box, one too narrow cuts the title)
821function charColumns(ch: string): number {
822 const cp = ch.codePointAt(0) ?? 0
823 if (/\p{M}|\p{Cf}/u.test(ch) || cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) return 0
824 if ((cp >= 0x1160 && cp <= 0x11ff) || (cp >= 0xd7b0 && cp <= 0xd7ff)) return 0
825 if (cp > 0xffff) return cp >= 0x1d400 && cp <= 0x1d7ff ? 1 : 2
826 return isNotOne(cp) ? 2 : 1
827}
828// the columns a string takes as plain Text, where the terminal draws a wide character (CJK, emoji) over two
829const columnsOf = (s: string) => clusters(s).reduce((w, c) => w + (isEmojiCluster(c) ? 2 : [...c].reduce((n, ch) => n + charColumns(ch), 0)), 0)
830const fit = (s: string, w: number) => {
831 const chars = [...cellText(s)]
832 if (chars.length <= w) return chars.join('')
833 return w <= 1 ? '…'.slice(0, w) : chars.slice(0, w - 1).join('').trimEnd() + '…'
834}
835
836type Cell = [number, number, number]
837
838class Grid {
839 cells: Cell[]
840 constructor(
841 readonly columns: number,
842 readonly rows: number,
843 ) {
844 this.cells = Array.from({ length: columns * rows }, () => [32, DEFAULT, DEFAULT] as Cell)
845 }
846 set(x: number, y: number, ch: string | number, fg: number, bg: number) {
847 if (x < 0 || x >= this.columns || y < 0 || y >= this.rows) return
848 this.cells[y * this.columns + x] = [typeof ch === 'number' ? ch : (ch.codePointAt(0) ?? 32), fg, bg]
849 }
850 bg(x: number, y: number) {
851 return this.cells[y * this.columns + x]?.[2] ?? DEFAULT
852 }
853 text(x: number, y: number, s: string, fg: number, bg?: number) {
854 let i = 0
855 for (const ch of cellText(s)) {
856 this.set(x + i, y, ch, fg, bg ?? this.bg(x + i, y))
857 i++
858 }
859 return i
860 }
861 encode() {
862 const words = new Uint32Array(this.cells.length * 3)
863 this.cells.forEach((c, i) => words.set(c, i * 3))
864 return (new Uint8Array(words.buffer) as Uint8Array & { toBase64: () => string }).toBase64()
865 }
866}
867
868const BRAILLE_BITS = [
869 [0x01, 0x08],
870 [0x02, 0x10],
871 [0x04, 0x20],
872 [0x40, 0x80],
873]
874
875function pill(g: Grid, y: number, from: number, to: number, bgAt: (x: number) => number[]) {
876 for (let x = from; x < to; x++) g.set(x, y, ' ', DEFAULT, pack(bgAt(x)))
877}
878
879const LEVELS = 8
880const q = (m: number) => Math.round(Math.max(0, Math.min(1, m)) * LEVELS) / LEVELS
881const wave = (t: number, periodMs: number, offset = 0) => 0.5 + 0.5 * Math.sin(((t / periodMs) + offset) * Math.PI * 2)
882const easeOut = (x: number) => 1 - Math.pow(1 - Math.max(0, Math.min(1, x)), 4)
883
884const glide = new Map<string, { from: number; to: number; at: number }>()
885const GLIDE_MS = 450
886// the twinkle moment of each bar's last moving frame, kept while it stands still
887const stillPhase = new Map<string, number>()
888
889// a head glides to a new place only while frames can carry the glide; otherwise it is drawn there at once
890function headAt(id: string, target: number, t: number, canGlide: boolean): number {
891 const g = glide.get(id)
892 if (!g || (!canGlide && Math.abs(g.to - target) > 0.01)) {
893 glide.set(id, { from: target, to: target, at: t })
894 return target
895 }
896 const cur = g.from + (g.to - g.from) * easeOut((t - g.at) / GLIDE_MS)
897 if (Math.abs(g.to - target) > 0.01) {
898 glide.set(id, { from: cur, to: target, at: t })
899 return cur
900 }
901 return cur
902}
903
904
905function trackCells(p: Plan, W: number, t: number): string {
906 const g = new Grid(W, 1)
907 const w = where(p)
908 const done = p.state === 'done'
909 const target = (done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))) * W
910 // after the turn a change lands at once: a glide would keep frames going through the engine's end-of-turn redraw
911 const fx = headAt(p.id, target, t, isTurnLive || hasRunningAgents(p))
912 // only a moving bar twinkles with the clock; one that stands still keeps the twinkle of its last frame, so a
913 // redraw (the end of a turn is one) rewrites none of its cells, even right after it stopped moving
914 const phase = isAnimated(p, t) ? t : (stillPhase.get(p.id) ?? 0)
915 stillPhase.set(p.id, phase)
916 const back = termBg()
917 const acc = hex(stateColor(p.state))
918 const light = mix(acc, [255, 255, 255], 0.35)
919 const grey = [120, 118, 128]
920 const track = mix(back, [128, 128, 128], isLight ? 0.14 : 0.18)
921 const fill = mix(track, acc, done ? 0.3 : 0.17)
922 const under = (x: number) => (x + 0.5 < fx ? fill : track)
923 pill(g, 0, 0, W, under)
924
925 const TWINKLE = done ? [3200, 3800, 4400, 3500] : [2200, 2800, 1900, 3300]
926 const DELAY = [0, 700, 1300, 400]
927 const dim = done ? 0.2 : 0.55
928 for (let col = 0; col < Math.min(W, Math.ceil(fx)); col++) {
929 const u = Math.min(1, (col + 0.5) / Math.max(1, fx))
930 const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(u, 1.5)
931 let bits = 0
932 for (let r = 0; r < 4; r++) {
933 for (let c = 0; c < 2; c++) {
934 const sx = col * 2 + c
935 if (sx / 2 >= fx || hash(sx, r, 1) > dense * 0.6) continue
936 bits |= BRAILLE_BITS[r]?.[c] ?? 0
937 }
938 }
939 if (bits === 0) continue
940 const cls = Math.floor(hash(col, 0, 2) * 4)
941 const period = TWINKLE[cls] ?? 2200
942 const blink = 1 - dim * wave(phase + (DELAY[cls] ?? 0), period, 0.25)
943 const bucket = done ? 1 : q(Math.min(1, Math.pow(u, 0.9) * 1.1))
944 const tone = mix(grey, light, bucket)
945 const opacity = (0.35 + 0.65 * dense) * blink
946 g.set(col, 0, 0x2800 + bits, pack(mix(fill, tone, q(opacity))), pack(fill))
947 }
948
949 let k = 0
950 p.stages.forEach(s => {
951 if (k > 0) {
952 const x = Math.round((k / w.total) * W)
953 if (x > 0 && x < W - 1) {
954 const passed = x < fx - 0.5
955 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)))
956 }
957 }
958 k += s.steps.length
959 })
960
961 const base = hex(stateColor(p.state))
962 const color = base
963 const single = p.stages.length === 1
964 const number = single ? Math.min(w.total, w.pos + 1) : w.stage + 1
965 const icon = p.state === 'done' ? '✓' : p.state === 'error' ? '✕' : p.state === 'needs_input' ? '?' : ''
966 let name = ''
967 let count = ''
968 if (W < 28) {
969 name = icon || String(number)
970 } else {
971 const agents = p.agents ?? []
972 name = done ? 'Done' : single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
973 const baseCount = p.id === AGENTS ? `${w.pos}/${w.total}` : done ? `${w.total}/${w.total}` : single ? `${number}/${w.total}` : `${w.step}/${w.stageSize}`
974 count = baseCount + (agents.length > 0 && p.id !== AGENTS ? ` · ${agents.filter(a => a.state === 'done').length}/${agents.length}` : '')
975 }
976 const lead = icon && W >= 28 ? `${icon} ` : ''
977 const maxName = Math.max(3, Math.floor(W * (W < 60 ? 0.75 : 0.55)) - cellsOf(lead) - cellsOf(count) - 5)
978 const shown = lead + fit(name, maxName)
979 const kw = cellsOf(shown) + (count ? count.length + 1 : 0) + 2
980 const kx = Math.round(Math.max(0, Math.min(W - kw, fx - kw / 2)))
981 const white = pack(inkOn(color))
982 pill(g, 0, kx, kx + kw, () => color)
983 let at = kx + 1
984 at += g.text(at, 0, shown, white, pack(color))
985 if (count) g.text(at + 1, 0, count, white, pack(color))
986 return g.encode()
987}
988
989function stripCells(v: { shown: AgentRun[]; hidden: AgentRun[] }, W: number, now: number): { cells: string; rows: number } {
990 const rows = v.shown.length + (v.hidden.length > 0 ? 1 : 0)
991 const g = new Grid(W, rows)
992 const back = termBg()
993 const text = pack(termFg())
994 v.shown.forEach((a, y) => {
995 const c = hex(agentColor(a.state))
996 const tint = mix(back, c, 0.18)
997 pill(g, y, 0, W, () => tint)
998 const running = a.state === 'running' || a.state === 'waiting'
999 if (running) {
1000 for (let col = 1; col < W - 1; col++) {
1001 let bits = 0
1002 let glow = 0
1003 for (let r = 0; r < 4; r++) {
1004 for (let cc = 0; cc < 2; cc++) {
1005 const sx = col * 2 + cc + y * 83
1006 if (hash(sx, r, 5) >= 0.08) continue
1007 const b = 1 - 0.55 * wave(now, 1900 + hash(sx, r, 6) * 1400, hash(sx, r, 7))
1008 bits |= BRAILLE_BITS[r]?.[cc] ?? 0
1009 glow = Math.max(glow, b)
1010 }
1011 }
1012 if (bits) g.set(col, y, 0x2800 + bits, pack(mix(tint, c, 0.2 + 0.4 * q(glow))), pack(tint))
1013 }
1014 }
1015 const indent = a.depth > 0 ? 2 : 0
1016 const dotColor = running ? mix(tint, c, 0.3 + 0.7 * q(wave(now, 1100))) : c
1017 g.text(2 + indent, y, '●', pack(dotColor), pack(tint))
1018 const time = elapsed((a.endedAt ?? now) - a.startedAt)
1019 const narrow = W < 30
1020 const tx = W - 2 - time.length
1021 // the tool word sits at the right, just before the time, so the name and its model get the rest of the row
1022 const word = narrow || !running ? '' : fit(a.tool, Math.max(0, Math.floor(W * 0.25)))
1023 const toolAt = tx - 1 - word.length
1024 let at = 4 + indent
1025 const spec = [a.model ? modelName(a.model) : '', a.effort ?? ''].filter(Boolean).join(' · ')
1026 const full = (a.depth > 0 ? '↳ ' : '') + a.title + (spec ? ` (${spec})` : '')
1027 const name = fit(full, Math.max(3, (narrow ? W - 2 : word ? toolAt - 1 : tx - 1) - at))
1028 // the model and effort are drawn dimmer than the name
1029 const cut = spec ? name.indexOf(' (') : -1
1030 const head = cut > 0 ? name.slice(0, cut) : name
1031 for (let i = -1; i < name.length + 1 && at + i < W - 1; i++) g.set(at + i, y, ' ', DEFAULT, pack(tint))
1032 at += g.text(at, y, head, text, pack(tint))
1033 const dim = pack(dimOn(tint, hex(scheme().dim)))
1034 if (head !== name) g.text(at, y, name.slice(head.length), dim, pack(tint))
1035 if (narrow) return
1036 if (word) {
1037 for (let i = -1; i <= word.length; i++) g.set(toolAt + i, y, ' ', DEFAULT, pack(tint))
1038 g.text(toolAt, y, word, pack(wordOn(tint, hex(a.state === 'running' && omarchy ? omarchy.accent : scheme().word[a.state]))), pack(tint))
1039 }
1040 for (let i = -1; i < time.length; i++) g.set(tx + i, y, ' ', DEFAULT, pack(tint))
1041 g.text(tx, y, time, dim, pack(tint))
1042 })
1043 if (v.hidden.length > 0) {
1044 const y = v.shown.length
1045 const tint = mix(back, [128, 128, 128], 0.16)
1046 pill(g, y, 0, W, () => tint)
1047 const doneCount = v.hidden.filter(a => a.state === 'done').length
1048 g.text(2, y, fit(`+${plural(v.hidden.length, 'more agent')} · ${doneCount} done`, W - 4), pack(dimOn(tint, hex(scheme().dim))), pack(tint))
1049 }
1050 return { cells: g.encode(), rows }
1051}
1052
1053type Band = { requestId: string; W: number; list: readonly Plan[] }
1054let band: Band | null = null
1055let isFrameBusy = false
1056// a bar twinkles only while Claude works on it; one waiting on the person or left open after the turn stands still
1057let isTurnLive = false
1058let frames: { cancel: () => void } | null = null
1059// when the engine last refused frames of this band for good; the frames rest a second before they try again
1060let refusedAt: number | null = null
1061// refused frames in a row: one sent between a redraw and its commit is refused once and the next goes through
1062let refusedInRow = 0
1063const REFUSED_MAX = 6
1064
1065const hasRunningAgents = (p: Plan) => (p.agents ?? []).some(a => a.state === 'running' || a.state === 'waiting')
1066const isGliding = (p: Plan, t: number) => {
1067 const g = glide.get(p.id)
1068 return g !== undefined && g.from !== g.to && t - g.at < GLIDE_MS + 100
1069}
1070const isAnimated = (p: Plan, t: number) => (isTurnLive && p.state === 'running') || hasRunningAgents(p) || isGliding(p, t)
1071
1072// the 30 fps clock runs only while a terminal band has something moving; the second timer starts and stops it
1073function syncFrames($: EngineInterface, now: number) {
1074 const isRested = refusedAt === null || now - refusedAt >= 1000
1075 const isWanted = band !== null && isRested && band.list.some(p => isAnimated(p, now))
1076 if (isWanted && !frames) frames = $.clock.every(33, () => void animate($))
1077 if (!isWanted && frames) {
1078 frames.cancel()
1079 frames = null
1080 }
1081}
1082
1083// ---------- terminal tint ----------
1084// the track's light or dark tint: a theme the person picked wins; "auto", and the default "dark" inside Terminal.app
1085// (whose stock profiles follow macOS), take the macOS appearance. Off a Mac, "auto" takes the desktop's
1086// color-scheme (GNOME and the desktops that share its setting). The appearance is read only while a terminal bar
1087// is on screen, at most every APPEARANCE_MS, and never again once neither command is there
1088const APPEARANCE_MS = 5000
1089let themeSetting = 'dark'
1090let system: 'unknown' | 'mac' | 'gnome' | 'none' = 'unknown'
1091let termProgram = ''
1092let isSystemDark: boolean | null = null
1093let appearanceAt = -Infinity
1094let isProbing = false
1095
1096const followsSystem = () =>
1097 system !== 'none' &&
1098 (themeSetting === 'auto' || (themeSetting === 'dark' && (system === 'unknown' || (system === 'mac' && termProgram === 'Apple_Terminal'))))
1099// an Omarchy theme paints the terminal itself, so its mode wins over the setting and the macOS appearance
1100const tintIsLight = () => (omarchy ? omarchy.isLight : followsSystem() && isSystemDark !== null ? !isSystemDark : /light/i.test(themeSetting))
1101
1102// the Omarchy theme is read like the appearance: while a terminal bar is on screen, at most every APPEARANCE_MS, so a
1103// theme switch follows within seconds; a machine without the file (or a file without an accent) is never read again
1104const OMARCHY_COLORS = 'cat "${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/current/theme/colors.toml"'
1105let hasOmarchy = true
1106let omarchyAt = -Infinity
1107
1108async function readOmarchy($: EngineInterface, now: number) {
1109 if (!hasOmarchy || now - omarchyAt < APPEARANCE_MS) return
1110 omarchyAt = now
1111 const r = await $.process.run(['/bin/sh', '-c', OMARCHY_COLORS], { timeoutMs: 2000 }).catch(() => null)
1112 const found = r && r.exitCode === 0 ? parseOmarchy(r.stdout) : null
1113 if (!found) {
1114 hasOmarchy = false
1115 omarchy = null
1116 return
1117 }
1118 const was = omarchy
1119 if (was && was.accent === found.accent && was.isLight === found.isLight && String(was.background) === String(found.background) && String(was.foreground) === String(found.foreground)) return
1120 omarchy = found
1121}
1122
1123// the macOS appearance: the key is missing in light mode, so anything but "Dark" reads as light; null off a Mac
1124async function readMac($: EngineInterface) {
1125 const r = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: 2000 }).catch(() => null)
1126 return r ? /dark/i.test(r.stdout) : null
1127}
1128
1129// the desktop's color-scheme: 'prefer-dark' or 'prefer-light'; 'default' says nothing, so the theme setting decides
1130async function readGnome($: EngineInterface) {
1131 const r = await $.process.run(['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], { timeoutMs: 2000 }).catch(() => null)
1132 if (!r || r.exitCode !== 0) return undefined
1133 return /prefer-dark/.test(r.stdout) ? true : /prefer-light/.test(r.stdout) ? false : null
1134}
1135
1136async function probeAppearance($: EngineInterface, now: number) {
1137 if (isProbing || !followsSystem() || now - appearanceAt < APPEARANCE_MS) return
1138 isProbing = true
1139 appearanceAt = now
1140 try {
1141 if (system !== 'gnome') {
1142 const dark = await readMac($)
1143 if (dark !== null) {
1144 if (system === 'unknown') {
1145 const t = await $.process.run(['/bin/sh', '-c', 'printf %s "$TERM_PROGRAM"'], { timeoutMs: 2000 }).catch(() => null)
1146 termProgram = t?.stdout.trim() ?? ''
1147 system = 'mac'
1148 }
1149 isSystemDark = dark
1150 return
1151 }
1152 }
1153 const dark = await readGnome($)
1154 if (dark === undefined) {
1155 // neither command: the theme setting alone decides from here on
1156 system = 'none'
1157 return
1158 }
1159 system = 'gnome'
1160 isSystemDark = dark
1161 } finally {
1162 isProbing = false
1163 }
1164}
1165
1166// reads the appearance when due and repaints the bars at once if the tint changed
1167async function syncTint($: EngineInterface, now: number) {
1168 const before = omarchy
1169 if (band) await readOmarchy($, now)
1170 if (band && !omarchy) await probeAppearance($, now)
1171 const next = tintIsLight()
1172 if (next === isLight && omarchy === before) return
1173 isLight = next
1174 const b = band
1175 if (b) await blitPlans($, b, b.list, now)
1176}
1177
1178// the size goes along, so a band redrawn at another width answers with a deny naming both sizes
1179function blitPlans($: EngineInterface, b: Band, list: readonly Plan[], now: number) {
1180 return Promise.all(
1181 list.flatMap(p => {
1182 const v = shownAgents(p, now, stripBudget(b.list.length))
1183 const strips = v ? stripCells(v, b.W, now) : null
1184 const calls = [$.ui.blit({ requestId: b.requestId, key: `track-${p.id}`, cells: trackCells(p, b.W, now), columns: b.W, rows: 1 })]
1185 if (strips) calls.push($.ui.blit({ requestId: b.requestId, key: `strips-${p.id}`, cells: strips.cells, columns: b.W, rows: strips.rows }))
1186 return calls.map(c => c.catch(() => undefined))
1187 }),
1188 )
1189}
1190
1191// blits the engine keeps refusing (the band hidden or unmounted, redrawn at another size, or its tree refused) stop
1192// the frames: the 1 s timer tries one frame again a second later, and a redraw lays the band out anew at once. A
1193// band shown again without a redraw (folded and unfolded) so comes back within a second instead of staying still
1194function stopFrames(b: Band, now: number) {
1195 if (band !== b) return
1196 refusedAt = now
1197 refusedInRow = 0
1198 frames?.cancel()
1199 frames = null
1200}types/index.d.ts 47 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 // the person folded the bar's agent strips away (its ▾ button, /progress-agents); the bar keeps one line
34 isFolded?: boolean
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'plan-line': {
40 plans: Plan[]
41 isOpen: boolean
42 // bumped every second while agents run, so elapsed times and folding redraw
43 tick: number
44 }
45 }
46}
47