Live TodoWrite progress bar above the prompt

A Claude Code Mod that pins a live progress bar above the prompt while the agent works through a TodoWrite todo list. Zero dependencies, pure JS, no build step.
When Claude Code grinds through a long task, the plan lives in the transcript — you have to scroll up to find it, or interrupt the agent to ask "where are you?". This mod watches TodoWrite tool calls and draws a one-line summary that stays visible the whole time:
██████░░░░ 60% · step 3/5 · Writing tests…
activeForm text of the in_progress todo (falls back to content, then to the first pending todo when nothing is in progress)The bar appears as soon as there is an active todo list (at least one in_progress or pending todo) and disappears when the list is empty or everything is completed. Every TodoWrite call re-renders it live.
Requires Claude Code v2.1.287+ (Mods support).
# Try it for one session:
/plugin install ./task-progress-mod
# or point Claude Code at the folder directly:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ~/workspace/task-progress-mod
After installing, reload plugins (/reload-plugins) if a session is already open. No MCP server, no daemon, no accounts, no network calls.
Plugin layout:
task-progress-mod/
.claude-plugin/plugin.json # name, version, description, author
hooks/hooks.json # -> ./register.js (single module)
hooks/register.js # the mod: tool.call + ui.render hooks
tests/test_mod.js # node smoke tests (mocked events)
README.md
LICENSE
Above the prompt, while a task is running:
████░░░░░░ 40% · step 3/5 · Writing tests
When the agent marks a todo completed and moves on, the bar updates on the next render — no scrolling, no asking, no tokens spent on status checks. When the last todo completes, the bar hides itself.
TodoWrite state the agent maintains and shows it without any model involvement.TodoWrite updates are already in the transcript, but they scroll away during long tool runs. This pins the one line you actually want (percent, step, current action) above the prompt where it never scrolls off.tool.call event, so it tracks the authoritative todo list, including activeForm phrasing that never appears in a statusline.Tuning constants live at the top of hooks/register.js:
BAR_WIDTH (default 10) — blocks in the barLABEL_MAX (default 56) — max chars of the current-step labelNo config file, no settings UI. Edit the two constants and reload the plugin.
Pane, but a Pane only draws at ≥144 terminal columns (110 once explicitly opened). The band above the prompt is AbovePrompt, which always draws — so the mod hooks { component: 'AbovePrompt' }. A one-line bar doesn't need a sidebar.tool.call payload for TodoWrite, so the mod extracts the list from e.input.todos, e.todos, e.params.todos, and e.arguments.todos. If a future build moves it somewhere new, the bar silently stops updating instead of crashing (fail-open).$.state. A hot reload resets the snapshot and the bar hides until the next TodoWrite call re-captures it. Self-healing, but if you stare at the bar across a reload you'll see it blink out once.TodoWrite, are invisible to the mod — by design; it shows the plan the agent committed to the todo list, nothing inferred.claude -p, and the Agent SDK, but nothing drawn appears there (engine behavior, not this mod's choice).register(on), $.ui.resolve(e), $.ui.invalidate('ui.render'), next(e) passthrough) and passes mocked-event smoke tests, but claude plugin validate / claude plugin test were not run in this environment — run them before publishing.node tests/test_mod.js # 33 checks, mocked on/$/e/next, zero dependencies
The suite covers: bar math at 0%/40%/75%, step numbering, activeForm vs content fallback, hiding when idle (empty / all-completed returns next(e)), three TodoWrite event shapes, malformed-input fail-open, $.ui.invalidate on capture, label truncation, and a static scan of register.js for loader-banned globals (require, process, fetch, timers, JSX).
MIT
hooks/register.js 129 lines1// task-progress-mod — live TodoWrite progress bar above the prompt.
2//
3// Hooks:
4// tool.call { tool: 'TodoWrite' } -> capture the todo list, invalidate ui.render
5// ui.render { component: 'AbovePrompt' } -> draw (or hide) the progress bar
6//
7// Pure JS, zero dependencies, no JSX: elements are called as plain functions
8// (Box({...}), Text({...})), which the mod loader supports without a build step.
9//
10// Loader constraints honored (static scan): `$` appears only as
11// `$.noun.method(...)` at call sites; event names are string literals;
12// no require/fetch/Node globals.
13
14'use strict';
15
16var BAR_WIDTH = 10;
17var FILLED = '█'; // U+2588 FULL BLOCK
18var EMPTY = '░'; // U+2591 LIGHT SHADE
19var LABEL_MAX = 56; // max chars of the current-step label before truncation
20
21// Snapshot of the latest todo list from the most recent TodoWrite call.
22// Module-level: a hot reload resets this to [] (bar hides), and the next
23// TodoWrite call re-captures it. Self-healing, no persistence needed.
24var latestTodos = [];
25
26function isTodoList(v) {
27 return (
28 Array.isArray(v) &&
29 v.every(function (t) {
30 return t !== null && typeof t === 'object';
31 })
32 );
33}
34
35// The exact tool.call event shape for TodoWrite input is not pinned down in
36// public docs, so extract defensively from every plausible location.
37function extractTodos(e) {
38 if (!e || typeof e !== 'object') return null;
39 var candidates = [
40 e.input && e.input.todos,
41 e.todos,
42 e.params && e.params.todos,
43 e.arguments && e.arguments.todos,
44 ];
45 for (var i = 0; i < candidates.length; i++) {
46 if (isTodoList(candidates[i])) return candidates[i];
47 }
48 return null;
49}
50
51function todoStatus(t) {
52 return typeof t.status === 'string' ? t.status : 'pending';
53}
54
55// Returns { pct, filled, step, total, label }, or null when there is nothing
56// worth showing (no todos, all completed, or no active step found).
57function progressView(todos) {
58 var total = todos.length;
59 if (total === 0) return null;
60 var done = 0;
61 var currentIdx = -1;
62 for (var i = 0; i < total; i++) {
63 var s = todoStatus(todos[i]);
64 if (s === 'completed') {
65 done++;
66 } else if (currentIdx === -1 && (s === 'in_progress' || s === 'pending')) {
67 currentIdx = i;
68 }
69 }
70 if (done === total || currentIdx === -1) return null;
71 var pct = Math.round((done / total) * 100);
72 var current = todos[currentIdx];
73 var label =
74 (typeof current.activeForm === 'string' && current.activeForm) ||
75 (typeof current.content === 'string' && current.content) ||
76 '';
77 if (label.length > LABEL_MAX) label = label.slice(0, LABEL_MAX - 1) + '…';
78 return {
79 pct: pct,
80 filled: Math.round((done / total) * BAR_WIDTH),
81 step: currentIdx + 1,
82 total: total,
83 label: label,
84 };
85}
86
87function barString(filled) {
88 var bar = '';
89 for (var i = 0; i < BAR_WIDTH; i++) bar += i < filled ? FILLED : EMPTY;
90 return bar;
91}
92
93export function register(on) {
94 // Observe TodoWrite calls: snapshot the list and ask the engine to re-render.
95 on('tool.call', { tool: 'TodoWrite' }, async function ($, e, next) {
96 var t = extractTodos(e);
97 if (t) {
98 latestTodos = t;
99 $.ui.invalidate('ui.render');
100 }
101 return next(e);
102 });
103
104 // Band above the prompt. Return next(e) (draw nothing) when idle.
105 on('ui.render', { component: 'AbovePrompt' }, async function ($, e, next) {
106 var v = progressView(latestTodos);
107 if (!v) return next(e);
108 // await works whether resolve() is sync or async in this build.
109 var resolved = await $.ui.resolve(e);
110 var Box = resolved.Box;
111 var Text = resolved.Text;
112 var suffix =
113 ' ' +
114 v.pct +
115 '% · step ' +
116 v.step +
117 '/' +
118 v.total +
119 (v.label ? ' · ' + v.label : '');
120 return Box({
121 flexDirection: 'row',
122 children: [
123 Text({ color: 'green', children: barString(v.filled) }),
124 Text({ dimColor: true, children: suffix }),
125 ],
126 });
127 });
128}
129