Shows done, percent and remaining tasks of the current OpenSpec change.

Claude Code plugin marketplace with plugins for working with OpenSpec.
/plugin marketplace add LiamKarlMitchell/liams-claude-plugins
/plugin install task-progress-hud@liams-claude-plugins
| Plugin | Description |
|---|---|
| task-progress-hud | Shows done, percent and remaining tasks of the current OpenSpec change in tasks.md. Adds a band above the prompt and a /task-progress pane. |
.claude-plugin/marketplace.json marketplace manifest
plugins/domain/<name>/ one directory per plugin
openspec/ specs and change history for these pluginshooks/register.tsx 306 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ProgressView } from '../types'
5import {
6 bandText,
7 barCells,
8 isTrackedFile,
9 parseTasks,
10 resolveTracked,
11 statusText,
12 summarize,
13} from './progress'
14
15const PANE = 'task-progress'
16const CHANGES = 'openspec/changes'
17// Temporarily off: the status line below the prompt. Set to true to restore it.
18const SHOW_STATUS = false
19
20// Tailwind-style palette: readable on dark and light terminals.
21const COLOR = {
22 name: '#a78bfa', // change name
23 active: '#38bdf8', // bar and percent while in progress
24 complete: '#4ade80', // bar, percent and done items at 100%
25 done: '#4ade80', // done count and checked boxes
26 open: '#fbbf24', // remaining count and open boxes
27 track: '#475569', // empty bar cells
28 muted: '#94a3b8', // secondary text
29 alarm: '#f87171', // missing tasks.md
30}
31
32const progress = atom(
33 { plugin: 'task-progress-hud', key: 'progress' } as const,
34 { kind: 'none' } as ProgressView,
35)
36const selected = atom(
37 { plugin: 'task-progress-hud', key: 'selectedChange' } as const,
38 null as string | null,
39)
40
41async function listChanges($: EngineInterface): Promise<string[]> {
42 try {
43 const entries = await $.fs.list(CHANGES)
44
45 return entries
46 .filter(entry => entry.kind === 'dir' && entry.name !== 'archive')
47 .map(entry => entry.name)
48 .sort()
49 } catch {
50 return []
51 }
52}
53
54async function refresh($: EngineInterface): Promise<void> {
55 const names = await listChanges($)
56 const choice = resolveTracked(names, await read($, selected))
57
58 let view: ProgressView = { kind: 'none' }
59
60 if (choice !== null && 'names' in choice) {
61 view = { kind: 'choose', names: choice.names }
62 } else if (choice !== null) {
63 try {
64 const items = parseTasks(
65 await $.fs.read(`${CHANGES}/${choice.change}/tasks.md`),
66 )
67
68 view = { kind: 'tracked', change: choice.change, items, ...summarize(items) }
69 } catch {
70 view = { kind: 'missing', change: choice.change }
71 }
72 }
73
74 // Skip the write when nothing changed, so an idle poll does not redraw.
75 const previous = await read($, progress)
76 if (JSON.stringify(previous) === JSON.stringify(view)) return
77
78 await update($, progress, () => view)
79 $.ui.status(SHOW_STATUS ? statusText(view) : undefined)
80}
81
82// Whether an edited file is the tracked change's tasks.md. The real paths
83// are compared when the tracked file exists; otherwise the normalized path
84// must end in the tracked location.
85async function isTrackedTasks($: EngineInterface, file: string): Promise<boolean> {
86 const view = await read($, progress)
87 if (view.kind !== 'tracked' && view.kind !== 'missing') return false
88
89 const tracked = `${CHANGES}/${view.change}/tasks.md`
90
91 try {
92 const want = await $.fs.stat(tracked, { resolve: true })
93 const got = await $.fs.stat(file, { resolve: true })
94
95 if (want.realPath !== undefined && got.realPath !== undefined) {
96 return want.realPath === got.realPath
97 }
98 } catch {
99 // The tracked file is missing or unreadable: fall back to the path text.
100 }
101
102 return isTrackedFile(file, tracked)
103}
104
105export const register: Register = on => {
106 on('session.start', async ($, e, next) => {
107 await $.command.register({
108 name: 'task-progress',
109 description: 'Show task progress of an OpenSpec change',
110 argumentHint: '[change]',
111 })
112 await refresh($)
113
114 return next(e)
115 })
116
117 on('command.run', { command: 'task-progress' }, async ($, e) => {
118 const name = e.args.trim()
119
120 if (name !== '') {
121 if (!(await listChanges($)).includes(name)) {
122 return { text: `No change named ${name}.` }
123 }
124 await update($, selected, () => name)
125 }
126
127 await refresh($)
128 await $.ui.open({ id: PANE, title: 'Task progress' })
129
130 const view = await read($, progress)
131 const text = bandText(view) ?? 'No change to track.'
132
133 return { text }
134 })
135
136 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
137 const ran = await next(e)
138
139 if (ran.deny === undefined && (await isTrackedTasks($, e.file_path))) {
140 await refresh($)
141 }
142
143 return ran
144 })
145
146 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
147 const ran = await next(e)
148
149 if (ran.deny === undefined && (await isTrackedTasks($, e.file_path))) {
150 await refresh($)
151 }
152
153 return ran
154 })
155
156 // A shell command may have written tasks.md, and the turn's end catches
157 // whatever the model changed. Both refresh; unchanged state is not redrawn.
158 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
159 const ran = await next(e)
160
161 if (ran.deny === undefined) await refresh($)
162
163 return ran
164 })
165
166 on('turn.complete', async ($, e, next) => {
167 await refresh($)
168
169 return next(e)
170 })
171
172 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
173 const view = await read($, progress)
174
175 if (view.kind === 'none' || view.kind === 'choose') return next(e)
176
177 const { Box, Button, Text } = $.ui.resolve(e)
178 // The label opens or closes the pane that /task-progress opens.
179 const toggle = (
180 <Button
181 key="toggle"
182 label="Task progress"
183 plain
184 hover={{ color: COLOR.name, scope: 'task-progress-toggle' }}
185 onPress={async () => {
186 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
187
188 if (isOpen) await $.ui.close({ id: PANE })
189 else await $.ui.open({ id: PANE, title: 'Task progress' })
190 }}
191 />
192 )
193
194 // No tasks.md yet: keep the label so the pane can still be opened.
195 if (view.kind === 'missing') {
196 return <Box>{toggle}</Box>
197 }
198
199 if (view.total === 0) {
200 return (
201 <Box>
202 {toggle}
203 <Text color={COLOR.muted}> {view.change}: no tasks</Text>
204 </Box>
205 )
206 }
207
208 const { filled, empty } = barCells(view.percent)
209 const tone = view.percent === 100 ? COLOR.complete : COLOR.active
210 const left = view.total - view.done
211
212 return (
213 <Box>
214 {toggle}
215 <Text>{' '}</Text>
216 <Text color={tone}>{filled}</Text>
217 <Text color={COLOR.track}>{empty}</Text>
218 <Text color={tone} bold>
219 {' '}
220 {view.percent}%
221 </Text>
222 <Text color={COLOR.done}>
223 {' '}
224 {view.done} done
225 </Text>
226 <Text color={COLOR.muted}>, </Text>
227 <Text color={COLOR.open}>{left} left</Text>
228 </Box>
229 )
230 })
231
232 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
233 const { Box, Text } = $.ui.resolve(e)
234 const view = await read($, progress)
235
236 if (view.kind === 'none') {
237 return (
238 <Box>
239 <Text color={COLOR.muted}>No OpenSpec change to track.</Text>
240 </Box>
241 )
242 }
243
244 if (view.kind === 'choose') {
245 return (
246 <Box flexDirection="column">
247 <Text color={COLOR.open}>Several changes. Run /task-progress with one name:</Text>
248 {view.names.map(name => (
249 <Text color={COLOR.name}>{name}</Text>
250 ))}
251 </Box>
252 )
253 }
254
255 if (view.kind === 'missing') {
256 return (
257 <Box>
258 <Text color={COLOR.muted}>{bandText(view)}</Text>
259 </Box>
260 )
261 }
262
263 if (view.total === 0) {
264 return (
265 <Box>
266 <Text color={COLOR.muted}>{view.change}: no tasks</Text>
267 </Box>
268 )
269 }
270
271 const { filled, empty } = barCells(view.percent)
272 const tone = view.percent === 100 ? COLOR.complete : COLOR.active
273 const left = view.total - view.done
274
275 return (
276 <Box flexDirection="column">
277 <Box>
278 <Text color={COLOR.name} bold>
279 {view.change}{' '}
280 </Text>
281 <Text color={tone}>{filled}</Text>
282 <Text color={COLOR.track}>{empty}</Text>
283 <Text color={tone} bold>
284 {' '}
285 {view.percent}%
286 </Text>
287 <Text color={COLOR.done}>
288 {' '}
289 {view.done} done
290 </Text>
291 <Text color={COLOR.muted}>, </Text>
292 <Text color={COLOR.open}>{left} left</Text>
293 </Box>
294 {view.items.map(item => (
295 <Box>
296 <Text color={item.done ? COLOR.done : COLOR.open}>
297 {item.done ? '[x]' : '[ ]'}{' '}
298 </Text>
299 <Text color={item.done ? COLOR.muted : undefined}>{item.text}</Text>
300 </Box>
301 ))}
302 </Box>
303 )
304 })
305}
306hooks/progress.ts 86 lines1import type { ProgressView, TaskItem } from '../types'
2
3// A checkbox line: "- [" then box content of spaces, x, X, ~ or -, then "]"
4// followed by a space or the end of the line. Prose such as "- [link](url)"
5// does not match.
6const BOX = /^\s*- \[([ xX~-]*)\](?=\s|$)/
7
8export function parseTasks(markdown: string): TaskItem[] {
9 const items: TaskItem[] = []
10
11 for (const line of markdown.split(/\r?\n/)) {
12 const box = BOX.exec(line)
13 if (box === null) continue
14
15 const mark = box[1]!.replace(/ /g, '')
16 items.push({
17 text: line.slice(box[0].length).trim(),
18 done: mark === 'x' || mark === 'X',
19 })
20 }
21
22 return items
23}
24
25export function summarize(items: TaskItem[]): {
26 done: number
27 total: number
28 percent: number
29} {
30 const done = items.filter(item => item.done).length
31 const total = items.length
32 const percent = total === 0 ? 0 : Math.round((done / total) * 100)
33
34 return { done, total, percent }
35}
36
37// The change to show: the selected one when it still exists, else the only
38// change, else a choice among several, else none.
39export function resolveTracked(
40 names: string[],
41 selected: string | null,
42): { change: string } | { names: string[] } | null {
43 if (selected !== null && names.includes(selected)) return { change: selected }
44 if (names.length === 1) return { change: names[0]! }
45 if (names.length > 1) return { names }
46
47 return null
48}
49
50// Whether an edited path is the tracked file. Separators and case are
51// ignored; a relative tracked path matches an absolute edited path that ends
52// in it, on a path boundary.
53export function isTrackedFile(edited: string, tracked: string): boolean {
54 const want = tracked.replace(/\\/g, '/').toLowerCase()
55 const got = edited.replace(/\\/g, '/').toLowerCase()
56
57 return got === want || got.endsWith(`/${want}`)
58}
59
60export function statusText(view: ProgressView): string | undefined {
61 if (view.kind === 'none' || view.kind === 'choose') return undefined
62 // No tasks.md yet (planning is still in progress): nothing to show below the prompt.
63 if (view.kind === 'missing') return undefined
64 if (view.total === 0) return 'openspec: no tasks'
65
66 return `openspec ${view.done}/${view.total} - ${view.percent}%`
67}
68
69// Ten cells: filled for the done share, empty for the rest.
70export function barCells(percent: number): { filled: string; empty: string } {
71 const filled = Math.round(percent / 10)
72
73 return { filled: '█'.repeat(filled), empty: '░'.repeat(10 - filled) }
74}
75
76export function bandText(view: ProgressView): string | undefined {
77 if (view.kind === 'none' || view.kind === 'choose') return undefined
78 if (view.kind === 'missing') return `${view.change}: tasks.md not written yet`
79 if (view.total === 0) return `${view.change}: no tasks`
80
81 const { filled, empty } = barCells(view.percent)
82 const left = view.total - view.done
83
84 return `${view.change} ${filled}${empty} ${view.percent}% ${view.done} done, ${left} left`
85}
86types/index.d.ts 24 lines1export type TaskItem = { text: string; done: boolean }
2
3export type ProgressView =
4 | { kind: 'none' }
5 | { kind: 'choose'; names: string[] }
6 | { kind: 'missing'; change: string }
7 | {
8 kind: 'tracked'
9 change: string
10 items: TaskItem[]
11 done: number
12 total: number
13 percent: number
14 }
15
16declare module 'claude-code' {
17 interface PluginState {
18 'task-progress-hud': {
19 progress: ProgressView
20 selectedChange: string | null
21 }
22 }
23}
24