Live plan progress bars above the Claude Code prompt: stages, steps, a pixel fill and soft sounds for decision, error and done (qa-mods fork: a wider title…

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