SLOPSHOPPER

task-progress

A bar above the prompt: gradient task progress while Claude works, usage rings (context, 5h, 7d) while idle.

newbandguardtoastprompttool
v0.5.0no licenseupdated 2026-10-16Arkboi-GG/claude-mods/mods/task-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-progress
› fix the failing auth test and add an audit log call ╭──────────────────────╮ │ task-progress │ ⏺ Read(src/auth.ts) │ task-progress loaded │ ⎿ 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 ⟨undefined⟩ [ Compact ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨undefined⟩ [ Compact ] ⟨Claude Code's own drawing⟩
README

claude-mods

A small collection of Claude Code mods.

Install

/plugin install <mod> --marketplace Arkboi-GG/claude-mods

The bars stack above the prompt, so you can install any combination.

Mods

  • task-progress: the band above the prompt. While Claude works on a multi-step task it shows one block per step: the running step fills as "In Progress", finished steps turn green "Completed", and a turn that stops with steps still open turns amber "Paused". When Claude is idle it shows usage rings (context, 5-hour and 7-day limits) with a Compact button. It reads the task list Claude keeps while working, so it appears for multi-step tasks. If you also use the skins mod, run /skin band off once so its own usage band doesn't show twice.
  • repo-status: branch, ahead/behind, changed-file count and the open PR's check state (needs git; gh is optional).
  • stack-health: status dots for your Docker Compose services and any local ports you list (web=5173,api=3000).
  • preflight: a live pre-commit checklist. After Claude edits files it re-runs your typecheck/lint (auto-detected, or set checks) and shows pass/fail with an error count.
  • env-guard: a green LOCAL badge that turns red PROD when a tool call matches your production patterns, and toasts when it happens.
  • agent-crabs: a pixel-art crab for every running subagent, dressed for its role (beret, hard hat, postman cap and more), with the agent's name, what it is working on and a timer. Finished crabs cheer and leave after a few seconds; failed ones turn grey. Name an agent by starting the Agent call's description with Name: , e.g. Olivia: rewrite welcome email; map names to outfits with the outfits option.
Source 2 files
hooks/register.tsx 327 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Step, Usage } from '../types'
5
6const steps = atom({ plugin: 'task-progress', key: 'steps' } as const, [] as Step[])
7
8const stoppedAt = atom({ plugin: 'task-progress', key: 'stoppedAt' } as const, 0)
9const tick = atom({ plugin: 'task-progress', key: 'tick' } as const, 0)
10const usage = atom({ plugin: 'task-progress', key: 'usage' } as const, { context: null, limits: [] } as Usage)
11
12const ORANGE = '#f97316'
13const TRACK = '#2b2b2b'
14const STOPS: Array<[number, number, number]> = [
15  [99, 102, 241],
16  [168, 85, 247],
17  [244, 114, 182],
18]
19
20const hex = (c: number[]) => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')
21
22// Colour at t (0..1) along the gradient.
23const grad = (t: number) => {
24  const x = Math.min(1, Math.max(0, t)) * (STOPS.length - 1)
25  const i = Math.min(STOPS.length - 2, Math.floor(x))
26  const f = x - i
27  return hex(STOPS[i].map((v, k) => v + (STOPS[i + 1][k] - v) * f))
28}
29
30// Marks when each step started, so its block can fill while it runs.
31const stamp = (prev: Step[], next: Step[], now: number): Step[] =>
32  next.map((s, i) => {
33    if (s.status !== 'in_progress') return { content: s.content, status: s.status }
34    const was = prev[i]
35    return { content: s.content, status: s.status, startedAt: was?.status === 'in_progress' && was.startedAt ? was.startedAt : now }
36  })
37
38// Applies one update_plan call (the desktop app's task list) to the steps.
39const applyPlan = (prev: Step[], e: Record<string, unknown>): Step[] => {
40  if (Array.isArray(e.items)) {
41    return (e.items as Array<{ content?: string; status?: Step['status'] }>).map(i => ({
42      content: String(i.content ?? ''),
43      status: i.status ?? 'pending',
44    }))
45  }
46  const next = prev.map(s => ({ ...s }))
47  if (e.advance === true) {
48    const cur = next.findIndex(s => s.status === 'in_progress')
49    if (cur >= 0) next[cur].status = 'completed'
50    const nxt = next.findIndex(s => s.status === 'pending')
51    if (nxt >= 0) next[nxt].status = 'in_progress'
52  }
53  if (Array.isArray(e.done)) {
54    for (const n of e.done as number[]) if (next[n - 1]) next[n - 1].status = 'completed'
55  }
56  if (typeof e.start === 'number' && next[e.start - 1]) next[e.start - 1].status = 'in_progress'
57  return next
58}
59
60// A sibling mod's element by key ('devrow', 'agent-crabs'), found anywhere in what is beneath.
61function findKeyed(node: any, key: string): any {
62  if (!node || typeof node !== 'object') return null
63  if (node.props?.key === key) return node
64  for (const kid of node.children ?? []) {
65    const hit = findKeyed(kid, key)
66    if (hit) return hit
67  }
68  return null
69}
70
71// 'five_hour' -> '5h', 'seven_day' -> '7d'; any other window keeps its own name.
72function limitLabel(kind: string): string {
73  const k = kind.toLowerCase()
74  if (k.includes('five') || k.includes('5h') || k.includes('5_h')) return '5h'
75  if (k.includes('seven') || k.includes('7d') || k.includes('week')) return '7d'
76  return k.replace(/_/g, ' ')
77}
78
79// The idle band: a ring per meter (context, 5-hour and 7-day limits), drawn as one SVG.
80function ringsSvg(meters: Array<{ label: string; percent: number }>, narrow: boolean) {
81  const R = 11
82  const C = 2 * Math.PI * R
83  let x = 0
84  let body = ''
85  const alt: string[] = []
86  for (const m of meters) {
87    const pct = Math.max(0, Math.min(100, Math.round(m.percent)))
88    // Narrow chat: the context label shortens and the gap between rings tightens.
89    const label = narrow && m.label === 'context' ? 'ctx' : m.label
90    const text = `${pct}% ${label}`
91    const w = Math.ceil(R * 2 + 14 + text.length * 7.6 + (narrow ? 8 : 26))
92    const cx = x + R + 4
93    const color = pct >= 95 ? '#ef4444' : pct >= 80 ? '#f59e0b' : '#a78bfa'
94    const dash = (C * pct) / 100
95    body +=
96      `<circle cx="${cx}" cy="15" r="${R}" fill="none" stroke="#8b8b9b" stroke-opacity=".25" stroke-width="3"/>` +
97      `<circle cx="${cx}" cy="15" r="${R}" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round" stroke-dasharray="${dash} ${C}" transform="rotate(-90 ${cx} 15)"/>` +
98      `<text x="${cx + R + 8}" y="19.5" font-family="system-ui,sans-serif" font-size="13"><tspan fill="#ffffff" font-weight="600">${pct}%</tspan><tspan fill="#7c8bd4"> ${label}</tspan></text>`
99    alt.push(`${m.label} ${pct}%`)
100    x += w
101  }
102  return {
103    source: `<svg xmlns="http://www.w3.org/2000/svg" width="${x}" height="30" viewBox="0 0 ${x} 30">${body}</svg>`,
104    alt: alt.join(', '),
105  }
106}
107
108async function refreshUsage($: any) {
109  try {
110    const u = await $.session.usage()
111    const snap: Usage = {
112      context: u.context?.percent ?? null,
113      limits: (u.rateLimits ?? []).map((l: any) => ({ label: limitLabel(String(l.kind)), percent: Number(l.percentUsed) })),
114    }
115    await update($, usage, prev => (JSON.stringify(prev) === JSON.stringify(snap) ? prev : snap))
116  } catch {
117    // no usage figures yet: keep the last reading
118  }
119}
120
121// The model's own way to show steps where the host has no task list tool (TodoWrite, update_plan): the mod declares one
122// tool and serves it. mcp__task-progress__update_steps takes the whole list each time, like TodoWrite.
123const STEPS_TOOL = 'update_steps'
124const STEPS_TOOL_FULL = 'mcp__task-progress__update_steps'
125const STEPS_TOOL_DESCRIPTION =
126  'Shows the person your progress as a bar above the prompt. At the start of any task that takes several steps, call it with the full list of steps (3 to 8, short, imperative), the first one in_progress and the rest pending. Call it again, with the whole list, each time a step finishes or the next one starts, until every step is completed. Skip it for a one-step answer or a quick question.'
127
128export const register: Register = on => {
129  on('session.start', async ($, e, next) => {
130    $.ui.toast('task-progress loaded')
131    try {
132      await $.tool.register({
133        name: STEPS_TOOL,
134        description: STEPS_TOOL_DESCRIPTION,
135        isDeferred: false,
136        inputSchema: {
137          type: 'object',
138          properties: {
139            steps: {
140              type: 'array',
141              description: 'The whole list of steps, in order. Replaces the list shown now.',
142              items: {
143                type: 'object',
144                properties: {
145                  content: { type: 'string', description: 'The step, short and imperative.' },
146                  status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
147                },
148                required: ['content', 'status'],
149              },
150            },
151          },
152          required: ['steps'],
153        },
154      })
155    } catch {
156      // A host without tool registration still gets the bar from TodoWrite or update_plan.
157    }
158    await refreshUsage($)
159    $.clock.every(60000, () => refreshUsage($))
160    // Once a second while a step runs, so its block keeps filling.
161    $.clock.every(1000, async () => {
162      const list = await read($, steps)
163      if (list.some(st => st.status === 'in_progress') && (await read($, stoppedAt)) === 0) await update($, tick, n => n + 1)
164    })
165    return next(e)
166  })
167
168  on('turn.complete', async ($, e, next) => {
169    await refreshUsage($)
170    const list = await read($, steps)
171    const isOpen = list.length > 0 && list.some(st => st.status !== 'completed')
172    await update($, stoppedAt, () => 0)
173    if (isOpen) {
174      const at = await $.clock.now()
175      await update($, stoppedAt, () => at)
176    }
177    return next(e)
178  })
179
180  on('prompt.submit', async ($, e, next) => {
181    await update($, steps, () => [])
182    await update($, stoppedAt, () => 0)
183    return next(e)
184  })
185
186  on('tool.call', async ($, e, next) => {
187    const now = await $.clock.now()
188    if (e.tool === 'TodoWrite') {
189      const todos = (e as { todos?: Step[] }).todos ?? []
190      await update($, steps, prev => stamp(prev, todos, now))
191    } else if (e.tool === 'mcp__ccd_turn__update_plan') {
192      await update($, steps, prev => stamp(prev, applyPlan(prev, e as Record<string, unknown>), now))
193    } else if (e.tool === 'mcp__ccd_turn__wrap_up') {
194      await update($, steps, prev => prev.map(s => ({ content: s.content, status: 'completed' as const })))
195    }
196    return next(e)
197  })
198
199  on('tool.call', { tool: STEPS_TOOL_FULL }, async ($, e, next) => {
200    const raw = (e as { steps?: Array<{ content?: unknown; status?: unknown }> }).steps
201    if (!Array.isArray(raw)) return { deny: 'steps must be a list of { content, status }.' }
202    const now = await $.clock.now()
203    const list: Step[] = raw.map(i => ({
204      content: String(i.content ?? ''),
205      status: i.status === 'in_progress' || i.status === 'completed' ? i.status : 'pending',
206    }))
207    await update($, steps, prev => stamp(prev, list, now))
208    return { result: 'Steps updated.' }
209  })
210
211  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
212    const below = await next(e)
213    const list = await read($, steps)
214    // Only while Claude works: then the bar stands in for the other stats bands,
215    // keeping just the dev row; when the turn ends they come back.
216    if (e.props.hasSurvey) return below
217
218    const isOpen = list.length > 0 && list.some(st => st.status !== 'completed')
219    // Paused: the turn ended with steps still open, so the bar stays, in amber.
220    const isPaused = !e.props.isWorking && isOpen
221
222    if (list.length === 0 || (!e.props.isWorking && !isPaused)) {
223      // Idle: the usage rings, with a Compact button on the right.
224      const u = await read($, usage)
225      const meters = [...(u.context === null ? [] : [{ label: 'context', percent: u.context }]), ...u.limits]
226      const { Box, Button, Svg, Text } = $.ui.resolve(e)
227      if (meters.length === 0) {
228        const wait = (
229          <Box paddingX={1}>
230            <Text dimColor>usage: waiting for the first reading</Text>
231          </Box>
232        )
233        return below ? <Box flexDirection="column">{wait}{below}</Box> : wait
234      }
235      const rings = ringsSvg(meters, e.props.bodyColumns < 70)
236      // Wraps when the chat is too narrow for both, so the button drops below the rings
237      // instead of being clipped.
238      const idle = (
239        <Box paddingX={1} justifyContent="space-between" alignItems="center" flexWrap="wrap" marginBottom={1}>
240          <Box flexShrink={0}>
241            <Svg source={rings.source} alt={rings.alt} />
242          </Box>
243          <Box flexShrink={0}>
244          <Button key="compact" label="Compact" onPress={async () => {
245            // Runs as if /compact was typed, once the session is idle. $.session.compact()
246            // is not available in every host (desktop/SDK sessions reject it).
247            try {
248              await $.command.run({ command: 'compact' })
249            } catch (err) {
250              $.ui.toast(`Compact failed: ${String((err as Error)?.message ?? err)}`)
251            }
252          }} />
253          </Box>
254        </Box>
255      )
256      return below ? <Box flexDirection="column">{idle}{below}</Box> : idle
257    }
258
259    const { Box, Text, Svg } = $.ui.resolve(e)
260    await read($, tick)
261    const stopped = await read($, stoppedAt)
262    const now = stopped || (await $.clock.now())
263    const done = list.filter(s => s.status === 'completed').length
264    const pct = Math.round((done / list.length) * 100)
265    const label = `Tasks ${done}/${list.length}`
266
267    // One block per step, a thin gap between them. A running step fills toward 90%
268    // as time passes, a finished one is green, a paused one amber.
269    const W = 640
270    const H = 22
271    const SEP = 3
272    const n = list.length
273    const bw = (W - SEP * (n - 1)) / n
274    const fit = (t: string) => {
275      const max = Math.max(3, Math.floor(bw / 7.2) - 1)
276      return t.length > max ? t.slice(0, max - 1) + '…' : t
277    }
278    const esc = (t: string) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;')
279    let blocks = ''
280    list.forEach((st, i) => {
281      const x = i * (bw + SEP)
282      blocks += `<rect x="${x}" width="${bw}" height="${H}" fill="${TRACK}"/>`
283      let text = fit(st.content)
284      let color = '#9ca3af'
285      if (st.status === 'completed') {
286        blocks += `<rect x="${x}" width="${bw}" height="${H}" fill="#22c55e"/>`
287        text = 'Completed'
288        color = '#ffffff'
289      } else if (st.status === 'in_progress') {
290        const secs = Math.max(0, (now - (st.startedAt ?? now)) / 1000)
291        const frac = 0.9 * (1 - Math.exp(-secs / 25))
292        const fill = isPaused ? '#f59e0b' : `url(#g${i})`
293        blocks +=
294          `<defs><linearGradient id="g${i}" x1="0" x2="1"><stop offset="0" stop-color="#6366f1"/><stop offset="0.55" stop-color="#a855f7"/><stop offset="1" stop-color="#f472b6"/></linearGradient></defs>` +
295          `<rect x="${x}" width="${Math.max(4, bw * frac)}" height="${H}" fill="${fill}"/>`
296        text = isPaused ? 'Paused' : 'In Progress'
297        color = '#ffffff'
298      }
299      blocks += `<text x="${x + bw / 2}" y="${H / 2 + 4.2}" text-anchor="middle" font-family="system-ui,sans-serif" font-size="12" font-weight="600" fill="${color}">${esc(text)}</text>`
300    })
301    const source =
302      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
303      `<defs><clipPath id="c"><rect width="${W}" height="${H}" rx="${H / 2}"/></clipPath></defs>` +
304      `<g clip-path="url(#c)">${blocks}</g></svg>`
305
306    const row = (
307      <Box paddingX={1} alignItems="center" marginBottom={1}>
308        <Box flexShrink={0}>
309          <Text color={isPaused ? '#f59e0b' : '#a855f7'}>● </Text>
310        </Box>
311        <Box flexShrink={0}>
312          <Text bold>{label}  </Text>
313        </Box>
314        <Box flexGrow={1} flexShrink={1} minWidth={0}>
315          <Svg source={source} alt={`${label}, ${pct}%${isPaused ? ', paused' : ''}`} />
316        </Box>
317        <Text dimColor>  {pct}%  </Text>
318        <Text color={ORANGE}>×{list.length}</Text>
319      </Box>
320    )
321    // Keeps the dev row and the agent crabs (agent-crabs mod) while the other bands step aside.
322    const devrow = findKeyed(below, 'devrow')
323    const crabs = findKeyed(below, 'agent-crabs')
324    return devrow || crabs ? <Box flexDirection="column">{crabs}{row}{devrow}</Box> : row
325  })
326}
327
types/index.d.ts 10 lines
1export type Step = { content: string; status: 'pending' | 'in_progress' | 'completed'; startedAt?: number }
2
3export type Usage = { context: number | null; limits: Array<{ label: string; percent: number }> }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'task-progress': { steps: Step[]; usage: Usage; stoppedAt: number; tick: number }
8  }
9}
10