SLOPSHOPPER

task-progress-hud

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

newpanebandguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-progress-hud
│ ┃ Task progress ✕ › fix the failing auth test and add an audit log call │ ┃ No OpenSpec change to track. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /task-progress │ ⎿ task-progress-hud: No change to track. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Task progress
No OpenSpec change to track.
README

liams-claude-plugins

Claude Code plugin marketplace with plugins for working with OpenSpec.

Install

/plugin marketplace add LiamKarlMitchell/liams-claude-plugins
/plugin install task-progress-hud@liams-claude-plugins

Plugins

PluginDescription
task-progress-hudShows done, percent and remaining tasks of the current OpenSpec change in tasks.md. Adds a band above the prompt and a /task-progress pane.

Layout

.claude-plugin/marketplace.json   marketplace manifest
plugins/domain/<name>/            one directory per plugin
openspec/                         specs and change history for these plugins
Source 3 files
hooks/register.tsx 306 lines
1import { 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}
306
hooks/progress.ts 86 lines
1import 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}
86
types/index.d.ts 24 lines
1export 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