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.

Claude Code mods — small changes to Claude Code's own interface, written as function hooks.
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.
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.
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.
hooks/register.tsx 267 lines1import { 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}
267types/index.d.ts 30 lines1export 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