plan-progress fork with an animated rainbow pixel bar in the terminal, drawn under the prompt beside limit-bars: glide, shimmer, glow and a sparkle burst on…

Stylish HUD mods for the Claude Code terminal, drawn under the prompt.

| Plugin | What it draws |
|---|---|
| limit-bars | Four braille rings, filled clockwise with the percentage inside: context (green → amber → red), session 5-hour limit (cyan → violet), weekly limit (pink → orange) and Fable weekly limit (mint → blue). A light orbits each filled arc while Claude works (the rings hold still while it is idle, so typing stays quick); a ring pulses red past 80% (context) or 90% (limits). Beside them: model with its effort level (italic, shading violet → pink from low to max), workspace folder, tokens used and when the session limit resets (↻ 4:30 PM). |
| plan-progress-fx | Animated rainbow progress bars for multi-step tasks, with gradient titles and percentages, placed to the right of the rings. On a narrow terminal they stay collapsed until you open them with ◉ Progress, and then take the rings' place. It also changes how Claude works so the bars stay current; see below. |
| reply-highlight | Sets Claude's replies apart in the transcript: a rainbow edge down the left (red at the top of each block to violet at the bottom) and a violet tint behind them. Text keeps the terminal's colour; what Claude bolds, and headings, run through a rainbow letter by letter. Code blocks and tables keep Claude Code's own drawing inside the panel. |
| backlog-pane | A side pane for the project: its git status on top (branch, ahead/behind, changed files coloured by kind), shown in any git repository, then its Backlog.md tasks when it has a backlog/ folder (in its root, or a parent inside the same repository). Tasks in progress carry a rainbow bar of checked acceptance criteria and a ✓ done button; the backlog list has ▸ start buttons; priority, type and first label sit in front of each title; a box at the bottom creates a task. Buttons run the backlog CLI, so files stay as Backlog.md writes them. Each section sits in a rounded card in its colour, titled on its first row with its count on the right. Rereads every few seconds; /backlog reopens it. |
| transcript-fx | Restyles the rest of the transcript to match. Tool calls are gold rows (an icon per kind, a short summary, ✓ / ✗ / ◐ / ⊘) with their results in a gold-edged panel, and folded runs are one gold count line (Read ×3 · Grep ×2). Your prompts sit in a padded teal box with a rainbow frame running clockwise around it. A small rounded square sits beside the spinner with a rainbow comet circling its border; the spinner keeps its time and tokens. Each turn closes with ✦ baked in 2m 50s · 3:42 PM, and a rainbow rule sits above the prompt when nothing else uses that band. /fx turns each part on or off. |
Each works alone; limit-bars and plan-progress-fx share the line under the prompt when both are installed.
Requires a Claude Code build with plugin hooks (ui.render).
claude plugin marketplace add LegendSilvia/claude-code-mods
claude plugin install limit-bars@claude-code-mods
claude plugin install plan-progress-fx@claude-code-mods
claude plugin install reply-highlight@claude-code-mods
claude plugin install backlog-pane@claude-code-mods
claude plugin install transcript-fx@claude-code-mods
Restart Claude Code. If you use a statusLine command in settings.json that shows the model, folder or context, you can remove it: limit-bars shows the same.
--.fable, or any other model-specific weekly window.○ ◔ ◑ ◕ ●) instead.| Command | Does |
|---|---|
/progress | Show or hide the bars |
/progress-demo | Run a sample plan |
/progress-sounds | Play the decision, error and done sounds |
/progress-clear | Remove all bars |
/progress-debug | Show the widths the bars were laid out by (the terminal's, and what the prompt line was told) |
| Command | Does | |
|---|---|---|
/fx | List the parts and whether each is on | |
| `/fx <part> on\ | off` | Turn one part on or off: tools, spinner, prompts, footer, rule (remembered across sessions) |
/fx debug | Show what the footer clock went by: the last turn's end and each recent footer's first drawing and decision |
The footer's clock time is taken when the turn ends; footers already on screen when the plugin reloads show only their duration.
The bars only move when Claude reports progress, so this plugin changes what Claude does, not just what the terminal draws:
plan_progress tool) for any task needing more than about three edits or commands, to update it as steps finish, and to mark it "needs input" before asking you to decide.Edit, Write, MultiEdit, NotebookEdit, Bash or PowerShell call of a turn is refused once, with a message telling Claude to create a bar first. Read-only shell calls (ls, git status, grep) don't count, and subagents are never refused.claude plugin validate plugins/limit-bars
claude plugin test plugins/limit-bars
claude plugin test plugins/plan-progress-fx
claude plugin test plugins/reply-highlight
claude plugin test plugins/backlog-pane
claude plugin test plugins/transcript-fx
MIT. plan-progress-fx is a fork of plan-progress by Kirill Serditov (MIT); see its LICENSE.
hooks/register.tsx 1310 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { AgentRun, Plan, PlanStage, PlanState, PlanStep, StepStatus } from '../types'
5
6const TOOL = 'mcp__plan-progress-fx__plan_progress'
7const plans = atom({ plugin: 'plan-progress-fx', 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-fx', key: 'isOpen' } as const, true)
12const narrowOpen = atom({ plugin: 'plan-progress-fx', key: 'narrowOpen' } as const, false)
13const tick = atom({ plugin: 'plan-progress-fx', key: 'tick' } as const, 0)
14const STRIP_H = 18
15const STRIP_GAP = 3
16const MAX_STRIPS = 4 // past this, the finished ones fold into one "+N more" strip
17const FOLD_MS = 5000 // finished strips stay this long, failed ones stay until the bar closes
18
19const STATE_COLOR: Record<PlanState, string> = { running: '#8B7CF6', needs_input: '#E09A1E', error: '#E5484D', done: '#30A46C' }
20const STATE_GLYPH: Record<PlanState, string> = { running: '●', needs_input: '?', error: '!', done: '✓' }
21// the hue range a bar's title runs through, by state
22const TITLE_HUES: Record<PlanState, [number, number]> = { running: [190, 300], needs_input: [30, 55], error: [350, 15], done: [120, 175] }
23const toHex = (c: number[]) => `#${c.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('')}`
24const STATUSES: StepStatus[] = ['pending', 'active', 'done', 'error', 'skipped']
25const TRACK_H = 22
26const NARROW = 360
27
28const RULES = `# Progress bars
29Tasks needing more than ~3 edits or commands get a bar via ${TOOL}: create it once with the full breakdown (2-7 stages with short steps, or kind "todo" for one flat list; titles of at most 4 words, in the user's language), then update it with short calls only: {id, next:true} when the active step is finished, or {id, done:[...], active:"..."}, {id, failed:"...", note}. Send state "needs_input" with a note before asking the user to decide. Never describe the bars to the user.`
30
31type Raw = Record<string, unknown>
32const str = (v: unknown, max = 120) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
33const status = (v: unknown): StepStatus => (STATUSES.includes(v as StepStatus) ? (v as StepStatus) : 'pending')
34const list = (v: unknown): Raw[] => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object') : []) as Raw[]
35const isFinished = (s: StepStatus) => s === 'done' || s === 'skipped'
36
37const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
38
39// short updates: {next:true}, {done:[titles]}, {active:title}, {failed:title} against the stored plan
40function applyOps(stages: PlanStage[], input: Raw): PlanStage[] {
41 const next = stages.map(s => ({ ...s, steps: s.steps.map(st => ({ ...st })) }))
42 const steps = next.flatMap(s => s.steps)
43 const find = (title: string) => steps.find(st => same(st.title, title))
44 if (input.next === true) {
45 const at = steps.findIndex(st => st.status === 'active') >= 0 ? steps.findIndex(st => st.status === 'active') : steps.findIndex(st => !isFinished(st.status))
46 const cur = steps[at]
47 if (cur) cur.status = 'done'
48 const following = steps.slice(at + 1).find(st => st.status === 'pending')
49 if (following) following.status = 'active'
50 }
51 for (const t of Array.isArray(input.done) ? input.done : []) {
52 const st = typeof t === 'string' ? find(t) : undefined
53 if (st) st.status = 'done'
54 }
55 const active = typeof input.active === 'string' ? find(input.active) : undefined
56 if (active) {
57 const at = steps.indexOf(active)
58 steps.forEach((st, i) => {
59 if (st.status === 'active' && i !== at) st.status = i < at ? 'done' : 'pending'
60 })
61 active.status = 'active'
62 }
63 const failed = typeof input.failed === 'string' ? find(input.failed) : undefined
64 if (failed) failed.status = 'error'
65
66 return next
67}
68
69function normalize(input: Raw, prev: Plan | null, now: number, id: string): Plan {
70 const isPartial = list(input.stages).length === 0 && prev !== null
71 const stages: PlanStage[] = isPartial ? applyOps(prev.stages, input) : list(input.stages)
72 .map(s => ({
73 name: str(s.name, 80) || 'Stage',
74 steps: list(s.steps).map(st => ({
75 title: str(st.title) || 'Step',
76 status: status(st.status),
77 substeps: list(st.substeps).map(sub => ({ title: str(sub.title) || '…', status: status(sub.status) })),
78 })),
79 }))
80 .filter(s => s.steps.length > 0) as PlanStage[]
81 const title = str(input.title, 80) || prev?.title || 'Plan'
82 const steps = stages.flatMap(s => s.steps)
83 const isAllDone = steps.length > 0 && steps.every(s => isFinished(s.status))
84 const asked = input.state as PlanState
85 const failedNow = typeof input.failed === 'string'
86 const state: PlanState = ['running', 'needs_input', 'error', 'done'].includes(asked) ? asked : isAllDone ? 'done' : failedNow ? 'error' : 'running'
87
88 return {
89 id,
90 title,
91 kind: input.kind === 'todo' || (isPartial && prev?.kind === 'todo') ? 'todo' : 'plan',
92 stages,
93 state,
94 note: str(input.note, 160) || null,
95 startedAt: prev && prev.title === title ? prev.startedAt : now,
96 }
97}
98
99const clean = (s: string) =>
100 s
101 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
102 .replace(/[*_`]/g, '')
103 .replace(/^\s*(\d+[.)]|[-*+]|\[[ xX]\])\s+/, '')
104 .replace(/^(\d+[.)]|\[[ xX]\])\s+/, '')
105 .trim()
106
107function parsePlan(markdown: string, now: number): Plan | null {
108 let title = ''
109 const headed: PlanStage[] = []
110 const items: { depth: number; text: string }[] = []
111 for (const line of markdown.split(/\r?\n/)) {
112 const h = line.match(/^(#{1,4})\s+(.*)$/)
113 if (h) {
114 const text = clean(h[2] ?? '')
115 if (h[1] === '#' && !title) title = text
116 else headed.push({ name: text, steps: [] })
117 continue
118 }
119 const li = line.match(/^(\s*)(\d+[.)]|[-*+])\s+(.*)$/)
120 if (!li) continue
121 const depth = Math.floor((li[1] ?? '').replace(/\t/g, ' ').length / 2)
122 const text = clean(li[3] ?? '').slice(0, 120)
123 if (!text) continue
124 items.push({ depth, text })
125 const stage = headed[headed.length - 1]
126 if (!stage) continue
127 const step = stage.steps[stage.steps.length - 1]
128 if (depth === 0 || !step) stage.steps.push({ title: text, status: 'pending', substeps: [] })
129 else step.substeps.push({ title: text, status: 'pending' })
130 }
131 let stages = headed.filter(s => s.steps.length > 0)
132 if (stages.length === 0) {
133 if (items.some(i => i.depth > 0)) {
134 for (const item of items) {
135 const stage = stages[stages.length - 1]
136 if (item.depth === 0 || !stage) stages.push({ name: item.text, steps: [] })
137 else stage.steps.push({ title: item.text, status: 'pending', substeps: [] })
138 }
139 stages = stages.map(s => (s.steps.length ? s : { ...s, steps: [{ title: s.name, status: 'pending', substeps: [] }] }))
140 } else if (items.length > 0) {
141 stages = [{ name: 'Tasks', steps: items.map(i => ({ title: i.text, status: 'pending' as StepStatus, substeps: [] })) }]
142 }
143 }
144 if (stages.length === 0) return null
145 const first = stages[0]?.steps[0]
146 if (first) first.status = 'active'
147
148 return { id: 'plan', title: title || 'Plan', kind: stages.length === 1 ? 'todo' : 'plan', stages, state: 'running', note: null, startedAt: now }
149}
150
151function st(title: string, s: StepStatus): PlanStep {
152 return { title, status: s, substeps: [] }
153}
154
155const DEMO = (now: number): Plan => ({
156 id: 'demo',
157 title: 'Orders module',
158 kind: 'plan',
159 state: 'running',
160 note: null,
161 startedAt: now - 260_000,
162 stages: [
163 { name: 'Analysis', steps: [st('Read modules', 'done'), st('Find dependencies', 'done'), st('List changes', 'done')] },
164 { name: 'DB migration', steps: [st('Table schema', 'done'), st('Create migration', 'done'), st('Move data', 'active'), st('Indexes', 'pending')] },
165 { name: 'API', steps: [st('Endpoints', 'pending'), st('Validation', 'pending'), st('Access rules', 'pending')] },
166 { name: 'Interface', steps: [st('List page', 'pending'), st('Order card', 'pending'), st('Filters', 'pending'), st('Empty states', 'pending')] },
167 { name: 'Verify', steps: [st('Tests', 'pending'), st('Build', 'pending')] },
168 ],
169})
170
171// ---------- drawing ----------
172
173type Where = { pos: number; total: number; stage: number; step: number; stageSize: number }
174
175function where(p: Plan): Where {
176 const steps = p.stages.flatMap((s, i) => s.steps.map((step, j) => ({ i, j, step })))
177 const at = steps.findIndex(x => !isFinished(x.step.status))
178 const pos = p.state === 'done' || at < 0 ? steps.length : at
179 const cur = steps[Math.min(pos, steps.length - 1)]
180 const stage = cur?.i ?? 0
181
182 return { pos, total: steps.length, stage, step: pos >= steps.length ? (p.stages[stage]?.steps.length ?? 0) : (cur?.j ?? 0) + 1, stageSize: p.stages[stage]?.steps.length ?? 0 }
183}
184
185const hex = (h: string) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16))
186const mix = (a: number[], b: number[], m: number) => a.map((v, i) => Math.round(v + ((b[i] ?? 0) - v) * m))
187const rgb = (c: number[]) => `rgb(${c.join(',')})`
188const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
189const hash = (a: number, b: number, k: number) => {
190 const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
191 return x - Math.floor(x)
192}
193const 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)
194
195const ICON_PATH: Partial<Record<PlanState, string>> = {
196 needs_input: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01',
197 error: 'M18 6 6 18M6 6l12 12',
198 done: 'M20 6 9 17l-5-5',
199}
200
201// last drawn head position per plan, so a redraw glides from where the bar was
202const lastHead = new Map<string, number>()
203
204function trackSvg(p: Plan, W: number): string {
205 const H = TRACK_H
206 const w = where(p)
207 const done = p.state === 'done'
208 // the fill is exactly the finished share: a fresh plan starts empty
209 const frac = done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))
210 const fx = frac * W
211 const key = p.id
212 const from = lastHead.get(key) ?? fx
213 lastHead.set(key, fx)
214
215 const acc = hex(STATE_COLOR[p.state])
216 const light = mix(acc, [255, 255, 255], 0.32)
217 const grey = [132, 130, 138]
218 const ease = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
219 const glide = Math.abs(from - fx) > 0.5
220
221 const bounds: number[] = []
222 let acc2 = 0
223 p.stages.forEach((s, i) => {
224 acc2 += s.steps.length
225 if (i < p.stages.length - 1) bounds.push((acc2 / w.total) * W)
226 })
227
228 // pixels: 3px grid, 7 rows, denser and closer to the state colour towards the head
229 const buckets = [0, 1, 2, 3, 4].map(b => {
230 const m = b / 4
231 const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(m, 1.5)
232 return { color: rgb(done ? light : mix(grey, light, m)), opacity: (0.35 + 0.65 * dense).toFixed(2) }
233 })
234 let px = ''
235 for (let col = 0; col * 3 < fx; col++) {
236 const x = col * 3
237 const u = Math.min(1, (x + 1.5) / fx)
238 const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(u, 1.5)
239 const bucket = done ? 4 : Math.min(4, Math.floor(Math.min(1, Math.pow(u, 0.9) * 1.1) * 4.99))
240 for (let r = 0; r < 7; r++) {
241 if (hash(col, r, 1) > dense + 0.1) continue
242 px += `<rect x="${x}" y="${1 + r * 3}" class="b${bucket} t${Math.floor(hash(col, r, 2) * 4)}"/>`
243 }
244 }
245
246 let marks = ''
247 let k = 0
248 p.stages.forEach((s, i) => {
249 s.steps.forEach((_, j) => {
250 if (k > 0) {
251 const x = (k / w.total) * W
252 const isStage = j === 0
253 // stage boundaries are full-height lines, steps are short ticks; bright once passed
254 const passed = x < fx - 1
255 const h = isStage ? H : 8
256 const fill = passed ? rgb(mix(light, [255, 255, 255], 0.45)) : '#8A8984'
257 const opacity = passed ? (isStage ? 0.95 : 0.6) : isStage ? 0.7 : 0.45
258 marks += `<rect x="${(x - (isStage ? 1 : 0.75)).toFixed(1)}" y="${(H - h) / 2}" width="${isStage ? 2 : 1.5}" height="${h}" rx=".75" fill="${fill}" opacity="${opacity}"/>`
259 }
260 k++
261 })
262 void i
263 })
264
265 // knob: a pill with stage and count, or a round dot with the stage number when narrow
266 const isNarrow = W < NARROW
267 const color = STATE_COLOR[p.state]
268 const icon = ICON_PATH[p.state]
269 const single = p.stages.length === 1
270 const number = single ? Math.min(w.total, w.pos + 1) : w.stage + 1
271 let knob = ''
272 let kw = H
273 if (isNarrow) {
274 const label = done ? '' : String(number)
275 knob = `<circle cx="0" cy="${H / 2}" r="${H / 2}" fill="${color}"/>${
276 done ? `<path d="${ICON_PATH.done}" transform="translate(-6 5) scale(.5)" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>` : `<text x="0" y="${H / 2 + 4.2}" text-anchor="middle" class="kt">${label}</text>`
277 }`
278 } else {
279 const name = done ? 'Done' : single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
280 const agents = p.agents ?? []
281 const base = p.id === AGENTS ? `${w.pos}/${w.total}` : done ? `${w.total}/${w.total}` : single ? `${number}/${w.total}` : `${w.step}/${w.stageSize}`
282 const agentCount = agents.length > 0 && p.id !== AGENTS ? ` · ${agents.filter(a => a.state === 'done').length}/${agents.length} agents` : ''
283 const count = base + agentCount
284 const iconW = icon ? 16 : 0
285 const countW = textWidth(count, 6.5)
286 const maxW = Math.max(80, W * 0.55)
287 let shown = name
288 while (shown.length > 3 && 20 + iconW + textWidth(shown) + 6 + countW > maxW) shown = shown.slice(0, -1)
289 if (shown !== name) shown = shown.trimEnd() + '…'
290 kw = Math.round(20 + iconW + textWidth(shown) + 6 + countW)
291 const left = -kw / 2 + 10
292 knob = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>`
293 if (icon) knob += `<path d="${icon}" transform="translate(${left} 5) scale(.5)" fill="none" stroke="#fff" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>`
294 knob += `<text x="${left + iconW}" y="${H / 2 + 4.2}" class="kt">${esc(shown)}<tspan class="kc" dx="6">${count}</tspan></text>`
295 }
296 const clampX = (x: number) => Math.max(kw / 2, Math.min(W - kw / 2, x))
297 const kx = clampX(fx)
298 const kFrom = clampX(from)
299
300 const style = `<style>
301.b0{fill:${buckets[0]?.color};fill-opacity:${buckets[0]?.opacity}}.b1{fill:${buckets[1]?.color};fill-opacity:${buckets[1]?.opacity}}
302.b2{fill:${buckets[2]?.color};fill-opacity:${buckets[2]?.opacity}}.b3{fill:${buckets[3]?.color};fill-opacity:${buckets[3]?.opacity}}
303.b4{fill:${buckets[4]?.color};fill-opacity:${buckets[4]?.opacity}}
304rect[class]{width:2px;height:2px}
305.t0,.t1,.t2,.t3{animation:tw ${done ? 3.2 : 2.2}s ease-in-out infinite}
306.t1{animation-duration:${done ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${done ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${done ? 3.5 : 3.3}s;animation-delay:-.4s}
307@keyframes tw{0%,100%{opacity:1}50%{opacity:${done ? 0.8 : 0.45}}}
308.kt{font:500 12px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#fff}
309.kc{font-weight:400;fill-opacity:.75}
310@media (prefers-reduced-motion:reduce){.t0,.t1,.t2,.t3{animation:none}}
311</style>`
312 const glideFill = glide ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${ease} fill="freeze"/>` : ''
313 const glideKnob = glide ? `<animateTransform attributeName="transform" type="translate" from="${kFrom.toFixed(1)} 0" to="${kx.toFixed(1)} 0" dur=".45s" ${ease} fill="freeze"/>` : ''
314
315 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${style}
316<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>
317<linearGradient id="base" x1="0" x2="${fx.toFixed(1)}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${rgb(acc)}" stop-opacity="${done ? 0.3 : 0.05}"/><stop offset="1" stop-color="${rgb(acc)}" stop-opacity=".33"/></linearGradient></defs>
318<g clip-path="url(#pill)"><rect width="${W}" height="${H}" fill="#808080" fill-opacity=".16"/>
319<g clip-path="url(#fill)"><rect width="${fx.toFixed(1)}" height="${H}" fill="url(#base)"/>${px}</g>${marks}</g>
320<g transform="translate(${kx.toFixed(1)} 0)">${glideKnob}${knob}</g></svg>`
321}
322
323const AGENT_COLOR: Record<AgentRun['state'], string> = {
324 running: STATE_COLOR.running,
325 waiting: STATE_COLOR.needs_input,
326 done: STATE_COLOR.done,
327 error: STATE_COLOR.error,
328}
329
330const elapsed = (ms: number) => {
331 const sec = Math.max(0, Math.round(ms / 1000))
332 return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m ${sec % 60}s`
333}
334
335// which strips show: all of a small batch; in a big one the unfinished first, the rest folded into one line
336function visibleAgents(p: Plan, now: number): { shown: AgentRun[]; hidden: AgentRun[] } | null {
337 const list = p.agents ?? []
338 if (list.length === 0) return null
339 const hasError = list.some(a => a.state === 'error')
340 if (p.agentsDoneAt && now - p.agentsDoneAt > FOLD_MS && !hasError) return null
341 if (list.length <= MAX_STRIPS) return { shown: list, hidden: [] }
342 const keep = new Set(list.filter(a => a.state !== 'done').slice(0, MAX_STRIPS - 1).map(a => a.id))
343 for (const a of [...list].reverse()) {
344 if (keep.size >= MAX_STRIPS - 1) break
345 keep.add(a.id)
346 }
347 return { shown: list.filter(a => keep.has(a.id)), hidden: list.filter(a => !keep.has(a.id)) }
348}
349
350// what each strip showed last time it was drawn, so a change morphs from the old status instead of jumping
351const lastStrip = new Map<string, { tool: string; color: string }>()
352const MORPH = '.2s'
353
354const stripsHeight = (n: number) => n * STRIP_H + (n - 1) * STRIP_GAP
355
356// one tinted strip per agent: state colour, name, what it does now and for how long; not a progress bar
357function stripsSvg(v: { shown: AgentRun[]; hidden: AgentRun[] }, W: number, now: number): string {
358 const isNarrow = W < NARROW
359 const rows: string[] = []
360 v.shown.forEach((a, i) => {
361 const c = AGENT_COLOR[a.state]
362 const y = i * (STRIP_H + STRIP_GAP)
363 const indent = a.depth > 0 ? 12 : 0
364 let px = ''
365 if (a.state === 'running') {
366 for (let col = 0; col * 3 < W; col++) {
367 for (let r = 0; r < 4; r++) {
368 if (hash(col + i * 41, r, 5) > 0.2) continue
369 px += `<rect x="${col * 3}" y="${y + 3 + r * 3.6}" class="t${Math.floor(hash(col, r, 6) * 4)}" fill="${c}" fill-opacity=".32"/>`
370 }
371 }
372 }
373 const nameRoom = isNarrow ? W - 30 - indent : W * 0.5
374 let name = (a.depth > 0 ? '↳ ' : '') + a.title
375 while (name.length > 4 && textWidth(name, 6.2) > nameRoom) name = name.slice(0, -1)
376 if (name !== (a.depth > 0 ? '↳ ' : '') + a.title) name = name.trimEnd() + '…'
377 const nameX = 19 + indent
378 const toolX = nameX + textWidth(name, 6.2) + 8
379 const time = elapsed((a.endedAt ?? now) - a.startedAt)
380 // a status change: the old word blurs out while the new one blurs in, and the tint flows to the new colour
381 const was = lastStrip.get(a.id)
382 lastStrip.set(a.id, { tool: a.tool, color: c })
383 const isToolChanged = was !== undefined && was.tool !== a.tool
384 const flow = (attr: string) => (was && was.color !== c ? `<animate attributeName="${attr}" from="${was.color}" to="${c}" dur="${MORPH}" fill="freeze"/>` : '')
385 const tool = isNarrow
386 ? ''
387 : (isToolChanged ? `<text x="${toolX}" y="${y + 12.5}" class="sn mo" style="fill:${was.color}">${esc(was.tool)}</text>` : '') +
388 `<text x="${toolX}" y="${y + 12.5}" class="sn${isToolChanged ? ' mi' : ''}" style="fill:${c}">${esc(a.tool)}</text>` +
389 `<text x="${W - 9}" y="${y + 12.5}" text-anchor="end" class="sn st">${time}</text>`
390 rows.push(
391 `<rect x="0" y="${y}" width="${W}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="${c}" fill-opacity=".15">${flow('fill')}</rect>${px}` +
392 `<circle cx="${10 + indent}" cy="${y + STRIP_H / 2}" r="3" fill="${c}"${a.state === 'running' ? ' class="sd"' : ''}>${flow('fill')}</circle>` +
393 `<text x="${nameX}" y="${y + 12.5}" class="sn">${esc(name)}</text>` +
394 tool,
395 )
396 })
397 if (v.hidden.length > 0) {
398 const y = v.shown.length * (STRIP_H + STRIP_GAP)
399 const doneCount = v.hidden.filter(a => a.state === 'done').length
400 rows.push(
401 `<rect x="0" y="${y}" width="${W}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="#808080" fill-opacity=".14"/>` +
402 `<text x="10" y="${y + 12.5}" class="sn st">+${plural(v.hidden.length, 'more agent')} · ${doneCount} done</text>`,
403 )
404 }
405 return `<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}
406.sd{animation:sp 1.1s ease-in-out infinite}@keyframes sp{50%{opacity:.3}}
407.mi{animation:mi ${MORPH} ease-out both}@keyframes mi{from{opacity:0;filter:blur(3px)}}
408.mo{animation:mo ${MORPH} ease-in both}@keyframes mo{to{opacity:0;filter:blur(3px)}}
409@media (prefers-reduced-motion:reduce){.sd,.mi,.mo{animation:none}.mo{opacity:0}}</style>${rows.join('')}`
410}
411
412// ---------- terminal: an animated pixel bar on a Raster, repainted by blit ----------
413// each cell is '▀' with its own top (fg) and bottom (bg) colour, so one row reads as two rows of pixels
414
415const FPS_MS = 100
416const GLIDE_MS = 500
417const BURST_MS = 1600
418const SHIMMER_MS = 1700
419const TRACK_RGB = [58, 56, 66]
420const WHITE = [255, 255, 255]
421const BLOCK_EIGHTHS = [0x258f, 0x258e, 0x258d, 0x258c, 0x258b, 0x258a, 0x2589] // ▏ … ▉
422const SPARKS = [0x2726, 0x2727, 0x22c6, 0x00b7, 0x002b] // ✦ ✧ ⋆ · +
423
424// where each bar's fill was heading and since when, and when its state last changed
425type Anim = { from: number; to: number; at: number; state: PlanState; stateAt: number }
426const anims = new Map<string, Anim>()
427// limit-bars draws its status under the prompt as a Box keyed STATUS_KEY, STATUS_W cells wide;
428// the bars sit to its right when both fit, and alone in its place otherwise
429const STATUS_KEY = 'lb-status'
430const STATUS_W = 66
431const MIN_TRACK = 24
432// cells kept free at the row's ends: the hint line's indent and the terminal's last column
433const EDGE = 4
434type El = { type: string; props?: Record<string, unknown>; children?: unknown[] }
435const isEl = (n: unknown): n is El => typeof n === 'object' && n !== null && !Array.isArray(n) && 'type' in n
436
437// the tree with the element keyed `key` swapped for what `swap` makes of it, and whether one was
438function swapKeyed(node: unknown, key: string, swap: (el: El) => unknown): [unknown, boolean] {
439 if (Array.isArray(node)) {
440 let hit = false
441 const out = node.map(n => {
442 if (hit) return n
443 const [m, h] = swapKeyed(n, key, swap)
444 hit = h
445 return m
446 })
447 return [out, hit]
448 }
449 if (!isEl(node)) return [node, false]
450 if (node.props?.key === key) return [swap(node), true]
451 if (!node.children) return [node, false]
452 const [children, hit] = swapKeyed(node.children, key, swap)
453 return [hit ? { ...node, children } : node, hit]
454}
455
456const titleWidth = (list: Plan[], cols: number) => Math.min(Math.round(cols * 0.3), Math.max(...list.map(p => p.title.length)))
457// glyph, gaps, percent and the close button take ~16 cells
458// a docked pane narrows the width a render hook is told to the transcript column, though the
459// line under the prompt still spans the window; any docked pane's drawing reports the window's
460// width and its own, so a reported width that matches the column beside such a pane is widened
461type Dock = { full: number; body: number }
462let dock: Dock | null = null
463export function lineColumns(reported: number, d: Dock | null): number {
464 if (!d || d.full <= reported) return reported
465 return Math.abs(reported - (d.full - d.body)) <= 6 ? d.full : reported
466}
467
468const isWide = (list: Plan[], cols: number) => {
469 const room = cols - EDGE - STATUS_W - 2
470 return room - titleWidth(list, room) - 16 >= MIN_TRACK
471}
472
473// what the band last drew, so the frame clock can repaint it between renders
474let band: { requestId: string; bars: { key: string; plan: Plan; cols: number }[] } | null = null
475// whether the terminal last laid the bars out narrow, where narrowOpen shows them, not isOpen
476let isNarrowLine = false
477// what the prompt line was last told and laid out by, for /progress-debug
478let lineSeen: { told: number | undefined; cols: number; wide: boolean } | null = null
479
480const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
481const easeOut = (k: number) => 1 - Math.pow(1 - clamp01(k), 3)
482const pack = (c: number[]) => (((c[0] ?? 0) & 255) << 16) | (((c[1] ?? 0) & 255) << 8) | ((c[2] ?? 0) & 255)
483// hue in degrees, saturation and lightness 0-1, to [r, g, b]
484function hsl(h: number, s: number, l: number): number[] {
485 const k = (n: number) => (n + h / 30) % 12
486 const a = s * Math.min(l, 1 - l)
487 return [0, 8, 4].map(n => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)))))
488}
489const shade = (c: number[], m: number) => (m >= 0 ? mix(c, WHITE, Math.min(1, m)) : mix(c, [0, 0, 0], Math.min(1, -m)))
490
491function fillShare(p: Plan): number {
492 if (p.state === 'done') return 1
493 const w = where(p)
494 return Math.min(1, w.pos / Math.max(1, w.total))
495}
496
497const shareAt = (a: Anim, now: number) => a.from + (a.to - a.from) * easeOut((now - a.at) / GLIDE_MS)
498
499// called while drawing: a new target glides from wherever the fill is now
500function aim(p: Plan, now: number): Anim {
501 const to = fillShare(p)
502 const prev = anims.get(p.id)
503 if (!prev) {
504 const a = { from: to, to, at: now, state: p.state, stateAt: p.state === 'done' ? now - BURST_MS : now }
505 anims.set(p.id, a)
506 return a
507 }
508 if (Math.abs(prev.to - to) > 1e-6) {
509 prev.from = shareAt(prev, now)
510 prev.to = to
511 prev.at = now
512 }
513 if (prev.state !== p.state) {
514 prev.state = p.state
515 prev.stateAt = now
516 }
517 return prev
518}
519
520// one width-1 glyph per character: wide and combining characters would shift every cell after them
521function cellText(s: string): number[] {
522 const out: number[] = []
523 for (const ch of s.replace(/\p{M}/gu, '')) {
524 const cp = ch.codePointAt(0) ?? 63
525 const isWide = (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xff00 && cp <= 0xff60)
526 out.push(cp < 0x20 || cp > 0xffff || isWide ? 63 : cp)
527 }
528 return out
529}
530
531function knobLabel(p: Plan, W: number): number[] {
532 const w = where(p)
533 const single = p.stages.length === 1
534 if (p.state === 'done') return cellText(` ✓ Done ${w.total}/${w.total} `)
535 const glyph = p.state === 'needs_input' ? '? ' : p.state === 'error' ? '✕ ' : ''
536 const name = single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
537 const count = single ? `${Math.min(w.total, w.pos + 1)}/${w.total}` : `${w.step}/${w.stageSize}`
538 const room = Math.max(4, Math.floor(W * 0.6) - count.length - glyph.length - 3)
539 let shown = cellText(name)
540 if (shown.length > room) shown = [...shown.slice(0, room - 1), 0x2026]
541 return W < 24 ? cellText(` ${count} `) : [32, ...cellText(glyph), ...shown, 32, ...cellText(count), 32]
542}
543
544function rasterCells(p: Plan, W: number, now: number): string {
545 const a = anims.get(p.id) ?? aim(p, now)
546 const words = new Uint32Array(W * 3)
547 const put = (x: number, cp: number, fg: number[], bg: number[]) => {
548 words[x * 3] = cp
549 words[x * 3 + 1] = pack(fg)
550 words[x * 3 + 2] = pack(bg)
551 }
552 const acc = hex(STATE_COLOR[p.state])
553 const light = mix(acc, WHITE, 0.35)
554 const deep = mix(acc, TRACK_RGB, 0.6)
555 const sinceState = now - a.stateAt
556 const isRunning = p.state === 'running'
557 const isDone = p.state === 'done'
558 const isBursting = isDone && sinceState < BURST_MS
559 const fx = shareAt(a, now) * W
560 const full = Math.floor(fx)
561 const part = fx - full
562
563 // the whole fill breathes while waiting on the person; an error flashes for a second, then holds
564 const breathe = p.state === 'needs_input' ? 0.18 * Math.sin(now / 420) : p.state === 'error' && sinceState < 1000 ? (Math.floor(sinceState / 160) % 2 ? 0.35 : -0.1) : 0
565 // a soft highlight sweeps along the fill while it runs; on done one bright wave crosses the bar
566 const sweep = isRunning ? ((now % SHIMMER_MS) / SHIMMER_MS) * (fx + 16) - 8 : isBursting ? (sinceState / 450) * (W + 10) - 5 : -99
567 const sweepW = isRunning ? 5 : 3
568
569 // stage boundaries, so the bar still reads as stages
570 const w = where(p)
571 const bounds = new Set<number>()
572 let acc2 = 0
573 p.stages.forEach((s, i) => {
574 acc2 += s.steps.length
575 if (i < p.stages.length - 1) bounds.add(Math.round((acc2 / Math.max(1, w.total)) * W))
576 })
577
578 // running and done bars are a rainbow flowing along the fill; waiting and error keep their own colour
579 const isRainbow = isRunning || isDone
580 const hueAt = (x: number) => ((x / Math.max(1, W)) * 320 + now / (isDone ? 14 : 9)) % 360
581
582 const pixel = (x: number, row: number): number[] => {
583 const u = clamp01((x + 0.5) / Math.max(1, fx))
584 const n = hash(x, row, 3)
585 // brighter towards the head, each pixel twinkling on its own clock
586 const base = isRainbow
587 ? hsl(hueAt(x + row * 0.5 + n * 3), 0.85, isDone ? 0.58 : 0.42 + 0.18 * Math.pow(u, 1.2) + 0.05 * n)
588 : mix(deep, light, Math.pow(u, 1.3) * (0.75 + 0.25 * n))
589 const tw = Math.sin(now / ((isDone ? 900 : 520) + n * 700) + n * 40) * (isDone ? 0.06 : 0.12)
590 const glow = Math.max(0, 1 - Math.abs(x - sweep) / sweepW) * (isRunning ? 0.45 : 0.85)
591 const head = isRunning && fx - x < 2.5 ? 0.18 + 0.15 * Math.sin(now / 160) : 0
592 return shade(base, tw + glow + head + breathe)
593 }
594
595 for (let x = 0; x < W; x++) {
596 if (x < full) {
597 put(x, 0x2580, pixel(x, 0), pixel(x, 1))
598 } else if (x === full && part > 0.06) {
599 const i = Math.min(BLOCK_EIGHTHS.length - 1, Math.floor(part * BLOCK_EIGHTHS.length))
600 put(x, BLOCK_EIGHTHS[i] ?? 0x258c, shade(isRainbow ? hsl(hueAt(x), 0.9, 0.6) : light, isRunning ? 0.25 + 0.2 * Math.sin(now / 160) : 0), TRACK_RGB)
601 } else {
602 // the empty track: faint dots, a brighter tick at each stage boundary
603 const isBound = bounds.has(x)
604 put(x, isBound ? 0x2502 : hash(x, 9, 4) > 0.82 ? 0x00b7 : 0x20, isBound ? [120, 118, 128] : [84, 82, 92], TRACK_RGB)
605 }
606 }
607
608 // done: sparks pop over the bar for a moment
609 if (isBursting) {
610 const frame = Math.floor(now / 70)
611 const fade = 1 - sinceState / BURST_MS
612 for (let x = 0; x < W; x++) {
613 if (hash(x, frame, 7) > 0.12 * fade + 0.02) continue
614 const spark = SPARKS[Math.floor(hash(x, frame, 8) * SPARKS.length)] ?? 0x2726
615 put(x, spark, hash(x, frame, 9) > 0.3 ? hsl(hash(x, frame, 10) * 360, 1, 0.72) : WHITE, pixel(x, 1))
616 }
617 }
618
619 // the knob: stage and count on a pill riding the head
620 const label = knobLabel(p, W)
621 if (label.length <= W) {
622 const start = Math.max(0, Math.min(W - label.length, Math.round(fx - label.length / 2)))
623 // a rainbow bar's pill takes the hue beneath it, darkened so the white text stays readable
624 const pill = isRainbow ? hsl(hueAt(fx), 0.75, 0.36) : shade(acc, breathe * 0.5)
625 label.forEach((cp, i) => put(start + i, cp, WHITE, pill))
626 }
627
628 return base64(new Uint8Array(words.buffer))
629}
630
631const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
632// the cells are always a multiple of 12 bytes, so there is never padding to add
633function base64(bytes: Uint8Array): string {
634 let out = ''
635 for (let i = 0; i < bytes.length; i += 3) {
636 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
637 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
638 }
639 return out
640}
641
642// true while some bar still moves: a glide, a shimmer, a pulse or a burst
643function isMoving(now: number): boolean {
644 return (band?.bars ?? []).some(b => {
645 const a = anims.get(b.plan.id)
646 return b.plan.state !== 'done' || (a !== undefined && (now - a.at < GLIDE_MS || now - a.stateAt < BURST_MS))
647 })
648}
649
650function plural(n: number, word: string) {
651 return `${n} ${word}${n === 1 ? '' : 's'}`
652}
653
654// ---------- engine glue ----------
655
656// a Windows host: the paths the engine hands out start with a drive letter there
657const isWindowsPath = (path: string) => /^[A-Za-z]:[\\/]/.test(path)
658
659// the engine's player (afplay on macOS), PowerShell where it cannot play; on Windows the player
660// resolves without a sound, so PowerShell plays there straight away
661function play($: EngineInterface, name: 'decision' | 'error' | 'done') {
662 // a quote in the path is doubled for PowerShell's single-quoted string
663 const file = `${$.plugin.root}/sounds/${name}.wav`.replace(/\//g, '\\').replace(/'/g, "''")
664 const powershell = () =>
665 $.process
666 .run(['powershell', '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `(New-Object Media.SoundPlayer '${file}').PlaySync()`], { timeoutMs: 5000 })
667 .catch(() => undefined)
668 void $.session
669 .root()
670 .then(root => (isWindowsPath(root) ? powershell() : $.audio.play({ asset: `sounds/${name}.wav` }).catch(powershell)))
671 .catch(() => undefined)
672}
673
674// the agents bar is the mod's own; the model never owes it an update
675const AGENTS = 'agents:auto' // slug() never yields ':', so no model id can take it
676const isOpenPlan = (p: Plan) => p.id !== AGENTS && p.state === 'running' && !p.stages.flatMap(s => s.steps).every(s => isFinished(s.status))
677
678const slug = (s: string) =>
679 s
680 .toLowerCase()
681 .replace(/[^\p{L}\p{N}]+/gu, '-')
682 .replace(/^-|-$/g, '')
683 .slice(0, 40) || 'plan'
684
685// adds or replaces one bar by id; keeps at most MAX_BARS, dropping finished ones first
686// computed inside update() from the latest list, so concurrent writers (parallel agents) do not drop each other
687function placeBar(list: readonly Plan[], next: Plan): Plan[] {
688 const prev = list.find(p => p.id === next.id)
689 // an update keeps its row; a new bar goes to the bottom
690 const rest = prev ? list.map(p => (p.id === next.id ? next : p)) : [...list, next]
691 while (rest.length > MAX_BARS) {
692 const doneAt = rest.findIndex(p => p.state === 'done')
693 rest.splice(doneAt >= 0 ? doneAt : 0, 1)
694 }
695 return rest
696}
697
698function chime($: EngineInterface, prev: PlanState | undefined, next: PlanState) {
699 if (next === prev) return
700 if (next === 'needs_input') play($, 'decision')
701 if (next === 'error') play($, 'error')
702 if (next === 'done') play($, 'done')
703}
704
705async function putPlan($: EngineInterface, next: Plan) {
706 let prev: Plan | undefined
707 await update($, plans, list => {
708 prev = list.find(p => p.id === next.id)
709 return placeBar(list, next)
710 })
711 chime($, prev?.state, next.state)
712 if (!prev) await update($, isOpen, () => true)
713}
714
715// ---------- agents: drawn from engine events alone, no model calls ----------
716// each subagent lives on a bar as one state strip: the open task bar it was started under,
717// the bar of its parent agent, or the mod's own "Agents" bar when no task is open.
718// Module maps: a reload forgets running agents, whose strips then stay until the bar is closed.
719const agentHome = new Map<string, string>() // agentId -> bar id
720const toolUses = new Map<string, string>() // tool_use_id -> agentId, to find who waits on a permission
721const waiting = new Set<string>()
722let foldUntil = 0 // keep ticking until finished strips have folded
723
724// the mod's own bar mirrors its agents as steps, finished first, so percent and count read done/total
725function syncAuto(p: Plan, now: number): Plan {
726 const agents = p.agents ?? []
727 const isOver = agents.length > 0 && agents.every(a => a.state === 'done' || a.state === 'error')
728 const agentsDoneAt = isOver ? (p.agentsDoneAt ?? now) : null
729 if (p.id !== AGENTS) return { ...p, agentsDoneAt }
730 const rank = (a: AgentRun) => (a.state === 'done' ? 0 : a.state === 'error' ? 1 : 2)
731 const steps: PlanStep[] = [...agents]
732 .sort((a, b) => rank(a) - rank(b))
733 .map(a => ({ title: a.title, status: a.state === 'done' ? 'done' : a.state === 'error' ? 'error' : 'active', substeps: [] }))
734 const state: PlanState = isOver
735 ? agents.some(a => a.state === 'error') ? 'error' : 'done'
736 : agents.some(a => a.state === 'waiting') ? 'needs_input' : 'running'
737 return { ...p, agentsDoneAt, stages: [{ name: 'Agents', steps }], state }
738}
739
740function addRun(p: Plan, run: AgentRun, parentId: string | undefined, now: number): Plan {
741 // a batch that has finished makes room for the next one
742 const list = p.agentsDoneAt ? [] : [...(p.agents ?? [])]
743 let at = list.length
744 const parentAt = parentId ? list.findIndex(a => a.id === parentId) : -1
745 if (parentAt >= 0) {
746 at = parentAt + 1
747 while (at < list.length && (list[at]?.depth ?? 0) > 0) at++
748 }
749 list.splice(at, 0, run)
750 return syncAuto({ ...p, agents: list, agentsDoneAt: null }, now)
751}
752
753// changes one agent's strip inside the latest list; sounds follow the bar's state
754async function editAgent($: EngineInterface, agentId: string, change: (a: AgentRun) => AgentRun) {
755 const home = agentHome.get(agentId)
756 if (!home) return
757 const now = await $.clock.now()
758 let before: PlanState | undefined
759 let after: PlanState | undefined
760 let isFolding = false
761 await update($, plans, list =>
762 list.map(p => {
763 if (p.id !== home || !p.agents?.some(a => a.id === agentId)) return p
764 before = p.state
765 const next = syncAuto({ ...p, agents: p.agents.map(a => (a.id === agentId ? change(a) : a)) }, now)
766 after = next.state
767 isFolding = !p.agentsDoneAt && next.agentsDoneAt !== null
768 return next
769 }),
770 )
771 if (isFolding) foldUntil = now + FOLD_MS + 1500
772 if (before !== undefined && after !== undefined) chime($, before, after)
773}
774
775async function dropPlan($: EngineInterface, id: string) {
776 lastHead.delete(id)
777 anims.delete(id)
778 for (const p of await read($, plans)) if (p.id === id) for (const a of p.agents ?? []) lastStrip.delete(a.id)
779 await update($, plans, list => list.filter(p => p.id !== id))
780}
781
782const STEP_SCHEMA = {
783 type: 'object',
784 required: ['title', 'status'],
785 properties: {
786 title: { type: 'string' },
787 status: { enum: STATUSES },
788 substeps: {
789 type: 'array',
790 items: { type: 'object', required: ['title', 'status'], properties: { title: { type: 'string' }, status: { enum: STATUSES } } },
791 },
792 },
793}
794
795// only calls that change something count as work for the enforcement below; reading and searching are free
796const WORK_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell'])
797const WORK_BEFORE_PLAN = 3 // the 4th changing call without a plan is refused once
798const CALLS_BEFORE_NUDGE = 6 // working calls without a plan update before a reminder
799
800
801export const register: Register = on => {
802 // per-turn bookkeeping; module variables are fine here, a reload just starts a fresh count
803 let workCalls = 0
804 let sinceUpdate = 0
805 let isPlanTouched = false
806 let hasRefused = false
807 let isWaitingOnBackground = false
808
809 on('turn.start', async ($, e, next) => {
810 workCalls = 0
811 sinceUpdate = 0
812 isPlanTouched = false
813 hasRefused = false
814 isWaitingOnBackground = false
815
816 return next(e)
817 })
818
819 // the rule lives in the cached system prompt; a message only carries one short line when bars are open,
820 // and the person answering clears any "needs input" without a model call
821 on('prompt.submit', async ($, e, next) => {
822 if (e.origin.kind !== 'composer') return next(e)
823 const list = await read($, plans)
824 if (list.some(p => p.state === 'needs_input')) {
825 await update($, plans, all => all.map(p => (p.state === 'needs_input' ? { ...p, state: 'running' as const, note: null } : p)))
826 }
827 const open = list.filter(p => p.state !== 'done' && p.id !== AGENTS)
828 if (open.length === 0) return next(e)
829 const line = `plan-progress open bars: ${open
830 .map(p => {
831 const w = where(p)
832 return `${p.id} (${p.stages[w.stage]?.name ?? ''} ${w.step}/${w.stageSize})`
833 })
834 .join(', ')}`
835
836 return next({ ...e, context: [...(e.context ?? []), line] })
837 })
838
839 // watches the main loop's changing calls: refuses once when multi-step work starts without a bar,
840 // and reminds to update the bar when it goes stale mid-turn
841 on('tool.call', async ($, e, next) => {
842 // a subagent's call only names its current tool on its strip; no gate, no reminders
843 if (e.agentId) {
844 const agentId = e.agentId
845 if (!agentHome.has(agentId)) return next(e)
846 await editAgent($, agentId, a => ({ ...a, state: 'running', tool: e.tool }))
847 if (e.tool_use_id) toolUses.set(e.tool_use_id, agentId)
848 const ran = await next(e)
849 if (e.tool_use_id) toolUses.delete(e.tool_use_id)
850 if (waiting.delete(agentId)) await editAgent($, agentId, a => (a.state === 'waiting' ? { ...a, state: 'running' } : a))
851 return ran
852 }
853 if (!WORK_TOOLS.has(e.tool)) return next(e)
854 isWaitingOnBackground = (e as unknown as Raw).run_in_background === true
855 const hasLivePlan = isPlanTouched || (await read($, plans)).some(isOpenPlan)
856 if (!hasLivePlan && !hasRefused && workCalls >= WORK_BEFORE_PLAN) {
857 hasRefused = true
858
859 return { deny: `plan-progress: several changes ahead. Create a bar with ${TOOL} first, then retry.` }
860 }
861 const ran = await next(e)
862 // a shell call that only read (ls, git status, grep) is not work
863 if (ran.deny !== undefined || ran.isReadOnly) return ran
864 workCalls += 1
865 sinceUpdate += 1
866 if (hasLivePlan && sinceUpdate >= CALLS_BEFORE_NUDGE) {
867 sinceUpdate = 0
868
869 return { ...ran, context: [...(ran.context ?? []), `plan-progress: bar is stale, send {id, next:true} or {id, done, active}.`] }
870 }
871
872 return ran
873 })
874
875 // an open bar at the end of a turn: a question to the user marks it waiting on its own;
876 // only a turn that did work and left the bar unexplained is sent back once
877 on('classic.Stop', async ($, e, next) => {
878 const result = await next(e)
879 if (e.stop_hook_active || result.block || isWaitingOnBackground || (e.background_tasks?.length ?? 0) > 0) return result
880 const open = (await read($, plans)).filter(isOpenPlan)
881 if (open.length === 0) return result
882 const asks = /\?\s*$/.test(e.last_assistant_message ?? '')
883 if (asks) {
884 const last = open[open.length - 1]
885 if (last) await putPlan($, { ...last, state: 'needs_input' })
886
887 return result
888 }
889 if (workCalls === 0 && !isPlanTouched) return result
890
891 return {
892 ...result,
893 block: `plan-progress: ${open.map(p => p.id).join(', ')} still open. Update each with ${TOOL}: {id, next:true}, or state "done", "needs_input" or "error" with a note.`,
894 }
895 })
896
897 on('session.start', async ($, e, next) => {
898 await $.tool.register({
899 name: 'plan_progress',
900 description: 'Live progress bar under the prompt, one per id. Create with title + stages; update with short ops (next, done, active, failed) or state.',
901 inputSchema: {
902 type: 'object',
903 required: ['id'],
904 properties: {
905 id: { type: 'string', description: 'Bar id; reuse it for updates' },
906 title: { type: 'string' },
907 kind: { enum: ['plan', 'todo'] },
908 stages: {
909 type: 'array',
910 description: 'Full breakdown, only when creating or restructuring',
911 items: { type: 'object', required: ['name', 'steps'], properties: { name: { type: 'string' }, steps: { type: 'array', items: STEP_SCHEMA } } },
912 },
913 next: { type: 'boolean', description: 'Active step finished, start the next one' },
914 done: { type: 'array', items: { type: 'string' }, description: 'Step titles now finished' },
915 active: { type: 'string', description: 'Step title now in progress' },
916 failed: { type: 'string', description: 'Step title that failed' },
917 state: { enum: ['running', 'needs_input', 'error', 'done'] },
918 note: { type: 'string', description: 'One line for needs_input or error' },
919 },
920 },
921 })
922 $.clock.every(1000, async () => {
923 if (agentHome.size > 0 || (await $.clock.now()) < foldUntil) await update($, tick, n => n + 1)
924 })
925 // the terminal bar's frames: repaint the mounted Rasters in place, no render pass
926 $.clock.every(FPS_MS, async () => {
927 if (!band) return
928 const now = await $.clock.now()
929 if (!isMoving(now)) return
930 const { requestId, bars } = band
931 for (const b of bars) void $.ui.blit({ requestId, key: b.key, cells: rasterCells(b.plan, b.cols, now) }).catch(() => undefined)
932 })
933 await $.command.register({ name: 'progress', description: 'Show or hide the progress bars' })
934 await $.command.register({ name: 'progress-demo', description: 'Show a sample plan in the progress bars' })
935 await $.command.register({ name: 'progress-sounds', description: 'Play the decision, error and done sounds' })
936 await $.command.register({ name: 'progress-clear', description: 'Remove all progress bars' })
937 await $.command.register({ name: 'progress-debug', description: 'Show the widths the progress bars were laid out by' })
938
939 return next(e)
940 })
941
942 on('prompt.compose', async ($, e, next) => {
943 const result = await next(e)
944
945 return { sections: [...result.sections, { id: 'plan-progress-fx:rules', text: RULES, scope: 'session' as const }] }
946 })
947
948 on('tool.call', { tool: TOOL }, async ($, e) => {
949 const raw = e as unknown as Raw
950 const now = await $.clock.now()
951 const list = await read($, plans)
952 const id = slug(str(raw.id, 60) || str(raw.title, 80))
953 const next = normalize(raw, list.find(p => p.id === id) ?? null, now, id)
954 if (next.stages.length === 0) return { deny: `plan_progress: no bar "${id}" yet; create it with title and stages.` }
955 isPlanTouched = true
956 sinceUpdate = 0
957 await putPlan($, next)
958 const w = where(next)
959
960 const active = next.stages.flatMap(st => st.steps).find(st => st.status === 'active')
961
962 return { result: `${id}: ${Math.min(w.pos, w.total)}/${w.total}, ${next.state}${active ? `, active "${active.title}"` : ''}` }
963 })
964
965 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
966 const live = (await read($, plans)).filter(p => p.state === 'running').pop()
967 if (live) await update($, plans, list => list.map(p => (p.id === live.id ? { ...p, state: 'needs_input' as const } : p)))
968 play($, 'decision')
969 const ran = await next(e)
970 if (live) await update($, plans, list => list.map(p => (p.id === live.id && p.state === 'needs_input' ? { ...p, state: 'running' as const } : p)))
971
972 return ran
973 })
974
975 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
976 play($, 'decision')
977 const ran = await next(e)
978 const text = ran.deny === undefined && ran.isError !== true ? (ran.result as { plan?: unknown } | undefined)?.plan : undefined
979 if (typeof text === 'string') {
980 const parsed = parsePlan(text, await $.clock.now())
981 if (parsed) await putPlan($, { ...parsed, id: slug(parsed.title) })
982 }
983
984 return ran
985 })
986
987 on('command.run', { command: 'progress' }, async $ => {
988 if ((await read($, plans)).length === 0) return { text: 'No plan yet. /progress-demo shows a sample.' }
989 // the flag the line under the prompt draws by, as the Progress toggle flips it
990 const open = isNarrowLine ? await read($, narrowOpen) : await read($, isOpen)
991 await (isNarrowLine ? update($, narrowOpen, () => !open) : update($, isOpen, () => !open))
992
993 return { text: open ? 'Progress bars hidden.' : 'Progress bars shown.' }
994 })
995
996 // the running demo's steps; a demo started again cancels them, or both walk the one bar
997 let demoSteps: Timer[] = []
998 on('command.run', { command: 'progress-demo' }, async $ => {
999 for (const t of demoSteps) t.cancel()
1000 demoSteps = []
1001 const demo = DEMO(await $.clock.now())
1002 await putPlan($, demo)
1003 await update($, isOpen, () => true)
1004 // walk the sample to the end, so the glide, shimmer and finishing burst all show
1005 const left = demo.stages.flatMap(s => s.steps).filter(s => !isFinished(s.status)).length
1006 for (let i = 1; i <= left; i++) {
1007 const step = $.clock.after(i * 900, async () => {
1008 const cur = (await read($, plans)).find(p => p.id === 'demo')
1009 if (!cur || cur.state === 'done') return
1010 const stages = applyOps(cur.stages, { next: true })
1011 const isLast = stages.flatMap(s => s.steps).every(s => isFinished(s.status))
1012 await putPlan($, { ...cur, stages, state: isLast ? 'done' : 'running' })
1013 })
1014 demoSteps.push(step)
1015 }
1016
1017 return { text: 'Sample plan running under the prompt.' }
1018 })
1019
1020 on('command.run', { command: 'progress-clear' }, async $ => {
1021 // as dropPlan does per bar: a bar made after this starts fresh, not gliding from the old one
1022 lastHead.clear()
1023 anims.clear()
1024 lastStrip.clear()
1025 await update($, plans, () => [])
1026
1027 return { text: 'Progress bars removed.' }
1028 })
1029
1030 // what the bars' width went by, beside the terminal's own: the evidence for a wrong fold
1031 on('command.run', { command: 'progress-debug' }, async (_$, e) => {
1032 const s = lineSeen
1033 return {
1034 text: [
1035 `terminal: ${e.presentation.columns} columns${e.presentation.isFullscreen ? ', fullscreen' : ''}`,
1036 `prompt line told: ${s?.told === undefined ? 'nothing' : `${s.told} columns`}`,
1037 `used: ${s ? `${s.cols} columns` : 'not drawn yet'}, docked pane: ${dock ? `${dock.full} wide, body ${dock.body}` : 'none seen'}`,
1038 `laid out: ${s ? (s.wide ? 'wide' : 'narrow') : 'not drawn yet'}`,
1039 ].join('\n'),
1040 }
1041 })
1042
1043 on('command.run', { command: 'progress-sounds' }, async $ => {
1044 play($, 'decision')
1045 $.clock.after(900, () => play($, 'error'))
1046 $.clock.after(1800, () => play($, 'done'))
1047
1048 return { text: 'Sounds: decision, error, done.' }
1049 })
1050
1051 // always drawn, so the person sees the mod is loaded; dim while there is nothing to show
1052 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1053 const list = await read($, plans)
1054 const count = list.length
1055 // a narrow screen keeps its own open flag, closed until the person opens it
1056 const wide = count === 0 || isWide(list, lineColumns(e.viewport?.columns ?? 200, dock))
1057 const open = wide ? await read($, isOpen) : await read($, narrowOpen)
1058 const { Box, Button, Text } = $.ui.resolve(e)
1059 // other mods add their labels to modes beneath us; keep them
1060 const below = await next(e)
1061 const press = () =>
1062 count === 0
1063 ? $.ui.toast('plan-progress is on. A bar appears when Claude starts a task with several steps.')
1064 : wide
1065 ? update($, isOpen, () => !open)
1066 : update($, narrowOpen, () => !open)
1067
1068 return (
1069 <Box flexDirection="row" alignItems="center" gap={1}>
1070 <Box key="progress-pill" flexDirection="row" hover={{ backgroundColor: '#2a2140' }}>
1071 <Button key="progress-toggle" plain dimColor={count === 0 || !open} label={open && count > 0 ? '◉' : '○'} onPress={press} />
1072 {[...' Progress'].map((ch, i) => (
1073 <Text key={`pg-${i}`} bold dimColor={count === 0 || !open} color={toHex(hsl(190 + i * 14, 0.85, 0.66))}>
1074 {ch}
1075 </Text>
1076 ))}
1077 {count > 1 ? <Text bold color="#ff6ec7">{` ${count}`}</Text> : null}
1078 </Box>
1079 {below}
1080 </Box>
1081 )
1082 })
1083
1084 // watch every docked pane, any plugin's, for the window's real width; the drawing passes through
1085 on('ui.render', { component: 'Pane' }, ($, e, next) => {
1086 if (e.surface === 'terminal' && e.viewport && e.props.placement === 'dock') {
1087 const seen = { full: e.viewport.columns, body: e.props.bodyColumns }
1088 if (dock?.full !== seen.full || dock.body !== seen.body) {
1089 dock = seen
1090 $.ui.invalidate('ui.render')
1091 }
1092 }
1093 return next(e)
1094 })
1095
1096 // the terminal draws the bars under the prompt (PromptHint); the band above it keeps the desktop's
1097 // the terminal draws the bars under the prompt (PromptHint), to the right of limit-bars' status;
1098 // where both don't fit they stay closed until opened, and then take the status's place
1099 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
1100 const list = await read($, plans)
1101 const t = $.ui.resolve(e)
1102 const Raster = e.surface === 'terminal' && 'Raster' in t ? t.Raster : null
1103 const cols = lineColumns(e.viewport?.columns ?? 100, dock)
1104 const wide = isWide(list, cols)
1105 if (e.surface === 'terminal') {
1106 isNarrowLine = !wide
1107 lineSeen = { told: e.viewport?.columns, cols, wide }
1108 }
1109 if (!Raster || list.length === 0 || !(wide ? await read($, isOpen) : await read($, narrowOpen))) {
1110 if (e.surface === 'terminal') band = null
1111 return next(e)
1112 }
1113 const { Box, Button, Text } = t
1114 const below = await next(e)
1115 const now = await $.clock.now()
1116 const room = (wide ? cols - STATUS_W - 2 : cols) - EDGE
1117 const titleW = titleWidth(list, room)
1118 const trackCols = Math.max(10, Math.min(512, room - titleW - 16))
1119 band = { requestId: e.requestId, bars: list.map(p => ({ key: `fx-${p.id}`, plan: p, cols: trackCols })) }
1120 const bars = (
1121 <Box key={wide ? 'pp-bars' : 'pp-solo'} flexDirection="column" flexGrow={1}>
1122 {list.map(p => {
1123 aim(p, now)
1124 const w = where(p)
1125 const pct = p.state === 'done' ? 100 : Math.round((Math.min(w.pos, w.total) / Math.max(1, w.total)) * 100)
1126 const title = p.title.length > titleW ? `${p.title.slice(0, Math.max(1, titleW - 1))}…` : p.title.padEnd(titleW)
1127 // the title runs through its state's hues; the percent wears the hue at the fill's head
1128 const [h0, h1] = TITLE_HUES[p.state]
1129 return (
1130 // every part keeps its width: the row is sized to fit, and a squeezed row would drop letters
1131 <Box key={`bar-${p.id}`} flexDirection="row" alignItems="center" gap={1} flexShrink={0}>
1132 <Box flexShrink={0}>
1133 <Text bold color={STATE_COLOR[p.state]}>{STATE_GLYPH[p.state]}</Text>
1134 </Box>
1135 <Box flexShrink={0} width={titleW}>
1136 <Text bold={p.state !== 'done'} wrap="truncate">
1137 {[...title].map((ch, i) => (
1138 <Text key={`t-${p.id}-${i}`} color={toHex(hsl(h0 + ((h1 - h0) * i) / Math.max(1, titleW - 1), 0.85, 0.66))}>
1139 {ch}
1140 </Text>
1141 ))}
1142 </Text>
1143 </Box>
1144 <Raster key={`fx-${p.id}`} columns={trackCols} rows={1} cells={rasterCells(p, trackCols, now)} />
1145 <Box flexShrink={0} width={4}>
1146 <Text bold color={toHex(hsl(p.state === 'done' ? 150 : (pct / 100) * 320, 0.9, 0.62))}>{`${String(pct).padStart(3, FIGURE_SPACE)}%`}</Text>
1147 </Box>
1148 <Button key={`close-${p.id}`} plain dimColor label="✕" onPress={() => dropPlan($, p.id)} />
1149 </Box>
1150 )
1151 })}
1152 </Box>
1153 )
1154 // limit-bars drew beneath us: sit beside its status, or in its place
1155 const [tree, hit] = swapKeyed(below, STATUS_KEY, status =>
1156 wide ? (
1157 <Box flexDirection="row" gap={2}>
1158 {status as RenderElement}
1159 {bars}
1160 </Box>
1161 ) : (
1162 bars
1163 ),
1164 )
1165 if (hit) return tree as RenderElement
1166 // limit-bars is above us (it does the placing) or absent
1167 return (
1168 <Box flexDirection="column">
1169 {below}
1170 {bars}
1171 </Box>
1172 )
1173 })
1174
1175 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1176 const list = await read($, plans)
1177 if (e.surface === 'terminal') return next(e)
1178 if (list.length === 0 || e.props.hasSurvey || !(await read($, isOpen))) {
1179 band = null
1180 return next(e)
1181 }
1182 const t = $.ui.resolve(e)
1183 const { Box, Button, Text } = t
1184 const Svg = 'Svg' in t ? t.Svg : null
1185 band = null
1186 const total = Math.max(320, (e.props.bodyColumns || 100) * 8)
1187 // every bar has the same width and is pinned to the right edge (fixed-width percent, close button),
1188 // so rows line up whatever their titles; the slack goes into the gap after the title.
1189 // Desktop reports ~8 CSS px per column; glyph, gaps, percent and the close button take ~126 px.
1190 const titleWidth = Math.min(Math.round(total * 0.3), Math.max(...list.map(p => Math.round(textWidth(p.title, 6.4)))))
1191 const trackW = Math.max(120, Math.min(1400, total - titleWidth - 140))
1192 await read($, tick)
1193 const now = await $.clock.now()
1194 // a hairline between task bars, so each bar and its agent strips read as one group
1195 const divider = `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="1"><rect width="${total}" height="1" fill="#808080" fill-opacity=".22"/></svg>`
1196
1197 return (
1198 <Box flexDirection="column" gap={1}>
1199 {list.flatMap((p, i) => {
1200 const v = visibleAgents(p, now)types/index.d.ts 41 lines1export type StepStatus = 'pending' | 'active' | 'done' | 'error' | 'skipped'
2export type PlanSubstep = { title: string; status: StepStatus }
3export type PlanStep = { title: string; status: StepStatus; substeps: PlanSubstep[] }
4export type PlanStage = { name: string; steps: PlanStep[] }
5export type PlanState = 'running' | 'needs_input' | 'error' | 'done'
6// one subagent shown as a state strip under a bar; depth 1 sits under its parent agent
7export type AgentRun = {
8 id: string
9 title: string
10 state: 'running' | 'waiting' | 'done' | 'error'
11 tool: string
12 startedAt: number
13 endedAt: number | null
14 depth: number
15}
16export type Plan = {
17 id: string
18 title: string
19 kind: 'plan' | 'todo'
20 stages: PlanStage[]
21 state: PlanState
22 note: string | null
23 startedAt: number
24 agents?: AgentRun[]
25 // when the current batch of agents all finished; their strips fold a few seconds later
26 agentsDoneAt?: number | null
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'plan-progress-fx': {
32 plans: Plan[]
33 isOpen: boolean
34 // on a screen too narrow for the bars beside the status: shown only once the person opens them
35 narrowOpen: boolean
36 // bumped every second while agents run, so elapsed times and folding redraw
37 tick: number
38 }
39 }
40}
41