SLOPSHOPPER

plan-progress

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

newbandspinnerguardcommandtoast
v0.8.3MITupdated 2026-10-15aecheman27-gif/claude-mods/plan-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-progress
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /progress ⎿ plan-progress: No plan yet. A bar appears when Claude starts a task with several steps. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts [ Progress ] ⟨Claude Code's own drawing⟩
README

claude-mods

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.

Source 2 files
hooks/register.tsx 2219 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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) : null
types/index.d.ts 53 lines
1export 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