SLOPSHOPPER

progress-tracker

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

newpanebandguardcommandtoast
v0.2.0MITupdated 2026-10-09mjoyshuvo/claude-progress-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · progress-tracker
│ ┃ Progress ✕ › fix the failing auth test and add an audit log call │ ┃ ● Demo: ship a feature Plan 1/7 │ ┃ ⏺ Read(src/auth.ts) │ ┃ PROGRESS THIS TASK WORKED WAITED ON ⎿ Read 6 lines │ ┃ 0% 0 of 7 0s 0s none ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Blocked on me 0 ] [ Found 1 ] ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ✓ Nothing waits on you. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Permission prompts, questions and plan │ ┃ approvals show up here. ✻ Worked for 42s · done 4:20 PM │ │ › /progress-tracker-demo │ ⎿ progress-tracker: Playing a progress-tracker demo above the prom │ │ ● Working ▟ ⠒ ⠠⢠⢤⠠⠰⡔⡢⢆⠄⣖⣐⡂⢖⢆⠔⣄⣦⢴⣐⢦⣆⢖⡶⣶▟ ✓ Done · 10 steps ▙ 0s work ▸ ☰ ✕ ▜ ⠐ ⠂⠡⠌⠉⠅⠂⠒⠄⠂⠉⠔⠗⠫⠬⠳⠚⠫⠧⠭⠋⠯⠿⠿⠿▜▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▛ ● Demo: ship a feature ▟ Plan 1/7 ▙ ╷ ╷ ╷ ╷ ╷ ▙ 0% · 0s work ▸ ☰ ✕ ▜▀▀▀▀▀▀▀▀▀▀▛ ╵ ╵ ╵ ╵ ╵ ▛ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● Working ▟ ⠒ ⠠⢠⢤⠠⠰⡔⡢⢆⠄⣖⣐⡂⢖⢆⠔⣄⣦⢴⣐⢦⣆⢖⡶⣶▟ ✓ Done · 10 steps ▙ 0s work ▸ ☰ ✕ ▜ ⠐ ⠂⠡⠌⠉⠅⠂⠒⠄⠂⠉⠔⠗⠫⠬⠳⠚⠫⠧⠭⠋⠯⠿⠿⠿▜▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▛ ● Demo: ship a feature ▟ Plan 1/7 ▙ ╷ ╷ ╷ ╷ ╷ ▙ 0% · 0s work ▸ ☰ ✕ ▜▀▀▀▀▀▀▀▀▀▀▛ ╵ ╵ ╵ ╵ ╵ ▛
Pane · Progress
● Demo: ship a feature Plan 1/7 PROGRESS THIS TASK WORKED WAITED ON YOU CONTEXT 0% 0 of 7 0s 0s none 49% [ Blocked on me 0 ] [ Found 1 ] ✓ Nothing waits on you. Permission prompts, questions and plan approvals show up here.
README

progress-tracker

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.

A desktop session: a Working bar counts steps, Claude's own list takes over with a real total, a test fails twice and turns the row red, a permission prompt turns it amber, and the list finishes green with its timeline open. The Progress pane beside the chat shows the measured numbers, the failing check's error lines, and the answered permission prompt.

Install the mod

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.

Check the desktop app's version

  1. In the Code tab, start a local session.
  2. Type /status, and read the Claude Code row.
  3. If the version is lower than 2.1.286, update the app. On macOS, select Claude > Check for Updates. On Windows, select Help > Check for Updates. Then start a new session.

Install in the desktop app

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.

  1. In a terminal, add the marketplace:
	claude plugin marketplace add mjoyshuvo/claude-progress-tracker
  1. Install the mod in the desktop app:
  2. In a local session of the Code tab, click the + button next to the prompt box.
  3. Select Plugins, then Add plugin.
  4. Select progress-tracker.
  5. For the scope, choose your user account.

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.

  1. Start a new session in the Code tab. To load the mod in a session that is already open, type /reload-plugins.
  2. Type /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.

Install in the terminal

  1. In a Claude Code session, add the marketplace:
	/plugin marketplace add mjoyshuvo/claude-progress-tracker
  1. Install the mod:
	/plugin install progress-tracker@mjoyshuvo-mods
  1. Start a new session.
  2. Type /progress-tracker-demo.

Load a local copy

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.

  1. Clone the repository:
	git clone https://github.com/mjoyshuvo/claude-progress-tracker.git ~/.claude/progress-tracker
  1. Open ~/.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 ~.

  1. Start a new session in the Code tab.

To update a local copy, run git pull in its folder, then start a new session.

Turn off other progress bar mods

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

Features

  • Live bar. Each task list gets a row with a moving bar. The pill names the current task and its step, for example Write tests 3/5.
  • Real totals only. A list that Claude declares shows 3/5 and a percent. Work without a list shows a step count, for example Working · 12 steps, because its total is not known.
  • Early task list. On the 3rd tool call of a turn with no list, the mod asks Claude for its task list, so most work gets a real total.
  • Waits on you. A question, a permission prompt, or a plan approval turns the row amber. A question or a permission prompt also shows a toast.
  • Failing checks. A failing test, build, lint, or typecheck turns the row red and shows the first error line. The pill counts repeated failures, for example ×3.
  • Measured numbers. The row shows the time Claude worked, the time it waited on you, and the input and output tokens that the API reported. It does not guess the time left.
  • Context warning. From 80% full, the row shows the context fill.
  • Progress pane. /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.
  • Task timeline. Each row expands to list its tasks with their times. On desktop, parallel steps and sub-agents appear on a branch.
  • Sub-agent count. While sub-agents that Claude started run, the pill shows how many.
  • Plan mode. Planning gets its own row, and an approved plan becomes a row with the plan's steps.

