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

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
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.
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.
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.
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.
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.
MIT
hooks/register.tsx 164 lines1import { 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}
164types/index.d.ts 9 lines1export 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