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
★ 41v0.8.2MITupdated 2026-10-08zycck/claude-mods/plugins/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 Code mods

English · Русский · 简体中文 · 日本語 · 한국어 · Español · Português · Deutsch · Français

plan-progress

Live progress bars above the Claude Code prompt. Claude splits a task into stages and steps, and the bar fills as it works. The subagents for each task are listed under its bar.

plan-progress: two tasks with their agents, a question, an error, a plan rewritten mid-run, both tasks done

Watch with sound (MP4, 14 s)

  • One row per task: state, title, bar, percent, close button
  • The pill on the bar shows the current stage and step; hover it for the time spent so far
  • Stages are capsules, steps are dots; hover one to see when it was reached
  • A finished bar turns green and shows the total time, then leaves after 30 s (doneBarSeconds in /config)
  • Four states: running, needs input, error, done
  • Each subagent gets a row under its task: name, model and effort, current tool, time
  • The plan can change mid-run; finished steps are kept by title
  • Bars are saved per session and come back when the session is resumed
  • Short sounds for a question, an error and completion
  • Works in the desktop app and in the terminal
  • In the terminal the bar follows a light or dark GNOME desktop with the auto theme, and takes the colors of the active Omarchy theme

Install

Requires Claude Code 2.1.286 or newer (claude --version; claude update to upgrade). On older versions the hooks module does not load and no bars appear; the startup banner says plan-progress: hooks module did not load.

/plugin marketplace add zycck/claude-mods
/plugin install plan-progress@zycck-mods

Update:

claude plugin marketplace update zycck-mods
claude plugin update plan-progress@zycck-mods

