SLOPSHOPPER

progress

Shows how far through the current task list Claude is, as a bar above the prompt, with a toast and a chime when the last step lands.

bandguardtoastprompttimeraudio
v0.1.0no licenseupdated 2026-10-18qalfarhan/claude-mods/progress
A shopper browsing a rack in a slop shop
README

claude-mods

Claude Code mods — small changes to Claude Code's own interface, written as function hooks.

progress

A bar above the prompt showing how far through the current task list Claude is: the step count, how long the run has taken, and the step under way. When the last step lands it raises a toast and plays a short chime.

▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░  3/7 · 4m 12s
▸ Build the mod

It reads whichever task list the session keeps — the desktop app's plan tool, TodoWrite, or TaskCreate/TaskUpdate — so it behaves the same in a terminal session and in the desktop app. A finished bar stays on screen as its own receipt until the next prompt. The band yields the row whenever a survey needs it.

Install

At the prompt of a terminal session:

/plugin install progress --marketplace qalfarhan/claude-mods

From a shell:

claude plugin install progress --marketplace qalfarhan/claude-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with qalfarhan/claude-mods, then Add plugin.

The chime plays through afplay, so it is macOS only; on Linux and Windows the toast still appears and nothing else changes.

Developing

Run a mod from this folder without installing it, either with claude --plugin-dir ~/.claude/mods/progress for one session, or by naming the folder in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json so it loads in every session. Add CLAUDE_CODE_PLUGIN_DIR_WATCH=1 beside it and saves hot-reload.

claude plugin validate progress
claude plugin test progress

progress/tsconfig.json extends progress/.claude-plugin/types/tsconfig.json, which the engine writes itself each time it loads the mod — so in a fresh clone that folder is absent and tsc -p progress cannot run until a session has loaded the mod once. Type-checking needs TypeScript 7: 5.6 cannot instantiate the tool-call argument union and reports a spurious TS2589 that moves between call sites.

