SLOPSHOPPER

progress-bar

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

newbandspinnerrowsguardtoast
v1.0.1MITupdated 2026-10-24CharlesMod/progress-bar
A shopper browsing a rack in a slop shop
README

progress-bar

The bar through one ask: a four-step plan fills, each finished step blinks gold and shrinks while the next one zooms in, and the whole bar sparkles when the work is done

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.

Install

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.

How it works

  • The tags are stripped from Claude's replies as they are drawn and from the saved transcript, so the reader never sees them and later turns don't carry them.
  • A short system-prompt section asks Claude to begin each status update with its estimate for the whole request, such as [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.
  • The mod reads the last tag in each of Claude's messages and redraws the bar. The bar only moves forward: a lower estimate than one already shown leaves it where it is. The tag is hidden on screen but kept in the saved message: those saved messages are what Claude reads back of its own updates, and a history with the tags removed taught it to leave them out. No extra tool calls are involved. A tag can flash on screen for a moment while a message streams. Subagents' messages are ignored.
  • Where the host leaves the mod's system-prompt section out, an ask's first tool call carries the same instructions to Claude alone, so the bar moves in a session with no task list too.
  • When you add to a request while it works, or send a message right after stopping it, Claude is told what the bar shows and asked, beside your message and for its eyes alone, for a fresh estimate of where the whole request stands. If its next tag is more than 15 points below the bar, the request has grown and the bar goes back to it. A smaller drop is ignored, so the bar doesn't rewind. A message after stopping carries the request on instead of starting a new one.
  • Every tool call that finishes creeps the bar a little forward, at no token cost: about a third of the way to a ceiling 30% of the way from what has really been said (Claude's estimate, the learned fill, a command's count) to 90%. So the bar always moves, a little less each time, and is never more than that 30% of the gap ahead of anything real; a real estimate above it takes over. It never moves back.
  • The mod learns from your own work, kept across sessions: how many tool calls each finished step took (each finished request of five calls or more, with no task list: quick ones are no guide to long ones). Once it has seen three, each tool call fills the step in progress from that history, held under 85% so only a check-off or Claude's final tag fills it. The history costs no tokens: it never reaches Claude.
  • Claude's estimates are read as what they have turned out to mean: each tag is blended with how far through its step's calls Claude was when it gave similar estimates before.
  • A command's own count moves the bar too: a 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.
  • When Claude writes to you mid-request without a tag, the next tool call's result tells it so at once, with what the bar shows. When two tool calls in a row go by with no estimate, the second one's result carries a one-line reminder to tag the next update, for Claude alone. With a task list it says the step in progress's section stays empty until Claude estimates that step. A lapse Claude lets pass is reminded again every fifth call, until the history can fill the bar; a tag, or a step starting, starts the count again. Calls that keep the list don't count.
  • A new request clears the bar to empty, and it stays on screen from the start. A quick answer that never gave an estimate fills it, without the finale. An interrupted one keeps its last value, marked · stopped.
  • A long command can move the bar by its own count, with no estimate from Claude. The bar reads a 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.
  • A move smaller than 3% of the bar keeps the drawing already shown, so small steps don't blink the bar; they add up until the fill has moved far enough to see.
  • A new drawing's frame is blank while it loads, so the last drawing stays mounted beneath it and shows through until it has. Only a drawing that carries on from the last one (the same sections, its fill easing on from where that one ended) goes over it; a bar that resets or moves back is drawn alone, so no old fill shows through.
  • In the desktop app the bar is a battery-style gauge spanning the band above the prompt. It steps aside while a survey uses that band. Its fill reveals a blue (#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.
  • Each new estimate eases the fill from where it was to the new estimate: a gentle start and a long, soft landing. A drawing never changes once made, so when the app draws the band again (a turn starting or stopping) it keeps its frame and never blanks or replays; when you come back to a session, what had been drawn is shown finished.
  • When the session has a task list (the desktop app's plan, TodoWrite's list, or the tasks TaskCreate and TaskUpdate keep), the bar is cut into one section per step and kept in step with the list. The step in progress takes 65% of the bar and the other steps share the rest, so most of the bar is the part that is moving. When a step is checked off, the sections slide to their new widths over half a second, then the fill eases on. A section fills the moment its step is checked off, and only then: Claude's tag is its estimate for the step in progress, and fills at most 90% of that step's section. A tag in the same response as a check-off counts for the step that check-off starts, since the text arrives before the call. A step that starts with no tag starts empty, and a new plan resets the bar to match it, even if that moves it back. The desktop app's wrap-up checks off the whole list. A section blinks gold with a few sparkles as the fill passes its end; when one move finishes several, they blink one after another.
  • When Claude's answer ends the request, the bar fills even if its last estimate was lower, and the finale plays. The one exception is a turn that ends to wait on work it left running (a background command or agent, a monitor, a scheduled wake-up): that bar holds until the work wakes Claude again.
  • When Claude's final [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.
  • Remote Control clients (the phone app, claude.ai) and VS Code draw neither the band above the prompt nor the hint line under it. While one is attached, the same text bar is also pinned as the mod's status line, and it's removed when the client leaves. So it is when the desktop app doesn't draw the band a moment after the bar changes, as when it shows a session that runs on another machine; it steps aside once the band draws again.
  • In a terminal, a 16-cell block bar that fills in eighths is added to the end of the hint line under the prompt.

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.

Develop

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.

Source 2 files
hooks/register.tsx 1581 lines
1import { 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 = meant
types/index.d.ts 24 lines
1export 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