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

A small collection of Claude Code mods.
/plugin install <mod> --marketplace Arkboi-GG/claude-mods
The bars stack above the prompt, so you can install any combination.
skins mod, run /skin band off once so its own usage band doesn't show twice.git; gh is optional).web=5173,api=3000).checks) and shows pass/fail with an error count.LOCAL badge that turns red PROD when a tool call matches your production patterns, and toasts when it happens.Name: , e.g. Olivia: rewrite welcome email; map names to outfits with the outfits option.hooks/register.tsx 327 lines1import { 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, '&').replace(/</g, '<')
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}
327types/index.d.ts 10 lines1export 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