SLOPSHOPPER

progress-bar

Progress bars above the Claude Code prompt for long tasks: file downloads tracked by size, scripts by step counter, read from ~/.claude/progress/*.json

newbandguardcommandstatusprocess
v0.1.0MITupdated 2026-10-23piotrmarekc/claude-progress-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · progress-bar
› fix the failing auth test and add an audit log call ⏺ 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 › /progress-bar ⎿ progress-bar: Usage: /progress-bar en|pl ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

progress-bar

A Claude Code mod that draws progress bars above the prompt for long-running work: a model download, a test suite, any script running in the background. You see percent, amount, rate and time left without asking the agent "is it done yet?".

model.gguf ██████████████░░░░░░░░░░░░░░░░ 47% 10.8 GB / 22.9 GB · 85 MB/s · 2 min left
test suite ██████████░░░░░░░░░░░░░░░░░░░░ 3 / 9 step · 12 min left
● Research coding models · running · 4 min 12 s · 23 tool calls
✓ Write final report · done · 6 min 40 s · 41 tool calls

Install

At the prompt of a Claude Code terminal session:

/plugin install progress-bar --marketplace piotrmarekc/claude-progress-bar

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install progress-bar --marketplace piotrmarekc/claude-progress-bar

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

How it works

Every 2 seconds the mod reads ~/.claude/progress/*.json. Each file is one bar, in one of two shapes:

Files (downloads): progress is the summed size of the listed files.

{ "label": "model.gguf", "total": 22863084160, "files": ["C:/models/part0", "C:/models/part1"] }

Counter (scripts): the script rewrites done as it goes.

{ "label": "test suite", "done": 3, "total": 9, "unit": "step" }

A bar disappears when done reaches total, when none of the listed files exists, or when the JSON file is deleted. Delete the file when the task ends.

Add "pid" (the writer's process id; on Windows the Windows PID) and the bar also disappears once that process is gone, so a killed script does not leave a stale bar. The mod checks it with tasklist on Windows and ps -p elsewhere; if the check itself fails, the bar stays.

Language

Bar text is English by default. Switch it at any time with:

/progress-bar pl
/progress-bar en

The choice is remembered across sessions. The language option in the plugin's settings sets the starting value.

Rate and time left are averaged from the first reading the mod saw. Git Bash paths such as /c/models/x are translated to C:/models/x.

Subagents and teammates

Below the bars, every subagent or teammate the session runs gets a row: its description, state (running, waiting, done, failed), time since the mod first saw it and the number of tool calls it has made. Agents report no percentage, so the call count is the progress signal. A finished agent stays for 10 seconds with ✓ or ✗, then disappears. Agents that were already finished when the mod loaded are not shown.

Reporting progress from a script

PF=$HOME/.claude/progress/my-task.json; mkdir -p "$(dirname "$PF")"
trap 'rm -f "$PF"' EXIT
PID=$(cat /proc/$$/winpid 2>/dev/null || echo $$)   # Git Bash on Windows: the Windows PID
step() { echo "{\"label\": \"my task: $2\", \"done\": $1, \"total\": 5, \"unit\": \"step\", \"pid\": $PID}" > "$PF"; }
step 0 "start"
# ... work ...
step 1 "build"

Tip: tell Claude in your CLAUDE.md to write a progress file whenever it starts a long download or background job.

License

MIT

Source 2 files
hooks/register.tsx 164 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AgentRow, Item } from '../types'
5
6// Every task is a file ~/.claude/progress/<id>.json:
7//   { "label": "download", "total": 22863084160, "files": ["C:/.../part0", ...] }  progress = sum of file sizes (bytes)
8//   { "label": "test suite", "total": 8, "done": 3, "unit": "step" }               progress = counter the script writes
9// A bar disappears when done >= total, when none of the files exists (yet or any more), or when the JSON is gone.
10// Optional "pid": the writer's OS process id (on Windows the Windows PID); the bar hides once that process is gone,
11// so a killed script does not leave a stale bar behind.
12type Tracker = { label?: string; total?: number; done?: number; unit?: string; files?: string[]; pid?: number }
13
14const items = atom({ plugin: 'progress-bar', key: 'items' } as const, [])
15const agents = atom({ plugin: 'progress-bar', key: 'agents' } as const, [])
16
17const BAR = 30
18const DONE_SHOWN_MS = 10_000
19// Git Bash scripts write /c/models/...; Windows does not resolve that.
20const winPath = (p: string) => p.replace(/^\/([a-zA-Z])\//, '$1:/')
21const TEXT = {
22  en: { left: 'left', dot: '.', calls: 'tool calls', running: 'running', waiting: 'waiting', done: 'done', failed: 'failed' },
23  pl: { left: 'zostało', dot: ',', calls: 'wywołań narzędzi', running: 'pracuje', waiting: 'czeka', done: 'gotowe', failed: 'błąd' },
24}
25const fmtElapsed = (ms: number) => {
26  const s = Math.floor(ms / 1000)
27  return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${s % 60} s`
28}
29
30export const register: Register = (on, options) => {
31  // Language: /progress-bar en|pl (kept in $.store across sessions) wins over the userConfig option.
32  let L = options.language === 'pl' ? TEXT.pl : TEXT.en
33  const num = (n: number, digits: number) => n.toFixed(digits).replace('.', L.dot)
34  const fmtBytes = (b: number) => (b >= 1e9 ? `${num(b / 1e9, 1)} GB` : `${num(b / 1e6, 0)} MB`)
35  const fmtEta = (s: number) =>
36    s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Math.round(s / 60)} min` : `${num(s / 3600, 1)} h`
37  // ponytail: rate = average since the first reading (session start or task start); ETA reacts slowly to a changing link
38  const first = new Map<string, { t: number; done: number }>()
39  // ponytail: agent start = first time the mod saw it (within 2 s of spawn); counts reset on a mod reload
40  const calls = new Map<string, number>()
41  const seen = new Map<string, { t0: number; endAt?: number }>()
42
43  on('tool.call', ($, e, next) => {
44    if (e.agentId) calls.set(e.agentId, (calls.get(e.agentId) ?? 0) + 1)
45    return next(e)
46  })
47
48  on('command.run', { command: 'progress-bar' }, async ($, e) => {
49    const lang = e.args.trim().toLowerCase()
50    if (lang !== 'en' && lang !== 'pl') return { text: 'Usage: /progress-bar en|pl' }
51    await $.store.set('language', lang)
52    L = TEXT[lang]
53    return { text: lang === 'pl' ? 'Pasek postępu: język polski.' : 'Progress bar: English.' }
54  })
55
56  on('session.start', async ($, e, next) => {
57    await $.command.register({
58      name: 'progress-bar',
59      description: 'Progress bar language: en or pl',
60      argumentHint: 'en|pl',
61    })
62    const stored = await $.store.get('language')
63    if (stored === 'en' || stored === 'pl') L = TEXT[stored]
64    const isWindows = (await $.env.get('USERPROFILE')) !== undefined
65    const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
66    const dir = `${home}/.claude/progress`
67    // A check that cannot run counts as alive: better a stale bar than a hidden live one.
68    const isAlive = (pid: number) =>
69      (isWindows
70        ? $.process.run(['tasklist', '/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'], { timeoutMs: 5000 }).then(r => r.stdout.includes(`"${pid}"`))
71        : $.process.run(['ps', '-p', String(pid)], { timeoutMs: 5000 }).then(r => r.exitCode === 0)
72      ).catch(() => true)
73
74    const tick = async () => {
75      const entries = await $.fs.list(dir).catch(() => [])
76      const now = await $.clock.now()
77      const found: Item[] = []
78      for (const entry of entries) {
79        if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
80        const id = entry.name.slice(0, -5)
81        const t = await $.fs.read(`${dir}/${entry.name}`).then(s => JSON.parse(s) as Tracker).catch(() => null)
82        if (!t || !(t.total! > 0)) continue
83        if (typeof t.pid === 'number' && !(await isAlive(t.pid))) continue
84        let done = t.done ?? 0
85        let unit = t.unit ?? ''
86        if (t.files?.length) {
87          const sizes = await Promise.all(t.files.map(f => $.fs.stat(winPath(f)).then(s => s.size).catch(() => -1)))
88          if (sizes.every(s => s < 0)) continue
89          done = sizes.reduce((a, s) => a + Math.max(0, s), 0)
90          unit = 'B'
91        }
92        if (done >= t.total!) continue
93        const start = first.get(id) ?? { t: now, done }
94        if (!first.has(id) || done < start.done) first.set(id, { t: now, done })
95        const dt = (now - start.t) / 1000
96        const rate = dt > 0 && done > start.done ? (done - start.done) / dt : 0
97        found.push({ id, label: t.label ?? id, done, total: t.total!, unit, rate })
98      }
99      for (const id of [...first.keys()]) if (!found.some(i => i.id === id)) first.delete(id)
100      await update($, items, prev => (prev.length === 0 && found.length === 0 ? prev : found))
101
102      const rows: AgentRow[] = []
103      for (const a of await $.agent.list().catch(() => [])) {
104        const isEnded = a.status === 'completed' || a.status === 'failed' || a.status === 'killed'
105        // An agent already finished when the mod first sees it is history, never shown.
106        if (!seen.has(a.id)) seen.set(a.id, isEnded ? { t0: now, endAt: -Infinity } : { t0: now })
107        const s = seen.get(a.id)!
108        if (isEnded && s.endAt === undefined) s.endAt = now
109        if (s.endAt !== undefined && now - s.endAt > DONE_SHOWN_MS) continue
110        const state = !isEnded ? (a.status === 'running' ? 'running' : 'waiting') : a.status === 'completed' ? 'done' : 'failed'
111        rows.push({ id: a.id, label: a.name ?? a.description, state, ms: (s.endAt ?? now) - s.t0, calls: calls.get(a.id) ?? 0 })
112      }
113      await update($, agents, prev => (prev.length === 0 && rows.length === 0 ? prev : rows))
114    }
115
116    $.ui.status(undefined)
117    await tick()
118    $.clock.every(2000, () => void tick())
119    return next(e)
120  })
121
122  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
123    const list = await read($, items)
124    const crew = await read($, agents)
125    if (e.props.hasSurvey || (list.length === 0 && crew.length === 0)) return next(e)
126    const { Box, Text } = $.ui.resolve(e)
127
128    return (
129      <Box flexDirection="column">
130        {list.map(i => {
131          const p = Math.min(1, i.done / i.total)
132          const filled = Math.round(p * BAR)
133          const isBytes = i.unit === 'B'
134          const amount = isBytes
135            ? `${fmtBytes(i.done)} / ${fmtBytes(i.total)}`
136            : `${i.done} / ${i.total}${i.unit ? ` ${i.unit}` : ''}`
137          const speed = isBytes && i.rate > 0 ? ` · ${fmtBytes(i.rate)}/s` : ''
138          const eta = i.rate > 0 ? ` · ${L.left} ${fmtEta((i.total - i.done) / i.rate)}` : ''
139          return (
140            <Box key={i.id} columnGap={1}>
141              <Text bold>{i.label}</Text>
142              <Text>
143                <Text color="green">{'█'.repeat(filled)}</Text>
144                <Text dimColor>{'░'.repeat(BAR - filled)}</Text>
145              </Text>
146              <Text>{`${Math.floor(p * 100)}%`}</Text>
147              <Text dimColor>{`${amount}${speed}${eta}`}</Text>
148            </Box>
149          )
150        })}
151        {crew.map(a => (
152          <Box key={`agent-${a.id}`} columnGap={1}>
153            <Text color={a.state === 'done' ? 'green' : a.state === 'failed' ? 'red' : 'yellow'}>
154              {a.state === 'done' ? '✓' : a.state === 'failed' ? '✗' : '●'}
155            </Text>
156            <Text bold>{a.label}</Text>
157            <Text dimColor>{`· ${L[a.state]} · ${fmtElapsed(a.ms)} · ${a.calls} ${L.calls}`}</Text>
158          </Box>
159        ))}
160      </Box>
161    )
162  })
163}
164
types/index.d.ts 9 lines
1export type Item = { id: string; label: string; done: number; total: number; unit: string; rate: number }
2export type AgentRow = { id: string; label: string; state: 'running' | 'waiting' | 'done' | 'failed'; ms: number; calls: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'progress-bar': { items: Item[]; agents: AgentRow[] }
7  }
8}
9