A slim footer bar showing the model's own estimate of how far along your latest request is


A Claude Code mod that draws a slim progress bar in the prompt footer, showing Claude's own running estimate of how far along your latest request is. It's made for long requests that run 10 to 40 minutes.
At the prompt of a Claude Code terminal session:
/plugin install progress-bar --marketplace CharlesMod/progress-bar
Answer y to add the marketplace, then pick a scope (user scope makes it load in every session, the desktop app's local sessions included).
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with CharlesMod/progress-bar, then Add plugin.
Requires Claude Code 2.1.299 or newer.
[D20.9%] (with a task list, the estimate for the step in progress). Once the work is done, the message that says so opens with [D100%] (or the update before a last check carries it), since the bar reads a tag only when its message has finished streaming. Quick questions that need no work get no tag; their bar fills quietly, with no finale.PROGRESS 3/4 marker, a line opening with N of M, a test runner's [ 45%] at a line's end or a build's [12/40] at its start.· stopped.PROGRESS 3/4 or PROGRESS 40% marker anywhere, or a line that opens with N of M (as a CI run's batch lines do). It reads these from the output of Bash, BashOutput, TaskOutput and Monitor, and from a background task's notification, such as a Monitor event. A file read or a search that quotes such a line is ignored. The count moves the step in progress (the whole request, with no list) forward only, and never fills it: a check-off or Claude's [D100%] does that. A notification that wakes Claude carries on the same request instead of starting a new bar.#4a8ce0) to green (#00dc00) ramp laid along the whole bar, so the colour follows how far it has got. Change the ramp gradient's stops in hooks/register.tsx to recolor it. The fill's corners follow the outline's, and while the request works a soft light flows along the fill. While the bar is still empty, a soft blue glow breathes along its middle (with a task list, in the step in progress while its section is empty), fading out before any edge, so it never sits blank; a finished bar is still.[D100%] arrives, the bar turns gold and plays a short visual "ding": a shine passes across, sparkles burst, and a gold border blinks three times. It plays when you see it: when you come back to a session, the bar is simply where the request stands, as if it had all happened in view (no fill moving, sections sliding or steps blinking), and a request that finished while you were away plays only this whole-bar finale. A moment later the whole bar fades out and leaves the band, so the band's pane goes too, until your next request brings it back.Note: a mod's system-prompt hook does not run where an organization manages Claude Code, unless that organization lists the plugin in its managed settings.
claude plugin validate .
claude plugin test .
python3 sim/build.py
sim/build.py is the simulator. It drives the real plugin through scripted asks (tags, tool calls, task lists, the learned fill) and writes sim/out/index.html, which replays every drawing the band would show, mounted as the desktop app mounts them, and measures each move from the drawing's own SVG clock: eased (with its wait and length), jumped, stuck, or snaps in the app (an animation that begins at 0, which the desktop app shows already finished). Serve sim/out over http and open it in a browser that renders it: SVG clocks start only once a page has drawn a frame, and the page says so when it hasn't. Add a scenario to sim/record.sim.tsx to check a change before trying it live.
hooks/register.tsx 1581 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { Progress, Steps } from '../types'
5
6// The bar moves on one thing only: the model's own progress tags, and only forward.
7const progress = atom(
8 { plugin: 'progress-bar', key: 'progress' } as const,
9 { percent: null, phase: 'idle' } as Progress,
10)
11
12// The session's task list (the desktop app's plan, or TodoWrite's list): the bar is cut into one
13// section per step, the finished steps fill theirs, and the model's estimate fills the one it's on.
14const steps = atom({ plugin: 'progress-bar', key: 'steps' } as const, { total: 0, done: 0 } as Steps)
15
16// The bar's state the person has seen: written as it changes while the session is in view (the
17// app attaches a session's view while the person is on it, and detaches it when they leave). It is
18// kept in the session's state, which outlives the module: when the app starts the plugin afresh,
19// a drawing of a state already seen is drawn finished rather than played again.
20const shownState = atom({ plugin: 'progress-bar', key: 'shown' } as const, null as string | null)
21const inView = atom({ plugin: 'progress-bar', key: 'inView' } as const, true)
22// Bumped to draw the band again once a new drawing has had time to load (see drawBand).
23// The task tools' list, kept in the session's state so a reload of the module keeps it.
24const taskState = atom({ plugin: 'progress-bar', key: 'tasks' } as const, [] as [string, string][])
25const tick = atom({ plugin: 'progress-bar', key: 'tick' } as const, 0)
26
27/** The bar's state as one string: two equal ones draw the same bar. */
28function stateKey(p: Progress, st: Steps): string {
29 return `${p.percent}|${p.phase}|${p.quiet === true}|${st.total}|${st.done}|${st.workers ?? 0}`
30}
31
32/** Marks the bar's state now as seen, when the session is in view. */
33async function seen($: EngineInterface) {
34 if (!(await read($, inView))) return
35 const key = stateKey(await read($, progress), await read($, steps))
36 if ((await read($, shownState)) !== key) await update($, shownState, () => key)
37}
38
39// `[D20.9%]`; the leading D is optional so a plain `[20.9%]` counts too.
40const TAG = /\[D?\s*(\d{1,3}(?:\.\d+)?)\s*%\]/g
41
42const INSTRUCTIONS = `# Progress estimate
43
44The person tracks your progress on their latest request with a progress bar, which moves only on tags you write. Begin every message you write to them while working on it (a status update between tool calls, a note on what you found or will do next) with your honest estimate of how complete the work is, as a tag: "[D20%] Found where the setting is read; changing it now." Words like "halfway" don't move the bar; only the tag does.
45
46- While you keep a task list, the bar has one section per step and the tag is your estimate for the step you have in progress: [D50%] means that step is half done. A section fills only when its step is checked off, and a message that checks off a step tags the step it starts. With no list, the tag is your estimate for the whole request.
47- The bar only moves forward: a lower estimate than an earlier one leaves it where it is, so don't overshoot early.
48- The moment the work is done (the change made, the check passed), your very next words are [D100%], even if a summary or a last tidy-up is still to come: "[D100%] All three tests pass. Here's what changed…". The bar reads a tag only once its message has finished streaming, so a [D100%] at the end of a long final answer, or after a last tool call, comes late. Leave the tag out of replies to quick questions that take no work.
49- The tag is hidden from the person on screen.`
50
51// When the person adds to an ask while it works (or right after stopping it), the model is asked
52// for a fresh estimate; one this far below the bar rolls it back, as the ask has grown. A smaller
53// drop is the usual noise, and the bar holds.
54export const ROLLBACK_MIN = 15
55
56/** The note the model gets beside the person's message, with what the bar shows now. */
57export function rescope(shown: number, hasList: boolean): string {
58 const at = `${Math.round(shown)}%`
59 return hasList
60 ? `The person added to their request while you were working on it. The progress bar shows ${at} through your task list. If what they asked for adds work, update the list, and in your next update to them give a fresh tag like [D40%] for the step you then have in progress.`
61 : `The person added to their request while you were working on it. The progress bar shows ${at}. In your next update to them, give a fresh tag like [D40%] for where the whole request now stands, counting any new work they asked for: if it is much more than was left, a lower estimate is right, and the bar goes back to it.`
62}
63
64// After this many tool calls in a row with no estimate, the next result carries a reminder; one
65// the model lets pass is said again after this many more, while the step (or the ask) goes on.
66const REMIND_AFTER = 2
67const REMIND_AGAIN = 5
68
69// The tools that keep the task list.
70const LIST_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'mcp__ccd_turn__update_plan', 'mcp__ccd_turn__wrap_up'])
71
72/**
73 * The reminder for a lapse: `missed` when an update to the person just went out with no tag, else
74 * after several tool calls with none. With a list, the step in progress's section stays empty
75 * until tagged. `shown` is what the bar shows, so the estimate has something to start from.
76 */
77export function reminder(hasList: boolean, missed = false, shown: number | null = null): string {
78 const lapse = missed ? 'Your last update to the person had no progress tag' : 'The progress bar has had no estimate for several tool calls'
79 const at = shown === null ? '' : ` (it shows ${Math.round(shown)}%${hasList ? ' through your task list' : ''})`
80 return hasList
81 ? `${lapse}${at}, so the section for the step you have in progress stays empty. Begin your next update to the person with a tag for how far along that step is (not the whole request), like "[D40%] The parser handles the new case; testing it now."`
82 : `${lapse}${at}, so the bar is only guessing. Begin your next update to the person with a tag for the whole request, like "[D40%] Found the cause; fixing it now."`
83}
84
85/** The last progress tag in a message's text blocks, clamped to 0-100. */
86export function lastPercent(texts: readonly string[]): number | null {
87 let found: number | null = null
88 for (const text of texts) {
89 for (const match of text.matchAll(TAG)) {
90 found = Math.min(100, Math.max(0, Number(match[1])))
91 }
92 }
93 return found
94}
95
96// A command's own count: a `PROGRESS 3/4` or `PROGRESS 40%` marker anywhere, or a line that opens
97// with `N of M` (after any indent or markup), as a gate's batch lines do.
98const MARKER = /\bPROGRESS\s+(\d+)\s*(?:\/\s*(\d+)|%)/g
99const COUNT = /^(?:\s|<[^>]*>)*(\d+) of (\d+)\b/gm
100// A test runner's `[ 45%]` at a line's end (pytest), and a build's `[12/40]` at its start (ninja).
101const PERCENT_END = /\[\s*(\d{1,3})%\]\s*$/gm
102const BRACKETED = /^\s*\[(\d+)\/(\d+)\]/gm
103
104/** How far (0-100) the last count in a command's output says it has got, or null with none. */
105export function progressIn(text: string): number | null {
106 let found: { at: number; percent: number } | null = null
107 const consider = (at: number, done: number, total: number | null) => {
108 const percent = total === null ? done : total > 0 && done <= total ? (done / total) * 100 : null
109 if (percent !== null && percent <= 100 && (found === null || at >= found.at)) found = { at, percent }
110 }
111 for (const m of text.matchAll(MARKER)) consider(m.index!, Number(m[1]), m[2] === undefined ? null : Number(m[2]))
112 for (const m of text.matchAll(COUNT)) consider(m.index!, Number(m[1]), Number(m[2]))
113 for (const m of text.matchAll(PERCENT_END)) consider(m.index!, Number(m[1]), null)
114 for (const m of text.matchAll(BRACKETED)) consider(m.index!, Number(m[1]), Number(m[2]))
115 return found === null ? null : (found as { percent: number }).percent
116}
117
118// The tools whose output is a command's own: a file read or a search that quotes a count is not.
119const COMMANDS = new Set(['Bash', 'BashOutput', 'TaskOutput', 'Monitor'])
120
121/** The text of a row's blocks, a tool result's included. */
122function rowText(content: readonly { type: string; [field: string]: unknown }[]): string {
123 const parts: string[] = []
124 for (const block of content) {
125 if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
126 if (block.type === 'tool_result') {
127 const inner = block.content
128 if (typeof inner === 'string') parts.push(inner)
129 else if (Array.isArray(inner)) for (const b of inner) if (b && b.type === 'text' && typeof b.text === 'string') parts.push(b.text)
130 }
131 }
132 return parts.join('\n')
133}
134
135/** A text with its progress tags taken out, along with the spaces before them. */
136export function stripTags(text: string): string {
137 return text.replace(/[ \t]*\[D?\s*\d{1,3}(?:\.\d+)?\s*%\]/g, '').trimEnd()
138}
139
140/** Where the bar goes on a new estimate: forward to it, or nowhere; it never moves back. */
141export function settle(shown: number | null, stated: number): number {
142 return shown === null ? stated : Math.max(shown, stated)
143}
144
145// How far into its section an estimate fills a step that isn't checked off: a full section always
146// means a checked-off step.
147const STEP_CAP = 90
148
149/**
150 * Where on the whole bar (0-100) an estimate puts it: with a task list, that far into the step in
151 * progress (short of its end), after the steps done; with none, the estimate itself.
152 */
153export function place(stated: number, { total, done }: Steps): number {
154 if (total < 1) return stated
155 if (done >= total) return 100
156 return ((done + Math.min(stated, STEP_CAP) / 100) / total) * 100
157}
158
159/** A list's steps and how many are done, from its items' statuses. */
160export function tally(statuses: readonly string[]): Steps {
161 return { total: statuses.length, done: statuses.filter(status => status === 'completed').length }
162}
163
164function format(percent: number): string {
165 return `${percent.toFixed(percent % 1 === 0 ? 0 : 1)}%`
166}
167
168const H = 14
169const PAD = 5 // room around the bar for the gold glow and sparkles
170// The fill's rounded corners, across (in the bar's stretched units) and down (in pixels): the
171// outline's own corners, less the gap between them, so the fill's ends sit inside it evenly.
172const FILL_RX = 3
173const FILL_RY = 2.5
174// The light flowing along a working fill: one band every FLOW_U units, moving one band a FLOW_S.
175const FLOW_U = 160
176const FLOW_S = 1.4
177// The glow in an empty working bar: GLOW_W of the bar across, breathing between GLOW_LOW and
178// GLOW_HIGH once a GLOW_S.
179const GLOW_W = 0.9
180const GLOW_LOW = 0.3
181const GLOW_HIGH = 0.85
182const GLOW_S = 2.8
183
184
185// The fill's easing: a gentle start and a long, soft landing (a cubic bezier from time to distance,
186// as SMIL spells it), so a move glides to a stop rather than braking at its end.
187// A heavy, viscous move, like thick oil: a slow gathering start, no snap at either end, and a
188// settle so long the fill is still creeping when the next estimate comes to speed it up again.
189const EASE = { x1: 0.45, y1: 0, x2: 0.04, y2: 1 }
190const KEY_SPLINE = `${EASE.x1} ${EASE.y1} ${EASE.x2} ${EASE.y2}`
191// The same settle, for a move that starts already moving: it sets off at about the speed the
192// last one had, rather than from rest.
193const GLIDE_SPLINE = `0.2 0.3 ${EASE.x2} ${EASE.y2}`
194
195/** How far along an EASE move is (0-1) at `t` (0-1) of its time. */
196export function eased(t: number): number {
197 if (t <= 0) return 0
198 if (t >= 1) return 1
199 const bez = (p1: number, p2: number, u: number) => 3 * (1 - u) * (1 - u) * u * p1 + 3 * (1 - u) * u * u * p2 + u * u * u
200 let lo = 0
201 let hi = 1
202 for (let i = 0; i < 30; i++) {
203 const mid = (lo + hi) / 2
204 if (bez(EASE.x1, EASE.x2, mid) < t) lo = mid
205 else hi = mid
206 }
207 return bez(EASE.y1, EASE.y2, (lo + hi) / 2)
208}
209
210/** Seconds the fill takes to ease from `from` to `to`: a short move is quick, a long one unhurried. */
211export function fillSeconds(from: number, to: number, finishing = false): number {
212 if (to - from < 0.0005) return 0
213 // The last move, to a finished bar, is quicker: the finale waits for it.
214 return finishing ? 1.1 + 2.4 * Math.sqrt(to - from) : 2 + 5 * Math.sqrt(to - from)
215}
216
217/** Where along the ease (0-1 of its time) the fill has covered fraction `k` of its move. */
218function easeTime(k: number): number {
219 const bezier = (a: number, b: number, t: number) => 3 * (1 - t) * (1 - t) * t * a + 3 * (1 - t) * t * t * b + t * t * t
220 let lo = 0
221 let hi = 1
222 for (let step = 0; step < 24; step++) {
223 const mid = (lo + hi) / 2
224 if (bezier(EASE.y1, EASE.y2, mid) < k) lo = mid
225 else hi = mid
226 }
227 return bezier(EASE.x1, EASE.x2, (lo + hi) / 2)
228}
229
230// The finale's timing, in seconds after it starts.
231const SHINE_S = 0.6
232const BLINK_S = 1.2
233const BLINKS = [0, 1, 0, 1, 0, 1, 0.45]
234const SPARKLES = [0.04, 0.16, 0.28, 0.4, 0.52, 0.64, 0.76, 0.88, 0.97]
235const SPARKLE_S = 0.6
236
237/** Seconds a section that has just filled blinks gold. */
238const SECTION_BLINK_S = 0.6
239
240/**
241 * When (seconds into the drawing) each section the move from `from` to `to` finishes blinks: as
242 * the fill passes its end, but never before the section before it has finished blinking, so
243 * finishing several reads as one blink after another. The last section's blink is the finale's
244 * when the ask is done. `from` and `to` are places on the bar, whose sections end at `edges`; the
245 * fill starts `delay` seconds into the drawing.
246 */
247export function blinkTimes(from: number, to: number, n: number, isDone: boolean, edges = layout(n, n), delay = 0): { i: number; at: number }[] {
248 if (n < 2 || to - from < 0.0005) return []
249 const seconds = fillSeconds(from, to)
250 const times: { i: number; at: number }[] = []
251 for (let i = 0; i < n - (isDone ? 1 : 0); i++) {
252 const end = edges[i + 1]!
253 if (from < end - 0.0005 && end <= to + 0.0005) {
254 const arrival = delay + seconds * easeTime((end - from) / (to - from))
255 const previous = times.length === 0 ? -Infinity : times[times.length - 1]!.at + SECTION_BLINK_S
256 times.push({ i, at: Math.max(arrival, previous) })
257 }
258 }
259 return times
260}
261
262/** A sparkle that pops once at CSS position `x` and row `y`, `begin` seconds into the drawing. */
263function sparkle(x: string, y: number, scale: number, begin: number): string {
264 const t = begin.toFixed(3)
265 return `<use href="#star" y="${y}" opacity="0" style="x:${x};transform-box:fill-box;transform-origin:center">
266 <animate attributeName="opacity" values="0;1;0" dur="${SPARKLE_S}s" begin="${t}s" fill="freeze"/>
267 <animateTransform attributeName="transform" type="scale" values="${0.2 * scale};${1.4 * scale};${0.6 * scale}" dur="${SPARKLE_S}s" begin="${t}s" fill="freeze"/>
268</use>`
269}
270
271// The share of the bar the step in progress takes, the others sharing the rest, and how long the
272// sections take to slide to their new widths when a step is checked off.
273export const ZOOM = 0.65
274export const REFLOW_S = 0.5
275
276// A drawing's clock starts as its frame begins to load, before the band shows it; everything in it
277// waits this long first, so a move plays on screen rather than while the frame is still blank.
278export const LEAD_S = 0.3
279
280/**
281 * Where the sections' edges fall (0-1 along the bar) for `n` steps with step `current` in progress:
282 * it takes ZOOM of the bar and the others share the rest; with no step in progress, or no more room
283 * to give it than its equal share, they are equal.
284 */
285export function layout(n: number, current: number, zoom = ZOOM): number[] {
286 if (n < 2 || current < 0 || current >= n || zoom <= 1 / n) return Array.from({ length: n + 1 }, (_, i) => i / n)
287 const rest = (1 - zoom) / (n - 1)
288 const edges = [0]
289 for (let i = 0; i < n; i++) edges.push(edges[i]! + (i === current ? zoom : rest))
290 edges[n] = 1
291 return edges
292}
293
294/** Where on the bar (0-1) a place `p` of the way through the ask (0-1, its steps equal) is drawn. */
295export function onBar(p: number, edges: readonly number[]): number {
296 const n = edges.length - 1
297 if (p >= 1) return 1
298 const i = Math.min(n - 1, Math.floor(p * n + 1e-9))
299 return edges[i]! + (p * n - i) * (edges[i + 1]! - edges[i]!)
300}
301
302// The bar's own units: the band stretches them to its width, so a section's place and width are
303// plain numbers the drawing can ease (a CSS length with calc() can't be).
304const U = 1000
305const GAP_U = 5
306const INSET_U = 3
307// The room left of and right of the bar for its glow, in the same units: the bar and everything
308// around it lay out in one stretched span, so nothing needs a calc() (which a nested drawing's
309// width doesn't take).
310const MARGIN_U = 11
311const SPAN_U = U + 2 * MARGIN_U
312
313/** An eased move of one attribute, `begin` seconds in, or nothing when it doesn't move. */
314function ease(attribute: string, from: number, to: number, seconds: number, begin = 0): string {
315 return seconds > 0 && Math.abs(from - to) >= 0.01
316 ? `<animate attributeName="${attribute}" from="${from.toFixed(2)}" to="${to.toFixed(2)}" dur="${seconds}s" begin="${Math.max(MIN_BEGIN_S, begin).toFixed(3)}s" calcMode="spline" keyTimes="0;1" keySplines="${KEY_SPLINE}" fill="freeze"/>`
317 : ''
318}
319
320/**
321 * Moves every animation in `markup` out of it, each pointed at the element it animated by an id
322 * (one given where the element had none). A nested <svg> can run its animations on a clock of its
323 * own that a Chromium page doesn't always start, so a drawing's animations all go on the outermost
324 * <svg> and still drive the elements inside.
325 */
326export function hoist(markup: string, prefix = 'm'): { markup: string; animations: string } {
327 const out: string[] = []
328 const animations: string[] = []
329 const open: { at: number; id: string | null }[] = []
330 let next = 0
331 let last = 0
332 for (const m of markup.matchAll(/<(\/?)([a-zA-Z][\w:-]*)([^>]*?)(\/?)>/g)) {
333 out.push(markup.slice(last, m.index))
334 last = m.index! + m[0].length
335 const [tag, closing, name, , selfClosing] = m
336 if (closing) {
337 open.pop()
338 out.push(tag)
339 } else if (selfClosing && /^(animate|animateTransform|animateMotion|set)$/.test(name!) && open.length > 0) {
340 const parent = open[open.length - 1]!
341 if (parent.id === null) {
342 const has = out[parent.at]!.match(/\sid="([^"]+)"/)
343 parent.id = has ? has[1]! : `${prefix}${next++}`
344 if (!has) out[parent.at] = out[parent.at]!.replace(/^<([a-zA-Z][\w:-]*)/, `<$1 id="${parent.id}"`)
345 }
346 animations.push(tag.replace(/^<([a-zA-Z][\w:-]*)/, `<$1 href="#${parent.id}"`))
347 } else {
348 out.push(tag)
349 if (!selfClosing) open.push({ at: out.length - 1, id: null })
350 }
351 }
352 out.push(markup.slice(last))
353 return { markup: out.join(''), animations: animations.join('\n') }
354}
355
356// The earliest any animation in a drawing begins. The desktop app runs a drawing's clock for a
357// while before it shows the drawing, and an animation that begins at 0 is over (shown at its end)
358// by then; one that begins any later plays from its start, in view.
359export const MIN_BEGIN_S = 0.05
360
361/** When a drawing's last animation ends, in seconds from when it is shown. */
362export function endOf(svg: string): number {
363 let end = 0
364 for (const m of svg.matchAll(/<(?:animate|animateTransform|animateMotion|set)\b[^>]*>/g)) {
365 const begin = Number(m[0].match(/\bbegin="(-?[\d.]+)s"/)?.[1] ?? 0)
366 const dur = Number(m[0].match(/\bdur="([\d.]+)s"/)?.[1] ?? 0)
367 end = Math.max(end, begin + dur)
368 }
369 return end
370}
371
372/**
373 * The drawing as it stands `elapsed` seconds after it was first shown: every animation begun that
374 * much earlier. One begun and over by then is drawn finished; one that hasn't begun keeps a begin
375 * of at least MIN_BEGIN_S, so it still plays in view. Past the drawing's end it's one still string,
376 * which is how a drawing is drawn once the person comes back to the session.
377 */
378export function played(svg: string, elapsed: number): string {
379 if (elapsed <= 0) return svg
380 const by = Math.min(elapsed, endOf(svg) + 0.001)
381 return svg.replace(/<(animate|animateTransform|animateMotion|set)\b[^>]*>/g, tag => {
382 const begin = tag.match(/\bbegin="(-?[\d.]+)s"/)
383 if (begin === null) return tag
384 const at = Number(begin[1]) - by
385 const dur = Number(tag.match(/\bdur="([\d.]+)s"/)?.[1] ?? 0)
386 // Begun in the past and still going (a loop): its start moves back, and it runs on in step.
387 // Ended by now: it takes its last frame at once, as a page doesn't apply an animation whose
388 // whole run lies before it was shown, frozen or not.
389 const ended = at + dur <= 0 && !/repeatCount="indefinite"/.test(tag)
390 const moved = ended ? 0 : at > 0 ? Math.max(MIN_BEGIN_S, at) : at
391 const out = tag.replace(/\bbegin="-?[\d.]+s"/, `begin="${moved.toFixed(3)}s"`)
392 return ended ? out.replace(/\bdur="[\d.]+s"/, 'dur="0.001s"') : out
393 })
394}
395
396// A finished ask's bar holds this long after its last animation, then fades out over FADE_S, until
397// the next ask brings it back.
398const FADE_HOLD_S = 1.2
399const FADE_S = 0.8
400
401/** The drawing, fading out once its last animation has ended and held a moment. */
402export function fadingOut(svg: string): string {
403 const at = Math.max(MIN_BEGIN_S, endOf(svg) + FADE_HOLD_S)
404 const open = svg.indexOf('>') + 1
405 const close = svg.lastIndexOf('</svg>')
406 return `${svg.slice(0, open)}<g><animate attributeName="opacity" from="1" to="0" begin="${at.toFixed(3)}s" dur="${FADE_S}s" fill="freeze"/>${svg.slice(open, close)}</g>${svg.slice(close)}`
407}
408
409/**
410 * One attribute eased through `points` (seconds, value), holding between them, frozen at the last.
411 * A hold at the start is the animation's begin, never part of it, so it never begins at 0.
412 */
413function path(attribute: string, points: readonly [number, number][], first = KEY_SPLINE): string {
414 let kept = points.filter(([t], i) => i === 0 || t - points[i - 1]![0] > 1e-6)
415 if (kept.length < 2 || kept.every(([, v]) => Math.abs(v - kept[0]![1]) < 0.005)) return ''
416 while (kept.length > 2 && Math.abs(kept[1]![1] - kept[0]![1]) < 0.005) kept = kept.slice(1)
417 const begin = Math.max(MIN_BEGIN_S, kept[0]![0])
418 const shift = begin - kept[0]![0]
419 const end = kept[kept.length - 1]![0] + shift
420 const dur = end - begin
421 return `<animate attributeName="${attribute}" values="${kept.map(([, v]) => v.toFixed(2)).join(';')}" keyTimes="${kept.map(([t]) => ((t + shift - begin) / dur).toFixed(4)).join(';')}" calcMode="spline" keySplines="${kept.slice(1).map((_, i) => (i === 0 ? first : KEY_SPLINE)).join(';')}" begin="${begin.toFixed(3)}s" dur="${dur.toFixed(3)}s" fill="freeze"/>`
422}
423
424/**
425 * The bar: a rounded outline across the band with an inset fill that eases from `from` to `to`
426 * (fractions of the ask, its steps equal) and stays there. With `n` steps the bar is cut into
427 * sections, the step in progress (`slices.at`) zoomed to ZOOM of it; when that step is not the one
428 * the last drawing zoomed (`was`), the sections first slide to their new widths, the fill keeping
429 * to them, and then the fill eases on. The fill reveals a fixed blue-to-green ramp laid along the
430 * whole bar, so its colour follows how far it has got. Each section the move finishes blinks gold
431 * as the fill passes its end; when the ask is done the outline turns gold on arrival and the
432 * finale plays once, leaving a soft gold glow. Everything in it plays from when the drawing is
433 * mounted and freezes where it ends, so a drawing is one state and never needs another to finish
434 * it. Explicit colours: the drawing doesn't inherit currentColor, and the band refuses a drawing
435 * with a <style> element.
436 */
437export function battery(from: number, to: number, isDone: boolean, n = 1, slices = { at: 0, of: 0 }, was = slices.at, stepBlinks = true, made: number | null = null, startAt: number | null = null): string {
438 // A move that takes over from one still under way starts where that one's fill is (`startAt`,
439 // along the bar) at once and already moving, so the fill glides on rather than stopping.
440 const lead = startAt === null ? LEAD_S : MIN_BEGIN_S
441 // The loops (the flowing light, the rings) keep to the wall clock when the drawing says when it
442 // was made: each new drawing picks them up where the last one's were, rather than starting them
443 // over, which the eye reads as a flicker. A drawing on its own starts them after LEAD_S.
444 // `lag` holds a loop that many seconds behind the clock, for one that follows another.
445 const loopAt = (period: number, lag = 0) => made === null
446 ? `${(LEAD_S + lag).toFixed(3)}s`
447 : `${(-((((made - lag) % period) + period) % period)).toFixed(3)}s`
448 // Whole pixels, so the fill's top and bottom edges are crisp: a half-pixel edge is drawn half
449 // see-through, and where the drawing beneath shows through it, the fill looks a pixel thicker.
450 const inset = 3
451 // A place along the bar (0-1) as a share of the drawing's width, for what is drawn unstretched.
452 const along = (k: number) => `${(((MARGIN_U + k * U) / SPAN_U) * 100).toFixed(3)}%`
453 const edges = layout(n, slices.at)
454 const before = layout(n, was)
455 const reflow = before.some((x, i) => Math.abs(x - edges[i]!) > 1e-6) ? REFLOW_S : 0
456 // A check-off plays out in the old layout first: the fill reaches the end of the steps checked
457 // off while they are still wide and they blink there; only then do the sections slide to their
458 // new widths, and the fill eases on into the step in progress.
459 const finished = reflow > 0 && was < slices.at ? Math.max(from, Math.min(to, slices.at / n)) : from
460 const a = startAt ?? onBar(from, before)
461 const m1 = Math.max(a, onBar(finished, before))
462 const reach = lead + fillSeconds(a, m1, isDone)
463 // A drawing made on the person's return blinks no step: they finished while the person was away.
464 const blinksBefore = stepBlinks ? blinkTimes(a, m1, n, isDone, before, lead) : []
465 const slideAt = reflow > 0 ? Math.max(reach, ...blinksBefore.map(({ at }) => at + SECTION_BLINK_S)) : lead
466 const m2 = onBar(finished, edges)
467 const c = onBar(to, edges)
468 const fillAt = slideAt + reflow
469 const seconds = fillSeconds(m2, c, isDone)
470 const total = fillAt + seconds
471 // The fill keeps to its place through the sections' slide, then eases to the new estimate.
472 const W = U - 2 * INSET_U
473 const move = path('width', [[0, a * W], [lead, a * W], [reach, m1 * W], [slideAt, m1 * W], [fillAt, m2 * W], [total, c * W]], startAt === null ? KEY_SPLINE : GLIDE_SPLINE)
474 // The fill's leading edge is rounded, a meniscus rather than a cut. While the ask works, a soft
475 // light flows along it, as a loading bar does; a finished ask's fill is still.
476 const fill = (attributes: string) => `<rect ${attributes} x="${INSET_U}" y="${inset}" height="${H - 2 * inset}" rx="${FILL_RX}" ry="${FILL_RY}" width="${((move ? a : c) * W).toFixed(2)}">${move}</rect>`
477 const body = fill('data-fill="" fill="url(#ramp)"') + (isDone ? '' : fill('fill="url(#flow)"'))
478 // The outline turns gold as the fill arrives at the end of a finished ask.
479 const outlineAt = (begin: number) => isDone
480 ? `<animate attributeName="stroke" to="#d9a521" dur="0.25s" begin="${begin.toFixed(3)}s" fill="freeze"/>`
481 : ''
482 // With a task list, the one fill shows through n sections, so it moves exactly as one bar does
483 // and reads as one bar filling across them. A section slides from its last place to its new one.
484 const place = (e: readonly number[], i: number, grow: number) => {
485 const start = (i === 0 ? 0 : GAP_U / 2) - grow
486 const end = (i === n - 1 ? 0 : GAP_U / 2) - grow
487 return { x: e[i]! * U + start, w: (e[i + 1]! - e[i]!) * U - start - end }
488 }
489 const section = (i: number, grow: number, attributes: string, inner = '') => {
490 const o = place(before, i, grow)
491 const m = place(edges, i, grow)
492 const at = reflow > 0 ? o : m
493 return `<rect x="${at.x.toFixed(2)}" width="${at.w.toFixed(2)}" ${attributes}>${ease('x', o.x, m.x, reflow, slideAt)}${ease('width', o.w, m.w, reflow, slideAt)}${inner}</rect>`
494 }
495 const cut = n > 1
496 ? `<clipPath id="sec">${Array.from({ length: n }, (_, i) => section(i, -INSET_U, `y="${inset}" height="${H - 2 * inset}" rx="3" ry="2.5"`)).join('')}</clipPath>`
497 : ''
498 // The step in progress is cut once more, faintly, into a slice per worker it sent out.
499 const span = (edges[slices.at + 1] ?? 1) - (edges[slices.at] ?? 0)
500 const ticks = slices.of > 1 && slices.at < n
501 ? Array.from({ length: slices.of - 1 }, (_, k) =>
502 `<rect data-tick="" x="${(((edges[slices.at] ?? 0) + span * (k + 1) / slices.of) * U).toFixed(2)}" y="${inset + 2}" width="1.2" height="${H - 2 * inset - 4}" fill="#8a8a8a" opacity="${reflow > 0 ? 0 : 0.7}">${reflow > 0 ? `<animate attributeName="opacity" to="0.7" dur="0.2s" begin="${fillAt.toFixed(3)}s" fill="freeze"/>` : ''}</rect>`).join('')
503 : ''
504 const blinksAfter = stepBlinks ? blinkTimes(m2, c, n, isDone, edges, fillAt) : []
505 const finaleAt = Math.max(total, ...[...blinksBefore, ...blinksAfter].map(({ at }) => at + SECTION_BLINK_S))
506 const stroke = 'data-outline="" fill="none" stroke="#8a8a8a" stroke-width="1" vector-effect="non-scaling-stroke"'
507 const outlines = n > 1
508 ? Array.from({ length: n }, (_, i) => section(i, 0.5, `y="0.5" height="${H - 1}" rx="${n > 6 ? 3.5 : 5}" ry="${n > 6 ? 3 : 4}" ${stroke}`, outlineAt(finaleAt))).join('')
509 : `<rect x="0.5" y="0.5" width="${U - 1}" height="${H - 1}" rx="5" ry="4" ${stroke}>${outlineAt(finaleAt)}</rect>`
510 // A section the move finishes blinks gold where it stands then, with a few sparkles, one after
511 // another: a step checked off blinks while it is still wide.
512 const blinks = [...blinksBefore.map(b => ({ ...b, e: before })), ...blinksAfter.map(b => ({ ...b, e: edges }))]
513 const blinking = blinks.map(({ i, at, e }) => {
514 const m = place(e, i, 1.5)
515 return `<rect x="${m.x.toFixed(2)}" width="${m.w.toFixed(2)}" y="-1.5" height="${H + 2}" rx="6" ry="5" fill="none" stroke="#ffd54a" stroke-width="2" vector-effect="non-scaling-stroke" filter="url(#glow)" opacity="0"><animate attributeName="opacity" values="0;1;0;1;0" dur="${SECTION_BLINK_S}s" begin="${at.toFixed(3)}s" fill="freeze"/></rect>`
516 }).join('')
517 // The sparkles stay outside the stretched units, so they keep their shape.
518 const sparkles = blinks
519 .map(({ i, at, e }) => [0.2, 0.5, 0.8].map((k, j) => sparkle(along(e[i]! + k * (e[i + 1]! - e[i]!)), j % 2 ? -2 : H + 2, 0.6, at + 0.05 + j * 0.08)).join(''))
520 .join('')
521 // The ask's end: a shine sweeps across, the gold border blinks three times and settles to a
522 // glow, and a burst of sparkles pops along the bar. The glow stays.
523 // The shine sweeps in the bar's own units: an eased number, where a percentage doesn't move.
524 const shine = isDone
525 ? `<rect clip-path="url(#c)" x="-80" y="0" width="70" height="${H}" fill="url(#shine)" transform="skewX(-35)" opacity="0">
526 <animate attributeName="opacity" to="1" dur="0.01s" begin="${finaleAt.toFixed(3)}s" fill="freeze"/>
527 <animate attributeName="x" from="-80" to="${U + 80}" dur="${SHINE_S}s" begin="${finaleAt.toFixed(3)}s" fill="freeze"/>
528</rect>`
529 : ''
530 const flourish = isDone
531 ? `<rect x="-2" width="${U + 4}" y="-1.5" height="${H + 2}" rx="6" ry="5" fill="none" stroke="#ffd54a" stroke-width="3" vector-effect="non-scaling-stroke" filter="url(#glow)" opacity="0"><animate attributeName="opacity" values="${BLINKS.join(';')}" dur="${BLINK_S}s" begin="${finaleAt.toFixed(3)}s" fill="freeze"/></rect>
532`
533 : ''
534 // An empty bar of a working ask isn't left blank: a soft blue glow breathes in its middle,
535 // inside the bar, until the first estimate gives the fill something to show.
536 // With a list it glows in the step in progress alone, while that step's section is still empty.
537 const step = Math.min(slices.at, n - 1)
538 const glowFrom = (edges[step] ?? 0) * U
539 const glowSpan = ((edges[step + 1] ?? 1) - (edges[step] ?? 0)) * U
540 const rings = !isDone && slices.at < n && to <= slices.at / n + 1e-9 && from <= to
541 ? `<rect data-ring="" x="${(glowFrom + glowSpan * (1 - GLOW_W) / 2).toFixed(2)}" y="${inset}" width="${(glowSpan * GLOW_W).toFixed(2)}" height="${H - 2 * inset}" fill="url(#glowing)" opacity="${GLOW_LOW}"><animate attributeName="opacity" values="${GLOW_LOW};${GLOW_HIGH};${GLOW_LOW}" keyTimes="0;0.5;1" calcMode="spline" keySplines="0.45 0 0.55 1;0.45 0 0.55 1" dur="${GLOW_S}s" begin="${loopAt(GLOW_S)}" repeatCount="indefinite"/></rect>`
542 : ''
543 const burst = isDone ? SPARKLES.map((k, j) => sparkle(along(k), j % 2 ? -2 : H + 2, 1.3, finaleAt + 0.1 + j * 0.08)).join('\n') : ''
544 // The stretched bar is a nested <svg>; its animations are hoisted onto the outer one.
545 const stretched = hoist(`<svg x="0" y="0" width="100%" height="${H}" viewBox="${-MARGIN_U} 0 ${SPAN_U} ${H}" preserveAspectRatio="none" overflow="visible">
546<defs><linearGradient id="ramp" gradientUnits="userSpaceOnUse" x1="0" x2="${U}" y1="0" y2="0"><stop offset="0" stop-color="#4a8ce0"/><stop offset="0.45" stop-color="#3fc7a0"/><stop offset="1" stop-color="#00dc00"/></linearGradient>
547${isDone ? '' : `<linearGradient id="flow" gradientUnits="userSpaceOnUse" x1="0" x2="${FLOW_U}" y1="0" y2="0" spreadMethod="repeat"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.28"/><stop offset="1" stop-color="#fff" stop-opacity="0"/><animateTransform attributeName="gradientTransform" type="translate" from="0" to="${FLOW_U}" dur="${FLOW_S}s" begin="${loopAt(FLOW_S)}" repeatCount="indefinite"/></linearGradient>`}
548<radialGradient id="glowing" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="#6aa8f0" stop-opacity="0.75"/><stop offset="0.45" stop-color="#5b9ae8" stop-opacity="0.4"/><stop offset="1" stop-color="#4a8ce0" stop-opacity="0"/></radialGradient>
549<clipPath id="c"><rect x="0" y="0" width="${U}" height="${H}" rx="5" ry="4"/></clipPath>${cut}</defs>
550${outlines}
551${n > 1 ? `<g clip-path="url(#sec)">${body}</g>` : body}
552<g data-extra="">${ticks}
553${blinking}
554${rings}
555${shine}
556${flourish}</g>
557</svg>`)
558 return `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${H + 2 * PAD}" overflow="visible">
559<defs>
560<path id="star" d="M0 -4 L1.1 -1.1 L4 0 L1.1 1.1 L0 4 L-1.1 1.1 L-4 0 L-1.1 -1.1 Z" fill="#fff3b0"/>
561<filter id="glow" x="-10%" y="-60%" width="120%" height="220%"><feGaussianBlur stdDeviation="1.6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
562<linearGradient id="shine" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.9"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
563</defs>
564<g transform="translate(0 ${PAD})">
565${stretched.markup}
566<g data-extra="">${sparkles}
567${burst}</g>
568</g>
569${stretched.animations}
570</svg>`
571}
572
573// What the bar learns across sessions, from the person's own work: how many tool calls each
574// finished step took (each finished ask with no list), and what the model's estimates turned out
575// to mean, as pairs of [what it said, the share of the step's calls it had made when it said it].
576export type History = { steps: number[]; asks: number[]; said: [number, number][] }
577const KEEP = 60
578const KEEP_SAID = 300
579// The finished steps (or asks) a fill from calls needs before it is drawn at all.
580const LEARN_MIN = 3
581// An ask with no list shorter than this is a quick one, no guide to the long asks the bar is for;
582// a step or an ask shorter than CALIBRATE_MIN says too little of what an estimate meant.
583const ASK_MIN = 5
584const CALIBRATE_MIN = 5
585// A fill from calls alone never claims more than this: only a check-off or the model says done.
586const INFER_CAP = 85
587// How many estimates near one it takes for the learned meaning to count as much as the words.
588const CALIBRATE_WEIGHT = 5
589
590function emptyHistory(): History {
591 return { steps: [], asks: [], said: [] }
592}
593
594/** A stored history, with anything that isn't one dropped. */
595export function readHistory(value: unknown): History {
596 const v = (value ?? {}) as Partial<Record<keyof History, unknown>>
597 const counts = (x: unknown) => (Array.isArray(x) ? x.filter((n): n is number => typeof n === 'number' && n >= 1) : [])
598 const said = Array.isArray(v.said)
599 ? v.said.filter((pair): pair is [number, number] => Array.isArray(pair) && pair.length === 2 && pair.every(n => typeof n === 'number'))
600 : []
601 return {
602 steps: counts(v.steps).slice(-KEEP),
603 asks: counts(v.asks).filter(n => n >= ASK_MIN).slice(-KEEP),
604 said: said.slice(-KEEP_SAID),
605 }
606}
607
608/**
609 * How far along (0-100) a step or an ask is after `calls` tool calls, from how many the finished
610 * ones took: the mean share done over those that ran longer than this, so the longer it runs the
611 * longer the work it is taken for. Capped short of done; null until there's enough history.
612 */
613export function inferred(calls: number, took: readonly number[]): number | null {
614 if (took.length < LEARN_MIN || calls < 1) return null
615 const longer = took.filter(k => k > calls)
616 if (longer.length === 0) return INFER_CAP
617 return Math.min(INFER_CAP, (longer.reduce((sum, k) => sum + calls / k, 0) / longer.length) * 100)
618}
619
620/**
621 * What a model's estimate has meant: its words blended with the mean share of calls done when it
622 * said about as much before (within 10), the more such the more the history counts. Done is done.
623 */
624export function calibrated(said: number, pairs: readonly [number, number][]): number {
625 if (said >= 100) return said
626 const near = pairs.filter(([s]) => Math.abs(s - said) <= 10)
627 if (near.length < 1) return said
628 const meant = near.reduce((sum, [, real]) => sum + real, 0) / near.length
629 const weight = near.length / (near.length + CALIBRATE_WEIGHT)
630 return Math.min(99, Math.max(0, weight * meant + (1 - weight) * said))
631}
632
633/** The history with one finished step (or ask) of `took` calls added, and its estimates' pairs. */
634export function learnedFrom(h: History, kind: 'steps' | 'asks', took: number, tags: readonly [number, number][]): History {
635 const said = took < CALIBRATE_MIN ? [] : tags.filter(([s]) => s < 100).map(([s, at]): [number, number] => [s, (100 * at) / took])
636 const kept = kind === 'asks' && took < ASK_MIN ? h[kind] : [...h[kind], took].slice(-KEEP)
637 return {
638 ...h,
639 [kind]: kept,
640 said: [...h.said, ...said].slice(-KEEP_SAID),
641 }
642}
643
644// Kept under a key of its own: an earlier key's history learned from quick asks and goes unread.
645const HISTORY_KEY = 'history.2'
646let history: History | null = null
647
648/** The history, read once a load of the module; a store that can't be read is no history. */
649async function learned($: EngineInterface): Promise<History> {
650 if (history === null) {
651 try {
652 history = readHistory(await $.store.get(HISTORY_KEY))
653 } catch {
654 history = emptyHistory()
655 }
656 }
657 return history
658}
659
660/** Adds the step (or ask) that just finished to the history, read afresh so other sessions' work is kept. */
661async function learn($: EngineInterface, kind: 'steps' | 'asks') {
662 const took = calls
663 const said = tags
664 calls = 0
665 tags = []
666 if (took < 1) return
667 try {
668 history = learnedFrom(readHistory(await $.store.get(HISTORY_KEY)), kind, took, said)
669 await $.store.set(HISTORY_KEY, history)
670 } catch {
671 history = learnedFrom(history ?? emptyHistory(), kind, took, said)
672 }
673}
674
675// Tool calls in the step in progress (the ask, with no list), and the model's estimates in it,
676// each with the calls made when it was given.
677let calls = 0
678let tags: [number, number][] = []
679// How many of `tags` the current response gave: a check-off later in it carries them to the step it starts.
680let responseTags = 0
681// Whether the model has tagged this ask at all: a fill from the history is no word of its.
682let modelTagged = false
683// Whether the model's last words to the person, mid-ask, went out with no tag.
684let missed = false
685// Whether this ask has been told when to say 100%.
686let nudged = false
687// The model's last tag this ask, as it said it.
688let saidLast: number | null = null
689// The model's own estimate past which the next result says it.
690const FINISH_AT = 80
691const FINISH_NUDGE = 'Progress is near the end. As soon as the work is done, begin your next message with [D100%], before any summary or last tidy-up.'
692// Whether this response's tag was read from its stored row, or from its stream: whichever comes
693// first is heard, and the other passes.
694let rowTagged = false
695let streamTagged = false
696// The model's messages already read, by their words.
697const seenWords = new Set<string>()
698// The task tools' list, by task id in the order made, with each task's status. It outlives an
699// ask: the tasks are the session's, and a later ask works on through them.
700const tasks = new Map<string, string>()
701let tasksLoaded = false
702/** The task list as the session's state has it, read once a load of the module. */
703async function loadTasks($: EngineInterface) {
704 if (tasksLoaded) return
705 tasksLoaded = true
706 try {
707 for (const [id, status] of await read($, taskState)) if (!tasks.has(id)) tasks.set(id, status)
708 } catch {
709 // No list kept: the tools' next calls say it afresh.
710 }
711}
712/** The task list moved: kept in the session's state, and drawn. */
713async function tasksMoved($: EngineInterface) {
714 await update($, taskState, () => [...tasks.entries()])
715 await listed($, [...tasks.values()])
716}
717// Whether the ask left work running that will wake it (a background command or agent, a monitor,
718// a scheduled wake-up), since its last wake.
719let waiting = false
720
721/** Whether a tool call leaves work running that will wake the session when it moves. */
722function waits(e: { tool: string }): boolean {
723 if (e.tool === 'Monitor' || e.tool === 'ScheduleWakeup' || e.tool === 'CronCreate') return true
724 return (e.tool === 'Bash' || e.tool === 'Agent') && (e as { run_in_background?: unknown }).run_in_background === true
725}
726
727// The bar's last state: the fill eased to `to` (a fraction of the ask) from `from`, cut into `n`
728// sections with step `at` zoomed, having slid from step `was` zoomed, with the ask done or not.
729// The next estimate's drawing eases on from `to`.
730type State = { from: number; to: number; isDone: boolean; n: number; at: number; was: number; stepBlinks: boolean; made: number; startAt: number | null }
731let last: State | null = null
732
733// Tool calls since the model's last estimate, and whether this lapse has had its reminder.
734let untagged = 0
735let reminded = false
736
737// Whether the system prompt carries the instructions. A host that composes its prompt without the
738// mods' sections leaves the model with no word of the bar, and with no task list to draw it the
739// bar would wait on a tag that never comes: then an ask's first tool call carries them instead.
740let composed = false
741let instructed = false
742
743// The task list's items' statuses, kept so a plan update that names no items can be applied.
744let statuses: string[] = []
745
746// The model's highest estimate for the step in progress (for the whole ask, with no list); the
747// highest tag in its current response, which a check-off later in that response carries to the
748// step it starts; and whether its last tag was [D100%].
749let estimate: number | null = null
750let fresh: number | null = null
751let finishing = false
752
753// The highest real word on the step (the ask, with no list): the model's tags, the history's fill,
754// a command's count, workers back. `estimate` is that or the creep over it, whichever is higher.
755let real: number | null = null
756let creep: number | null = null
757
758// Each tool call done creeps the bar this share of the way to a ceiling CREEP_ROOM of the way from
759// the real word to CREEP_CAP: always a little forward, less each time, never the ceiling, and at
760// most that much ahead of what anything has said.
761const CREEP_RATE = 0.35
762const CREEP_ROOM = 0.3
763const CREEP_CAP = 90
764
765/** Where the creep stands after one more tool call, from where it was and the real word under it. */
766export function crept(was: number | null, said: number | null): number {
767 const base = said ?? 0
768 const from = Math.max(was ?? 0, base)
769 const ceiling = Math.max(from, base + CREEP_ROOM * (CREEP_CAP - base))
770 return from + CREEP_RATE * (ceiling - from)
771}
772
773/** A real word on the step moves the bar forward, never back, under or over the creep. */
774function heard(percent: number) {
775 real = settle(real, percent)
776 estimate = settle(estimate, percent)
777}
778
779/** Forgets the last ask's drawing: a new ask starts its bar from empty. */
780function startAsk() {
781 waiting = false
782 nudged = false
783 saidLast = null
784 missed = false
785 last = null
786 untagged = 0
787 reminded = false
788 instructed = false
789 // The task tools' list is the session's: a new ask carries on with it, done tasks and all. One
790 // all done is no list until a task is added to it, so a new ask doesn't start full.
791 const kept = [...tasks.values()]
792 statuses = kept.every(status => status === 'completed') ? [] : kept
793 workers.clear()
794 estimate = null
795 real = null
796 creep = null
797 fresh = null
798 finishing = false
799 calls = 0
800 tags = []
801 modelTagged = false
802}
803
804/** The list's statuses after one of the desktop app's plan updates. */
805export function applyPlan(before: readonly string[], e: { items?: unknown; advance?: unknown; done?: unknown; start?: unknown }): string[] {
806 if (Array.isArray(e.items)) {
807 return e.items.map(item => (item && typeof item === 'object' && typeof (item as { status?: unknown }).status === 'string' ? (item as { status: string }).status : 'pending'))
808 }
809 const after = [...before]
810 if (Array.isArray(e.done)) for (const i of e.done) if (typeof i === 'number' && after[i - 1] !== undefined) after[i - 1] = 'completed'
811 if (typeof e.start === 'number' && after[e.start - 1] !== undefined) after[e.start - 1] = 'in_progress'
812 if (e.advance === true) {
813 const current = after.indexOf('in_progress')
814 if (current !== -1) after[current] = 'completed'
815 const following = after.indexOf('pending')
816 if (following !== -1) after[following] = 'in_progress'
817 }
818 return after
819}
820
821// A new drawing remounts the band's frame, which blanks it for a moment: a move smaller than this
822// share of the bar keeps the drawing it has, so small estimates don't blink the bar.
823const MIN_MOVE = 0.015
824
825/**
826 * The drawing of the bar for `percent`: the same string for as long as nothing visible has changed,
827 * so the band keeps the drawing it has (and whatever is playing in it plays on); an estimate that
828 * moves the fill far enough to see is a new drawing that eases the fill on from the last one. A
829 * change to the list alone redraws the bar where it is.
830 */
831export function draw(percent: number, isDone: boolean, n = 1, slices = { at: 0, of: 0 }, isReturn = false): string {
832 const to = percent / 100
833 const at = slices.at
834 const edges = layout(n, at)
835 const unseen = last !== null && last.isDone === isDone && last.n === n && last.at === at && to < 1
836 && Math.abs(onBar(to, edges) - onBar(last.to, edges)) < MIN_MOVE
837 if (!unseen && (last === null || last.to !== to || last.isDone !== isDone || last.n !== n || last.at !== at)) {
838 // A drawing made on the person's return shows where the ask stands, as if it had all happened
839 // in view: no fill moving, no section sliding, no step blinking; only a finished ask's finale.
840 const from = isReturn ? to : last === null ? 0 : last.to === to ? to : Math.min(to, last.to)
841 // A move still under way, in the same sections: the new one takes over from where its fill is
842 // when the new drawing shows (about SHOW_S from now), moving on, not from where it would end.
843 let startAt: number | null = null
844 if (!isReturn && last !== null && last.n === n && last.at === at && last.was === at && to >= last.to) {
845 const a0 = last.startAt ?? onBar(last.from, edges)
846 const c0 = onBar(last.to, edges)
847 const into = nowS() + SHOW_S - last.made - (last.startAt === null ? LEAD_S : MIN_BEGIN_S)
848 const span = fillSeconds(a0, c0)
849 if (span > 0 && into < span) startAt = a0 + (c0 - a0) * eased(Math.max(0, into) / span)
850 }
851 // The sections slide only between two layouts of the same list.
852 const was = isReturn ? at : last !== null && last.n === n ? last.at : at
853 // How a drawing was made stays its own: a later redraw of it draws it the same.
854 // Steps blink only as they are checked off in view: not on a return, and not when the list is
855 // new or cut afresh (its steps were done before the person saw them).
856 const blinks = !isReturn && last !== null && last.n === n
857 last = { from, to, isDone, n, at, was, stepBlinks: blinks, made: nowS(), startAt }
858 }
859 return battery(last.from, last.to, last.isDone, n, slices, last.was, last.stepBlinks, last.made, last.startAt)
860}
861
862export const register: Register = on => {
863 on('prompt.compose', async ($, e, next) => {
864 const { sections } = await next(e)
865 composed = true
866 return {
867 sections: [...sections, { id: 'progress-bar:instructions', text: INSTRUCTIONS, scope: 'session' }],
868 }
869 })
870
871 // A background task's notification wakes a turn of the same ask, carrying what it says: its count
872 // moves the bar, and the turn it starts is no new ask.
873 on('prompt.submit', async ($, e, next) => {
874 const notified = e.origin.kind === 'task-notification'
875 if (notified) {
876 woken = true
877 waiting = false
878 await trace($, 'submit: a task notification')
879 await counted($, progressIn(e.text))
880 return next(e)
881 }
882 woken = false
883 await trace($, `submit: ${e.origin.kind}${e.command !== undefined ? ' /' + e.command : ''} at ${JSON.stringify(await read($, progress))}`)
884 // The person adds to an ask still working, or one they just stopped: the model is asked where
885 // it stands now, and its next tag may roll the bar back if the ask has grown a lot.
886 const p = await read($, progress)
887 if ((p.phase === 'working' || p.phase === 'stopped') && e.command === undefined && e.isMeta !== true && e.text.trim() !== '') {
888 rescoping = p.percent ?? 0
889 return next({ ...e, context: [...(e.context ?? []), rescope(rescoping, statuses.length > 0)] })
890 }
891 return next(e)
892 }).catch(($, e, next) => next(e))
893
894 // A typed ask starts a new bar, empty and on screen from the start. A message sent while an ask
895 // is still working joins it, and a continuation turn (empty text) or one a notification woke
896 // keeps it.
897 on('turn.start', async ($, e, next) => {
898 const wasWoken = woken
899 woken = false
900 await trace($, `turn.start${e.agentId !== undefined ? ' (agent)' : ''}: text ${e.text === '' ? 'empty' : 'given'}, woken ${wasWoken}, rescoping ${rescoping}, at ${JSON.stringify(await read($, progress))}`)
901 if (e.agentId === undefined && e.text !== '' && !wasWoken) {
902 const { phase } = await read($, progress)
903 // A message right after stopping an ask carries it on, to be re-estimated.
904 if (phase === 'stopped' && rescoping !== null) {
905 await update($, progress, (p): Progress => ({ ...p, phase: 'working' }))
906 await syncStatus($)
907 } else if (phase !== 'working') {
908 await update($, progress, (): Progress => ({ percent: 0, phase: 'working' }))
909 await update($, steps, (): Steps => ({ total: 0, done: 0 }))
910 await loadTasks($)
911 startAsk()
912 await seen($)
913 await syncStatus($)
914 }
915 }
916 return next(e)
917 })
918
919 // A command's output on the main loop moves the step in progress by its own count.
920 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
921 const origin = e.origin as { kind: string; tool?: string }
922 if (e.agentId === undefined && origin.kind === 'tool' && origin.tool !== undefined && COMMANDS.has(origin.tool)) {
923 await counted($, progressIn(rowText(e.message.content)))
924 }
925 return next(e)
926 }).catch(($, e, next) => next(e))
927
928 on('session.append', async ($, e, next) => {
929 if (e.agentId === undefined && e.origin.kind === 'model' && e.message.type === 'assistant') {
930 const texts = e.message.content.flatMap(block =>
931 block.type === 'text' && typeof block.text === 'string' ? [block.text] : [],
932 )
933 const percent = lastPercent(texts)
934 // Words to the person mid-ask with no tag: the next tool call's result says so. A message
935 // already read comes by again with its tag taken out, which is no lapse.
936 // It may come by again under another id, so it is known by its words, tags taken out.
937 const words = stripTags(texts.join('\n')).trim()
938 const again = seenWords.has(words)
939 seenWords.add(words)
940 if (!again && !streamTagged && percent === null && words !== '' && (await read($, progress)).phase === 'working') {
941 missed = true
942 await trace($, `untagged: ${JSON.stringify(words.slice(0, 40))}`)
943 }
944 if (percent !== null) {
945 rowTagged = true
946 if (!streamTagged) await heardTag($, percent, 'row')
947 }
948 if (percent !== null) {
949 // The stored row keeps its tag: it is what the model reads back of its own updates, and a
950 // history of untagged ones teaches it to leave the tag out. The screen draws it without.
951 }
952 }
953 return next(e)
954 }).catch(($, e, next) => next(e))
955
956 // Each model response starts with no tag of its own.
957 // Each response's tag is also read from its own text as it streams: a host may take the tags
958 // out before the row is stored, where the session.append hook would have read them.
959 on('turn.step', async function* ($, e, next) {
960 if (e.agentId === undefined) {
961 fresh = null
962 responseTags = 0
963 rowTagged = false
964 streamTagged = false
965 }
966 const result = yield* next(e)
967 if (e.agentId === undefined && typeof result.answer === 'string') {
968 const percent = lastPercent([result.answer])
969 if (percent !== null) {
970 streamTagged = true
971 if (!rowTagged) await heardTag($, percent, 'stream')
972 }
973 }
974 return result
975 })
976
977 // The turn's end only marks the ask as no longer working, so the next typed ask starts afresh;
978 // an answer that ended on [D100%] with steps still unchecked fills the bar then. A worker's turn
979 // ending is that worker back.
980 on('turn.complete', async ($, e, next) => {
981 if (e.agentId !== undefined && workers.get(e.agentId) === false) {
982 workers.set(e.agentId, true)
983 await workersMoved($)
984 } else if (e.agentId === undefined) {
985 // Learning the ask resets its count, so whether it did any work is read first.
986 const worked = calls > 0
987 await trace($, `turn.complete: ${e.reason}, calls ${calls}, tagged ${modelTagged}, finishing ${finishing}, waiting ${waiting}, list ${statuses.length}, at ${JSON.stringify(await read($, progress))}`)
988 if (e.reason === 'answer' && statuses.length === 0 && (await read($, progress)).phase === 'working') await learn($, 'asks')
989 await update($, progress, (p): Progress => {
990 if (p.phase !== 'working') return p
991 if (p.percent === null) return { percent: null, phase: 'idle' }
992 if (e.reason === 'answer' && finishing) return { percent: 100, phase: 'done' }
993 // A quick answer (no tool calls, no estimate) fills its bar, without the finale.
994 if (e.reason === 'answer' && !modelTagged && statuses.length === 0 && !worked) return { percent: 100, phase: 'done', quiet: true }
995 // An answer ends the ask, whatever the last estimate said: the bar fills, its finale plays,
996 // and it fades. Only a stopped ask keeps where it stood.
997 // An ask that ends its turn to wait on work it left running holds, until that work wakes it.
998 if (e.reason === 'answer' && waiting) return { ...p, phase: 'done' }
999 return e.reason === 'answer' ? { percent: 100, phase: 'done' } : { ...p, phase: 'stopped' }
1000 })
1001 await trace($, ` -> ${JSON.stringify(await read($, progress))}`)
1002 await seen($)
1003 await syncStatus($)
1004 }
1005 return next(e)
1006 })
1007
1008 // A Remote Control client joining or leaving changes whether the status line carries the bar.
1009 on('session.attach', async ($, e, next) => {
1010 // The app attaches a session's view when the person comes back to it (and draws nothing while
1011 // they're away): the next drawing is made for their return.
1012 returning = true
1013 await trace($, 'session.attach')
1014 await update($, inView, () => true)
1015 const joined = await next(e)
1016 await seen($)
1017 await syncStatus($)
1018 return joined
1019 }).catch(($, e, next) => next(e))
1020
1021 on('session.detach', async ($, e, next) => {
1022 await trace($, 'session.detach')
1023 await update($, inView, () => false)
1024 const left = await next(e)
1025 await syncStatus($)
1026 return left
1027 }).catch(($, e, next) => next(e))
1028
1029 // A worker the main loop sends out while a step is in progress is a slice of that step, filled
1030 // when its turn ends (in turn.complete). A worker's own helpers are its business.
1031 on('agent.spawn', async ($, e, next) => {
1032 const result = await next(e)
1033 if (e.parentAgentId === undefined && result.agentId !== undefined && statuses.includes('in_progress')) {
1034 workers.set(result.agentId, false)
1035 await workersMoved($)
1036 }
1037 return result
1038 }).catch(($, e, next) => next(e))
1039
1040 // An ask's first tool call carries the instructions where the system prompt doesn't. Tool calls
1041 // that go by with no estimate get a reminder, attached to the third one's result for the model
1042 // alone, and again every eighth after while it lets them pass; a tag or a new step starts the
1043 // count again.
1044 on('tool.call', async ($, e, next) => {
1045 const result = await next(e)
1046 if (e.agentId !== undefined || 'deny' in result && result.deny !== undefined) return result
1047 if ((await read($, progress)).phase !== 'working') return result
1048 if (waits(e)) waiting = true
1049 // A call that moves the list starts or ends a step: it is neither work on the one in progress
1050 // nor a lapse of it.
1051 if (LIST_TOOLS.has(e.tool)) return result
1052 calls += 1
1053 // The finished work's calls fill the step (the ask) as it goes, under any estimate the model gives.
1054 const h = await learned($)
1055 const hasList = statuses.length > 0
1056 const fill = inferred(calls, hasList ? h.steps : h.asks)
1057 if (fill !== null) {
1058 heard(fill)
1059 }
1060 // And a little more, for the call done, however little anything has said.
1061 creep = crept(creep, real)
1062 estimate = settle(estimate, creep)
1063 await redraw($, false)
1064 if (!composed && !instructed && real === null) {
1065 instructed = true
1066 return { ...result, context: [...(result.context ?? []), INSTRUCTIONS] }
1067 }
1068 // Nearly there by the model's own word: once an ask, the next result says when to say 100%.
1069 const lastStep = statuses.length === 0 || statuses.indexOf('in_progress') === statuses.length - 1
1070 if (!nudged && lastStep && saidLast !== null && saidLast >= FINISH_AT && saidLast < 100) {
1071 nudged = true
1072 return { ...result, context: [...(result.context ?? []), FINISH_NUDGE] }
1073 }
1074 // An update just went out untagged: said at once, as the lapse is fresh.
1075 if (missed) {
1076 missed = false
1077 untagged = 0
1078 reminded = true
1079 return { ...result, context: [...(result.context ?? []), reminder(statuses.includes('in_progress'), true, (await read($, progress)).percent)] }
1080 }
1081 untagged += 1
1082 // Once the history fills the bar, a lapse the model lets pass isn't said again.
1083 if (untagged < (reminded ? (fill !== null ? Infinity : REMIND_AGAIN) : REMIND_AFTER)) return result
1084 untagged = 0
1085 reminded = true
1086 return { ...result, context: [...(result.context ?? []), reminder(statuses.includes('in_progress'), false, (await read($, progress)).percent)] }
1087 }).catch(($, e, next) => next(e))
1088
1089 on('tool.call', { tool: 'mcp__ccd_turn__update_plan' }, async ($, e, next) => {
1090 const result = await next(e)
1091 if (e.agentId === undefined) await listed($, applyPlan(statuses, e))
1092 return result
1093 }).catch(($, e, next) => next(e))
1094
1095 // The task tools keep the list one task at a time: a task made is pending, an update moves its
1096 // status (a deleted one leaves the list), and a listing says the whole of it afresh.
1097 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
1098 const result = await next(e)
1099 const id = (result.result as { task?: { id?: unknown } } | undefined)?.task?.id
1100 if (e.agentId === undefined && typeof id === 'string') {
1101 await loadTasks($)
1102 tasks.set(id, 'pending')
1103 await tasksMoved($)
1104 }
1105 return result
1106 }).catch(($, e, next) => next(e))
1107 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
1108 const result = await next(e)
1109 const ok = (result.result as { success?: unknown } | undefined)?.success === true
1110 if (e.agentId === undefined && ok && typeof e.taskId === 'string' && typeof e.status === 'string') {
1111 await loadTasks($)
1112 if (e.status === 'deleted') tasks.delete(e.taskId)
1113 else tasks.set(e.taskId, e.status)
1114 await tasksMoved($)
1115 }
1116 return result
1117 }).catch(($, e, next) => next(e))
1118 on('tool.call', { tool: 'TaskList' }, async ($, e, next) => {
1119 const result = await next(e)
1120 const listing = (result.result as { tasks?: unknown } | undefined)?.tasks
1121 if (e.agentId === undefined && Array.isArray(listing)) {
1122 tasksLoaded = true
1123 tasks.clear()
1124 for (const t of listing as { id?: unknown; status?: unknown }[]) {
1125 if (typeof t.id === 'string' && typeof t.status === 'string') tasks.set(t.id, t.status)
1126 }
1127 await tasksMoved($)
1128 }
1129 return result
1130 }).catch(($, e, next) => next(e))
1131 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
1132 const result = await next(e)
1133 if (e.agentId === undefined && Array.isArray(e.todos)) await listed($, applyPlan([], { items: e.todos }))
1134 return result
1135 }).catch(($, e, next) => next(e))
1136
1137 // The desktop app's wrap-up checks off the whole list.
1138 on('tool.call', { tool: 'mcp__ccd_turn__wrap_up' }, async ($, e, next) => {
1139 const result = await next(e)
1140 if (e.agentId === undefined && statuses.length > 0) await listed($, statuses.map(() => 'completed'))
1141 return result
1142 }).catch(($, e, next) => next(e))
1143
1144 // The app surfaces draw the bar across the band above the prompt: the desktop app doesn't
1145 // raise PromptHint. A survey has the band to itself.
1146 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1147 try {
1148 return await drawBand($, e, next)
1149 } catch (error) {
1150 // A drawing that fails leaves the band to the engine and says why, once per message.
1151 const message = `progress-bar: ${error instanceof Error ? error.message : String(error)}`
1152 if (message !== lastError) {
1153 lastError = message
1154 $.ui.toast(message)
1155 }
1156 return next(e)
1157 }
1158 })
1159
1160 // The tags are for the bar, not the reader: a reply is drawn without them, though the stored
1161 // row keeps them for the model.
1162 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
1163 const text = stripTags(e.props.text)
1164 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
1165 })
1166
1167 // A terminal keeps its own hint line under the prompt (its pills stay live) with a block bar
1168 // added at the end.
1169 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
1170 const bar = await shown($)
1171 if (bar === null || e.surface !== 'terminal') {
1172 return next(e)
1173 }
1174 return next({ ...e, props: { ...e.props, tail: ` ${textBar(bar)}` } })
1175 })
1176}
1177
1178let lastError = ''
1179
1180/** The model's tag for the step (or the ask) in progress, as read from `where`. */
1181async function heardTag($: EngineInterface, percent: number, where: string) {
1182 saidLast = percent
1183 missed = false
1184 untagged = 0
1185 reminded = false
1186 tags.push([percent, calls])
1187 responseTags += 1
1188 modelTagged = true
1189 const meant = calibrated(percent, (await learned($)).said)
1190 // The first tag after the person added to the ask: well below the bar, the bar goes back.
1191 let rewind = false
1192 if (rescoping !== null) {
1193 const list = tally(statuses)
1194 const now = (await read($, progress)).percent ?? 0
1195 rewind = (list.total > 0 ? place(meant, list) : meant) < now - ROLLBACK_MIN
1196 rescoping = null
1197 }
1198 if (rewind) {
1199 real = meant
1200 estimate = meanttypes/index.d.ts 24 lines1export type Phase = 'idle' | 'working' | 'done' | 'stopped'
2
3export type Progress = {
4 /** The highest estimate the model has given this ask, 0-100; null before the first tag. */
5 percent: number | null
6 phase: Phase
7 /** An ask answered with no estimate at all: its bar fills, without the finale. */
8 quiet?: boolean
9}
10
11export type Steps = {
12 /** The task list's steps, and how many of them are done; no list is 0 steps. */
13 total: number
14 done: number
15 /** The workers the step in progress sent out; none is 0, and no list leaves it out. */
16 workers?: number
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'progress-bar': { progress: Progress; steps: Steps; shown: string | null; inView: boolean; tick: number; tasks: [string, string][] }
22 }
23}
24