A live progress bar for the current task list and plan, drawn in the band above the prompt.

progress-tracker is a Claude Code mod made for the Code tab of the Claude desktop app. It shows what Claude is doing while it works. It draws a live progress bar in the band above the prompt, and it adds a Progress pane that lists what waits on you and what Claude found.
The mod also runs in the terminal. There, the bar is drawn in text characters, and the task list has no timeline graph.
![]()
Mods are on by default in current versions of Claude Code. The desktop app runs its own copy of Claude Code, and mods work there from version 2.1.286. In the terminal, you need version 2.1.287 or later.
/status, and read the Claude Code row.The desktop app's plugin browser lists plugins only from the marketplaces you added. You add this mod's marketplace once, from a terminal. That step needs the claude command. If you don't have it, install Claude Code for the terminal first, or load a local copy instead.
claude plugin marketplace add mjoyshuvo/claude-progress-tracker
You can also install it from the terminal instead:
claude plugin install progress-tracker@mjoyshuvo-mods
The terminal and the desktop app's local sessions read the same settings files, so either way installs the mod for both.
/reload-plugins./progress-tracker-demo. A sample run plays above the prompt for about 30 seconds.To turn the mod off or uninstall it, click +, then select Plugins > Manage plugins.
The mod runs in local sessions only. Cloud sessions and WSL sessions do not load plugins.
/plugin marketplace add mjoyshuvo/claude-progress-tracker
/plugin install progress-tracker@mjoyshuvo-mods
/progress-tracker-demo.To run the mod from a folder on your computer, for example to change its code, load a local copy instead of installing it. Use one way only: install the mod, or load a local copy.
git clone https://github.com/mjoyshuvo/claude-progress-tracker.git ~/.claude/progress-tracker
~/.claude/settings.json, and set CLAUDE_CODE_PLUGIN_DIRS in its env block to the folder: {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/progress-tracker"
}
}
If the file already has an env block, add the line to it. To load more than one folder, separate the paths with : on macOS and Linux, or with ; on Windows. Each path must be absolute or start with ~.
To update a local copy, run git pull in its folder, then start a new session.
Only one mod can draw in the band above the prompt. If you also use plan-progress, turn it off in + > Plugins > Manage plugins, or run this command:
claude plugin disable plan-progress@zycck-mods
Write tests 3/5.3/5 and a percent. Work without a list shows a step count, for example Working · 12 steps, because its total is not known.×3./progress opens a pane with the numbers as labelled tiles and two tabs: Blocked on me and Found. Press an entry to open its details./progress-tracker-demo plays a 7-step job on its own row:
· 1 agent.×2, and the Found tab lists the failure and a finding.Allow npm publish until it is answered.The demo bar moves even while Claude is idle.
Each task list gets one row, and the band shows at most 3 rows. A row has a label, the bar, its numbers, and buttons.
The label is the approved plan's title, the title Claude gave the list, or the first task.
The colour of a row tells you its state:
| Colour | State | Pill text |
|---|---|---|
| Purple | Claude is working. | Build 2/5 or Working · 12 steps |
| Red | A check failed in the current task. | ✕ Verify 1/2 or ✕ Test 3/5 ×3 |
| Amber | Claude waits for you. | Waiting for you or Waiting for approval |
| Green | Every task is done. | ✓ Done 5/5 or ✓ Done · 12 steps |
The numbers after the bar are all measured:
| Number | Meaning |
|---|---|
60% | The finished tasks of a list that Claude declared. An automatic bar has no percent. |
4m 10s work | The time inside Claude's turns while the list ran, less the time it waited on you. Idle time between your messages does not count. |
1m 05s wait | The time the row was amber. |
1.2M in · 14k out | The tokens that each model response reported while the list ran, sub-agents included. |
context 85% | The context window's fill. It appears from 80%. |
Input counts every prompt token the model read: uncached tokens, tokens read from the prompt cache, and tokens written to it. Each model call reads the whole conversation again, so input grows much faster than output, and most of it comes from the cache.
The buttons on a row:
| Button | Action |
|---|---|
▸ in the terminal, ▶ Tasks on desktop | Lists every task with its time. |
☰ | Opens the Progress pane. |
✕ | Hides the row. |
A toast appears when a row starts to wait for your answer or permission, and when a task list finishes, for example ✓ Fix login done in 12m 03s · 2 retries.
A finished row hides itself after a minute. New tasks after a finished list start a new row. At the next session start, the mod drops rows that are finished, hidden, or older than 12 hours.
The bar moves while Claude works on a purple or red row. It holds still while the row is amber. In the terminal, the mod draws the bar with block characters and repaints it about 15 times a second. On desktop, the bar is an SVG that animates itself, and a finished bar keeps a slow twinkle.
To open the pane, type /progress or press ☰ on a row.
The top of the pane names the current list and shows its numbers as tiles: progress, this task's time, time worked, time waited on you, tokens in with the cached share, tokens out, model calls, and context. The tiles wrap when the pane is narrow.
The pane has two tabs. Press 1 or 2 to switch.
| Tab | Shows |
|---|---|
| Blocked on me | Permission prompts, questions, and plan approvals. Open ones come first. Answered ones say how long they waited. |
| Found | Each failing check, once per red spell, and the findings Claude logged with found. |
To open an entry's details, press its title (▸). To close them, press it again. The details depend on the kind of entry:
Every entry also shows its task, how long it waited, and the time. The pane keeps the last 50 entries of each kind.
The mod reads only the main conversation. It ignores task calls that sub-agents make.
In the terminal, Claude's TaskCreate, TaskUpdate, and TodoWrite calls fill the bar. When you approve a plan in plan mode, the plan gets its own row with the plan's title and its numbered or bulleted steps. Claude's first task list then replaces those steps.
progress_tracker toolThe desktop app has no TaskCreate or TodoWrite, so the mod adds its own tool, mcp__progress-tracker__progress_tracker. The mod also adds a short rule to the system prompt. The rule tells Claude to use the tool for work of more than about 3 edits or commands.
| Field | Type | Effect |
|---|---|---|
title | string | Names the row. Use it only with tasks. |
tasks | string list | Creates a row with these tasks. The first task starts. |
next | boolean | Finishes the current task and starts the next one. |
active | string | Makes the task with this name the current one. Part of the name is enough. |
add | string list | Adds tasks found during the work to the end of the list. |
skip | boolean | Closes the current task as not needed. Its part of the bar is drawn dimmer. |
failed | string | Turns the row red with this reason. |
fixed | boolean | Clears the red state. |
found | string | Adds a finding, such as a bug, a root cause, or a decision, to the Found tab. It works without a row. |
The tool replies with the state of the row, for example 2/5, running, active "Write tests", next "Docs". If Claude names a task that does not exist, the tool refuses the call and lists the task names.
If Claude makes 12 tool calls without moving the bar, the mod adds one note to a tool result that tells Claude to update it. Only Claude sees the note.
Outside plan mode, the mod starts a row named "Working" on the 3rd tool call of a turn, if no other row is still open. Each tool call becomes a finished step, named after the call's description or file, for example "Read config.ts". The pill counts the steps, because the total is not known. When the turn ends, the open step becomes "Write reply", and the row turns green.
On that 3rd tool call, Claude also gets a note that only it reads. The note asks Claude to send its task list if more steps are coming. When Claude sends a list with progress_tracker, TaskCreate, or TodoWrite, the list replaces the automatic steps, and the row shows a real total.
In plan mode, a row named "Planning" appears. Each tool call becomes a finished step until Claude sends its own planning steps with progress_tracker. The row turns amber while Claude asks you to approve the plan. Whether you approve or reject the plan, the Planning row turns green. If you leave plan mode without an answer to a plan, the mod removes the Planning row.
AskUserQuestion, or when a permission prompt waits for you, the row turns amber until you answer.pytest, tsc, ruff, make, npm test, cargo build, go test, and .venv/bin/pytest all count. Commands such as grep, go run, npm run dev, and test -f do not count, even when they exit with code 1.Search 1/3 · 2 agents. Agents from other places do not count.On desktop, press ▶ Tasks on a row to open its timeline. A line connects the steps, and each step shows how long it took. A running task shows its time so far.
· 3 in parallel.· Explore agent. A sub-agent that starts during one of Claude's own tasks appears under that task, on a branch.background instead of a time.· failed ×2.When the timeline opens, the line draws itself from the top, and the dots appear one by one. Then a light pulse runs down the line on a loop and splits into the branches.
Run these commands from the mod's folder.
To start Claude Code with your local copy of the mod:
claude --plugin-dir .
To check the manifest and the hooks the way Claude Code loads them:
claude plugin validate .
To run the tests in tests/progress-tracker.test.ts:
claude plugin test .
To run the mod against a stand-in for Claude Code, without Claude Code:
node dev/simulate.mjs
To redraw docs/demo.gif from the mod's own bar and timeline SVGs, you need Google Chrome in /Applications and Python 3 with Pillow:
node design/demo-gif.mjs
To write an animated preview of a terminal bar design to design/preview-two-row-smooth.html:
node design/harness.mjs two-row-smooth
design/variants/ holds three terminal bar designs: glow-thumb.mjs, halfblock-caps.mjs, and two-row-smooth.mjs. To switch designs, copy one over hooks/bar.mjs. design/CONTRACT.md describes what a design module must export.
| Path | Contents |
|---|---|
.claude-plugin/plugin.json | The plugin manifest. |
.claude-plugin/marketplace.json | The mjoyshuvo-mods marketplace entry. |
hooks/progress-tracker.mjs | The hooks: task tracking, plan mode, the Working bar, the progress_tracker tool, the Progress pane, and drawing. |
hooks/bar.mjs | The terminal bar (barCells). |
hooks/bar-svg.mjs | The desktop bar (barSvg). |
hooks/timeline-svg.mjs | The task timeline rows (timelineSvg). |
types/index.d.ts | The types of the stored rows and the pane's log. |
tests/progress-tracker.test.ts | The tests. |
dev/simulate.mjs | The stand-in run. |
design/demo-gif.mjs | Draws docs/demo.gif. design/frames-to-gif.py joins its frames. |
design/ | Bar design previews and variants. |
I found a Claude Code mod that shows a progress bar above the prompt, zycck/claude-mods, but I couldn't get it working on my machine. So I built my own version to learn how mods work.
MIT. See LICENSE.
If the mod helps you, a star on GitHub helps other people find it.
hooks/progress-tracker.mjs 1843 lines1// Progress tracker: one live bar per task list, in the band above the prompt.
2//
3// A "track" is one task list. TaskCreate / TaskUpdate / TodoWrite fill the
4// newest track; an approved plan, or new tasks after a finished list, start a
5// fresh one. Only the main loop counts: subagent calls (e.agentId) are skipped.
6
7import { barCells, ROWS as BAR_ROWS } from "./bar.mjs";
8import { barSvg, SVG_HEIGHT } from "./bar-svg.mjs";
9import { timelineSvg, ROW_HEIGHT, GUTTER_WIDTH } from "./timeline-svg.mjs";
10
11const MAX_ROWS = 3;
12const MIN_BAR_COLUMNS = 16;
13const FRAME_MS = 66;
14const STALE_MS = 12 * 60 * 60 * 1000;
15// A finished bar stays this long, then hides itself.
16const DONE_LINGER_MS = 60 * 1000;
17
18const DOT = { working: "#a99cf5", failed: "#e5604f", done: "#7cc68a", waiting: "#f0b34a" };
19const PILL = {
20 working: { bg: "#a99cf5", fg: "#1d1a2e" },
21 failed: { bg: "#e5604f", fg: "#ffffff" },
22 done: { bg: "#7cc68a", fg: "#14261a" },
23 waiting: { bg: "#f0b34a", fg: "#2a1c05" },
24};
25
26// The running animation: one timer repaints every moving bar with $.ui.blit.
27// Timers die with a hot reload, so this holds no data worth keeping.
28const animation = { timer: null, requestId: null, bars: [], t: 0 };
29
30// Held by the host, so tracks survive a hot reload of this file.
31const TRACKS = { plugin: "progress-tracker", key: "tracks" };
32// The session's log for the Progress pane: what waited on the person, and what Claude
33// found. Kept apart from the tracks, so it outlives them.
34const LOG = { plugin: "progress-tracker", key: "log" };
35const LOG_CAP = 50;
36// The Progress pane and its selected tab.
37const PANE = "progress";
38const TAB = { plugin: "progress-tracker", key: "tab" };
39// The pane entry whose details are open, by id; null when none is.
40const OPEN = { plugin: "progress-tracker", key: "open" };
41const TABS = [
42 { id: "blocked", label: "Blocked on me", hotkey: "1" },
43 { id: "found", label: "Found", hotkey: "2" },
44];
45// When the main loop's running turn began, or null between turns: work time counts only inside turns.
46const TURN = { plugin: "progress-tracker", key: "turn" };
47// The progress_tracker inputs that move the bar; a call with none of them only logs a finding.
48const TOOL_OPS = new Set(["title", "tasks", "next", "active", "add", "skip", "failed", "fixed"]);
49
50// Parallel tool calls each read-modify-write the tracks; run them one by one.
51let queue = Promise.resolve();
52
53// Outside plan mode a turn's first tool calls wait here; the AUTO_BAR_CALLS-th starts
54// a "Working" bar from them when Claude has no bar running. Reset on each turn.
55const AUTO_BAR_CALLS = 3;
56let turnSteps = [];
57let hasTurnBar = false;
58let lastMode = null;
59// Tokens the running turn's model calls used so far, so a Working bar that starts on the
60// turn's 3rd tool call still counts the calls before it.
61let turnTokens = emptyTokens();
62
63// The sub-agents the main loop started, by the Agent call that started each one.
64// The pill counts these alone; the timeline marks the call's step as a sub-agent.
65const spawned = new Map();
66
67// The mod's own tool, for sessions without TaskCreate / TodoWrite (the desktop app).
68const TOOL_NAME = "progress_tracker";
69const TOOL = `mcp__progress-tracker__${TOOL_NAME}`;
70const RULES = `# Progress bar
71Work over ~3 edits/commands, plan mode included: call ${TOOL} with {title, tasks:[3-8 short names]}, then {next:true} per finished task, {active:"name"}, {add:["found work"]}, {skip:true}, {failed:"why"}, {fixed:true}, {found:"a bug, root cause or decision worth keeping"}. Don't mention the bar.`;
72
73export function register(on) {
74 on("session.start", async ($, e, next) => {
75 const result = await next(e);
76 // Finished, dismissed and abandoned (over 12 h old) tracks go away.
77 const cutoff = Date.now() - STALE_MS;
78 await update($, (tracks) =>
79 tracks.filter((t) => !t.isDismissed && !isComplete(t) && (t.startedAt ?? Date.now()) > cutoff),
80 );
81 await $.tool.register({
82 name: TOOL_NAME,
83 description:
84 "Live progress bar above the prompt. Use it for any work over ~3 edits/commands, plan mode included: create with title + 3-8 short tasks, then call with next per finished task, active, add, skip, failed or fixed; found logs a finding. Don't mention the bar.",
85 inputSchema: {
86 type: "object",
87 properties: {
88 title: { type: "string", description: "Short name of the whole job; only when creating" },
89 tasks: { type: "array", items: { type: "string" }, description: "Every task in order; only when creating" },
90 next: { type: "boolean", description: "Current task finished, start the next one" },
91 active: { type: "string", description: "Task now in progress" },
92 add: { type: "array", items: { type: "string" }, description: "Tasks found mid-run, appended to the list" },
93 skip: { type: "boolean", description: "Current task is not needed; close it and start the next" },
94 failed: { type: "string", description: "What broke, one line" },
95 fixed: { type: "boolean", description: "The failure is resolved" },
96 found: { type: "string", description: "A bug, root cause or decision worth keeping, one line" },
97 },
98 },
99 });
100 await $.command.register({ name: "progress-tracker-demo", description: "Play a sample run of the progress bar" });
101 await $.command.register({ name: "progress", description: "Show what waits on you and what was found" });
102 return result;
103 });
104
105 on("command.run", { command: "progress" }, async ($) => {
106 await openPane($);
107 return { text: "Opened the Progress pane." };
108 });
109
110 on("command.run", { command: "progress-tracker-demo" }, async ($) => {
111 await playDemo($);
112 return { text: "Playing a progress-tracker demo above the prompt (about 30 seconds)." };
113 });
114
115 on("prompt.compose", async ($, e, next) => {
116 const result = await next(e);
117 return { sections: [...result.sections, { id: "progress-tracker:rules", text: RULES, scope: "session" }] };
118 });
119
120 // Org security mods can bypass user-tier prompt.compose, so the tool carries its own rule and stays loaded.
121 on("tool.describe", { tool: TOOL }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }));
122
123 on("tool.call", { tool: TOOL }, async ($, e) => {
124 if (typeof e.found === "string" && e.found.trim()) await logFinding($, "found", e.found.trim(), e.tool_use_id);
125 if (typeof e.found === "string" && !Object.keys(e).some((k) => TOOL_OPS.has(k))) return { result: "noted" };
126 if (Array.isArray(e.tasks) && e.tasks.length > 0) {
127 const tasks = e.tasks.map((subject, i) => ({
128 id: `step${i + 1}`,
129 subject: String(subject),
130 status: i === 0 ? "in_progress" : "pending",
131 }));
132 await update($, (tracks) => {
133 // A just-approved plan's bar, or a planning bar still guessing its steps, takes Claude's list.
134 const active = activeOf(tracks);
135 const isFreshPlan = active?.isFromPlan && !active.tasks.some((t) => t.status === "completed");
136 if (isFreshPlan || active?.isAutoSteps) {
137 const label = active.isAutoSteps && e.title ? String(e.title) : active.label;
138 return replace(tracks, { ...active, ...declared(active), label, tasks, failedCommand: null });
139 }
140 return [...tracks, { ...newTrack(tracks, e.title ? String(e.title) : null), tasks }];
141 });
142 } else {
143 await update($, (tracks) => {
144 const active = activeOf(tracks);
145 return active ? replace(tracks, applyOps(active, e)) : tracks;
146 });
147 }
148 const { value: tracks = [] } = await $.state.get(TRACKS);
149 const active = activeOf(tracks);
150 if (!active || active.tasks.length === 0) {
151 return { deny: `${TOOL_NAME}: no bar yet; create one with title and tasks.` };
152 }
153 if (typeof e.active === "string" && findTask(active.tasks, e.active) === -1) {
154 const names = active.tasks.map((t) => `"${t.subject}"`).join(", ");
155 return { deny: `${TOOL_NAME}: no task named "${e.active}"; tasks are ${names}.` };
156 }
157 const done = active.tasks.filter((t) => t.status === "completed").length;
158 const at = active.tasks.findIndex((t) => t.status === "in_progress");
159 const upcoming = active.tasks.slice(at + 1).find((t) => t.status === "pending");
160 const state = isComplete(active) ? "done" : active.failedCommand ? "failed" : "running";
161 const parts = [`${done}/${active.tasks.length}`, state];
162 if (at !== -1) parts.push(`active "${active.tasks[at].subject}"`);
163 if (at !== -1 && upcoming) parts.push(`next "${upcoming.subject}"`);
164 return { result: parts.join(", ") };
165 });
166
167 // Plan mode: a "Planning" bar from the first prompt or tool call made in plan mode.
168 // Claude's own progress_tracker list replaces its steps; until then each tool call
169 // becomes a done step, so the bar moves. The plan_mode note on every plan-mode
170 // turn is the surest sign.
171 on("prompt.attachment", { type: "plan_mode" }, async ($, e, next) => {
172 await followMode($, "plan");
173 return next(e);
174 });
175
176 // A reload asks again for every note in the transcript, old ones included, in order.
177 // The exit note that followed an old plan_mode note drops the bar that note just started.
178 for (const type of ["plan_mode_exit", "auto_mode"]) {
179 on("prompt.attachment", { type }, async ($, e, next) => {
180 await followMode($, type === "auto_mode" ? "auto" : "default");
181 return next(e);
182 });
183 }
184
185 on("agent.spawn", async ($, e, next) => {
186 if (e.parentAgentId) return next(e);
187 await followMode($, e.permissionMode);
188 const r = await next(e);
189 if (r.agentId) {
190 spawned.set(e.tool_use_id, { id: r.agentId, type: e.subagentType, isBackground: e.background === true });
191 }
192 return r;
193 });
194
195 on("classic.UserPromptSubmit", async ($, e, next) => {
196 await followMode($, e.permission_mode);
197 return next(e);
198 });
199
200 // A permission dialog waits on the person: the bar turns amber and the pane lists it.
201 on("classic.PermissionRequest", async ($, e, next) => {
202 if (!e.agent_id) {
203 const input = typeof e.tool_input === "object" && e.tool_input ? e.tool_input : {};
204 const what = e.tool_name === "Bash" && input.command ? String(input.command).trim() : stepNameOf({ ...input, tool: e.tool_name });
205 await openBlock($, "permission", `Allow ${what}`, { details: { tool: String(e.tool_name ?? ""), request: requestOf(e.tool_name, input) } });
206 }
207 return next(e);
208 });
209
210 // Where no PermissionRequest fires, the permission notification stands in for it.
211 on("classic.Notification", async ($, e, next) => {
212 if (!e.agent_id && e.notification_type === "permission_prompt") {
213 await openBlock($, "permission", String(e.message ?? "Needs your permission"), { isFallback: true });
214 }
215 return next(e);
216 });
217
218 on("classic.PreToolUse", async ($, e, next) => {
219 if (!e.agent_id) await followMode($, e.permission_mode);
220 return next(e);
221 });
222
223 on("tool.call", async ($, e, next) => {
224 const startedAt = Date.now();
225 const r = await next(e);
226 if (e.agentId) return r;
227 // The call ran or was refused: whatever it asked of the person is answered.
228 await noteBlock($, (b) => b.kind === "permission" && b.outcome == null && (b.answeredAt == null || b.toolUseId === e.tool_use_id), {
229 outcome: outcomeOf(r),
230 });
231 await closeBlocks($, e.tool_use_id);
232 if (NOT_A_STEP.has(e.tool)) return r;
233 const step = stepOf(e, startedAt);
234 await onPlanning($, (t) => (t.isAutoSteps && !t.isWaiting ? autoStep(t, step) : t));
235 const listNudge = await autoWork($, step);
236 if (step.agent) await noteAgentRun($, step);
237 const nudges = [listNudge, await countWork($)].filter(Boolean);
238 return nudges.length > 0 && r.deny === undefined ? { ...r, context: [...(r.context ?? []), ...nudges] } : r;
239 });
240
241 // Every model response reports its tokens; they count toward the list that is running.
242 // Sub-agents' responses count too: their work is part of the list's work.
243 // turn.step streams, so its hook is an async generator that forwards the stream untouched.
244 on("turn.step", async function* ($, e, next) {
245 const r = yield* next(e);
246 const tokens = tokensOfUsage(r?.usage);
247 if (!tokens) return r;
248 if (!e.agentId) turnTokens = addTokens(turnTokens, tokens);
249 await update($, (tracks) => {
250 const active = activeOf(tracks);
251 if (!active || isComplete(active) || active.isDemo) return tracks;
252 return replace(tracks, { ...active, tokens: addTokens(active.tokens, tokens) });
253 });
254 return r;
255 });
256
257 on("turn.start", async ($, e, next) => {
258 if (!e.agentId) {
259 turnSteps = [];
260 turnTokens = emptyTokens();
261 hasTurnBar = false;
262 await $.state.set(TURN, Date.now());
263 }
264 return next(e);
265 });
266
267 // The turn's "Working" bar ends with the turn and turns green; its open step becomes
268 // "Write reply", timed from the last tool call to the end of the turn.
269 on("turn.complete", async ($, e, next) => {
270 if (!e.agentId) {
271 await update($, (tracks) => tracks.map((t) => (t.isWorkBar && t.isAutoSteps ? closeWorkBar(t) : t)));
272 await closeBlocks($);
273 const { value: turnStartedAt = null } = await $.state.get(TURN);
274 await $.state.set(TURN, null);
275 if (turnStartedAt != null) await update($, (tracks) => tracks.map((t) => addTurn(t, turnStartedAt, Date.now())));
276 }
277 return next(e);
278 });
279
280 // Asking for approval turns the bar amber until the person answers. Either answer
281 // ends this round of planning (green); approved, the plan's steps get a bar of their own.
282 on("tool.call", { tool: "ExitPlanMode" }, async ($, e, next) => {
283 if (e.agentId) return next(e);
284 await onPlanning($, (t) => ({ ...toStep(t, t.tasks.length - 1), isWaiting: true }));
285 await openBlock($, "approval", "Approve the plan", { keepsBar: true, toolUseId: e.tool_use_id });
286 const r = await next(e);
287 const approved = typeof e.plan === "string" ? e.plan : r.result?.plan;
288 await noteBlock($, (b) => b.kind === "approval" && b.toolUseId === e.tool_use_id, {
289 outcome: isAnswered(r) ? "Approved" : "Not approved",
290 details: { plan: planTitleOf(approved), steps: planStepsOf(approved).length },
291 });
292 await closeBlocks($);
293 await onPlanning($, (t) => ({
294 ...toStep(t, t.tasks.length),
295 isPlanning: false,
296 isWaiting: false,
297 isAutoSteps: false,
298 }));
299 if (!isAnswered(r)) return r;
300 const plan = typeof e.plan === "string" ? e.plan : r.result?.plan;
301 await update($, (tracks) => [
302 ...tracks,
303 { ...newTrack(tracks, planTitleOf(plan)), tasks: planStepsOf(plan), isFromPlan: true },
304 ]);
305 return r;
306 });
307
308 // A question to the person turns the working bar amber until it is answered.
309 on("tool.call", { tool: "AskUserQuestion" }, async ($, e, next) => {
310 if (e.agentId) return next(e);
311 const questions = (Array.isArray(e.questions) ? e.questions : []).map((q) => ({
312 question: String(q?.question ?? ""),
313 options: (Array.isArray(q?.options) ? q.options : []).map((o) => String(o?.label ?? o)),
314 }));
315 await openBlock($, "question", questions[0]?.question || "A question for you", { toolUseId: e.tool_use_id, details: { questions } });
316 let r;
317 try {
318 r = await next(e);
319 return r;
320 } finally {
321 // The answers come back keyed by the question's text.
322 const answers = isAnswered(r ?? {}) && r.result && typeof r.result.answers === "object" ? r.result.answers : null;
323 await noteBlock($, (b) => b.kind === "question" && b.toolUseId === e.tool_use_id, {
324 outcome: answers ? "Answered" : "Not answered",
325 answers: answers ? Object.fromEntries(Object.entries(answers).map(([q, a]) => [q, String(a)])) : null,
326 });
327 await closeBlocks($);
328 }
329 });
330
331 on("tool.call", { tool: "TaskCreate" }, async ($, e, next) => {
332 const r = await next(e);
333 if (e.agentId || !isAnswered(r)) return r;
334 const id = String(r.result?.task?.id ?? e.tool_use_id);
335 const task = { id, subject: String(e.subject ?? ""), status: "pending" };
336 await update($, (tracks) => {
337 // Claude's first task after an approved plan, or on an automatic bar, replaces its steps.
338 const active = activeOf(tracks);
339 if (active?.isFromPlan || (active?.isCounter && !isComplete(active))) {
340 return replace(tracks, { ...active, ...declared(active), tasks: [task] });
341 }
342 return withActive(tracks, (t) => ({ ...t, tasks: [...t.tasks, task] }));
343 });
344 return r;
345 });
346
347 on("tool.call", { tool: "TaskUpdate" }, async ($, e, next) => {
348 const r = await next(e);
349 if (e.agentId || !isAnswered(r)) return r;
350 const id = String(e.taskId);
351 await update($, (tracks) =>
352 tracks.map((t) => {
353 if (!t.tasks.some((task) => task.id === id)) return t;
354 const tasks =
355 e.status === "deleted"
356 ? t.tasks.filter((task) => task.id !== id)
357 : t.tasks.map((task) =>
358 task.id === id
359 ? { ...task, subject: e.subject ?? task.subject, status: e.status ?? task.status }
360 : task,
361 );
362 const isStatusChange = e.status !== undefined;
363 return { ...t, tasks, failedCommand: isStatusChange ? null : t.failedCommand };
364 }),
365 );
366 return r;
367 });
368
369 on("tool.call", { tool: "TodoWrite" }, async ($, e, next) => {
370 const r = await next(e);
371 if (e.agentId || !isAnswered(r) || !Array.isArray(e.todos)) return r;
372 const tasks = e.todos.map((todo, i) => ({
373 id: `todo${i}`,
374 subject: String(todo.content ?? ""),
375 status: todo.status,
376 }));
377 await update($, (tracks) => withActive(tracks, (t) => ({ ...t, ...declared(t), tasks }), tasks));
378 return r;
379 });
380
381 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
382 const r = await next(e);
383 if (e.agentId || r.deny !== undefined) return r;
384 const command = String(e.command ?? "");
385 await update($, (tracks) => {
386 const active = activeOf(tracks);
387 if (!active || isComplete(active)) return tracks;
388 if (r.isError && isCheck(command)) {
389 return replace(tracks, { ...active, failedCommand: command, failedReason: errorLineOf(r.result), failedLines: errorLinesOf(r.result), failedRuns: (active.failedRuns ?? 0) + 1, failedToolUseId: e.tool_use_id });
390 }
391 if (!r.isError && active.failedCommand && sameCheck(active.failedCommand, command)) {
392 return replace(tracks, { ...active, failedCommand: null, failedReason: null });
393 }
394 return tracks;
395 });
396 return r;
397 });
398
399 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
400 if (e.props.hasSurvey) {
401 animate($, null, []);
402 return next(e);
403 }
404 const { value: tracks = [] } = await $.state.get(TRACKS);
405 const agents = await runningAgentsOf($);
406 const shown = tracks
407 .filter((t) => !t.isDismissed && t.tasks.length > 0)
408 .map((t, i, all) => (i === all.length - 1 ? { ...t, agents: t.isDemo ? (t.demoAgents ?? 0) : agents } : t));
409 const ui = $.ui.resolve(e);
410 const ctx = await contextOf($);
411 const dismiss = (id) => () =>
412 void update($, (all) => all.map((t) => (t.id === id ? { ...t, isDismissed: true } : t)));
413 const toggle = (id) => () =>
414 void update($, (all) => all.map((t) => (t.id === id ? { ...t, isExpanded: !t.isExpanded } : t)));
415
416 // Off the terminal there is no Raster: an SVG draws the bar and SMIL moves it.
417 if (e.surface !== "terminal") {
418 animate($, null, []);
419 if (shown.length === 0) return next(e);
420 return drawDesktop(ui, shown.slice(-MAX_ROWS), e.props, { dismiss, toggle, pane: () => void openPane($) }, ctx);
421 }
422
423 const rows = shown
424 .slice(-Math.max(1, Math.min(MAX_ROWS, Math.floor(e.props.maxRows / BAR_ROWS))))
425 .map((t) => rowOf(t, e.props.bodyColumns - 2, ctx));
426 animate($, e.requestId, rows.filter((r) => r.bar && isLive(r.status) && (e.props.isWorking || r.isDemo)));
427 if (rows.length === 0) return next(e);
428
429 // Expanded rows share the rows the bars leave free, in order.
430 let spare = e.props.maxRows - rows.length * BAR_ROWS;
431 const actions = { dismiss, toggle, pane: () => void openPane($) };
432 return ui.Box({
433 flexDirection: "column",
434 paddingX: 1,
435 children: rows.map((r) => {
436 const lines = r.isExpanded ? Math.max(0, spare) : 0;
437 spare -= lines;
438 return draw(ui, r, actions, lines);
439 }),
440 });
441 });
442
443 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
444 const { value: log = emptyLog() } = await $.state.get(LOG);
445 const { value: saved = "blocked" } = await $.state.get(TAB);
446 const tab = TABS.some((t) => t.id === saved) ? saved : "blocked";
447 const { value: tracks = [] } = await $.state.get(TRACKS);
448 const active = activeOf(tracks.filter((t) => t.tasks.length > 0));
449 const { value: openId = null } = await $.state.get(OPEN);
450 const kit = { ...$.ui.resolve(e), openId, toggle: (id) => () => void $.state.set(OPEN, openId === id ? null : id) };
451 return drawPane(kit, { log, tab, track: active, ctx: await contextOf($) }, (id) => () => void $.state.set(TAB, id));
452 });
453}
454
455// ---- state ----
456
457function update($, change) {
458 queue = queue
459 .then(async () => {
460 const { value: tracks = [] } = await $.state.get(TRACKS);
461 let changed = change(tracks);
462 if (changed === tracks) return;
463 const before = new Map(tracks.map((t) => [t.id, t]));
464 changed = changed.map((t) => stampWait(before.get(t.id), stampDone($, stampFailure(before.get(t.id), stampTasks(t)))));
465 await $.state.set(TRACKS, changed);
466 for (const t of changed) notify($, before.get(t.id), t);
467 const failures = changed.filter((t) => t.failureToLog && t.failureToLog !== before.get(t.id)?.failureToLog);
468 if (failures.length > 0) {
469 await writeLog($, (log) => ({
470 ...log,
471 findings: capped([...log.findings, ...failures.map((t, i) => ({ kind: "failed", ...t.failureToLog, id: entryId("failed", log.findings.length + i), at: Date.now(), isDemo: t.isDemo }))]),
472 }));
473 }
474 })
475 .catch(() => undefined);
476 return queue;
477}
478
479// ---- measured numbers: work, wait, tokens, context ----
480
481// A list Claude declared itself: its total is real, so the bar counts x/y again.
482function declared(track) {
483 return {
484 isFromPlan: false,
485 isAutoSteps: false,
486 isCounter: false,
487 isWorkBar: false,
488 label: track.isWorkBar ? null : track.label,
489 };
490}
491
492// Time spent waiting on the person: from the moment the bar turns amber until it turns back.
493function stampWait(old, track) {
494 const now = Date.now();
495 if (track.isWaiting && !old?.isWaiting) return { ...track, waitingSince: now };
496 if (!track.isWaiting && old?.isWaiting && track.waitingSince != null) {
497 return { ...track, waitMs: (track.waitMs ?? 0) + (now - track.waitingSince), waitingSince: null };
498 }
499 return track;
500}
501
502// A finished turn adds the part of it that overlaps the track's own life.
503function addTurn(track, turnStart, turnEnd) {
504 if (track.isDemo) return track;
505 const overlap = Math.min(turnEnd, track.doneAt ?? turnEnd) - Math.max(turnStart, track.startedAt ?? turnStart);
506 return overlap > 0 ? { ...track, turnMs: (track.turnMs ?? 0) + overlap } : track;
507}
508
509// Work: the time Claude was in a turn while the track ran, less the time it waited on the
510// person. Idle time between turns counts as neither. The demo runs outside any turn.
511export function workOf(track, now, turnStartedAt) {
512 const end = Math.min(now, track.doneAt ?? now);
513 const start = track.isDemo ? track.startedAt : turnStartedAt;
514 const live = start != null ? Math.max(0, end - Math.max(start, track.startedAt ?? start)) : 0;
515 return Math.max(0, (track.turnMs ?? 0) + live - waitOf(track, now));
516}
517
518export function waitOf(track, now) {
519 return (track.waitMs ?? 0) + (track.waitingSince != null ? now - track.waitingSince : 0);
520}
521
522// The context window's fill as the engine reports it; null where it reports none.
523async function usageOf($) {
524 try {
525 const usage = await $.session.usage();
526 return { contextPercent: usage.context?.percent ?? null };
527 } catch {
528 return { contextPercent: null };
529 }
530}
531
532async function contextOf($) {
533 const { value: turnStartedAt = null } = await $.state.get(TURN);
534 return { now: Date.now(), turnStartedAt, ...(await usageOf($)) };
535}
536
537// Tokens as the API reports them per response. Input is every prompt token the model
538// read: uncached, read from the cache, and written to it. Each call re-reads the whole
539// conversation, so input grows much faster than output, and most of it comes from the cache.
540function emptyTokens() {
541 return { input: 0, cached: 0, output: 0, calls: 0 };
542}
543
544function tokensOfUsage(usage) {
545 if (!usage) return null;
546 const cached = usage.cache_read_input_tokens ?? 0;
547 const input = (usage.input_tokens ?? 0) + cached + (usage.cache_creation_input_tokens ?? 0);
548 return { input, cached, output: usage.output_tokens ?? 0, calls: 1 };
549}
550
551function addTokens(a = emptyTokens(), b) {
552 return { input: a.input + b.input, cached: a.cached + b.cached, output: a.output + b.output, calls: a.calls + b.calls };
553}
554
555// 820, 14.2k, 142k, 1.23M.
556export function countOf(n) {
557 if (n < 1000) return String(n);
558 if (n < 100_000) return `${(n / 1000).toFixed(1)}k`;
559 if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
560 return `${(n / 1_000_000).toFixed(2)}M`;
561}
562
563// Notes when each task starts and finishes. A reopened task loses its finish.
564function stampTasks(track) {
565 const now = Date.now();
566 const tasks = track.tasks.map((t) => {
567 if (t.status === "in_progress" && (t.startedAt == null || t.doneAt != null)) {
568 return { ...t, startedAt: t.startedAt ?? now, doneAt: null };
569 }
570 if (t.status === "completed" && t.doneAt == null) return { ...t, doneAt: now };
571 return t;
572 });
573 return tasks.every((t, i) => t === track.tasks[i]) ? track : { ...track, tasks };
574}
575
576// ---- the session log (Progress pane) ----
577
578function emptyLog() {
579 return { blocks: [], findings: [] };
580}
581
582function capped(list) {
583 return list.slice(-LOG_CAP);
584}
585
586// Unqueued: for use inside a queued job. Everything else goes through changeLog.
587async function writeLog($, change) {
588 const { value: log = emptyLog() } = await $.state.get(LOG);
589 const changed = change(log);
590 if (changed !== log) await $.state.set(LOG, changed);
591}
592
593function changeLog($, change) {
594 queue = queue.then(() => writeLog($, change)).catch(() => undefined);
595 return queue;
596}
597
598async function currentTaskOf($) {
599 const { value: tracks = [] } = await $.state.get(TRACKS);
600 const active = activeOf(tracks);
601 return active?.isAutoSteps ? null : (active?.tasks.find((t) => t.status === "in_progress")?.subject ?? null);
602}
603
604// Something waits on the person: the bar turns amber and the pane lists it. A plan
605// approval keeps its own Planning bar; a fallback adds nothing when one is already open.
606async function openBlock($, kind, text, { isFallback = false, keepsBar = false, toolUseId = null, details = null } = {}) {
607 const task = await currentTaskOf($);
608 if (!keepsBar) {
609 await update($, (tracks) => {
610 const active = activeOf(tracks);
611 return active && !isComplete(active) && !active.isWaiting ? replace(tracks, { ...active, isWaiting: true }) : tracks;
612 });
613 }
614 await changeLog($, (log) => {
615 if (isFallback && log.blocks.some((b) => b.kind === kind && b.answeredAt == null)) return log;
616 const block = { id: entryId(kind, log.blocks.length), kind, text: shorten(text, 120), task, at: Date.now(), answeredAt: null, toolUseId, details };
617 return { ...log, blocks: capped([...log.blocks, block]) };
618 });
619}
620
621// Adds what happened to the newest block that matches: the answer, how the call ended.
622function noteBlock($, isTarget, fields) {
623 return changeLog($, (log) => {
624 const at = log.blocks.findLastIndex(isTarget);
625 if (at === -1) return log;
626 return { ...log, blocks: log.blocks.map((b, i) => (i === at ? { ...b, ...fields } : b)) };
627 });
628}
629
630// How a call ended, as its result says. A refusal is not said to be the person's: a hook
631// or a rule can refuse a call too.
632function outcomeOf(r) {
633 if (r.deny !== undefined) return "It did not run";
634 return r.isError ? "It ran and ended with an error" : "It ran";
635}
636
637// What a permission prompt asked to do, in full: the command, else the file, else the input.
638function requestOf(tool, input) {
639 if (input.command) return String(input.command);
640 if (input.file_path || input.notebook_path) return String(input.file_path ?? input.notebook_path);
641 if (input.url) return String(input.url);
642 const text = JSON.stringify(input);
643 return text === "{}" ? String(tool ?? "") : shorten(text, 400);
644}
645
646// A log entry's id: stable while the entry lives, so its details stay open across renders.
647function entryId(kind, n) {
648 return `${kind}-${Date.now().toString(36)}-${n}`;
649}
650
651// A permission prompt learns which call it belonged to when that call ends.
652async function closeBlocks($, toolUseId = null) {
653 await changeLog($, (log) => {
654 if (!log.blocks.some((b) => b.answeredAt == null)) return log;
655 const now = Date.now();
656 const close = (b) => ({ ...b, answeredAt: now, toolUseId: b.toolUseId ?? toolUseId });
657 return { ...log, blocks: log.blocks.map((b) => (b.answeredAt == null ? close(b) : b)) };
658 });
659 await update($, (tracks) => {
660 const active = activeOf(tracks);
661 return active?.isWaiting && !active.isPlanning ? replace(tracks, { ...active, isWaiting: false }) : tracks;
662 });
663}
664
665async function logFinding($, kind, text, toolUseId = null) {
666 const task = await currentTaskOf($);
667 await changeLog($, (log) => ({
668 ...log,
669 findings: capped([...log.findings, { id: entryId(kind, log.findings.length), kind, text: shorten(text, 1000), task, at: Date.now(), toolUseId }]),
670 }));
671}
672
673async function openPane($) {
674 try {
675 const opened = await $.ui.open({ id: PANE, title: "Progress" });
676 if (!opened.isPlaced) $.ui.toast(`Progress pane waits: ${opened.reason ?? "this window shows no panes"}`);
677 } catch {
678 // No panes on this surface.
679 }
680}
681
682// Each new failing run counts against the task in progress; the first of a red spell
683// also goes to the Found tab.
684function stampFailure(old, track) {
685 if (!track.failedRuns || track.failedRuns === old?.failedRuns) return track;
686 const at = track.tasks.findIndex((t) => t.status === "in_progress");
687 if (at === -1) return track;
688 const tasks = track.tasks.map((t, i) => (i === at ? { ...t, failures: (t.failures ?? 0) + 1 } : t));
689 const isNewSpell = old?.failedCommand == null;
690 return {
691 ...track,
692 tasks,
693 failureToLog: isNewSpell
694 ? {
695 text: track.failedReason ?? track.failedCommand,
696 task: tasks[at].subject,
697 toolUseId: track.failedToolUseId ?? null,
698 command: track.failedCommand,
699 lines: track.failedLines ?? [],
700 }
701 : null,
702 };
703}
704
705// A toast when a bar starts waiting on the person, and when a real task list finishes.
706function notify($, old, track) {
707 try {
708 if (track.isWaiting && !old?.isWaiting && !track.isPlanning) {
709 $.ui.toast(`Waiting for you · ${track.label ?? track.tasks[0]?.subject ?? "Claude"}`);
710 }
711 const isAuto = track.isWorkBar || track.isPlanning || track.isAutoSteps;
712 if (track.doneAt != null && old?.doneAt == null && old !== undefined && !isAuto) {
713 const retries = track.tasks.reduce((sum, t) => sum + (t.failures ?? 0), 0);
714 const time = track.startedAt ? ` in ${durationOf(track.doneAt - track.startedAt)}` : "";
715 $.ui.toast(`✓ ${track.label ?? track.tasks[0]?.subject ?? "Tasks"} done${time}${retries ? ` · ${retries} ${retries === 1 ? "retry" : "retries"}` : ""}`);
716 }
717 } catch {
718 // No toasts on this surface.
719 }
720}
721
722// Notes when a track finishes (to freeze its clock) and hides it a minute later.
723// A track that starts again (new tasks, a reopened task) loses the stamp.
724function stampDone($, track) {
725 const isDone = isComplete(track);
726 if (isDone === (track.doneAt != null)) return track;
727 if (!isDone) return { ...track, doneAt: null };
728 const doneAt = Date.now();
729 if (!track.isDemo) {
730 try {
731 $.clock.after(DONE_LINGER_MS, () =>
732 void update($, (all) =>
733 all.map((t) => (t.id === track.id && t.doneAt === doneAt && !t.isExpanded ? { ...t, isDismissed: true } : t)),
734 ),
735 );
736 } catch {
737 // No timer: the finished bar stays until ✕ or the next session.
738 }
739 }
740 return { ...track, doneAt };
741}
742
743// Ids count up from the highest one still held, so two tracks never share a Raster key.
744function newTrack(tracks, label) {
745 const highest = Math.max(0, ...tracks.map((t) => Number(t.id.replace(/^track/, "")) || 0));
746 return {
747 id: `track${highest + 1}`,
748 label,
749 tasks: [],
750 failedCommand: null,
751 isDismissed: false,
752 startedAt: Date.now(),
753 doneAt: null,
754 };
755}
756
757function activeOf(tracks) {
758 return tracks.findLast((t) => !t.isDismissed);
759}
760
761function replace(tracks, track) {
762 return tracks.map((t) => (t.id === track.id ? track : t));
763}
764
765// Applies `change` to the newest track. A finished list (or none) gets a fresh
766// track first, unless `incoming` (a TodoWrite list) still names one of its tasks.
767function withActive(tracks, change, incoming) {
768 const active = activeOf(tracks);
769 const isSameList =
770 incoming !== undefined &&
771 active !== undefined &&
772 incoming.some((task) => active.tasks.some((old) => old.subject === task.subject));
773 if (active && (!isComplete(active) || active.tasks.length === 0 || isSameList)) {
774 return replace(tracks, change(active));
775 }
776 return [...tracks, change(newTrack(tracks, null))];
777}
778
779// /progress-tracker-demo: a scripted run on its own track that shows every feature: three
780// reads in parallel, a sub-agent, a failing check that recovers, time left, and the
781// finished timeline opened on its own. The track goes away a while after it finishes.
782const DEMO_TASKS = ["Plan", "Read config.ts", "Read api.ts", "Read db.ts", "Build", "Test", "Ship"];
783// Each step: [delay, change to the demo track, change to the pane's log]. Demo log
784// entries carry isDemo and go when the demo does.
785const DEMO_STEPS = [
786 [1400, (t) => applyOps(t, { next: true })],
787 [1600, demoParallelReads],
788 [700, (t) => ({ ...t, demoAgents: 1, isExpanded: true })],
789 [1600, demoAgentDone],
790 [600, (t) => applyOps(t, { next: true })],
791 [1200, (t) => applyOps(t, { failed: "2 tests failing" })],
792 [1300, (t) => applyOps(t, { failed: "1 test failing" }), demoFinding("Retry loop in api.ts drops the auth header", "Test")],
793 [1600, (t) => applyOps(t, { fixed: true })],
794 [500, (t) => ({ ...t, isWaiting: true }), demoBlock("Allow npm publish", "Ship")],
795 [1800, (t) => ({ ...t, isWaiting: false }), demoAnswer],
796 [900, (t) => applyOps(t, { next: true })],
797 [1400, (t) => applyOps(t, { next: true })],
798];
799const DEMO_LINGER_MS = 12000;
800
801function demoFinding(text, task) {
802 return (log) => ({ ...log, findings: [...log.findings, { id: entryId("found", log.findings.length), kind: "found", text, task, at: Date.now(), isDemo: true }] });
803}
804
805function demoBlock(text, task) {
806 return (log) => ({
807 ...log,
808 blocks: [
809 ...log.blocks,
810 {
811 id: entryId("permission", log.blocks.length),
812 kind: "permission",
813 text,
814 task,
815 at: Date.now(),
816 answeredAt: null,
817 isDemo: true,
818 details: { tool: "Bash", request: text.replace(/^Allow /, "") },
819 },
820 ],
821 });
822}
823
824function demoAnswer(log) {
825 return {
826 ...log,
827 blocks: log.blocks.map((b) => (b.isDemo && b.answeredAt == null ? { ...b, answeredAt: Date.now(), outcome: "It ran" } : b)),
828 };
829}
830
831function dropDemoLog(log) {
832 const keep = (list) => list.filter((entry) => !entry.isDemo);
833 return { blocks: keep(log.blocks), findings: keep(log.findings) };
834}
835
836async function playDemo($) {
837 let id = null;
838 await update($, (tracks) => {
839 const track = newTrack(tracks, "Demo: ship a feature");
840 id = track.id;
841 const tasks = DEMO_TASKS.map((subject, i) => ({
842 id: `demo${i + 1}`,
843 subject,
844 status: i === 0 ? "in_progress" : "pending",
845 }));
846 return [...tracks, { ...track, tasks, isDemo: true }];
847 });
848 const onDemo = (change) => update($, (tracks) => tracks.map((t) => (t.id === id ? change(t) : t)));
849 await openPane($);
850 let at = 0;
851 for (const [delay, change, logChange] of DEMO_STEPS) {
852 at += delay;
853 $.clock.after(at, () => {
854 void onDemo(change);
855 if (logChange) void changeLog($, logChange);
856 });
857 }
858 $.clock.after(at + DEMO_LINGER_MS, () => {
859 void update($, (tracks) => tracks.filter((t) => t.id !== id));
860 void changeLog($, dropDemoLog);
861 });
862}
863
864// The three reads all start when Plan finishes and end at different times.
865function demoParallelReads(track) {
866 const now = Date.now();
867 const start = track.tasks[0].doneAt ?? now - 1600;
868 const lengths = [900, 1300, 1600];
869 const tasks = track.tasks.map((task, i) => {
870 if (i >= 1 && i <= 3) return { ...task, status: "completed", startedAt: start, doneAt: start + lengths[i - 1] };
871 return i === 4 ? { ...task, status: "in_progress", startedAt: start + Math.max(...lengths) } : task;
872 });
873 return { ...track, tasks };
874}
875
876function demoAgentDone(track) {
877 const now = Date.now();
878 const run = {
879 taskId: "demo5",
880 subject: "Map the routes",
881 agent: { type: "Explore", isBackground: false },
882 startedAt: now - 1600,
883 doneAt: now,
884 };
885 return { ...track, demoAgents: 0, agentRuns: [...(track.agentRuns ?? []), run] };
886}
887
888// Done and waiting-for-approval bars hold still; the rest move while Claude works.
889function isLive(status) {
890 return status === "working" || status === "failed";
891}
892
893// ---- plan mode ----
894
895// Counts work calls since the bar last moved. After NUDGE_AFTER of them Claude gets one
896// note it alone reads, so a forgotten {next:true} does not freeze the bar.
897const NUDGE_AFTER = 12;
898
899async function countWork($) {
900 let nudge = null;
901 await update($, (tracks) => {
902 const active = activeOf(tracks);
903 if (!active || active.isAutoSteps || isComplete(active)) return tracks;
904 const key = active.tasks.map((t) => t.status).join(",");
905 const calls = key === active.statusKey ? (active.callsSinceMove ?? 0) + 1 : 1;
906 if (calls >= NUDGE_AFTER) {
907 const current = active.tasks.find((t) => t.status === "in_progress");
908 const done = active.tasks.filter((t) => t.status === "completed").length;
909 nudge = `The progress bar still shows "${current?.subject ?? "nothing"}" in progress (${done}/${active.tasks.length}). If that task is done, update it now (${TOOL_NAME} next/active, or TaskUpdate). Don't mention this note.`;
910 }
911 return replace(tracks, { ...active, statusKey: key, callsSinceMove: nudge ? 0 : calls });
912 });
913 return nudge;
914}
915
916// Tools that are bookkeeping, not work: they never become a planning step.
917const NOT_A_STEP = new Set([TOOL, "ExitPlanMode", "AskUserQuestion", "ToolSearch", "TaskCreate", "TaskUpdate", "TodoWrite"]);
918
919// In plan mode a Planning bar starts. Out of it, a Planning bar that never reached
920// ExitPlanMode (the person left plan mode by hand) is dropped.
921async function followMode($, mode) {
922 if (mode) lastMode = mode;
923 if (mode === "plan") return startPlanning($);
924 if (mode) await update($, (tracks) => (tracks.some((t) => t.isPlanning) ? tracks.filter((t) => !t.isPlanning) : tracks));
925}
926
927async function startPlanning($) {
928 await update($, (tracks) => {
929 if (tracks.some((t) => t.isPlanning && !t.isDismissed)) return tracks;
930 const tasks = [
931 { id: "plan1", subject: "Explore", status: "in_progress" },
932 { id: "approval", subject: "Approval", status: "pending" },
933 ];
934 return [...tracks, { ...newTrack(tracks, "Planning"), tasks, isPlanning: true, isAutoSteps: true, isCounter: true }];
935 });
936}
937
938// The step in progress is done and named after the call; a new one starts before Approval.
939function autoStep(track, step) {
940 const at = track.tasks.findIndex((t) => t.status === "in_progress");
941 if (at === -1) return track;
942 const done = { ...track.tasks[at], ...taskOf(step), status: "completed" };
943 const subject = track.isWorkBar ? "Working" : step.isWrite ? "Write plan" : "Explore";
944 const following = { id: `plan${at + 2}`, subject, status: "in_progress" };
945 return { ...track, tasks: [...track.tasks.slice(0, at), done, following, ...track.tasks.slice(at + 1)] };
946}
947
948function closeWorkBar(track) {
949 const steps = track.tasks.filter((task) => task.status === "completed");
950 const open = track.tasks.find((task) => task.status === "in_progress");
951 const lastEnd = Math.max(0, ...steps.map((task) => task.doneAt ?? 0));
952 const reply = open
953 ? [{ ...open, subject: "Write reply", status: "completed", startedAt: lastEnd || open.startedAt, doneAt: Date.now() }]
954 : [];
955 return { ...track, tasks: [...steps, ...reply], isAutoSteps: false };
956}
957
958// One finished tool call, timed by the hook that ran it.
959function stepOf(call, startedAt) {
960 const run =
961 spawned.get(call.tool_use_id) ??
962 (call.tool === "Agent"
963 ? { type: String(call.subagent_type ?? "general-purpose"), isBackground: call.run_in_background === true }
964 : undefined);
965 const agent = run ? { type: run.type, isBackground: run.isBackground } : undefined;
966 return { subject: stepNameOf(call), startedAt, doneAt: Date.now(), isWrite: call.tool === "Write" || call.tool === "Edit", agent };
967}
968
969function taskOf(step) {
970 return { subject: step.subject, startedAt: step.startedAt, doneAt: step.doneAt, agent: step.agent };
971}
972
973// A sub-agent run during one of Claude's own tasks is listed under that task.
974async function noteAgentRun($, step) {
975 await update($, (tracks) => {
976 const active = activeOf(tracks);
977 const task = active?.tasks.find((t) => t.status === "in_progress");
978 if (!task || active.isAutoSteps) return tracks;
979 const run = { taskId: task.id, ...taskOf(step) };
980 return replace(tracks, { ...active, agentRuns: [...(active.agentRuns ?? []), run] });
981 });
982}
983
984// The note Claude gets when a turn's work starts without a list: the bar can only count
985// steps until Claude says how many there are.
986const LIST_NUDGE = `This work has no task list yet, so the progress bar can only count steps. If more steps are coming, call ${TOOL} now with {title, tasks:[every step you plan]} so the bar shows a real total. Don't mention this note.`;
987
988// Returns the note for Claude when this call starts a Working bar.
989async function autoWork($, step) {
990 if (lastMode === "plan") return null;
991 turnSteps.push(step);
992 let nudge = null;
993 await update($, (tracks) => {
994 const active = activeOf(tracks);
995 if (active?.isWorkBar && active.isAutoSteps) return replace(tracks, autoStep(active, step));
996 if (hasTurnBar || turnSteps.length < AUTO_BAR_CALLS || (active && !isComplete(active))) return tracks;
997 hasTurnBar = true;
998 nudge = LIST_NUDGE;
999 const done = turnSteps.map((s, i) => ({ id: `plan${i + 1}`, ...taskOf(s), status: "completed" }));
1000 const tasks = [...done, { id: `plan${done.length + 1}`, subject: "Working", status: "in_progress" }];
1001 const track = { ...newTrack(tracks, "Working"), startedAt: turnSteps[0].startedAt, tokens: turnTokens };
1002 return [...tracks, { ...track, tasks, isWorkBar: true, isAutoSteps: true, isCounter: true }];
1003 });
1004 return nudge;
1005}
1006
1007export function stepNameOf(call) {
1008 const file = String(call.file_path ?? call.path ?? "").split("/").filter(Boolean).at(-1);
1009 const name =
1010 call.description ??
1011 (call.tool === "Read" && file ? `Read ${file}` : null) ??
1012 (call.tool === "Write" && file ? `Write ${file}` : null) ??
1013 (call.tool === "Edit" && file ? `Edit ${file}` : null) ??
1014 (call.pattern ? `Search ${call.pattern}` : null) ??
1015 (call.tool === "Bash" ? String(call.command ?? "").trim().split(/\s+/)[0] || "Run a command" : null) ??
1016 String(call.tool ?? "Step").replace(/^mcp__.*__/, "");
1017 return shorten(String(name).replace(/\s+/g, " ").trim(), 40);
1018}
1019
1020function onPlanning($, change) {
1021 return update($, (tracks) => tracks.map((t) => (t.isPlanning && !t.isDismissed ? change(t) : t)));
1022}
1023
1024// Steps before `at` done, `at` in progress, the rest pending.
1025function toStep(track, at) {
1026 const tasks = track.tasks.map((t, i) => ({
1027 ...t,
1028 status: i < at ? "completed" : i === at ? "in_progress" : "pending",
1029 }));
1030 return { ...track, tasks };
1031}
1032
1033// A plan's numbered steps, else its bullets, as tasks: at most 8, short titles.
1034function planStepsOf(plan) {
1035 const all = typeof plan === "string" ? plan.split("\n") : [];
1036 // Steps live under the work headings; Context and Verification lists are not tasks.
1037 const SKIP = /^#{1,4}\s+.*\b(context|background|verif\w*|testing|test plan|risks?|notes?|files?|out of scope)\b/i;
1038 const WORK = /^#{1,4}\s+.*\b(steps?|implementation|phases?|changes?|approach|plan|tasks?)\b/i;
1039 const sections = [];
1040 for (const line of all) {
1041 if (/^#{1,4}\s/.test(line)) sections.push({ heading: line, lines: [] });
1042 else (sections.at(-1) ?? sections[sections.push({ heading: "", lines: [] }) - 1]).lines.push(line);
1043 }
1044 const kept = sections.filter((s) => !SKIP.test(s.heading));
1045 const work = kept.filter((s) => WORK.test(s.heading));
1046 const lines = (work.length > 0 ? work : kept).flatMap((s) => s.lines);
1047 const pick = (pattern) =>
1048 lines.map((line) => line.match(pattern)?.[1]).filter((text) => text && text.trim().length > 0);
1049 const numbered = pick(/^\s{0,3}\d+[.)]\s+(.+)/);
1050 const steps = (numbered.length > 0 ? numbered : pick(/^\s{0,1}[-*]\s+(.+)/)).slice(0, 8);
1051 const titles = steps.length > 0 ? steps : ["Carry out the plan"];
1052 return titles.map((text, i) => ({
1053 id: `step${i + 1}`,
1054 subject: shorten(text.replace(/[*_`#]/g, "").replace(/\s+/g, " ").trim(), 40),
1055 status: i === 0 ? "in_progress" : "pending",
1056 }));
1057}
1058
1059// The tool's short updates against the newest track.
1060function applyOps(track, ops) {
1061 let tasks = track.tasks;
1062 let failedCommand = track.failedCommand;
1063 if (Array.isArray(ops.add) && ops.add.length > 0) {
1064 const added = ops.add.map((subject, i) => ({ id: `add${tasks.length + i + 1}`, subject: String(subject), status: "pending" }));
1065 tasks = [...tasks, ...added];
1066 if (!tasks.some((t) => t.status === "in_progress")) {
1067 const first = tasks.findIndex((t) => t.status === "pending");
1068 tasks = tasks.map((t, i) => (i === first ? { ...t, status: "in_progress" } : t));
1069 }
1070 }
1071 if (ops.next === true || ops.skip === true) {
1072 const at = tasks.findIndex((t) => t.status === "in_progress");
1073 const upTo = at === -1 ? tasks.findIndex((t) => t.status !== "completed") : at;
1074 const closed = ops.skip === true ? { status: "completed", isSkipped: true } : { status: "completed" };
1075 tasks = tasks.map((t, i) =>
1076 i === upTo ? { ...t, ...closed } : i === upTo + 1 && t.status === "pending" ? { ...t, status: "in_progress" } : t,
1077 );
1078 failedCommand = null;
1079 }
1080 if (typeof ops.active === "string") {
1081 const at = findTask(tasks, ops.active);
1082 if (at !== -1) {
1083 tasks = tasks.map((t, i) =>
1084 i < at ? { ...t, status: "completed" } : i === at ? { ...t, status: "in_progress" } : t.status === "in_progress" ? { ...t, status: "pending" } : t,
1085 );
1086 }
1087 }
1088 let failedRuns = track.failedRuns;
1089 if (typeof ops.failed === "string") {
1090 failedCommand = ops.failed || "failed";
1091 failedRuns = (failedRuns ?? 0) + 1;
1092 }
1093 if (ops.fixed === true) failedCommand = null;
1094 const isSameFailure = failedCommand === track.failedCommand;
1095 return {
1096 ...track,
1097 tasks,
1098 failedCommand,
1099 failedRuns,
1100 failedReason: isSameFailure ? track.failedReason : null,
1101 failedLines: isSameFailure ? track.failedLines : null,
1102 };
1103}
1104
1105// Exact name first, then a prefix, then a part of the name; -1 when none fits.
1106export function findTask(tasks, name) {
1107 const want = String(name).trim().toLowerCase();
1108 const names = tasks.map((t) => t.subject.trim().toLowerCase());
1109 const exact = names.indexOf(want);
1110 if (exact !== -1 || want === "") return exact;
1111 const prefix = names.findIndex((n) => n.startsWith(want));
1112 return prefix !== -1 ? prefix : names.findIndex((n) => n.includes(want) || want.includes(n));
1113}
1114
1115function isComplete(track) {
1116 return track.tasks.length > 0 && track.tasks.every((t) => t.status === "completed");
1117}
1118
1119// Only a failing test, build, lint or typecheck turns a row red: grep, diff and
1120// test -f exit 1 as an answer, not a failure. The command's head decides, not any word in it.
1121const RUNNERS = /^(pytest|py\.test|tox|nox|jest|vitest|mocha|tsc|eslint|ruff|mypy|flake8|pylint|black|isort|pre-commit|rspec|phpunit|xcodebuild|gradle|gradlew|mvn|make|bazel|ctest)$/;
1122
1123export function isCheck(command) {
1124 return command.split(/&&|\|\||;|\|/).some((part) => {
1125 const words = part.trim().split(/\s+/).filter((w) => w && !w.startsWith("-"));
1126 while (words.length > 0 && (/^\w+=/.test(words[0]) || words[0] === "cd" || words[0] === "time")) {
1127 words.splice(0, words[0] === "cd" ? 2 : 1);
1128 }
1129 return isCheckWords(words);
1130 });
1131}
1132
1133function isCheckWords([first = "", ...rest]) {
1134 const head = first.split("/").at(-1);
1135 const [a = "", b = ""] = rest;
1136 if (RUNNERS.test(head)) return true;
1137 if (/^(npx|bunx|pnpx|uv|poetry|pipx)$/.test(head)) return isCheckWords(a === "run" || a === "exec" ? rest.slice(1) : rest);
1138 if (/^python[\d.]*$/.test(head)) return isCheckWords(rest);
1139 if (/^(npm|pnpm|yarn|bun)$/.test(head)) {
1140 return /^(test|t|build|lint|typecheck|check)$/.test(a) || (a === "run" && /^(test|build|lint|typecheck|type-check|check)(:|$)/.test(b));
1141 }
1142 if (head === "cargo") return /^(test|build|check|clippy|nextest)$/.test(a);
1143 if (head === "go") return /^(test|build|vet)$/.test(a);
1144 if (head === "dotnet" || head === "swift") return /^(test|build)$/.test(a);
1145 if (head === "claude") return a === "plugin" && /^(test|validate)$/.test(b);
1146 return false;
1147}
1148
1149const ERROR_LINE = /\b(error|errors|failed|failure|FAIL|assert\w*)\b|✕/i;
1150
1151// Up to 8 lines of a failed check's output for the pane: the lines that name a problem,
1152// else the last lines of the output.
1153function errorLinesOf(result) {
1154 const text = `${result?.stdout ?? ""}\n${result?.stderr ?? ""}`;
1155 const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
1156 const hits = lines.filter((l) => ERROR_LINE.test(l));
1157 return (hits.length > 0 ? hits.slice(0, 8) : lines.slice(-8)).map((l) => shorten(l, 200));
1158}
1159
1160// The first line of a failed check's output that names the problem.
1161function errorLineOf(result) {
1162 const text = `${result?.stdout ?? ""}\n${result?.stderr ?? ""}`;
1163 const line = text.split("\n").find((l) => /\b(error|errors|failed|failure|FAIL|assert\w*)\b|✕/i.test(l));
1164 return line ? line.trim() : null;
1165}
1166
1167// A re-run with other flags or paths still counts as the same check.
1168export function sameCheck(a, b) {
1169 const head = (c) => c.trim().split(/\s+/).filter((w) => !w.startsWith("-")).slice(0, 2).join(" ");
1170 return a === b || head(a) === head(b);
1171}
1172
1173function isAnswered(r) {
1174 return r.deny === undefined && !r.isError;
1175}
1176
1177function planTitleOf(plan) {
1178 if (typeof plan !== "string") return "Plan";
1179 const heading = plan.split("\n").find((line) => /^#{1,3}\s+\S/.test(line));
1180 const title = (heading ?? plan.split("\n").find((line) => line.trim()) ?? "")
1181 .replace(/^#+\s*/, "")
1182 .replace(/^plan:\s*/i, "")
1183 .trim();
1184 return title || "Plan";
1185}
1186
1187// ---- drawing ----
1188
1189// Sub-agents the main loop started that are still running; 0 where the engine cannot list them.
1190async function runningAgentsOf($) {
1191 if (spawned.size === 0) return 0;
1192 try {
1193 const ours = new Set([...spawned.values()].map((run) => run.id));
1194 const agents = await $.agent.list();
1195 return agents.filter((a) => a.status === "running" && ours.has(a.id)).length;
1196 } catch {
1197 return 0;
1198 }
1199}
1200hooks/bar.mjs 217 lines1// two-row-smooth: a 2-row slider drawn on a 4-pixel-tall canvas.
2// Each cell is split into 2x2 quadrants (▀ ▄ ▌ ▐ ▖ ▗ ▘ ▝ ▙ ▛ ▜ ▟), so the track and
3// the pill are capsules with round ends at half-cell horizontal resolution. Edge
4// cells take an averaged fg/bg colour pair, which anti-aliases the curve. Inside
5// the filled part, plain cells switch to braille so the dot field has real gaps:
6// sparse and dim on the left, dense and bright at the pill, twinkling over time.
7export const ROWS = 2;
8
9const DEF = 0x01000000;
10const TERM_BG = 0x1a1a1c;
11const SPACE = 0x20;
12const TICK_TOP = 0x2577; // ╷ row 0: lower half of a line
13const TICK_BOT = 0x2575; // ╵ row 1: upper half of a line
14const H = 4; // pixel rows (2 per terminal row)
15const R = 2; // vertical capsule radius in pixels
16const RX_TRACK = 2; // horizontal cap radius in cells (circle: a half-cell pixel is ~square)
17const RX_PILL = 2;
18const LEVELS = 8; // dot brightness steps
19const COV_STEPS = 7; // anti-alias coverage steps
20
21// quadrant mask (TL=1, TR=2, BL=4, BR=8) → glyph drawn in fg
22const QUAD = [
23 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b,
24 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
25];
26
27const PAL = {
28 working: {
29 empty: 0x29292f, fill: 0x2b2740, dotLo: 0x4a436e, dotHi: 0xe8e3ff,
30 tickFill: 0xb3a8f7, tickEmpty: 0x56555f,
31 pill: 0xaa9df6, pillMid: 0x988aea, pillLip: 0x7a6bd0, ink: 0x15112b,
32 twP: 540, shimmer: true, breathe: 0, twinkle: 0.18,
33 },
34 failed: {
35 empty: 0x29292f, fill: 0x36242a, dotLo: 0x67393b, dotHi: 0xffc4ba,
36 tickFill: 0xf59a8c, tickEmpty: 0x56555f,
37 pill: 0xd04636, pillMid: 0xbb3d2e, pillLip: 0x972e22, ink: 0xffffff,
38 twP: 1600, shimmer: false, breathe: 0, twinkle: 0.08,
39 },
40 done: {
41 empty: 0x29292f, fill: 0x22322a, dotLo: 0x3b5e47, dotHi: 0xd2f6da,
42 tickFill: 0x95dca8, tickEmpty: 0x56555f,
43 pill: 0x8fd19e, pillMid: 0x7cc08c, pillLip: 0x5c9f6d, ink: 0x0e2414,
44 twP: 0, shimmer: false, breathe: 1, twinkle: 0,
45 },
46 waiting: {
47 empty: 0x29292f, fill: 0x342c1f, dotLo: 0x6b5530, dotHi: 0xffe7b8,
48 tickFill: 0xf2c46d, tickEmpty: 0x56555f,
49 pill: 0xf0b34a, pillMid: 0xdca13d, pillLip: 0xb07d2a, ink: 0x2a1c05,
50 twP: 0, shimmer: false, breathe: 1, twinkle: 0,
51 },
52};
53
54const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
55const qcov = (v) => Math.round(clamp01(v) * (COV_STEPS - 1)) / (COV_STEPS - 1);
56function mix(a, b, k) {
57 if (k <= 0) return a;
58 if (k >= 1) return b;
59 const ch = (s) => {
60 const x = (a >> s) & 255, y = (b >> s) & 255;
61 return Math.round(x + (y - x) * k) & 255;
62 };
63 return (ch(16) << 16) | (ch(8) << 8) | ch(0);
64}
65function hash(a, b, c) {
66 let h = Math.imul(a | 0, 0x27d4eb2d) ^ Math.imul(b | 0, 0x165667b1) ^ Math.imul(c | 0, 0x9e3779b1);
67 h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
68 h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
69 h ^= h >>> 16;
70 return (h >>> 0) / 4294967296;
71}
72
73// Coverage of the box [x, x+w] x [y, y+1] by a capsule spanning x0..x1, 0..H
74// with elliptical ends (horizontal radius rx, vertical radius R).
75function capsuleCov(x, y, w, x0, x1, rx) {
76 if (x + w <= x0 || x >= x1) return 0;
77 let hit = 0;
78 for (let sy = 0; sy < 4; sy++) {
79 for (let sx = 0; sx < 4; sx++) {
80 const px = x + ((sx + 0.5) / 4) * w, py = y + (sy + 0.5) / 4;
81 if (px < x0 || px > x1) continue;
82 const cx = px < x0 + rx ? x0 + rx : px > x1 - rx ? x1 - rx : px;
83 const dx = (px - cx) / rx, dy = (py - R) / R;
84 if (dx * dx + dy * dy <= 1) hit++;
85 }
86 }
87 return hit / 16;
88}
89
90// Braille dot bits: [dotRow][dotCol]
91const BR = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
92
93export function barCells({ width, done, total, status, pill, t, skipped = [] }) {
94 const W = width;
95 const P = PAL[status] || PAL.working;
96 const text = Array.from(String(pill ?? "")).slice(0, Math.max(1, W - 4));
97 const pillW = text.length + 4; // cap + pad + text + pad + cap
98 const frac = clamp01(total > 0 ? done / total : 0);
99 const fillEnd = status === "done" ? W : frac * W;
100 let pillX = Math.round(fillEnd - pillW / 2);
101 if (pillX > W - pillW) pillX = W - pillW;
102 if (pillX < 0) pillX = 0;
103 const pillEnd = pillX + pillW;
104 const textX = pillX + 2;
105
106 const breathe = P.breathe ? Math.round(0.5 + 0.5 * Math.sin((t / 3600) * Math.PI * 2)) : 0;
107 const span = Math.max(1, pillX);
108 const shimmerPos = P.shimmer ? ((t % 2200) / 2200) * (span + 8) - 4 : -99;
109
110 const ticks = new Set();
111 for (let i = 1; i < total; i++) ticks.add(Math.round((i / total) * W));
112
113 const out = new Array(W * 2);
114 for (let r = 0; r < 2; r++) {
115 for (let x = 0; x < W; x++) {
116 const trackCol = x < pillX + pillW / 2 ? P.fill : P.empty;
117 const inPill = x >= pillX && x < pillEnd;
118 let cell;
119 if (r === 0 && x >= textX && x < textX + text.length) {
120 cell = [text[x - textX].codePointAt(0), P.ink, P.pill];
121 } else {
122 // four quadrant samples: TL, TR, BL, BR
123 const ct = [], cp = [], pc = [];
124 for (let q = 0; q < 4; q++) {
125 const qx = x + (q & 1) * 0.5, qy = 2 * r + (q >> 1);
126 ct.push(capsuleCov(qx, qy, 0.5, 0, W, RX_TRACK));
127 cp.push(inPill ? capsuleCov(qx, qy, 0.5, pillX, pillEnd, RX_PILL) : 0);
128 pc.push(qy === 0 || qy === 1 ? P.pill : qy === 2 ? P.pillMid : P.pillLip);
129 }
130 const solidTrack = ct.every((v) => v === 1) && cp.every((v) => v === 0);
131 if (solidTrack && !inPill && ticks.has(x) && x > 2 && x < W - 3) {
132 cell = [r === 0 ? TICK_TOP : TICK_BOT, x < pillX ? P.tickFill : P.tickEmpty, trackCol];
133 } else if (solidTrack && x < pillX) {
134 cell = dotCell(x, r, pillX, span, shimmerPos, breathe, t, P);
135 // A skipped task's slice: its dots fade toward the fill.
136 if (skipped.includes(Math.floor((x / W) * total))) cell = [cell[0], mix(cell[1], P.fill, 0.65), cell[2]];
137 } else {
138 cell = shapeCell(ct, cp, pc, trackCol);
139 }
140 }
141 out[r * W + x] = cell;
142 }
143 }
144 return out;
145}
146
147// Split the 4 quadrants into inside/outside of the top-most edge, average each
148// group's coverage (quantized), and draw the matching quadrant glyph.
149function shapeCell(ct, cp, pc, trackCol) {
150 const pillEdge = cp.some((v) => v > 0);
151 const cov = pillEdge ? cp : ct;
152 let mask = 0;
153 for (let q = 0; q < 4; q++) if (cov[q] >= 0.5) mask |= 1 << q;
154 const group = (inside) => {
155 let n = 0, sct = 0, scp = 0, lip = 0;
156 for (let q = 0; q < 4; q++) {
157 if (((mask >> q) & 1) !== (inside ? 1 : 0)) continue;
158 n++; sct += ct[q]; scp += cp[q];
159 lip += q >> 1; // bottom quadrants carry the darker pill rows
160 }
161 if (!n) return null;
162 const t = qcov(sct / n), p = qcov(scp / n);
163 if (t === 0 && p === 0) return -1; // terminal background
164 let c = mix(TERM_BG, trackCol, t);
165 if (p > 0) {
166 const top = pc[0], bot = pc[2];
167 c = mix(c, lip === 0 ? top : lip === n ? bot : mix(top, bot, 0.5), p);
168 }
169 return c;
170 };
171 let fg = group(true), bg = group(false);
172 // full pill cell on the lower row: show the face/lip bevel with ▀
173 if (mask === 15 && pillEdge && pc[0] !== pc[2]) {
174 return [0x2580, pc[0], pc[2]];
175 }
176 if (fg === null) return bg === -1 ? [SPACE, TERM_BG, DEF] : [SPACE, bg, bg];
177 if (bg === null || fg === bg) return fg === -1 ? [SPACE, TERM_BG, DEF] : [SPACE, fg, fg];
178 if (fg === -1) { // draw the visible part as fg instead
179 return [QUAD[15 - mask], bg, DEF];
180 }
181 return [QUAD[mask], fg, bg === -1 ? DEF : bg];
182}
183
184function dotCell(x, r, pillX, span, shimmerPos, breathe, t, P) {
185 const u = clamp01((x + 1) / span); // 0 at the left cap, 1 next to the pill
186 const near = pillX - x; // 1 = cell touching the pill
187 const sh = shimmerPos > -50 ? Math.max(0, 1 - Math.abs(x - shimmerPos) / 3.5) : 0;
188 let density = 0.1 + 0.8 * Math.pow(u, 1.6) + 0.35 * sh;
189 if (near <= 2) density = Math.max(density, near === 1 ? 0.97 : 0.88);
190
191 let bits = 0;
192 // row 0 uses dot rows 1..3, row 1 uses dot rows 0..2 → one dot of margin top and bottom
193 for (let dr = r === 0 ? 1 : 0; dr < (r === 0 ? 4 : 3); dr++) {
194 for (let dc = 0; dc < 2; dc++) {
195 const id = (r * 4 + dr) * 2 + dc;
196 let v = hash(x, id, 1);
197 if (P.twP) {
198 // most dots keep their seat; a few re-roll each staggered epoch → twinkle
199 const epoch = Math.floor((t + hash(x, id, 7) * P.twP) / P.twP);
200 if (hash(x, id, 1000 + epoch) < P.twinkle) v = hash(x, id, 2000 + epoch);
201 }
202 if (v < density) bits |= BR[dr][dc];
203 }
204 }
205 if (!bits) return [SPACE, P.dotLo, P.fill];
206
207 let lvl = Math.round(1 + 4.6 * Math.pow(u, 1.3));
208 if (near <= 2) lvl = near === 1 ? 7 : 6;
209 if (P.twP) {
210 const e = Math.floor((t + hash(x, r, 9) * P.twP * 2) / (P.twP * 2));
211 if (hash(x, r, 3000 + e) < P.twinkle * 0.9) lvl += 1;
212 }
213 lvl += Math.round(2.2 * sh) + breathe;
214 if (lvl > LEVELS - 1) lvl = LEVELS - 1;
215 return [0x2800 + bits, mix(P.dotLo, P.dotHi, lvl / (LEVELS - 1)), P.fill];
216}
217hooks/bar-svg.mjs 122 lines1// The desktop bar: one SVG, like the Ultracode effort slider. A rounded track,
2// a grid of square dots that grows denser and brighter toward a rounded pill
3// riding the fill edge, and tick marks between tasks. With `isMoving`, SMIL
4// makes the dots twinkle and a soft shine sweep toward the pill; otherwise the
5// same picture holds still.
6
7export const SVG_HEIGHT = 24;
8
9const DOT = 3;
10const PITCH = 4;
11const DOT_ROWS = 4;
12const MAX_COLUMNS = 150;
13const PILL_FONT = 11;
14const PILL_CHAR = 6.7;
15
16const PALETTE = {
17 working: { track: "#2a2838", dot: "#b9adff", tick: "#e6e1ff", pill: "#a99cf5", ink: "#1d1a2e" },
18 failed: { track: "#33242a", dot: "#ff9a8a", tick: "#ffe1db", pill: "#e5604f", ink: "#ffffff" },
19 waiting: { track: "#332b1f", dot: "#ffd27a", tick: "#fff0d1", pill: "#f0b34a", ink: "#2a1c05" },
20 done: { track: "#22302a", dot: "#9fe0ad", tick: "#e3f7e7", pill: "#7cc68a", ink: "#14261a" },
21};
22const EMPTY_TICK = "#55545e";
23const BAND_DARK = "#212121";
24
25export function barSvg({ width, done, total, status, pill, isMoving, skipped = [] }) {
26 const p = PALETTE[status];
27 const h = SVG_HEIGHT;
28 const r = h / 2;
29 const pillW = Math.min(width, Math.ceil(pill.length * PILL_CHAR) + 22);
30 const fillX = Math.round((done / total) * width);
31 const pillX = Math.max(0, Math.min(width - pillW, fillX - pillW / 2));
32 const fillEnd = Math.max(pillX + r, fillX);
33
34 const parts = [
35 // A moving bar is drawn in a sandboxed frame; without a matching color-scheme the browser paints
36 // an opaque white backdrop. The corners outside the track take the band's colour when dark.
37 isMoving ? `<style>:root{color-scheme:light dark}.bd{fill:none}@media (prefers-color-scheme:dark){.bd{fill:${BAND_DARK}}}</style><rect class="bd" width="${width}" height="${h}"/>` : "",
38 `<defs><clipPath id="track"><rect width="${width}" height="${h}" rx="${r}"/></clipPath>`,
39 `<clipPath id="fill"><rect width="${fillEnd}" height="${h}"/></clipPath>`,
40 `<linearGradient id="shine" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/>`,
41 `<stop offset=".5" stop-color="#fff" stop-opacity=".22"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs>`,
42 `<rect width="${width}" height="${h}" rx="${r}" fill="${p.track}"/>`,
43 `<g clip-path="url(#track)"><g clip-path="url(#fill)">`,
44 dots(p, fillEnd, h, status, isMoving),
45 ];
46 if (isMoving && status === "working" && fillEnd > 40) {
47 const band = Math.max(40, fillEnd * 0.25);
48 parts.push(
49 `<rect x="${-band}" width="${band}" height="${h}" fill="url(#shine)">`,
50 `<animate attributeName="x" from="${-band}" to="${fillEnd}" dur="1.1s" repeatCount="indefinite"/></rect>`,
51 );
52 }
53 // A skipped task's slice is veiled in the track colour, so its dots read dimmer.
54 for (const i of skipped) {
55 const x0 = Math.round((i / total) * width);
56 const x1 = Math.round(((i + 1) / total) * width);
57 parts.push(`<rect class="skip" x="${x0}" width="${x1 - x0}" height="${h}" fill="${p.track}" fill-opacity=".65"/>`);
58 }
59 parts.push(`</g>`);
60 for (let k = 1; k < total; k++) {
61 const x = Math.round((k / total) * width);
62 if (x > pillX - 2 && x < pillX + pillW + 2) continue;
63 const isFilled = x < fillX;
64 parts.push(
65 `<rect x="${x - 0.5}" y="${h * 0.25}" width="1" height="${h * 0.5}" rx=".5" fill="${isFilled ? p.tick : EMPTY_TICK}" fill-opacity="${isFilled ? 0.9 : 0.8}"/>`,
66 );
67 }
68 parts.push(`</g>`);
69 parts.push(
70 `<rect x="${pillX}" y="1" width="${pillW}" height="${h - 2}" rx="${(h - 2) / 2}" fill="${p.pill}"/>`,
71 `<text x="${pillX + pillW / 2}" y="${h / 2}" fill="${p.ink}" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="${PILL_FONT}" font-weight="600" text-anchor="middle" dominant-baseline="central">${escape(pill)}</text>`,
72 );
73 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}">${parts.join("")}</svg>`;
74}
75
76// Square dots on a grid: sparse and faint at the left, dense and bright at the
77// fill edge. Which dots show and how they twinkle come from a hash of their
78// place, so the same bar always draws the same field.
79function dots(p, fillEnd, h, status, isMoving) {
80 const top = (h - (DOT_ROWS - 1) * PITCH - DOT) / 2;
81 // At most MAX_COLUMNS dot columns, so a wide bar stays under the SVG size cap.
82 const step = Math.max(PITCH, fillEnd / MAX_COLUMNS);
83 const out = [];
84 for (let x = PITCH; x < fillEnd - 2; x += step) {
85 const ramp = Math.pow(x / Math.max(1, fillEnd), 1.6);
86 for (let row = 0; row < DOT_ROWS; row++) {
87 const seed = hash(x * 31 + row * 7);
88 if (seed > 0.3 + ramp * 0.7) continue;
89 const opacity = Math.min(1, (0.3 + ramp * 0.8) * (0.75 + 0.35 * hash(seed * 997)));
90 const y = top + row * PITCH;
91 const rect = `<rect x="${Math.round(x)}" y="${y}" width="${DOT}" height="${DOT}" rx=".4" fill="${p.dot}" fill-opacity="${opacity.toFixed(2)}"`;
92 if (!isMoving) {
93 out.push(`${rect}/>`);
94 continue;
95 }
96 const slow = status === "failed" ? 1.8 : status === "done" ? 2.2 : 1;
97 const dur = ((0.35 + hash(seed * 13) * 0.55) * slow).toFixed(2);
98 const begin = (-hash(seed * 29) * Number(dur)).toFixed(2);
99 const dim = (opacity * 0.15).toFixed(2);
100 const twinkle = `<animate attributeName="fill-opacity" values="${opacity.toFixed(2)};${dim};${opacity.toFixed(2)}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>`;
101 // Glitter: some dots, more of them near the pill, flash white for a blink.
102 const isSparkle = status === "working" && hash(seed * 53) < 0.08 + ramp * 0.22;
103 const flashDur = (0.9 + hash(seed * 71) * 1.3).toFixed(2);
104 const flashBegin = (-hash(seed * 89) * Number(flashDur)).toFixed(2);
105 const sparkle = isSparkle
106 ? `<animate attributeName="fill" values="${p.dot};#ffffff;${p.dot};${p.dot}" keyTimes="0;.06;.16;1" dur="${flashDur}s" begin="${flashBegin}s" repeatCount="indefinite"/>`
107 : "";
108 out.push(`${rect}>${twinkle}${sparkle}</rect>`);
109 }
110 }
111 return out.join("");
112}
113
114function hash(n) {
115 const s = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
116 return s - Math.floor(s);
117}
118
119function escape(text) {
120 return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
121}
122hooks/timeline-svg.mjs 118 lines1// The gutter of a finished bar's task list: one small SVG per row that together draw a
2// connected timeline. Steps that ran at the same time leave the main line on a branch
3// (fork, run, join); a sub-agent's row carries a robot head instead of a dot.
4//
5// SMIL moves it: on opening, the line draws itself down and the dots pop in row by row;
6// then a light pulse runs down the line on a loop, splitting into branches, and each dot
7// rings as it passes. Every row times its part by its index, so the rows act as one.
8
9export const ROW_HEIGHT = 22;
10export const GUTTER_WIDTH = 30;
11
12const MAIN_X = 7;
13const BRANCH_X = 21;
14const LINE = "#5d5c68";
15const DONE = "#7cc68a";
16const AGENT = "#a99cf5";
17const BAND_DARK = "#212121";
18
19const STAGGER = 0.07; // seconds between rows as the line draws in
20const DRAW = 0.18;
21const SLOT = 0.28; // seconds the pulse spends crossing one row
22const PAUSE = 1.6; // seconds between pulses
23
24// pos: "main" | "start" | "middle" | "end" | "only" (the last four are rows on the branch).
25export function timelineSvg({ pos, isFirst, isLast, isAgent, isSkipped, index = 0, count = 1 }) {
26 const h = ROW_HEIGHT;
27 const mid = h / 2;
28 const isBranch = pos !== "main";
29 const x = isBranch ? BRANCH_X : MAIN_X;
30 const drawAt = index * STAGGER;
31 const pulse = pulseTiming(index, count);
32 const parts = [
33 // Drawn in a sandboxed frame; without a matching color-scheme the browser paints an
34 // opaque white backdrop. Dark, the corners take the band's colour.
35 `<style>:root{color-scheme:light dark}.bd{fill:none}@media (prefers-color-scheme:dark){.bd{fill:${BAND_DARK}}}</style>`,
36 `<rect class="bd" width="${GUTTER_WIDTH}" height="${h}"/>`,
37 ];
38 const draw = `<animate attributeName="stroke-dashoffset" from="1" to="0" begin="${drawAt}s" dur="${DRAW}s" fill="freeze"/>`;
39 const stroke = (shape) =>
40 parts.push(`${shape} fill="none" stroke="${LINE}" stroke-width="1.5" pathLength="1" stroke-dasharray="1" stroke-dashoffset="1">${draw}</path>`);
41 const line = (x1, y1, x2, y2) => stroke(`<path d="M${x1} ${y1} L${x2} ${y2}"`);
42 const curve = (d) => stroke(`<path d="${d}"`);
43
44 // The main line runs through every row; it starts at the first dot and ends at the last.
45 const top = isFirst && !isBranch ? mid : 0;
46 const bottom = isLast && !isBranch ? mid : h;
47 line(MAIN_X, top, MAIN_X, bottom);
48 parts.push(runner(MAIN_X, top, bottom, pulse));
49
50 if (pos === "start" || pos === "only") curve(`M${MAIN_X} 1 Q${BRANCH_X} 1 ${BRANCH_X} ${mid}`);
51 if (pos === "middle" || pos === "end") line(BRANCH_X, 0, BRANCH_X, mid);
52 if (pos === "start" || pos === "middle") line(BRANCH_X, mid, BRANCH_X, h);
53 if (pos === "end" || pos === "only") curve(`M${BRANCH_X} ${mid} Q${BRANCH_X} ${h - 1} ${MAIN_X} ${h - 1}`);
54 if (isBranch) {
55 const branchTop = pos === "start" || pos === "only" ? mid : 0;
56 const branchBottom = pos === "end" || pos === "only" ? mid : h;
57 parts.push(runner(BRANCH_X, branchTop, branchBottom, pulse));
58 }
59
60 const popAt = drawAt + DRAW * 0.6;
61 if (isAgent) parts.push(robot(x, mid, popAt, index));
62 else parts.push(dot(x, mid, isSkipped, popAt, pulse));
63 return `<svg xmlns="http://www.w3.org/2000/svg" width="${GUTTER_WIDTH}" height="${h}" viewBox="0 0 ${GUTTER_WIDTH} ${h}">${parts.join("")}</svg>`;
64}
65
66// One pulse cycle covers every row in turn, then rests. Rows share the cycle length and
67// start, and each moves its pulse only inside its own slot of it.
68function pulseTiming(index, count) {
69 const cycle = count * SLOT + PAUSE;
70 const begin = count * STAGGER + DRAW + 0.3;
71 const at = (seconds) => Math.min(1, Math.max(0, seconds / cycle)).toFixed(4);
72 return { cycle, begin, from: at(index * SLOT), middle: at((index + 0.5) * SLOT), to: at((index + 1) * SLOT), ring: at((index + 0.5) * SLOT + 0.5) };
73}
74
75// A bright bead with a soft halo that crosses the row in its slot and waits off-canvas otherwise.
76function runner(x, y1, y2, p) {
77 const out = -6;
78 const end = ROW_HEIGHT + 6;
79 const values = `${out};${out};${y1};${y2};${end};${end}`;
80 const keyTimes = `0;${p.from};${p.from};${p.to};${p.to};1`;
81 const move = `<animate attributeName="cy" values="${values}" keyTimes="${keyTimes}" dur="${p.cycle}s" begin="${p.begin}s" repeatCount="indefinite"/>`;
82 return [
83 `<circle cx="${x}" cy="${out}" r="4" fill="${DONE}" fill-opacity=".35">${move}</circle>`,
84 `<circle cx="${x}" cy="${out}" r="1.8" fill="#ffffff">${move}</circle>`,
85 ].join("");
86}
87
88function dot(x, y, isSkipped, popAt, p) {
89 const pop = `<animate attributeName="r" values="0;5;4" keyTimes="0;.7;1" begin="${popAt}s" dur=".25s" fill="freeze"/>`;
90 if (isSkipped) {
91 return `<circle cx="${x}" cy="${y}" r="0" fill="${BAND_DARK}" stroke="${DONE}" stroke-opacity=".6" stroke-width="1.5">${pop}</circle>`;
92 }
93 const ringTimes = `0;${p.middle};${p.middle};${p.ring};1`;
94 const ring = [
95 `<circle cx="${x}" cy="${y}" r="4" fill="none" stroke="${DONE}" stroke-width="1.2" stroke-opacity="0">`,
96 `<animate attributeName="r" values="4;4;4;9;9" keyTimes="${ringTimes}" dur="${p.cycle}s" begin="${p.begin}s" repeatCount="indefinite"/>`,
97 `<animate attributeName="stroke-opacity" values="0;0;.8;0;0" keyTimes="${ringTimes}" dur="${p.cycle}s" begin="${p.begin}s" repeatCount="indefinite"/>`,
98 `</circle>`,
99 ].join("");
100 return `${ring}<circle cx="${x}" cy="${y}" r="0" fill="${DONE}">${pop}</circle>`;
101}
102
103// A robot head: antenna with a blinking light, rounded face, two eyes that blink.
104function robot(x, y, popAt, index) {
105 const blinkAt = (index * 0.37) % 2.8;
106 const eye = (ex) =>
107 `<circle cx="${ex}" cy="${y}" r="1.4" fill="#1d1a2e"><animate attributeName="r" values="1.4;1.4;.25;1.4" keyTimes="0;.9;.95;1" dur="2.8s" begin="${blinkAt}s" repeatCount="indefinite"/></circle>`;
108 return [
109 `<g opacity="0"><animate attributeName="opacity" from="0" to="1" begin="${popAt}s" dur=".25s" fill="freeze"/>`,
110 `<line x1="${x}" y1="${y - 8}" x2="${x}" y2="${y - 5}" stroke="${AGENT}" stroke-width="1.5"/>`,
111 `<circle cx="${x}" cy="${y - 8.5}" r="1.4" fill="${AGENT}"><animate attributeName="fill-opacity" values="1;.25;1" dur="1.2s" repeatCount="indefinite"/></circle>`,
112 `<rect x="${x - 6}" y="${y - 5}" width="12" height="10" rx="3" fill="${AGENT}"/>`,
113 eye(x - 2.5),
114 eye(x + 2.5),
115 `</g>`,
116 ].join("");
117}
118types/index.d.ts 81 lines1export type ProgressTask = {
2 id: string;
3 subject: string;
4 status: "pending" | "in_progress" | "completed";
5 startedAt?: number | null;
6 doneAt?: number | null;
7 isSkipped?: boolean;
8 tokens?: number;
9 failures?: number;
10};
11
12export type ProgressTrack = {
13 id: string;
14 label: string | null;
15 tasks: ProgressTask[];
16 failedCommand: string | null;
17 failedRuns?: number;
18 isDismissed: boolean;
19 isWaiting?: boolean;
20 isExpanded?: boolean;
21 // An automatic bar (Working, Planning): it counts steps, its total unknown.
22 isCounter?: boolean;
23 // Measured time: turns that overlapped the track, and waits on the person.
24 turnMs?: number;
25 waitMs?: number;
26 waitingSince?: number | null;
27 // Tokens the API reported for the model calls made while the track ran. input counts
28 // every prompt token read (uncached, cache reads and cache writes); cached is the reads.
29 tokens?: { input: number; cached: number; output: number; calls: number };
30};
31
32// The Progress pane's log: what waited on the person, and findings.
33export type ProgressBlock = {
34 id?: string;
35 kind: "permission" | "question" | "approval";
36 text: string;
37 task: string | null;
38 at: number;
39 answeredAt: number | null;
40 toolUseId?: string | null;
41 // How it ended: the call's outcome, the plan's answer, or whether a question was answered.
42 outcome?: string;
43 // A question's answers, keyed by the question's text.
44 answers?: Record<string, string> | null;
45 details?: {
46 tool?: string;
47 request?: string;
48 questions?: { question: string; options: string[] }[];
49 plan?: string;
50 steps?: number;
51 } | null;
52 isDemo?: boolean;
53};
54
55export type ProgressFinding = {
56 id?: string;
57 kind: "found" | "failed";
58 text: string;
59 task: string | null;
60 at: number;
61 toolUseId?: string | null;
62 // A failing check: its command and the output lines that name the problem.
63 command?: string;
64 lines?: string[];
65 isDemo?: boolean;
66};
67
68export type ProgressLog = { blocks: ProgressBlock[]; findings: ProgressFinding[] };
69
70declare module "claude-code" {
71 interface PluginState {
72 "progress-tracker": {
73 tracks: ProgressTrack[];
74 log: ProgressLog;
75 tab: "blocked" | "found";
76 turn: number | null;
77 open: string | null;
78 };
79 }
80}
81