Source 2 files
hooks/register.tsx 267 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Run, Step, StepStatus } from '../types'
5
6const run = atom({ plugin: 'progress', key: 'run' } as const, null)
7const nowSeconds = atom({ plugin: 'progress', key: 'nowSeconds' } as const, 0)
8
9/** The widest the bar is drawn; a narrow band gets less. */
10const BAR_CELLS = 21
11/** What the bar's counter and timer take beside it. */
12const GUTTER = 24
13
14// The task list reaches a hook as the tool's own arguments, whose shapes
15// differ per tool and, for the app's plan tool, are not in the built-in
16// table at all. Read them as plain data and normalise to Step[].
17type Loose = Record<string, unknown>
18
19const loose = (value: unknown): Loose =>
20  typeof value === 'object' && value !== null ? (value as Loose) : {}
21
22const isStatus = (value: unknown): value is StepStatus =>
23  value === 'pending' || value === 'in_progress' || value === 'completed'
24
25const text = (value: unknown): string | undefined =>
26  typeof value === 'string' && value.trim() !== '' ? value : undefined
27
28/** Rows of `{ content, status }`: the whole list, in order. */
29function listed(value: unknown): Step[] | undefined {
30  if (!Array.isArray(value)) return undefined
31
32  const steps: Step[] = []
33
34  for (const row of value) {
35    const label = text(loose(row).content)
36    if (label === undefined) continue
37
38    const status = loose(row).status
39    steps.push({ text: label, status: isStatus(status) ? status : 'pending' })
40  }
41
42  return steps.length === 0 ? undefined : steps
43}
44
45/** Completes the step under way and starts the next one waiting. */
46function advanced(prev: Step[]): Step[] {
47  // The list is already a fresh copy, so the found steps are written in place.
48  const steps = prev.map(step => ({ ...step }))
49  const under = steps.find(step => step.status === 'in_progress')
50
51  if (under !== undefined) under.status = 'completed'
52
53  const next = steps.find(step => step.status === 'pending')
54
55  if (next !== undefined) next.status = 'in_progress'
56
57  return steps
58}
59
60/** `done` and `start` name steps by their 1-based place in the list. */
61function marked(prev: Step[], done: unknown, start: unknown): Step[] {
62  const steps = prev.map(step => ({ ...step }))
63  const at = (place: unknown): Step | undefined =>
64    typeof place === 'number' && Number.isInteger(place) && place >= 1 && place <= steps.length
65      ? steps[place - 1]
66      : undefined
67
68  for (const place of Array.isArray(done) ? done : []) {
69    const step = at(place)
70
71    if (step !== undefined) step.status = 'completed'
72  }
73
74  const started = at(start)
75
76  if (started !== undefined) started.status = 'in_progress'
77
78  return steps
79}
80
81const isWhole = (steps: Step[]): boolean =>
82  steps.length > 0 && steps.every(step => step.status === 'completed')
83
84function bar(done: number, total: number, cells: number): string {
85  const filled = total === 0 ? 0 : Math.round((done / total) * cells)
86
87  return '▓'.repeat(filled) + '░'.repeat(Math.max(0, cells - filled))
88}
89
90function elapsed(ms: number): string {
91  const seconds = Math.max(0, Math.round(ms / 1000))
92  const minutes = Math.floor(seconds / 60)
93
94  if (minutes === 0) return `${seconds}s`
95  if (minutes < 60) return `${minutes}m ${seconds % 60}s`
96
97  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
98}
99
100/**
101 * Writes the list the change produces, keeping the run's clock across writes,
102 * and says once when the last step lands.
103 */
104async function commit(
105  $: EngineInterface,
106  change: (prev: Step[]) => Step[],
107): Promise<void> {
108  const held = await read($, run)
109  const live = held !== null && held.finishedAt === null ? held : null
110  const prev = live === null ? [] : live.steps
111  const steps = change(prev)
112
113  if (steps.length === 0) {
114    if (held !== null) await update($, run, () => null)
115
116    return
117  }
118
119  const at = await $.clock.now()
120  const isDone = isWhole(steps)
121  const next: Run = {
122    steps,
123    startedAt: live === null ? at : live.startedAt,
124    finishedAt: isDone ? at : null,
125  }
126
127  await update($, run, () => next)
128  // The tick only runs while a run is live, so prime the readout here.
129  await update($, nowSeconds, () => Math.floor(at / 1000))
130
131  if (!isDone || isWhole(prev)) return
132
133  const count = `${steps.length} step${steps.length === 1 ? '' : 's'}`
134
135  $.ui.toast(`Progress: all ${count} done in ${elapsed(at - next.startedAt)}`)
136  // No player outside macOS, and no sound is worth failing a hook over.
137  $.audio.play({ asset: 'sounds/done.wav' }).catch(() => undefined)
138}
139
140/** Turns the elapsed readout over, one second at a time, while a run is live. */
141async function tick($: EngineInterface): Promise<void> {
142  try {
143    const held = await read($, run)
144
145    if (held === null || held.finishedAt !== null) return
146
147    const second = Math.floor((await $.clock.now()) / 1000)
148
149    // Skip the write when the second has not turned: a write redraws.
150    if ((await read($, nowSeconds)) !== second) {
151      await update($, nowSeconds, () => second)
152    }
153  } catch {
154    // A tick that cannot read the run has nothing to say.
155  }
156}
157
158export const register: Register = on => {
159  on('tool.call', async ($, e, next) => {
160    const answered = await next(e)
161
162    if (answered.deny !== undefined || answered.isError === true) return answered
163
164    // `tool` is compared as a string: the app's plan tool is an MCP tool and
165    // is not in the built-in table this module is checked against.
166    const tool: string = e.tool
167    const args = loose(e)
168
169    if (tool === 'TodoWrite') {
170      const steps = listed(args.todos)
171
172      if (steps !== undefined) await commit($, () => steps)
173    } else if (tool === 'mcp__ccd_turn__update_plan') {
174      const steps = listed(args.items)
175
176      if (steps !== undefined) await commit($, () => steps)
177      else if (args.advance === true) await commit($, advanced)
178      else if (args.done !== undefined || args.start !== undefined) {
179        await commit($, prev => marked(prev, args.done, args.start))
180      }
181    } else if (tool === 'TaskCreate') {
182      const label = text(args.subject)
183      const id = text(loose(loose(answered.result).task).id)
184
185      if (label !== undefined) {
186        await commit($, prev => [...prev, { text: label, status: 'pending', id }])
187      }
188    } else if (tool === 'TaskUpdate') {
189      const id = text(args.taskId)
190      const status = args.status
191      const label = text(args.subject)
192
193      if (id !== undefined) {
194        await commit($, prev =>
195          status === 'deleted'
196            ? prev.filter(step => step.id !== id)
197            : prev.map(step =>
198                step.id !== id
199                  ? step
200                  : {
201                      ...step,
202                      text: label ?? step.text,
203                      status: isStatus(status) ? status : step.status,
204                    },
205              ),
206        )
207      }
208    }
209
210    return answered
211  }).catch(($, e, next) => next(e))
212
213  // A finished run stays on screen as its own receipt, until the next ask.
214  on('prompt.submit', async ($, e, next) => {
215    const held = await read($, run)
216
217    if (held !== null && held.finishedAt !== null) await update($, run, () => null)
218
219    return next(e)
220  }).catch(($, e, next) => next(e))
221
222  on('session.start', ($, e, next) => {
223    $.clock.every(1000, () => {
224      void tick($)
225    })
226
227    return next(e)
228  })
229
230  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
231    const held = await read($, run)
232
233    if (e.props.hasSurvey || held === null) return next(e)
234
235    // Read while drawing to subscribe: the tick's write redraws the band.
236    const second = await read($, nowSeconds)
237    const { Box, Text } = $.ui.resolve(e)
238
239    const total = held.steps.length
240    const done = held.steps.filter(step => step.status === 'completed').length
241    const isDone = held.finishedAt !== null
242    const under = held.steps.find(step => step.status === 'in_progress')
243    const ended = held.finishedAt ?? Math.max(second * 1000, held.startedAt)
244    const cells = Math.max(8, Math.min(BAR_CELLS, e.props.bodyColumns - GUTTER))
245    const label = isDone
246      ? `✓ ${total} step${total === 1 ? '' : 's'} done`
247      : under?.text
248
249    return (
250      <Box flexDirection="column">
251        <Box gap={1}>
252          <Text color={isDone ? 'success' : 'claude'}>{bar(done, total, cells)}</Text>
253          <Text dimColor tabular>
254            {done}/{total} · {elapsed(ended - held.startedAt)}
255          </Text>
256        </Box>
257        {label === undefined ? null : (
258          <Text color={isDone ? 'success' : 'text'} dimColor={!isDone} wrap="truncate-end">
259            {isDone ? '' : '▸ '}
260            {label}
261          </Text>
262        )}
263      </Box>
264    )
265  })
266}
267
types/index.d.ts 30 lines
1export type StepStatus = 'pending' | 'in_progress' | 'completed'
2
3/** One line of the task list, however the list reached us. */
4export type Step = {
5  /** The step's label, as the task list carries it. */
6  text: string
7  status: StepStatus
8  /** The task's id, where the list is kept as separate tasks. */
9  id?: string
10}
11
12/** One task list's life: its steps, when it opened, when it closed. */
13export type Run = {
14  steps: Step[]
15  /** When the list's first write landed, ms since the epoch. */
16  startedAt: number
17  /** When every step had completed, or null while the run is live. */
18  finishedAt: number | null
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    progress: {
24      run: Run | null
25      /** Whole seconds since the epoch, bumped by the tick so the elapsed readout redraws. */
26      nowSeconds: number
27    }
28  }
29}
30