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

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