Commands

  • /progress shows or hides the bars
  • /progress-clear removes all bars
  • /progress-agents folds the agent strips under the bars, or shows them again (the chevron button next to a bar's ✕ does it for that bar)
  • /plan-progress-autoclose turns off finished bars leaving on their own, or turns it back on; the choice is kept across sessions

The Progress button in the footer does the same as /progress.

How it works

The mod registers a plan_progress tool. Claude sends the plan once, then short updates such as {id, next: true} or {id, done: ["Routes"]}. An unknown step name is refused with the list of the bar's steps. A plan approved in plan mode becomes the bar plan. Agent rows come from engine events and cost no tokens.

On the desktop the bar is an SVG image with a hover layer on top. In the terminal it is a character grid that animates only while Claude is working.

Tests

plugins/plan-progress/tests runs the real module against a stub engine: node compile.cjs ../hooks/register.tsx register.mjs, then node regress.mjs and node scenarios.mjs.

License

MIT

Source 2 files
hooks/register.tsx 2122 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
259function st(title: string, s: StepStatus): PlanStep {
260  return { title, status: s, substeps: [] }
261}
262
263// ---------- drawing ----------
264
265type Where = { pos: number; total: number; stage: number; step: number; stageSize: number }
266
267// pos counts the finished steps wherever they are; the current step is the active one, else the first still open
268function where(p: Plan): Where {
269  const steps = p.stages.flatMap((s, i) => s.steps.map((step, j) => ({ i, j, step })))
270  const pos = p.state === 'done' ? steps.length : steps.filter(x => isFinished(x.step.status)).length
271  const cur = p.state === 'done' ? undefined : (steps.find(x => x.step.status === 'active') ?? steps.find(x => !isFinished(x.step.status)))
272  const stage = (cur ?? steps[steps.length - 1])?.i ?? 0
273
274  return { pos, total: steps.length, stage, step: cur ? cur.j + 1 : (p.stages[stage]?.steps.length ?? 0), stageSize: p.stages[stage]?.steps.length ?? 0 }
275}
276
277const hex = (h: string) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16))
278const mix = (a: number[], b: number[], m: number) => a.map((v, i) => Math.round(v + ((b[i] ?? 0) - v) * m))
279const rgb = (c: number[]) => `rgb(${c.join(',')})`
280const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
281const hash = (a: number, b: number, k: number) => {
282  const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
283  return x - Math.floor(x)
284}
285const 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)
286
287const ICON_PATH: Partial<Record<PlanState, string>> = {
288  needs_input: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01',
289  error: 'M18 6 6 18M6 6l12 12',
290  done: 'M20 6 9 17l-5-5',
291}
292
293// how long each finished step took (from the previous finish, or the plan's start) and each finished stage
294function stepTimes(p: Plan): { steps: Map<PlanStep, number>; stages: (number | undefined)[] } {
295  const ends = p.stages.flatMap(s => s.steps).flatMap(st => (st.doneAt === undefined ? [] : [st.doneAt])).sort((a, b) => a - b)
296  const startOf = (at: number) => Math.max(p.startedAt, ...ends.filter(t => t < at))
297  const steps = new Map<PlanStep, number>()
298  const stages = p.stages.map(s => {
299    for (const st of s.steps) if (st.doneAt !== undefined) steps.set(st, st.doneAt - startOf(st.doneAt))
300    const times = s.steps.map(st => st.doneAt)
301    if (times.some(t => t === undefined)) return undefined
302    const done = times as number[]
303    return Math.max(...done) - Math.min(...done.map(startOf))
304  })
305  return { steps, stages }
306}
307
308// 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
309function addDot(dots: Map<string, string>, cls: string, x: number, y: number) {
310  dots.set(cls, `${dots.get(cls) ?? ''}M${x} ${y}h2v2h-2z`)
311}
312
313// last drawn head position per plan, so a redraw glides from where the bar was
314const lastHead = new Map<string, { W: number; x: number }>()
315
316// the track draws in a sandboxed frame (for hover); its page must stay see-through in either theme
317const SEE_THROUGH = '<style>:root,html,body{background:transparent!important;color-scheme:light dark;margin:0;overflow:hidden}svg{display:block}</style>'
318
319// a clock that counts in the frame by itself, so the drawing never has to be redrawn each second (a redraw
320// reloads the frame and everything in it blinks): each digit is a reel of its figures behind a one-line window,
321// stepped by a CSS animation whose negative delay is the time already run. Plain SVG, since the host's frame
322// drops foreignObject. {{T:start}} becomes those seconds on every draw (liveSource)
323const CLOCK_W = 48 // "59m 59s"
324const LINE = 16
325const CLOCK_CSS = `.ckt{font-variant-numeric:tabular-nums}
326.rs1{animation:r10 10s steps(10) var(--d) infinite}.rs10{animation:r6 60s steps(6) var(--d) infinite}
327.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}}
328.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}
329.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}
330.ph1{animation:ho 3600s steps(1,end) var(--d) both}.ph2{animation:hm 3600s steps(1,end) var(--d) both}
331@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}}`
332
333// x is the clock's left edge, top the window's top. Under an hour it reads "12m 05s", the minutes part hidden for
334// the first minute; from an hour on "1h 05m", as elapsed() writes a finished time, up to 99h
335function liveClock(x: number, top: number, start: number, cls: string, textCls: string, isCentered = false): string {
336  const base = top + 12
337  const reel = (cx: number, figures: string[], reelCls: string) =>
338    `<g class="${reelCls}"><text class="${textCls} ckt" text-anchor="middle">${figures
339      .map((f, i) => `<tspan x="${cx.toFixed(1)}" y="${base + i * LINE}">${f}</tspan>`)
340      .join('')}</text></g>`
341  const unit = (ux: number, label: string) => `<text x="${ux.toFixed(1)}" y="${base}" class="${textCls}">${label}</text>`
342  const digits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
343  const tens = ['', ...digits.slice(1)]
344  const id = `ck${start}x${Math.round(x)}y${Math.round(top)}`
345  const minutes =
346    `<g class="ph1"><g class="rmm">${reel(x + 3.5, tens, 'rm10')}${reel(x + 10.5, digits, 'rm1')}${unit(x + 14, 'm')}</g>` +
347    `${reel(x + 31, digits.slice(0, 6), 'rs10')}${reel(x + 38, digits, 'rs1')}${unit(x + 41.5, 's')}</g>`
348  const hours =
349    `<g class="ph2">${reel(x + 3.5, tens, 'rh10')}${reel(x + 10.5, digits, 'rh1')}${unit(x + 14, 'h')}` +
350    `${reel(x + 31, digits.slice(0, 6), 'rm6')}${reel(x + 38, digits, 'rm1')}${unit(x + 41.5, 'm')}</g>`
351  return (
352    `<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>` +
353    `<g clip-path="url(#${id})"${isCentered ? ' class="cc"' : ''}>${minutes}${hours}</g></g>`
354  )
355}
356
357// the desktop rebuilds a strip's picture on every redraw of the band, another plugin's invalidate included, so a
358// source reused from an earlier draw would restart its clocks at that draw's offset (issue #6): the offset is
359// taken from now on every draw
360function liveSource(template: string, now: number): string {
361  return template.replace(/\{\{T:(\d+)\}\}/g, (_, t: string) => Math.max(0, (now - Number(t)) / 1000).toFixed(1))
362}
363
364// a bar is drawn twice: the track itself as a plain picture, which the desktop keeps steady whatever else redraws,
365// and a see-through layer on top for the hover parts (checkpoint times, the pill's clock). That layer needs an
366// interactive frame, and the desktop rebuilds such frames on every redraw of the band; empty until hovered, the
367// rebuild is invisible. A plan is immutable, so both drawings at one width are reused until the plan changes
368type Track = { base: string; overlay: string }
369const drawn = new WeakMap<Plan, { W: number; color: string; track: Track }>()
370
371function trackSvg(p: Plan, W: number): Track {
372  const cached = drawn.get(p)
373  const color = stateColor(p.state)
374  if (cached?.W === W && cached.color === color) return cached.track
375  const track = drawTrack(p, W)
376  drawn.set(p, { W, color, track })
377  return track
378}
379
380function drawTrack(p: Plan, W: number): Track {
381  const H = TRACK_H
382  const w = where(p)
383  const done = p.state === 'done'
384  // the fill is exactly the finished share: a fresh plan starts empty
385  const frac = done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))
386  const fx = frac * W
387  const key = p.id
388  // a resize redraws at once: a head drawn at another width would glide in from the wrong place
389  const last = lastHead.get(key)
390  const from = last?.W === W ? last.x : fx
391  lastHead.set(key, { W, x: fx })
392
393  const acc = hex(stateColor(p.state))
394  const light = mix(acc, [255, 255, 255], 0.32)
395  const grey = [132, 130, 138]
396  const ease = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
397  const glide = Math.abs(from - fx) > 0.5
398
399  const bounds: number[] = []
400  let acc2 = 0
401  p.stages.forEach((s, i) => {
402    acc2 += s.steps.length
403    if (i < p.stages.length - 1) bounds.push((acc2 / w.total) * W)
404  })
405
406  // pixels: 3px grid, 7 rows, denser towards the head, twinkling and warming from grey to the state colour
407  const buckets = [0, 1, 2, 3, 4].map(b => {
408    const m = b / 4
409    const dense = 0.22 + 0.78 * Math.pow(m, 1.5)
410    return { color: rgb(mix(grey, light, m)), opacity: (0.35 + 0.65 * dense).toFixed(2) }
411  })
412  const dots = new Map<string, string>()
413  for (let col = 0; col * 3 < fx; col++) {
414    const x = col * 3
415    const u = Math.min(1, (x + 1.5) / fx)
416    const dense = 0.22 + 0.78 * Math.pow(u, 1.5)
417    const bucket = Math.min(4, Math.floor(Math.min(1, Math.pow(u, 0.9) * 1.1) * 4.99))
418    for (let r = 0; r < 7; r++) {
419      if (hash(col, r, 1) > dense + 0.1) continue
420      addDot(dots, `b${bucket} t${Math.floor(hash(col, r, 2) * 4)}`, x, 1 + r * 3)
421    }
422  }
423  const px = [...dots].map(([cls, d]) => `<path class="${cls}" d="${d}"/>`).join('')
424
425  const took = stepTimes(p)
426  const tipRules: string[] = []
427  let marks = ''
428  let hits = ''
429  let tips = ''
430  let k = 0
431  p.stages.forEach((s, i) => {
432    s.steps.forEach((_, j) => {
433      if (k > 0) {
434        const x = (k / w.total) * W
435        const isStage = j === 0
436        // a stage boundary is a short capsule, a step a dot; bright once passed
437        const passed = x < fx - 1
438        const fill = passed ? rgb(mix(light, [255, 255, 255], 0.45)) : '#A8A69E'
439        const opacity = passed ? (isStage ? 0.95 : 0.8) : isStage ? 0.75 : 0.6
440        marks += isStage
441          ? `<rect x="${(x - 1.5).toFixed(1)}" y="${(H - 10) / 2}" width="3" height="10" rx="1.5" fill="${fill}" opacity="${opacity}"/>`
442          : `<circle cx="${x.toFixed(1)}" cy="${H / 2}" r="1.4" fill="${fill}" opacity="${opacity}"/>`
443        const before = p.stages[isStage ? i - 1 : i]
444        const ended = isStage ? before?.steps[before.steps.length - 1] : s.steps[j - 1]
445        const label = isStage ? (before?.name ?? '') : (ended?.title ?? '')
446        const ms = isStage ? took.stages[i - 1] : took.steps.get(ended as PlanStep)
447        const text = ms === undefined ? label : `${label} · ${elapsed(ms)}`
448        const tw = textWidth(text, 6.2) + 16
449        const tx = Math.max(0, Math.min(W - tw, x - tw / 2))
450        hits += `<rect class="h${k}" x="${(x - 5).toFixed(1)}" width="10" height="${H}" fill="#000" fill-opacity="0"/>`
451        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>`
452        tipRules.push(`.h${k}:hover~.p${k}`)
453      }
454      k++
455    })
456    void i
457  })
458
459  // knob: a pill with stage and count, or a round dot with the stage number when narrow
460  const isNarrow = W < NARROW
461  const color = stateColor(p.state)
462  const icon = ICON_PATH[p.state]
463  const single = p.stages.length === 1
464  const number = single ? w.step : w.stage + 1
465  let knob = ''
466  let timePill = ''
467  let kw = H
468  if (isNarrow) {
469    const label = done ? '' : String(number)
470    knob = `<circle cx="0" cy="${H / 2}" r="${H / 2}" fill="${color}"/>${
471      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>`
472    }`
473  } else {
474    const name = done ? (p.endedAt ? elapsed(p.endedAt - p.startedAt) : 'Done') : single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
475    // the pill carries the stage name alone (the fill and the percent already say how far along it is), and, while
476    // the person has folded the bar's agent strips away, how many of its agents are still at work
477    const agents = p.isFolded && p.id !== AGENTS ? (p.agents ?? []) : []
478    const running = agents.filter(a => a.state === 'running' || a.state === 'waiting').length
479    const count = agents.length === 0 ? '' : running > 0 ? `${running} running` : `${agents.length} done`
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  return a ? `agent ${a.title}: ${a.state}, ${a.tool}` : 'more agents'
563}
564
565// an unfinished bar stops at 99%: 199 of 200 steps would otherwise round up to 100%
566function percent(p: Plan, w: { pos: number; total: number }): number {
567  if (p.state === 'done') return 100
568  return Math.min(99, Math.round((Math.min(w.pos, w.total) / Math.max(1, w.total)) * 100))
569}
570
571const elapsed = (ms: number) => {
572  const sec = Math.max(0, Math.round(ms / 1000))
573  if (sec < 60) return `${sec}s`
574  return sec < 3600 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
575}
576
577// which strips show: all of a small batch; in a big one the unfinished first, the rest folded into one line
578function visibleAgents(p: Plan, now: number, max: number): { shown: AgentRun[]; hidden: AgentRun[] } | null {
579  const list = p.agents ?? []
580  if (list.length === 0) return null
581  const hasError = list.some(a => a.state === 'error')
582  if (p.agentsDoneAt && now - p.agentsDoneAt > FOLD_MS && !hasError) return null
583  if (list.length <= max) return { shown: list, hidden: [] }
584  const keep = new Set(list.filter(a => a.state !== 'done').slice(0, max - 1).map(a => a.id))
585  for (const a of [...list].reverse()) {
586    if (keep.size >= max - 1) break
587    keep.add(a.id)
588  }
589  return { shown: list.filter(a => keep.has(a.id)), hidden: list.filter(a => !keep.has(a.id)) }
590}
591
592// the strips drawn under a bar: none while the person has folded them away (the bar's pill still counts its agents)
593const shownAgents = (p: Plan, now: number, max: number) => (p.isFolded ? null : visibleAgents(p, now, max))
594// a bar gets a fold button while it has strips to show or fold; folded with none left (the batch over, or after a
595// restart) it keeps the choice for its next agents but shows no button that would do nothing
596const canFold = (p: Plan, now: number, max: number) => visibleAgents(p, now, max) !== null
597
598// what each strip showed last time it was drawn, so a change morphs from the old status instead of jumping
599const lastStrip = new Map<string, { tool: string; color: string; state: AgentRun['state'] }>()
600const MORPH = '.2s'
601
602// a strip's markup per agent object: drawn once per change, so its morph plays once and later redraws match
603const drawnRows = new WeakMap<AgentRun, { key: string; html: string }>()
604
605
606// one tinted strip per agent: state colour, name, what it does now and for how long; not a progress bar
607// Lucide "bot", drawn at 12 px in the gutter before each strip
608const BOT = '<rect width="16" height="12" x="4" y="8" rx="2"/><path d="M12 8V4H8M2 14h2M20 14h2M15 13v2M9 13v2"/>'
609
610// the desktop's fold control: Lucide "chevron-up" (fold) or "chevron-down" (show); a Button's label is text only, so
611// the picture is centred in the cell and a blank Button over it, centred the same way, takes the presses and draws its
612// own hover and focus ring around it (a picture laid over the Button would take the pointer)
613const FOLD_W = 16
614const FOLD_H = 16
615const foldChip = (isFolded: boolean) =>
616  `<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}` +
617  `@media (prefers-color-scheme:light){.fc{stroke:#3D3D3A}}</style>` +
618  `<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">` +
619  `<path d="${isFolded ? 'm6 9 6 6 6-6' : 'm18 15-6-6-6 6'}"/></g></svg>`
620const GUTTER = 36 // icon and agent number, left of the strip
621
622// claude-haiku-4-5-20251001 -> haiku 4.5; an alias stays as given
623const modelName = (m: string) => {
624  const r = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(m)
625  return r ? `${r[1]} ${r[2]}.${r[3]}` : m
626}
627
628// the agent's name, its model and effort in a dimmer parenthesis
629const nameMarkup = (name: string) => {
630  const at = name.indexOf(' (')
631  return at > 0 ? `${esc(name.slice(0, at))}<tspan class="st">${esc(name.slice(at))}</tspan>` : esc(name)
632}
633
634// one tinted strip per agent behind a bot icon and its number: the colour says how it went,
635// the word says what it does now (only while it runs or waits), the time how long it took
636type StripRow = { key: string; html: string; height: number }
637
638// each strip is its own drawing, so a change to one agent redraws that strip alone, never the bar or the others
639function stripsSvg(v: { shown: AgentRun[]; hidden: AgentRun[] }, all: AgentRun[], W: number): StripRow[] {
640  const isNarrow = W < NARROW
641  const SW = W - GUTTER
642  const rows: StripRow[] = []
643  // the icon and number take their colour from the scheme's class (gu for an agent, gm for the more line)
644  const gutter = (y: number, label: string, cls: string) =>
645    `<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>` +
646    `<text x="16" y="${y + 11.5}" class="sn sg">${label}</text></g>`
647  // the scheme's page under a tint, so the tint and the text over it look the same on any background; drawn first, so
648  // a "+N" wider than the gutter still shows whole
649  const backing = (y: number) => `<rect class="sb" x="${GUTTER}" y="${y}" width="${SW}" height="${STRIP_H}" rx="${STRIP_H / 2}"/>`
650  v.shown.forEach((a, i) => {
651    // the first strip keeps a little room from the track above it
652    const y = i === 0 ? 5 : STRIP_GAP
653    const rowKey = `${W}|${y}|${all.indexOf(a)}`
654    const cachedRow = drawnRows.get(a)
655    if (cachedRow?.key === rowKey) {
656      rows.push({ key: a.id, html: cachedRow.html, height: y + STRIP_H })
657      return
658    }
659    const c = agentColor(a.state)
660    const indent = a.depth > 0 ? 10 : 0
661    const word = a.state === 'running' || a.state === 'waiting' ? a.tool : ''
662    const dots = new Map<string, string>()
663    if (a.state === 'running') {
664      for (let col = 0; col * 3 < SW; col++) {
665        for (let r = 0; r < 4; r++) {
666          if (hash(col + i * 41, r, 5) > 0.2) continue
667          addDot(dots, `t${Math.floor(hash(col, r, 6) * 4)}`, GUTTER + col * 3, Math.round((y + 2.5 + r * 3.2) * 10) / 10)
668        }
669      }
670    }
671    const px = [...dots].map(([cls, d]) => `<path class="${cls}" fill="${c}" fill-opacity=".32" d="${d}"/>`).join('')
672    // a status change: the old word blurs out while the new one blurs in, and the tint flows to the new colour
673    const was = lastStrip.get(a.id)
674    lastStrip.set(a.id, { tool: word, color: c, state: a.state })
675    const isWordChanged = was !== undefined && was.tool !== word
676    const flow = (attr: string) => (was && was.color !== c ? `<animate attributeName="${attr}" from="${was.color}" to="${c}" dur="${MORPH}" fill="freeze"/>` : '')
677    // the tool word sits at the right, just before the clock, so the name and its model get the rest of the row
678    const toolEnd = W - 9 - CLOCK_W - 10
679    const wordW = Math.max(word ? textWidth(word, 6.2) : 0, isWordChanged && was.tool ? textWidth(was.tool, 6.2) : 0)
680    const nameX = GUTTER + 19 + indent
681    const nameRoom = isNarrow ? SW - 24 - indent : toolEnd - (wordW > 0 ? wordW + 12 : 0) - nameX
682    const spec = [a.model ? modelName(a.model) : '', a.effort ?? ''].filter(Boolean).join(' · ')
683    const full = (a.depth > 0 ? '↳ ' : '') + a.title + (spec ? ` (${spec})` : '')
684    let name = full
685    while (name.length > 4 && textWidth(name, 6.2) > nameRoom) name = name.slice(0, -1)
686    if (name !== full) name = name.trimEnd() + '…'
687    const time =
688      a.endedAt === null
689        ? liveClock(W - 9 - CLOCK_W, y, a.startedAt, 'sc', 'sn st')
690        : `<text x="${W - 9}" y="${y + 11.5}" text-anchor="end" class="sn st">${elapsed(a.endedAt - a.startedAt)}</text>`
691    const tool = isNarrow
692      ? ''
693      : (isWordChanged && was.tool ? `<text x="${toolEnd}" y="${y + 11.5}" text-anchor="end" class="sn mo w-${was.state}">${esc(was.tool)}</text>` : '') +
694        (word ? `<text x="${toolEnd}" y="${y + 11.5}" text-anchor="end" class="sn w-${a.state}${isWordChanged ? ' mi' : ''}">${esc(word)}</text>` : '') +
695        time
696    const html =
697      backing(y) +
698      gutter(y, String(all.indexOf(a) + 1), 'gu') +
699      `<rect x="${GUTTER}" y="${y}" width="${SW}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="${c}" fill-opacity=".15">${flow('fill')}</rect>${px}` +
700      `<circle cx="${GUTTER + 10 + indent}" cy="${y + STRIP_H / 2}" r="3" fill="${c}"${a.state === 'running' ? ' class="sd"' : ''}>${flow('fill')}</circle>` +
701      `<text x="${nameX}" y="${y + 11.5}" class="sn">${nameMarkup(name)}</text>` +
702      tool
703    drawnRows.set(a, { key: rowKey, html })
704    rows.push({ key: a.id, html, height: y + STRIP_H })
705  })
706  if (v.hidden.length > 0) {
707    const y = v.shown.length === 0 ? 5 : STRIP_GAP
708    const doneCount = v.hidden.filter(a => a.state === 'done').length
709    rows.push({
710      key: '+',
711      height: y + STRIP_H,
712      html:
713        backing(y) +
714        gutter(y, `+${v.hidden.length}`, 'gm') +
715        `<rect x="${GUTTER}" y="${y}" width="${SW}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="#808080" fill-opacity=".14"/>` +
716        `<text x="${GUTTER + 10}" y="${y + 11.5}" class="sn st">${plural(v.hidden.length, 'more agent')} · ${doneCount} done</text>`,
717    })
718  }
719  return rows
720}
721
722// a scheme's colours as the strip's classes; in the stylesheet the light set follows the dark one, so it wins where
723// its query holds, and the dim and tool word follow the name they colour over
724const schemeCss = (s: Scheme) =>
725  `.sb{fill:${s.page}}.sn{fill:${s.ink}}.st{fill:${s.dim}}.gu{color:${s.gutter}}.gm{color:${s.more}}` +
726  Object.entries(s.word).map(([state, c]) => `.w-${state}{fill:${c}}`).join('')
727const 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}
728${schemeCss(SCHEMES.dark)}@media (prefers-color-scheme:light){${schemeCss(SCHEMES.light)}}.gu .sn,.gm .sn{fill:currentColor}
729.sd{animation:sp 1.1s ease-in-out infinite}@keyframes sp{50%{opacity:.3}}
730.mi{animation:mi ${MORPH} ease-out both}@keyframes mi{from{opacity:0;filter:blur(3px)}}
731.mo{animation:mo ${MORPH} ease-in both}@keyframes mo{to{opacity:0;filter:blur(3px)}}
732.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}
733@keyframes tw{0%,100%{opacity:1}50%{opacity:.45}}
734${CLOCK_CSS}
735@media (prefers-reduced-motion:reduce){.sd,.mi,.mo,.t0,.t1,.t2,.t3{animation:none}.mo{opacity:0}}</style>`
736
737function plural(n: number, word: string) {
738  return `${n} ${word}${n === 1 ? '' : 's'}`
739}
740
741const DEFAULT = 0x01000000
742let isLight = false
743const termBg = () => omarchy?.background ?? (isLight ? [255, 255, 255] : [24, 24, 27])
744const termFg = () => omarchy?.foreground ?? (isLight ? [34, 34, 38] : [240, 238, 252])
745const scheme = () => SCHEMES[isLight ? 'light' : 'dark']
746const pack = (c: number[]) => ((c[0] ?? 0) << 16) | ((c[1] ?? 0) << 8) | (c[2] ?? 0)
747// a Raster cell takes one printable BMP character exactly one column wide, as the engine measures it
748// (Bun.stringWidth, ambiguous narrow); any other character refuses the whole tree and every bar vanishes.
749// NOT_ONE lists the printable BMP characters whose width is not one, as base-36 "start-extra" ranges
750const 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'
751const notOne = NOT_ONE.split(',').map(r => {
752  const [a = '0', n] = r.split('-')
753  const from = parseInt(a, 36)
754  return [from, from + (n ? parseInt(n, 36) : 0)] as const
755})
756function isNotOne(cp: number) {
757  let lo = 0
758  let hi = notOne.length - 1
759  while (lo <= hi) {
760    const mid = (lo + hi) >> 1
761    const [from, to] = notOne[mid] ?? [0, 0]
762    if (cp < from) hi = mid - 1
763    else if (cp > to) lo = mid + 1
764    else return true
765  }
766  return false
767}
768// marks and format characters (accents written apart, variation selectors, joiners, soft hyphens) take no cell
769// of their own and go; anything else that cannot fill exactly one cell is drawn as a middle dot
770const cellChar = (ch: string) => {
771  if (/\p{M}|\p{Cf}/u.test(ch)) return ''
772  const cp = ch.codePointAt(0) ?? 63
773  const isControl = cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)
774  return isControl || cp > 0xffff || (cp >= 0xd800 && cp <= 0xdfff) || isNotOne(cp) ? '·' : ch
775}
776// an emoji sequence (a variation selector, a keycap, joined people, a skin tone, a flag) is one picture two columns
777// wide, so it is found a grapheme cluster at a time; everything else is measured a character at a time, as the
778// engine measures it, since a cluster of letters (a Hindi conjunct, Thai SARA AM) is several columns
779const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null
780const clusters = (s: string): string[] => (segmenter ? Array.from(segmenter.segment(s), x => x.segment) : [...s])
781const isEmojiCluster = (c: string) =>
782  /\u20E3|\p{Regional_Indicator}/u.test(c) ||
783  (/\p{Extended_Pictographic}/u.test(c) && (/[\uFE0F\u200D\u{1F3FB}-\u{1F3FF}]/u.test(c) || (c.codePointAt(0) ?? 0) > 0xffff))
784// the cells a string takes inside a Raster: a dot for an emoji sequence, each other character as cellChar draws it
785const cellText = (s: string) => clusters(s).map(c => (isEmojiCluster(c) ? '·' : [...c].map(cellChar).join(''))).join('')
786const cellsOf = (s: string) => [...cellText(s)].length
787// the columns one character takes as plain Text: none for marks, format and control characters and the vowel and
788// final jamo of a Hangul syllable written apart; two for wide ones, and for any character beyond the basic plane
789// but mathematical letters (a guess too wide only pads the title box, one too narrow cuts the title)
790function charColumns(ch: string): number {
791  const cp = ch.codePointAt(0) ?? 0
792  if (/\p{M}|\p{Cf}/u.test(ch) || cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) return 0
793  if ((cp >= 0x1160 && cp <= 0x11ff) || (cp >= 0xd7b0 && cp <= 0xd7ff)) return 0
794  if (cp > 0xffff) return cp >= 0x1d400 && cp <= 0x1d7ff ? 1 : 2
795  return isNotOne(cp) ? 2 : 1
796}
797// the columns a string takes as plain Text, where the terminal draws a wide character (CJK, emoji) over two
798const columnsOf = (s: string) => clusters(s).reduce((w, c) => w + (isEmojiCluster(c) ? 2 : [...c].reduce((n, ch) => n + charColumns(ch), 0)), 0)
799const fit = (s: string, w: number) => {
800  const chars = [...cellText(s)]
801  if (chars.length <= w) return chars.join('')
802  return w <= 1 ? '…'.slice(0, w) : chars.slice(0, w - 1).join('').trimEnd() + '…'
803}
804
805type Cell = [number, number, number]
806
807class Grid {
808  cells: Cell[]
809  constructor(
810    readonly columns: number,
811    readonly rows: number,
812  ) {
813    this.cells = Array.from({ length: columns * rows }, () => [32, DEFAULT, DEFAULT] as Cell)
814  }
815  set(x: number, y: number, ch: string | number, fg: number, bg: number) {
816    if (x < 0 || x >= this.columns || y < 0 || y >= this.rows) return
817    this.cells[y * this.columns + x] = [typeof ch === 'number' ? ch : (ch.codePointAt(0) ?? 32), fg, bg]
818  }
819  bg(x: number, y: number) {
820    return this.cells[y * this.columns + x]?.[2] ?? DEFAULT
821  }
822  text(x: number, y: number, s: string, fg: number, bg?: number) {
823    let i = 0
824    for (const ch of cellText(s)) {
825      this.set(x + i, y, ch, fg, bg ?? this.bg(x + i, y))
826      i++
827    }
828    return i
829  }
830  encode() {
831    const words = new Uint32Array(this.cells.length * 3)
832    this.cells.forEach((c, i) => words.set(c, i * 3))
833    return (new Uint8Array(words.buffer) as Uint8Array & { toBase64: () => string }).toBase64()
834  }
835}
836
837const BRAILLE_BITS = [
838  [0x01, 0x08],
839  [0x02, 0x10],
840  [0x04, 0x20],
841  [0x40, 0x80],
842]
843
844function pill(g: Grid, y: number, from: number, to: number, bgAt: (x: number) => number[]) {
845  for (let x = from; x < to; x++) g.set(x, y, ' ', DEFAULT, pack(bgAt(x)))
846}
847
848const LEVELS = 8
849const q = (m: number) => Math.round(Math.max(0, Math.min(1, m)) * LEVELS) / LEVELS
850const wave = (t: number, periodMs: number, offset = 0) => 0.5 + 0.5 * Math.sin(((t / periodMs) + offset) * Math.PI * 2)
851const easeOut = (x: number) => 1 - Math.pow(1 - Math.max(0, Math.min(1, x)), 4)
852
853const glide = new Map<string, { from: number; to: number; at: number }>()
854const GLIDE_MS = 450
855// the twinkle moment of each bar's last moving frame, kept while it stands still
856const stillPhase = new Map<string, number>()
857
858// a head glides to a new place only while frames can carry the glide; otherwise it is drawn there at once
859function headAt(id: string, target: number, t: number, canGlide: boolean): number {
860  const g = glide.get(id)
861  if (!g || (!canGlide && Math.abs(g.to - target) > 0.01)) {
862    glide.set(id, { from: target, to: target, at: t })
863    return target
864  }
865  const cur = g.from + (g.to - g.from) * easeOut((t - g.at) / GLIDE_MS)
866  if (Math.abs(g.to - target) > 0.01) {
867    glide.set(id, { from: cur, to: target, at: t })
868    return cur
869  }
870  return cur
871}
872
873
874function trackCells(p: Plan, W: number, t: number): string {
875  const g = new Grid(W, 1)
876  const w = where(p)
877  const done = p.state === 'done'
878  const target = (done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))) * W
879  // after the turn a change lands at once: a glide would keep frames going through the engine's end-of-turn redraw
880  const fx = headAt(p.id, target, t, isTurnLive || hasRunningAgents(p))
881  // only a moving bar twinkles with the clock; one that stands still keeps the twinkle of its last frame, so a
882  // redraw (the end of a turn is one) rewrites none of its cells, even right after it stopped moving
883  const phase = isAnimated(p, t) ? t : (stillPhase.get(p.id) ?? 0)
884  stillPhase.set(p.id, phase)
885  const back = termBg()
886  const acc = hex(stateColor(p.state))
887  const light = mix(acc, [255, 255, 255], 0.35)
888  const grey = [120, 118, 128]
889  const track = mix(back, [128, 128, 128], isLight ? 0.14 : 0.18)
890  const fill = mix(track, acc, done ? 0.3 : 0.17)
891  const under = (x: number) => (x + 0.5 < fx ? fill : track)
892  pill(g, 0, 0, W, under)
893
894  const TWINKLE = done ? [3200, 3800, 4400, 3500] : [2200, 2800, 1900, 3300]
895  const DELAY = [0, 700, 1300, 400]
896  const dim = done ? 0.2 : 0.55
897  for (let col = 0; col < Math.min(W, Math.ceil(fx)); col++) {
898    const u = Math.min(1, (col + 0.5) / Math.max(1, fx))
899    const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(u, 1.5)
900    let bits = 0
901    for (let r = 0; r < 4; r++) {
902      for (let c = 0; c < 2; c++) {
903        const sx = col * 2 + c
904        if (sx / 2 >= fx || hash(sx, r, 1) > dense * 0.6) continue
905        bits |= BRAILLE_BITS[r]?.[c] ?? 0
906      }
907    }
908    if (bits === 0) continue
909    const cls = Math.floor(hash(col, 0, 2) * 4)
910    const period = TWINKLE[cls] ?? 2200
911    const blink = 1 - dim * wave(phase + (DELAY[cls] ?? 0), period, 0.25)
912    const bucket = done ? 1 : q(Math.min(1, Math.pow(u, 0.9) * 1.1))
913    const tone = mix(grey, light, bucket)
914    const opacity = (0.35 + 0.65 * dense) * blink
915    g.set(col, 0, 0x2800 + bits, pack(mix(fill, tone, q(opacity))), pack(fill))
916  }
917
918  let k = 0
919  p.stages.forEach(s => {
920    if (k > 0) {
921      const x = Math.round((k / w.total) * W)
922      if (x > 0 && x < W - 1) {
923        const passed = x < fx - 0.5
924        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)))
925      }
926    }
927    k += s.steps.length
928  })
929
930  const base = hex(stateColor(p.state))
931  const color = base
932  const single = p.stages.length === 1
933  const number = single ? Math.min(w.total, w.pos + 1) : w.stage + 1
934  const icon = p.state === 'done' ? '✓' : p.state === 'error' ? '✕' : p.state === 'needs_input' ? '?' : ''
935  let name = ''
936  let count = ''
937  if (W < 28) {
938    name = icon || String(number)
939  } else {
940    const agents = p.agents ?? []
941    name = done ? 'Done' : single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
942    const baseCount = p.id === AGENTS ? `${w.pos}/${w.total}` : done ? `${w.total}/${w.total}` : single ? `${number}/${w.total}` : `${w.step}/${w.stageSize}`
943    count = baseCount + (agents.length > 0 && p.id !== AGENTS ? ` · ${agents.filter(a => a.state === 'done').length}/${agents.length}` : '')
944  }
945  const lead = icon && W >= 28 ? `${icon} ` : ''
946  const maxName = Math.max(3, Math.floor(W * (W < 60 ? 0.75 : 0.55)) - cellsOf(lead) - cellsOf(count) - 5)
947  const shown = lead + fit(name, maxName)
948  const kw = cellsOf(shown) + (count ? count.length + 1 : 0) + 2
949  const kx = Math.round(Math.max(0, Math.min(W - kw, fx - kw / 2)))
950  const white = pack(inkOn(color))
951  pill(g, 0, kx, kx + kw, () => color)
952  let at = kx + 1
953  at += g.text(at, 0, shown, white, pack(color))
954  if (count) g.text(at + 1, 0, count, white, pack(color))
955  return g.encode()
956}
957
958function stripCells(v: { shown: AgentRun[]; hidden: AgentRun[] }, W: number, now: number): { cells: string; rows: number } {
959  const rows = v.shown.length + (v.hidden.length > 0 ? 1 : 0)
960  const g = new Grid(W, rows)
961  const back = termBg()
962  const text = pack(termFg())
963  v.shown.forEach((a, y) => {
964    const c = hex(agentColor(a.state))
965    const tint = mix(back, c, 0.18)
966    pill(g, y, 0, W, () => tint)
967    const running = a.state === 'running' || a.state === 'waiting'
968    if (running) {
969      for (let col = 1; col < W - 1; col++) {
970        let bits = 0
971        let glow = 0
972        for (let r = 0; r < 4; r++) {
973          for (let cc = 0; cc < 2; cc++) {
974            const sx = col * 2 + cc + y * 83
975            if (hash(sx, r, 5) >= 0.08) continue
976            const b = 1 - 0.55 * wave(now, 1900 + hash(sx, r, 6) * 1400, hash(sx, r, 7))
977            bits |= BRAILLE_BITS[r]?.[cc] ?? 0
978            glow = Math.max(glow, b)
979          }
980        }
981        if (bits) g.set(col, y, 0x2800 + bits, pack(mix(tint, c, 0.2 + 0.4 * q(glow))), pack(tint))
982      }
983    }
984    const indent = a.depth > 0 ? 2 : 0
985    const dotColor = running ? mix(tint, c, 0.3 + 0.7 * q(wave(now, 1100))) : c
986    g.text(2 + indent, y, '●', pack(dotColor), pack(tint))
987    const time = elapsed((a.endedAt ?? now) - a.startedAt)
988    const narrow = W < 30
989    const tx = W - 2 - time.length
990    // the tool word sits at the right, just before the time, so the name and its model get the rest of the row
991    const word = narrow || !running ? '' : fit(a.tool, Math.max(0, Math.floor(W * 0.25)))
992    const toolAt = tx - 1 - word.length
993    let at = 4 + indent
994    const spec = [a.model ? modelName(a.model) : '', a.effort ?? ''].filter(Boolean).join(' · ')
995    const full = (a.depth > 0 ? '↳ ' : '') + a.title + (spec ? ` (${spec})` : '')
996    const name = fit(full, Math.max(3, (narrow ? W - 2 : word ? toolAt - 1 : tx - 1) - at))
997    // the model and effort are drawn dimmer than the name
998    const cut = spec ? name.indexOf(' (') : -1
999    const head = cut > 0 ? name.slice(0, cut) : name
1000    for (let i = -1; i < name.length + 1 && at + i < W - 1; i++) g.set(at + i, y, ' ', DEFAULT, pack(tint))
1001    at += g.text(at, y, head, text, pack(tint))
1002    const dim = pack(dimOn(tint, hex(scheme().dim)))
1003    if (head !== name) g.text(at, y, name.slice(head.length), dim, pack(tint))
1004    if (narrow) return
1005    if (word) {
1006      for (let i = -1; i <= word.length; i++) g.set(toolAt + i, y, ' ', DEFAULT, pack(tint))
1007      g.text(toolAt, y, word, pack(wordOn(tint, hex(a.state === 'running' && omarchy ? omarchy.accent : scheme().word[a.state]))), pack(tint))
1008    }
1009    for (let i = -1; i < time.length; i++) g.set(tx + i, y, ' ', DEFAULT, pack(tint))
1010    g.text(tx, y, time, dim, pack(tint))
1011  })
1012  if (v.hidden.length > 0) {
1013    const y = v.shown.length
1014    const tint = mix(back, [128, 128, 128], 0.16)
1015    pill(g, y, 0, W, () => tint)
1016    const doneCount = v.hidden.filter(a => a.state === 'done').length
1017    g.text(2, y, fit(`+${plural(v.hidden.length, 'more agent')} · ${doneCount} done`, W - 4), pack(dimOn(tint, hex(scheme().dim))), pack(tint))
1018  }
1019  return { cells: g.encode(), rows }
1020}
1021
1022type Band = { requestId: string; W: number; list: readonly Plan[] }
1023let band: Band | null = null
1024let isFrameBusy = false
1025// a bar twinkles only while Claude works on it; one waiting on the person or left open after the turn stands still
1026let isTurnLive = false
1027let frames: { cancel: () => void } | null = null
1028// when the engine last refused frames of this band for good; the frames rest a second before they try again
1029let refusedAt: number | null = null
1030// refused frames in a row: one sent between a redraw and its commit is refused once and the next goes through
1031let refusedInRow = 0
1032const REFUSED_MAX = 6
1033
1034const hasRunningAgents = (p: Plan) => (p.agents ?? []).some(a => a.state === 'running' || a.state === 'waiting')
1035const isGliding = (p: Plan, t: number) => {
1036  const g = glide.get(p.id)
1037  return g !== undefined && g.from !== g.to && t - g.at < GLIDE_MS + 100
1038}
1039const isAnimated = (p: Plan, t: number) => (isTurnLive && p.state === 'running') || hasRunningAgents(p) || isGliding(p, t)
1040
1041// the 30 fps clock runs only while a terminal band has something moving; the second timer starts and stops it
1042function syncFrames($: EngineInterface, now: number) {
1043  const isRested = refusedAt === null || now - refusedAt >= 1000
1044  const isWanted = band !== null && isRested && band.list.some(p => isAnimated(p, now))
1045  if (isWanted && !frames) frames = $.clock.every(33, () => void animate($))
1046  if (!isWanted && frames) {
1047    frames.cancel()
1048    frames = null
1049  }
1050}
1051
1052// ---------- terminal tint ----------
1053// the track's light or dark tint: a theme the person picked wins; "auto", and the default "dark" inside Terminal.app
1054// (whose stock profiles follow macOS), take the macOS appearance. Off a Mac, "auto" takes the desktop's
1055// color-scheme (GNOME and the desktops that share its setting). The appearance is read only while a terminal bar
1056// is on screen, at most every APPEARANCE_MS, and never again once neither command is there
1057const APPEARANCE_MS = 5000
1058let themeSetting = 'dark'
1059let system: 'unknown' | 'mac' | 'gnome' | 'none' = 'unknown'
1060let termProgram = ''
1061let isSystemDark: boolean | null = null
1062let appearanceAt = -Infinity
1063let isProbing = false
1064
1065const followsSystem = () =>
1066  system !== 'none' &&
1067  (themeSetting === 'auto' || (themeSetting === 'dark' && (system === 'unknown' || (system === 'mac' && termProgram === 'Apple_Terminal'))))
1068// an Omarchy theme paints the terminal itself, so its mode wins over the setting and the macOS appearance
1069const tintIsLight = () => (omarchy ? omarchy.isLight : followsSystem() && isSystemDark !== null ? !isSystemDark : /light/i.test(themeSetting))
1070
1071// the Omarchy theme is read like the appearance: while a terminal bar is on screen, at most every APPEARANCE_MS, so a
1072// theme switch follows within seconds; a machine without the file (or a file without an accent) is never read again
1073const OMARCHY_COLORS = 'cat "${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/current/theme/colors.toml"'
1074let hasOmarchy = true
1075let omarchyAt = -Infinity
1076
1077async function readOmarchy($: EngineInterface, now: number) {
1078  if (!hasOmarchy || now - omarchyAt < APPEARANCE_MS) return
1079  omarchyAt = now
1080  const r = await $.process.run(['/bin/sh', '-c', OMARCHY_COLORS], { timeoutMs: 2000 }).catch(() => null)
1081  const found = r && r.exitCode === 0 ? parseOmarchy(r.stdout) : null
1082  if (!found) {
1083    hasOmarchy = false
1084    omarchy = null
1085    return
1086  }
1087  const was = omarchy
1088  if (was && was.accent === found.accent && was.isLight === found.isLight && String(was.background) === String(found.background) && String(was.foreground) === String(found.foreground)) return
1089  omarchy = found
1090}
1091
1092// the macOS appearance: the key is missing in light mode, so anything but "Dark" reads as light; null off a Mac
1093async function readMac($: EngineInterface) {
1094  const r = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: 2000 }).catch(() => null)
1095  return r ? /dark/i.test(r.stdout) : null
1096}
1097
1098// the desktop's color-scheme: 'prefer-dark' or 'prefer-light'; 'default' says nothing, so the theme setting decides
1099async function readGnome($: EngineInterface) {
1100  const r = await $.process.run(['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], { timeoutMs: 2000 }).catch(() => null)
1101  if (!r || r.exitCode !== 0) return undefined
1102  return /prefer-dark/.test(r.stdout) ? true : /prefer-light/.test(r.stdout) ? false : null
1103}
1104
1105async function probeAppearance($: EngineInterface, now: number) {
1106  if (isProbing || !followsSystem() || now - appearanceAt < APPEARANCE_MS) return
1107  isProbing = true
1108  appearanceAt = now
1109  try {
1110    if (system !== 'gnome') {
1111      const dark = await readMac($)
1112      if (dark !== null) {
1113        if (system === 'unknown') {
1114          const t = await $.process.run(['/bin/sh', '-c', 'printf %s "$TERM_PROGRAM"'], { timeoutMs: 2000 }).catch(() => null)
1115          termProgram = t?.stdout.trim() ?? ''
1116          system = 'mac'
1117        }
1118        isSystemDark = dark
1119        return
1120      }
1121    }
1122    const dark = await readGnome($)
1123    if (dark === undefined) {
1124      // neither command: the theme setting alone decides from here on
1125      system = 'none'
1126      return
1127    }
1128    system = 'gnome'
1129    isSystemDark = dark
1130  } finally {
1131    isProbing = false
1132  }
1133}
1134
1135// reads the appearance when due and repaints the bars at once if the tint changed
1136async function syncTint($: EngineInterface, now: number) {
1137  const before = omarchy
1138  if (band) await readOmarchy($, now)
1139  if (band && !omarchy) await probeAppearance($, now)
1140  const next = tintIsLight()
1141  if (next === isLight && omarchy === before) return
1142  isLight = next
1143  const b = band
1144  if (b) await blitPlans($, b, b.list, now)
1145}
1146
1147// the size goes along, so a band redrawn at another width answers with a deny naming both sizes
1148function blitPlans($: EngineInterface, b: Band, list: readonly Plan[], now: number) {
1149  return Promise.all(
1150    list.flatMap(p => {
1151      const v = shownAgents(p, now, stripBudget(b.list.length))
1152      const strips = v ? stripCells(v, b.W, now) : null
1153      const calls = [$.ui.blit({ requestId: b.requestId, key: `track-${p.id}`, cells: trackCells(p, b.W, now), columns: b.W, rows: 1 })]
1154      if (strips) calls.push($.ui.blit({ requestId: b.requestId, key: `strips-${p.id}`, cells: strips.cells, columns: b.W, rows: strips.rows }))
1155      return calls.map(c => c.catch(() => undefined))
1156    }),
1157  )
1158}
1159
1160// blits the engine keeps refusing (the band hidden or unmounted, redrawn at another size, or its tree refused) stop
1161// the frames: the 1 s timer tries one frame again a second later, and a redraw lays the band out anew at once. A
1162// band shown again without a redraw (folded and unfolded) so comes back within a second instead of staying still
1163function stopFrames(b: Band, now: number) {
1164  if (band !== b) return
1165  refusedAt = now
1166  refusedInRow = 0
1167  frames?.cancel()
1168  frames = null
1169}
1170
1171async function animate($: EngineInterface) {
1172  const b = band
1173  if (!b || isFrameBusy) return
1174  isFrameBusy = true
1175  try {
1176    const now = await $.clock.now()
1177    // a redraw while the clock was read laid the band out anew: this frame belongs to the old one
1178    if (band !== b) return
1179    const live = b.list.filter(p => isAnimated(p, now))
1180    if (live.length === 0) return
1181    const answers = await blitPlans($, b, live, now)
1182    if (!answers.some(a => a?.deny)) {
1183      refusedInRow = 0
1184      refusedAt = null
1185    } else if (++refusedInRow >= REFUSED_MAX) stopFrames(b, now)
1186  } finally {
1187    isFrameBusy = false
1188  }
1189}
1190
1191
1192// ---------- engine glue ----------
1193
1194// the engine's player first (afplay on macOS); PowerShell where it cannot play
1195// set while a demo reel records: every sound with its moment, so a video can carry them
1196let soundLog: { at: number; name: string }[] | null = null
1197
1198function play($: EngineInterface, name: 'decision' | 'error' | 'done') {
1199  if (soundLog) void $.clock.now().then(at => soundLog?.push({ at, name }))
1200  const file = `${$.plugin.root}/sounds/${name}.wav`.replace(/\//g, '\\')
types/index.d.ts 47 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}
20export type Plan = {
21  id: string
22  title: string
23  kind: 'plan' | 'todo'
24  stages: PlanStage[]
25  state: PlanState
26  note: string | null
27  startedAt: number
28  // when the plan was finished; the pill then shows the time it took
29  endedAt?: number | null
30  agents?: AgentRun[]
31  // when the current batch of agents all finished; their strips fold a few seconds later
32  agentsDoneAt?: number | null
33  // the person folded the bar's agent strips away (its ▾ button, /progress-agents); the bar keeps one line
34  isFolded?: boolean
35}
36
37declare module 'claude-code' {
38  interface PluginState {
39    'plan-progress': {
40      plans: Plan[]
41      isOpen: boolean
42      // bumped every second while agents run, so elapsed times and folding redraw
43      tick: number
44    }
45  }
46}
47