SLOPSHOPPER

task-progress-mod

Live TodoWrite progress bar above the prompt

newbandguard
v0.1.0MITupdated 2026-10-05hahahahahahahahah6/task-progress-mod
A shopper browsing a rack in a slop shop
README

task-progress-mod

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…
  • Percentage bar — completed / total, 10-block resolution
  • step X/Y — position of the current step in the full list
  • Current step — the 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.

Install

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

What the user sees

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.

Differentiation

  • vs asking "where are you?" — interrupting the agent to check progress costs a turn and pollutes context; the mid-task answer is often stale by the time you read it. The mod is ambient: it reads the same TodoWrite state the agent maintains and shows it without any model involvement.
  • vs scrolling the transcript — 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.
  • vs statusline hacks — a custom statusline is static shell config that can't see tool-call payloads. This hooks the real tool.call event, so it tracks the authoritative todo list, including activeForm phrasing that never appears in a statusline.

Configuration

Tuning constants live at the top of hooks/register.js:

  • BAR_WIDTH (default 10) — blocks in the bar
  • LABEL_MAX (default 56) — max chars of the current-step label

No config file, no settings UI. Edit the two constants and reload the plugin.

Honest limitations

  • Component choice is deliberate. The spec sketch said 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.
  • TodoWrite event shape is defensive, not pinned. Public docs don't pin the exact 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 is module-level, not $.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.
  • Only TodoWrite is tracked. Plans kept in prose, or tools other than TodoWrite, are invisible to the mod — by design; it shows the plan the agent committed to the todo list, nothing inferred.
  • Drawing surfaces vary. The bar renders in the terminal and the Desktop Code tab. Hooks still run in VS Code, claude -p, and the Agent SDK, but nothing drawn appears there (engine behavior, not this mod's choice).
  • Not validated against the real engine here. The mod follows the published Mods API shape (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.

Development

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

License

MIT

Source 1 files
hooks/register.js 129 lines
1// 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