Watch the demo

/progress-tracker-demo plays a 7-step job on its own row:

  1. The Progress pane opens, and the row expands to list its tasks.
  2. Three file reads run in parallel.
  3. A sub-agent runs, and the pill shows · 1 agent.
  4. The "Test" step fails twice. The row turns red, the pill shows ×2, and the Found tab lists the failure and a finding.
  5. The step recovers. Then the row turns amber, and Blocked on me lists Allow npm publish until it is answered.
  6. The bar finishes green. About 12 seconds later, the row and its pane entries clear.

The demo bar moves even while Claude is idle.

Read the bar

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:

ColourStatePill text
PurpleClaude is working.Build 2/5 or Working · 12 steps
RedA check failed in the current task.✕ Verify 1/2 or ✕ Test 3/5 ×3
AmberClaude waits for you.Waiting for you or Waiting for approval
GreenEvery task is done.✓ Done 5/5 or ✓ Done · 12 steps

The numbers after the bar are all measured:

NumberMeaning
60%The finished tasks of a list that Claude declared. An automatic bar has no percent.
4m 10s workThe 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 waitThe time the row was amber.
1.2M in · 14k outThe 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:

ButtonAction
▸ in the terminal, ▶ Tasks on desktopLists 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.

Use the Progress pane

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.

TabShows
Blocked on mePermission prompts, questions, and plan approvals. Open ones come first. Answered ones say how long they waited.
FoundEach 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:

  • A question shows each question, its options, and the answer you gave.
  • A permission prompt shows the tool, the full request, and how the call ended: it ran, it ran and ended with an error, or it did not run.
  • A plan approval shows the plan's title, its number of steps, and whether you approved it.
  • A failing check shows the command and up to 8 output lines that name the problem.
  • A finding shows its full text.

Every entry also shows its task, how long it waited, and the time. The pane keeps the last 50 entries of each kind.

Where the tasks come from

The mod reads only the main conversation. It ignores task calls that sub-agents make.

Claude's task tools

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.

The progress_tracker tool

The 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.

FieldTypeEffect
titlestringNames the row. Use it only with tasks.
tasksstring listCreates a row with these tasks. The first task starts.
nextbooleanFinishes the current task and starts the next one.
activestringMakes the task with this name the current one. Part of the name is enough.
addstring listAdds tasks found during the work to the end of the list.
skipbooleanCloses the current task as not needed. Its part of the bar is drawn dimmer.
failedstringTurns the row red with this reason.
fixedbooleanClears the red state.
foundstringAdds 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.

The Working bar

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.

Plan mode

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.

Waits, failures, and sub-agents

  • When Claude asks you a question with AskUserQuestion, or when a permission prompt waits for you, the row turns amber until you answer.
  • When a check command fails during a task, the row turns red and shows the first error line of the output. A check is a test, build, lint, or typecheck command. The command's first words decide: 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.
  • The row turns back from red when the same check passes with any flags, or when a task changes state.
  • While sub-agents that Claude started in this conversation run, the pill shows how many, for example Search 1/3 · 2 agents. Agents from other places do not count.

Read the task timeline

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.

  • For an automatic step, the time is how long the tool call ran. For one of Claude's own tasks, the time runs from the task's start to its finish.
  • Steps whose run times overlap leave the line on a branch and join it again. The first of them says · 3 in parallel.
  • A sub-agent step has a robot icon and the agent type, for example · Explore agent. A sub-agent that starts during one of Claude's own tasks appears under that task, on a branch.
  • A sub-agent that runs in the background shows background instead of a time.
  • A task that failed shows how many times, for example · 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.

Known limits

  • Only steps that overlap in time count as parallel. Claude Code runs file reads at the same time, but it often runs shell commands one after another. So Bash calls that Claude sends together can still appear in a straight line.
  • Work time and tokens count from when the mod loaded. A row that was open before a reload shows only what happened after it.
  • A permission entry says how the call ended, not who decided. A hook or a settings rule can also stop a call.
  • Each timeline row is a small sandboxed frame. A long task list runs many animated frames at once.
  • The timeline colours are tuned for the dark theme.

Develop the mod

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.

Project layout

PathContents
.claude-plugin/plugin.jsonThe plugin manifest.
.claude-plugin/marketplace.jsonThe mjoyshuvo-mods marketplace entry.
hooks/progress-tracker.mjsThe hooks: task tracking, plan mode, the Working bar, the progress_tracker tool, the Progress pane, and drawing.
hooks/bar.mjsThe terminal bar (barCells).
hooks/bar-svg.mjsThe desktop bar (barSvg).
hooks/timeline-svg.mjsThe task timeline rows (timelineSvg).
types/index.d.tsThe types of the stored rows and the pane's log.
tests/progress-tracker.test.tsThe tests.
dev/simulate.mjsThe stand-in run.
design/demo-gif.mjsDraws docs/demo.gif. design/frames-to-gif.py joins its frames.
design/Bar design previews and variants.

Why I built it

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.

License

MIT. See LICENSE.

If the mod helps you, a star on GitHub helps other people find it.

Source 5 files
hooks/progress-tracker.mjs 1843 lines
1// 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}
1200
hooks/bar.mjs 217 lines
1// 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}
217
hooks/bar-svg.mjs 122 lines
1// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
121}
122
hooks/timeline-svg.mjs 118 lines
1// 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}
118
types/index.d.ts 81 lines
1export 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