SLOPSHOPPER

plan-progress-fx

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…

newpanebandspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-progress-fx
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ Denied by plan-progress-fx: plan-progress: several changes ahead. Create a bar with mcp__plan-progress-fx_ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /progress ⎿ plan-progress-fx: No plan yet. /progress-demo shows a sample. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ○ Progress ⟨Claude Code's own drawing⟩
README

claude-code-mods

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

a highlighted reply with rainbow headings and bold text, and the limit rings and progress bars under the Claude Code prompt

PluginWhat it draws
limit-barsFour 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-fxAnimated 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-highlightSets 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-paneA 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-fxRestyles 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.

Install

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.

Notes

  • The rate-limit rings fill in after the first reply of a session (Claude Code learns the limits from API responses) and only on a subscription account. A limit your account doesn't report shows --.
  • The Fable ring reads the weekly window whose name contains fable, or any other model-specific weekly window.
  • The rings and animation need the terminal; the desktop app gets pie glyphs (○ ◔ ◑ ◕ ●) instead.

plan-progress-fx commands

CommandDoes
/progressShow or hide the bars
/progress-demoRun a sample plan
/progress-soundsPlay the decision, error and done sounds
/progress-clearRemove all bars
/progress-debugShow the widths the bars were laid out by (the terminal's, and what the prompt line was told)

transcript-fx commands

CommandDoes
/fxList 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 debugShow 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.

How plan-progress-fx steers Claude

The bars only move when Claude reports progress, so this plugin changes what Claude does, not just what the terminal draws:

  • Rules in the system prompt. Every session it adds a short section telling Claude to create a bar (through the plugin's 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.
  • One refused call per turn. While no bar is open, the 4th file-changing 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.
  • Sent back once at the end of a turn. If the turn did work and a bar is still open when Claude stops, Claude is sent back once to update it. A reply that ends in a question marks the bar as waiting on you instead.
  • Reminders. With a bar open, every six changing calls without an update add a one-line reminder to the tool result, and each prompt you send carries one line naming the open bars.

Develop

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

License

MIT. plan-progress-fx is a fork of plan-progress by Kirill Serditov (MIT); see its LICENSE.

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