Progress bar above the prompt plus a live panel of every subagent with model, tokens, cost and a customizable pixel crab.

Eight mods for Claude Code (terminal and the desktop app's Code tab). Requires Claude Code 2.1.287 or newer.
| Mod | What it does | Commands |
|---|---|---|
| savvy-progress | Progress bar above the prompt; an Agents side panel with a pixel crab per subagent showing model, effort, task progress, context, tokens and estimated cost | /savvy, /crab-style <keyword> <color> <hat> |
| tool-skins | Themed icons and colors for tool calls (Tokyo Night, Nord, Dracula, Gruvbox, Solarized, Catppuccin, Rose Pine) | /skin <theme> |
| you-should-know | After a long reply, a Haiku side agent flags costs, risks and actions you might miss | /ysk on, /ysk off, /ysk focus <text> |
| file-tree | A panel showing which files Claude is reading or editing; turns green once committed | /files |
| cache-tax | Warns before a prompt reloads a cold prompt cache, with a token and cost estimate | /keepwarm, /cachetax ttl <minutes> |
| blast-radius | Stops rm -r, Remove-Item -Recurse, git clean -f, git reset --hard and similar, lists the files at stake, and waits for Proceed or Cancel | automatic |
| reflect | Spots corrections in your prompts and offers to save them as rules in CLAUDE.md | /reflect <rule> |
| terminal-browser | A text-mode browser pane; Claude can open pages there too | /browse <url or path> |
Cost figures are estimates at API list prices. you-should-know and reflect make small Haiku calls, which count toward your usage.
At a Claude Code prompt in a terminal:
/plugin install savvy-progress --marketplace Isaacg1000/isaac-mods
Answer y to add the marketplace, then pick the user scope. Repeat with each mod name you want.
Or from a shell, all at once:
claude plugin marketplace add Isaacg1000/isaac-mods
claude plugin install savvy-progress@isaac-mods --scope user
claude plugin install tool-skins@isaac-mods --scope user
claude plugin install you-should-know@isaac-mods --scope user
claude plugin install file-tree@isaac-mods --scope user
claude plugin install cache-tax@isaac-mods --scope user
claude plugin install blast-radius@isaac-mods --scope user
claude plugin install reflect@isaac-mods --scope user
claude plugin install terminal-browser@isaac-mods --scope user
Each mod lives in plugins/<name>/: hooks/register.tsx is the hooks module, types/index.d.ts its state contract. Check and test one with:
claude plugin validate plugins/<name>
claude plugin test plugins/<name>
When this folder is added as a local marketplace (claude plugin marketplace add <this folder>), edits are picked up with /reload-plugins.
hooks/register.tsx 624 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { Mascot, SubAgent, Todo, Turn } from '../types'
5
6const PANE = 'savvy-agents'
7const todos = atom({ plugin: 'savvy-progress', key: 'todos' } as const, [] as Todo[])
8const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [] as SubAgent[])
9const turn = atom({ plugin: 'savvy-progress', key: 'turn' } as const, null as Turn | null)
10const mascots = atom({ plugin: 'savvy-progress', key: 'mascots' } as const, {} as Record<string, Mascot>)
11const isCollapsed = atom({ plugin: 'savvy-progress', key: 'isCollapsed' } as const, false)
12const isFinishedOpen = atom({ plugin: 'savvy-progress', key: 'isFinishedOpen' } as const, true)
13const isBandHidden = atom({ plugin: 'savvy-progress', key: 'isBandHidden' } as const, false)
14
15// Colors picked to read on both the desktop app's dark theme and a terminal.
16const C = {
17 coral: '#E07A5F',
18 green: '#3FB68B',
19 blue: '#4A8FE7',
20 amber: '#E8A33D',
21 red: '#E0604F',
22 magenta: '#C0508F',
23 violet: '#8B7CF6',
24 track: '#3A3A3A',
25 crab: '#D97757',
26}
27
28// USD per million tokens, API list prices. An estimate: subscriptions are not billed per token.
29const PRICES: Array<[RegExp, number, number]> = [
30 [/haiku/i, 1, 5],
31 [/sonnet/i, 3, 15],
32 [/opus|fable/i, 5, 25],
33]
34
35function costOf(model: string, u: ModelUsage): number {
36 const [, input, output] = PRICES.find(([re]) => re.test(model)) ?? [/./, 3, 15]
37 return (
38 (u.input_tokens * input +
39 u.cache_creation_input_tokens * input * 1.25 +
40 u.cache_read_input_tokens * input * 0.1 +
41 u.output_tokens * output) /
42 1_000_000
43 )
44}
45
46const tokensOf = (u: ModelUsage) => u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
47
48// "Opus 5.5" from "claude-opus-5-5[1m]".
49function prettyModel(model: string): string {
50 const m = model.match(/(opus|sonnet|haiku|fable)[-_]?(\d+)(?:[-.](\d+))?/i)
51 if (!m) return model
52 const name = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1).toLowerCase()
53 return m[3] ? `${name} ${m[2]}.${m[3]}` : `${name} ${m[2]}`
54}
55
56// Effort level -> the word and color the panel shows.
57const EFFORT: Record<string, [string, string]> = {
58 low: ['light', C.green],
59 medium: ['medium', C.blue],
60 high: ['careful', C.amber],
61 xhigh: ['heavy', C.red],
62 max: ['max', C.magenta],
63}
64const effortLook = (effort: string): [string, string] => EFFORT[effort] ?? ['', C.blue]
65
66// Pixel crab, 12x9. b body, d shade, e eye, h hat, a accessory.
67const CRAB = [
68 '............',
69 '............',
70 '............',
71 '..b.bbbbb.b.',
72 '..bbbbbbbbb.',
73 '.bbebbbbebbb',
74 '.bbbbbbbbbbb',
75 '..dbbbbbbbd.',
76 '..b.b...b.b.',
77]
78const HATS: Record<string, string[]> = {
79 beret: ['....hhhh....', '...hhhhhh...', '..hhhhhhhh..'],
80 hardhat: ['............', '....hhhh....', '..hhhhhhhh..'],
81 cap: ['............', '...hhhhh....', '...hhhhhhhh.'],
82 crown: ['............', '...h.h.h.h..', '...hhhhhhh..'],
83 headphones: ['............', '...hhhhhhh..', '..h.......h.'],
84 wizard: ['.....h......', '....hhh.....', '...hhhhh....'],
85 antenna: ['.........a..', '.........h..', '.........h..'],
86 none: ['............', '............', '............'],
87}
88
89type Look = { color: string; hatColor: string; hat: string }
90
91// Built-in looks, matched against the subagent's type and description.
92const ROLES: Array<[RegExp, Look]> = [
93 [/design|graphic|\bui\b|ux|css|style|visual|brand/i, { color: C.crab, hatColor: '#5B4A8A', hat: 'beret' }],
94 [/review|test|qa|verify|check|audit/i, { color: C.crab, hatColor: '#E6C84A', hat: 'hardhat' }],
95 [/explore|research|search|find|investigat|competitor/i, { color: C.crab, hatColor: '#E6C84A', hat: 'crown' }],
96 [/plan|architect|strategy/i, { color: C.crab, hatColor: C.violet, hat: 'wizard' }],
97 [/code|dev|implement|build|fix|page|engineer/i, { color: C.crab, hatColor: '#9AA0A6', hat: 'headphones' }],
98]
99const DEFAULT_LOOK: Look = { color: C.crab, hatColor: '#4FB3C9', hat: 'antenna' }
100
101const NAMED: Record<string, string> = {
102 red: '#D9534F', orange: '#E8893A', yellow: '#E6C84A', green: '#3F8F5A', teal: '#2AA198', cyan: '#4FB3C9',
103 blue: '#4A7FD1', purple: '#7A5CC7', magenta: '#C0508F', pink: '#E27AA8', brown: '#8A5A3A', gray: '#888888', white: '#EEEEEE', black: '#222222',
104}
105const hex = (c: string) => NAMED[c.toLowerCase()] ?? c
106
107function lookFor(text: string, custom: Record<string, Mascot>): Look {
108 const t = text.toLowerCase()
109 const mine = Object.entries(custom).find(([word]) => t.includes(word.toLowerCase()))?.[1]
110 const role = ROLES.find(([re]) => re.test(t))?.[1] ?? DEFAULT_LOOK
111 return mine ? { color: role.color, hatColor: hex(mine.color), hat: mine.hat in HATS ? mine.hat : role.hat } : role
112}
113
114function crabSvg(look: Look, isDim = false): string {
115 const hat = HATS[look.hat] ?? HATS.none!
116 const rows = [...hat, ...CRAB.slice(3)]
117 const fill: Record<string, string> = { b: look.color, d: '#B85F43', e: '#1A1A1A', h: look.hatColor, a: '#F2F2F2' }
118 let rects = ''
119 rows.forEach((row, y) =>
120 [...row].forEach((ch, x) => {
121 const f = fill[ch]
122 if (f) rects += `<rect x="${x}" y="${y}" width="1.02" height="1.02" fill="${f}"/>`
123 }),
124 )
125 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 9" shape-rendering="crispEdges"${isDim ? ' opacity="0.7"' : ''}>${rects}</svg>`
126}
127
128// A thin progress line, as SVG for the desktop app.
129function barSvg(fraction: number, color: string): string {
130 const w = Math.round(Math.max(0, Math.min(1, fraction)) * 1000)
131 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 6" preserveAspectRatio="none"><rect width="1000" height="6" rx="3" fill="${C.track}"/><rect width="${w}" height="6" rx="3" fill="${color}"/></svg>`
132}
133
134// The band's track: dotted rail with a pill naming the current task.
135function pillSvg(fraction: number, label: string): string {
136 const W = 240
137 const H = 22
138 const pillW = Math.min(150, Math.max(70, label.length * 7.2 + 24))
139 const x = Math.round(Math.max(0, Math.min(W - pillW, fraction * W - pillW / 2)))
140 let dots = ''
141 for (let i = 6; i < W; i += 7) {
142 const y = 8 + ((i * 7) % 7)
143 dots += `<circle cx="${i}" cy="${y}" r="1" fill="#6B6B8A"/><circle cx="${i + 3}" cy="${y + 5}" r="0.8" fill="#55556E"/>`
144 }
145 const safe = label.replace(/[<>&"]/g, '')
146 return (
147 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}">` +
148 `<rect width="${W}" height="${H}" rx="11" fill="#26263A"/>${dots}` +
149 `<rect x="${x}" y="1" width="${pillW}" height="${H - 2}" rx="10" fill="${C.violet}"/>` +
150 `<text x="${x + pillW / 2}" y="15" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#FFFFFF">${safe}</text></svg>`
151 )
152}
153
154const fmtTokens = (n: number) => (n >= 1_000_000 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
155const fmtTime = (ms: number) => {
156 const s = Math.max(0, Math.round(ms / 1000))
157 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
158}
159const textBar = (fraction: number, width: number) => {
160 const filled = Math.round(Math.max(0, Math.min(1, fraction)) * width)
161 return ['━'.repeat(filled), '━'.repeat(width - filled)] as const
162}
163
164function todosFrom(tool: string, input: Record<string, unknown>, result: unknown, list: Todo[], fallbackId: string): Todo[] | undefined {
165 if (tool === 'TodoWrite') {
166 const items = (input.todos as Array<{ content: string; status: Todo['status'] }>) ?? []
167 return items.map((t, i) => ({ id: `todo-${i}`, content: t.content, status: t.status }))
168 }
169 if (tool === 'TaskCreate') {
170 const id = (result as { task?: { id: string } } | undefined)?.task?.id ?? fallbackId
171 return [...list, { id, content: String(input.subject ?? ''), status: 'pending' }]
172 }
173 if (tool === 'TaskUpdate') {
174 const id = String(input.taskId)
175 if (input.status === 'deleted') return list.filter(t => t.id !== id)
176 return list.map(t =>
177 t.id === id
178 ? { ...t, status: (input.status as Todo['status'] | undefined) ?? t.status, content: (input.subject as string | undefined) ?? t.content }
179 : t,
180 )
181 }
182 return undefined
183}
184
185function progressOf(a: SubAgent): number {
186 if (a.status !== 'running') return 1
187 if (a.todos.length > 0) return a.todos.filter(t => t.status === 'completed').length / a.todos.length
188 return Math.min(0.9, a.steps / 25)
189}
190
191// Overall progress: the main task list, else the agents' average.
192function overall(plan: Todo[], subs: SubAgent[]): number {
193 if (plan.length > 0) return plan.filter(t => t.status === 'completed').length / plan.length
194 if (subs.length > 0) return subs.reduce((s, a) => s + progressOf(a), 0) / subs.length
195 return 0
196}
197
198// One short name for the run ("Launch kit"), from Haiku; the prompt's opening words until then.
199async function nameTheRun($: EngineInterface) {
200 const t = await read($, turn)
201 if (!t || t.isTitled) return
202 await update($, turn, v => (v ? { ...v, isTitled: true } : v))
203 const reply = await $.model.complete({
204 model: 'haiku',
205 system: 'Name the task in 2 or 3 words, Sentence case, no punctuation or quotes. Reply with the name only.',
206 prompt: t.prompt.slice(0, 2000),
207 maxTokens: 20,
208 timeoutMs: 15000,
209 })
210 const name = reply.isAnswered ? reply.text.trim().split('\n')[0]!.replace(/["'.]/g, '').slice(0, 32) : ''
211 if (name) await update($, turn, v => (v ? { ...v, title: name } : v))
212}
213
214// What the main thread is doing now, for the band's pill when there is no task list.
215function activityOf(tool: string): string {
216 if (/^(Read|NotebookRead)/.test(tool)) return 'Reading'
217 if (/^(Edit|MultiEdit|NotebookEdit)$/.test(tool)) return 'Editing'
218 if (tool === 'Write') return 'Writing'
219 if (/^(Bash|PowerShell|Monitor)$/.test(tool)) return 'Running'
220 if (/^(Glob|Grep|ToolSearch|LSP)$/.test(tool)) return 'Searching'
221 if (/^Web/.test(tool)) return 'Browsing'
222 if (/^(Agent|Task|Workflow)$/.test(tool)) return 'Delegating'
223 if (/^(TodoWrite|Task[A-Z])/.test(tool)) return 'Planning'
224 if (/^mcp__/.test(tool)) return 'Connecting'
225 return 'Working'
226}
227
228async function openPane($: EngineInterface) {
229 await $.ui.open({ id: PANE, title: 'Agents', columns: 46 })
230}
231
232let ticker: { cancel: () => void } | undefined
233
234export const register: Register = on => {
235 on('session.start', async ($, e, next) => {
236 await $.command.register({ name: 'savvy', description: 'Open or close the Savvy Progress agents panel' })
237 await $.command.register({
238 name: 'crab-style',
239 description: 'Customize agent crabs: /crab-style <keyword> <hat color> <hat>',
240 argumentHint: `<keyword> <color> <${Object.keys(HATS).join('|')}>`,
241 })
242 const saved = (await $.store.get('mascots')) as Record<string, Mascot> | undefined
243 if (saved) await update($, mascots, () => saved)
244 return next(e)
245 })
246
247 on('command.run', { command: 'savvy' }, async $ => {
248 const pane = (await $.ui.panes()).find(p => p.id === PANE)
249 if (pane?.isShown) {
250 await $.ui.close({ id: PANE })
251 return { text: 'Agents panel closed.' }
252 }
253 await openPane($)
254 return { text: 'Agents panel opened.' }
255 })
256
257 on('command.run', { command: 'crab-style' }, async ($, e) => {
258 const [word, color, hat = 'cap'] = e.args.trim().split(/\s+/)
259 if (!word || !color) {
260 const current = await read($, mascots)
261 const list = Object.entries(current).map(([k, m]) => `${k}: ${m.color} ${m.hat}`).join(', ') || 'none'
262 return { text: `Usage: /crab-style <keyword> <color> <hat>. Hats: ${Object.keys(HATS).join(', ')}. Custom styles: ${list}` }
263 }
264 if (!(hat in HATS)) return { text: `Unknown hat "${hat}". Pick one of: ${Object.keys(HATS).join(', ')}` }
265 const next = await update($, mascots, m => ({ ...m, [word]: { color, hat } }))
266 await $.store.set('mascots', next)
267 return { text: `Agents matching "${word}" now wear a ${color} ${hat}.` }
268 })
269
270 on('turn.start', async ($, e, next) => {
271 const now = await $.clock.now()
272 const prompt = e.text.replace(/^\/\S+\s*/, '')
273 const title = prompt.split(/[.\n:!?,]/)[0]!.trim().split(/\s+/).slice(0, 4).join(' ') || 'Working'
274 await update($, turn, () => ({ title, prompt, isTitled: false, startedAt: now, endedAt: null, steps: 0, tools: 0, tokens: 0, usd: 0, activity: 'Thinking', isWorking: true }))
275 await update($, todos, list => (list.length > 0 && list.every(t => t.status === 'completed') ? [] : list))
276 await update($, agents, list => list.filter(a => a.status === 'running'))
277 await update($, isBandHidden, () => false)
278 ticker?.cancel()
279 ticker = $.clock.every(250, () => $.ui.invalidate('ui.render'))
280 return next(e)
281 })
282
283 on('turn.step', async function* ($, e, next) {
284 const id = e.agentId
285 if (id && e.effort !== undefined) {
286 const effort = String(e.effort)
287 await update($, agents, list => list.map(a => (a.id === id && !a.effort ? { ...a, effort } : a)))
288 }
289 const result = yield* next(e)
290 const usage = result.usage
291 if (usage) {
292 const usd = costOf(usage.model, usage)
293 if (id) {
294 await update($, agents, list =>
295 list.map(a =>
296 a.id === id
297 ? {
298 ...a,
299 steps: a.steps + 1,
300 model: usage.model,
301 input: a.input + usage.input_tokens,
302 output: a.output + usage.output_tokens,
303 cacheRead: a.cacheRead + usage.cache_read_input_tokens,
304 cacheWrite: a.cacheWrite + usage.cache_creation_input_tokens,
305 lastContext: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
306 usd: a.usd + usd,
307 }
308 : a,
309 ),
310 )
311 } else {
312 await update($, turn, t => (t ? { ...t, steps: t.steps + 1, usd: t.usd + usd, tokens: t.tokens + tokensOf(usage) } : t))
313 }
314 }
315 return result
316 })
317
318 on('agent.spawn', async ($, e, next) => {
319 const result = await next(e)
320 if (result.deny === undefined) {
321 const agent: SubAgent = {
322 id: result.agentId ?? e.tool_use_id,
323 label: e.description || e.name || e.subagentType,
324 kind: e.subagentType,
325 model: result.model,
326 effort: '',
327 steps: 0,
328 input: 0,
329 output: 0,
330 cacheRead: 0,
331 cacheWrite: 0,
332 lastContext: 0,
333 usd: 0,
334 todos: [],
335 status: 'running',
336 startedAt: await $.clock.now(),
337 endedAt: null,
338 }
339 await update($, agents, list => [...list.filter(a => a.id !== agent.id), agent].slice(-40))
340 $.clock.after(0, () => void nameTheRun($))
341 if ((await read($, agents)).filter(a => a.status === 'running').length >= 2) void openPane($)
342 }
343 return result
344 })
345
346 on('tool.call', async ($, e, next) => {
347 if (!e.agentId) await update($, turn, t => (t ? { ...t, tools: t.tools + 1, activity: activityOf(e.tool) } : t))
348 const ran = await next(e)
349 if (ran.deny !== undefined || ran.isError) return ran
350 const input = e as unknown as Record<string, unknown>
351 if (e.agentId) {
352 const id = e.agentId
353 const current = (await read($, agents)).find(a => a.id === id)
354 const nextTodos = current && todosFrom(e.tool, input, ran.result, current.todos, e.tool_use_id)
355 if (nextTodos) await update($, agents, list => list.map(a => (a.id === id ? { ...a, todos: nextTodos } : a)))
356 } else {
357 const nextTodos = todosFrom(e.tool, input, ran.result, await read($, todos), e.tool_use_id)
358 if (nextTodos) await update($, todos, () => nextTodos)
359 }
360 return ran
361 })
362
363 on('turn.complete', async ($, e, next) => {
364 const now = await $.clock.now()
365 if (e.agentId) {
366 const id = e.agentId
367 const status = e.reason === 'error' || e.isAborted ? ('failed' as const) : ('done' as const)
368 await update($, agents, list => list.map(a => (a.id === id ? { ...a, status, endedAt: now } : a)))
369 } else {
370 await update($, turn, t => (t ? { ...t, isWorking: false, endedAt: now } : t))
371 }
372 if (!(await read($, turn))?.isWorking && !(await read($, agents)).some(a => a.status === 'running')) {
373 ticker?.cancel()
374 ticker = undefined
375 }
376 return next(e)
377 })
378
379 // The band above the prompt: ● title [track with the current task] 75% crab ×4 ✕
380 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
381 const t = await read($, turn)
382 const plan = await read($, todos)
383 const subs = await read($, agents)
384 const running = subs.filter(a => a.status === 'running')
385 const isActive = (t?.isWorking ?? false) || running.length > 0
386 if (e.props.hasSurvey || !isActive || (await read($, isBandHidden))) return next(e)
387
388 const els = $.ui.resolve(e)
389 const { Box, Text, Button } = els
390 const Svg = 'Svg' in els ? els.Svg : undefined
391 const custom = await read($, mascots)
392 const now = await $.clock.now()
393
394 // With no task list and no agents there is nothing to measure: sweep instead.
395 const isMeasured = plan.length > 0 || subs.length > 0
396 const sweep = 1 - Math.abs(((now / 2500) % 2) - 1)
397 const fraction = isMeasured ? overall(plan, subs) : sweep
398 const done = plan.filter(x => x.status === 'completed').length
399 const current = plan.find(x => x.status === 'in_progress') ?? plan.find(x => x.status === 'pending')
400 const word = (current?.content ?? running[0]?.label ?? t?.activity ?? 'Working').split(/\s+/)[0]!.replace(/[^\p{L}\p{N}-]/gu, '')
401 const label = plan.length > 0 ? `${word} ${Math.min(done + 1, plan.length)}/${plan.length}` : word
402 const pct = isMeasured ? `${Math.round(fraction * 100)}%` : fmtTime(now - (t?.startedAt ?? now))
403 const crab = lookFor(running[0]?.label ?? '', custom)
404
405 const track = Svg ? (
406 <Svg source={pillSvg(fraction, label)} alt={`${label}, ${pct}`} width={240} height={22} />
407 ) : (
408 (() => {
409 const width = Math.max(10, Math.min(28, e.props.bodyColumns - 40))
410 const pos = Math.round(fraction * width)
411 return (
412 <Box flexDirection="row">
413 <Text color="#55556E">{'·'.repeat(Math.max(0, pos))}</Text>
414 <Text backgroundColor={C.violet} color="#FFFFFF" bold>
415 {' '}
416 {label}{' '}
417 </Text>
418 <Text color="#55556E">{'·'.repeat(Math.max(0, width - pos))}</Text>
419 </Box>
420 )
421 })()
422 )
423
424 return (
425 <Box flexDirection="row" alignItems="center" gap={2}>
426 <Box flexDirection="row" gap={1}>
427 <Text color={C.violet}>●</Text>
428 <Text bold wrap="truncate-end">
429 {t?.title ?? 'Working'}
430 </Text>
431 </Box>
432 {track}
433 <Text dimColor>{pct}</Text>
434 {subs.length > 0 && (
435 <Box flexDirection="row" alignItems="center" gap={1}>
436 {Svg ? <Svg source={crabSvg(crab)} alt="agents" width={24} height={18} /> : <Text color={C.crab}>▟█▙</Text>}
437 <Button key="open-agents" label={`×${subs.length}`} plain onPress={() => void openPane($)} />
438 </Box>
439 )}
440 <Button key="hide-band" label="✕" plain role="dismiss" onPress={() => update($, isBandHidden, () => true)} />
441 </Box>
442 )
443 })
444
445 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
446 const els = $.ui.resolve(e)
447 const { Box, Text, Button } = els
448 const Svg = 'Svg' in els ? els.Svg : undefined
449 const subs = await read($, agents)
450 const plan = await read($, todos)
451 const t = await read($, turn)
452 const custom = await read($, mascots)
453 const collapsed = await read($, isCollapsed)
454 const finishedOpen = await read($, isFinishedOpen)
455 const now = await $.clock.now()
456 const cols = Math.max(24, e.props.bodyColumns)
457
458 const totalUsd = subs.reduce((s, a) => s + a.usd, 0) + (t?.usd ?? 0)
459 const totalTokens = subs.reduce((s, a) => s + a.input + a.output + a.cacheRead + a.cacheWrite, 0) + (t?.tokens ?? 0)
460 const elapsed = t ? (t.endedAt ?? now) - t.startedAt : 0
461
462 const running = subs.filter(a => a.status === 'running').reverse()
463 const finished = subs.filter(a => a.status !== 'running').reverse()
464 const taken = (content: string) =>
465 subs.some(a => a.label.toLowerCase().includes(content.toLowerCase()) || content.toLowerCase().includes(a.label.toLowerCase()))
466 const planned = plan.filter(p => p.status === 'pending' && !taken(p.content))
467
468 const divider = <Text color={C.track}>{'─'.repeat(cols)}</Text>
469
470 const tile = (key: string, label: string, value: string) => (
471 <Box key={key} flexDirection="column" flexGrow={1} backgroundColor="#2A2A2A" paddingX={1}>
472 <Text color="#9A9A9A">{label}</Text>
473 <Text bold>{value}</Text>
474 </Box>
475 )
476
477 const row = (a: SubAgent) => {
478 const isRunning = a.status === 'running'
479 const look = lookFor(`${a.kind} ${a.label}`, custom)
480 const [effortWord, effortColor] = effortLook(a.effort)
481 const done = a.todos.filter(x => x.status === 'completed').length
482 const activeTodo = a.todos.find(x => x.status === 'in_progress')
483 const progress =
484 a.todos.length > 0
485 ? `${done}/${a.todos.length}${isRunning && activeTodo ? ` · ${activeTodo.content}` : ''}`
486 : isRunning
487 ? `step ${a.steps}`
488 : `${a.steps}/${a.steps}`
489 const ctxPct = Math.max(0, Math.round((a.lastContext / (a.lastContext > 200_000 || /1m/i.test(a.model) ? 1_000_000 : 200_000)) * 100))
490 const tokens = a.input + a.output + a.cacheRead + a.cacheWrite
491 const metrics = `ctx ${ctxPct}% · ${fmtTokens(tokens)} ≈$${a.usd.toFixed(2)} ${fmtTime((a.endedAt ?? now) - a.startedAt)}`
492 const mark =
493 a.status === 'failed' ? <Text color={C.red}>✗</Text> : isRunning ? <Text color={C.coral}>●</Text> : <Text color={C.green}>✓</Text>
494 const frac = progressOf(a)
495 const [barOn, barOff] = textBar(frac, Math.max(8, cols - 8))
496
497 const sprite = Svg ? (
498 <Svg source={crabSvg(look)} alt={`${a.label} crab`} width={40} height={30} />
499 ) : (
500 <Box flexDirection="column" width={6}>
501 <Text color={look.hatColor}> ▄▄▄ </Text>
502 <Text color={look.color}>▐▛█▜▌</Text>
503 <Text color={look.color}> ▘ ▝ </Text>
504 </Box>
505 )
506
507 if (collapsed) {
508 return (
509 <Box key={`agent-${a.id}`} flexDirection="row" justifyContent="space-between" gap={1}>
510 <Text bold wrap="truncate-end">
511 {a.label}
512 </Text>
513 <Box flexDirection="row" gap={1}>
514 <Text dimColor>{progress.split(' · ')[0]}</Text>
515 {mark}
516 </Box>
517 </Box>
518 )
519 }
520
521 return (
522 <Box key={`agent-${a.id}`} flexDirection="column">
523 <Box flexDirection="row" gap={1} alignItems="center">
524 <Box width={6} flexShrink={0} alignItems="center">
525 {sprite}
526 </Box>
527 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
528 <Box flexDirection="row" justifyContent="space-between" gap={1}>
529 <Text bold wrap="truncate-end">
530 {a.label}
531 </Text>
532 {mark}
533 </Box>
534 <Box flexDirection="row" gap={1}>
535 {effortWord && <Text color={effortColor}>{effortWord}</Text>}
536 <Text color="#9A9A9A">
537 {prettyModel(a.model)}
538 {a.effort ? ` · ${a.effort}` : ''}
539 </Text>
540 </Box>
541 <Box flexDirection="row" justifyContent="space-between" gap={1}>
542 <Text wrap="truncate-end">{progress}</Text>
543 <Text color="#9A9A9A">{metrics}</Text>
544 </Box>
545 {Svg ? (
546 <Svg source={barSvg(frac, effortColor)} alt={`${Math.round(frac * 100)}% done`} height={4} />
547 ) : (
548 <Box flexDirection="row">
549 <Text color={effortColor}>{barOn}</Text>
550 <Text color={C.track}>{barOff}</Text>
551 </Box>
552 )}
553 </Box>
554 </Box>
555 </Box>
556 )
557 }
558
559 const list = (items: SubAgent[]) =>
560 items.map((a, i) => (
561 <Box key={`wrap-${a.id}`} flexDirection="column">
562 {row(a)}
563 {i < items.length - 1 && !collapsed && divider}
564 </Box>
565 ))
566
567 return (
568 <Box flexDirection="column" gap={1}>
569 <Text bold>{t?.title ?? 'Agents'}</Text>
570 <Box flexDirection="row" gap={1}>
571 {tile('cost', 'Cost', `≈$${totalUsd.toFixed(2)}`)}
572 {tile('tokens', 'Tokens', fmtTokens(totalTokens))}
573 {tile('time', 'Time', fmtTime(elapsed))}
574 </Box>
575 <Box flexDirection="row" justifyContent="space-between">
576 <Button key="collapse" label={collapsed ? 'Expand' : 'Collapse'} plain onPress={() => update($, isCollapsed, v => !v)} />
577 {finished.length > 0 && (
578 <Button key="clear" label="Clear finished" plain onPress={() => update($, agents, l => l.filter(a => a.status === 'running'))} />
579 )}
580 </Box>
581
582 {subs.length === 0 && planned.length === 0 && (
583 <Text color="#9A9A9A">No agents yet. Helpers Claude starts show up here. Restyle their crabs with /crab-style.</Text>
584 )}
585
586 {running.length > 0 && (
587 <Box flexDirection="column">
588 <Text color="#9A9A9A">Running · {running.length}</Text>
589 {list(running)}
590 </Box>
591 )}
592
593 {finished.length > 0 && (
594 <Box flexDirection="column">
595 {running.length > 0 && divider}
596 <Button
597 key="toggle-finished"
598 label={`${finishedOpen ? '▾' : '▸'} Finished · ${finished.length}`}
599 plain
600 onPress={() => update($, isFinishedOpen, v => !v)}
601 />
602 {finishedOpen && list(finished)}
603 </Box>
604 )}
605
606 {planned.length > 0 && (
607 <Box flexDirection="column">
608 {divider}
609 <Text color="#9A9A9A">Planned · {planned.length}</Text>
610 {planned.map(p => (
611 <Box key={`plan-${p.id}`} flexDirection="row" justifyContent="space-between">
612 <Text color="#9A9A9A" wrap="truncate-end">
613 {plan.indexOf(p) + 1}. {p.content}
614 </Text>
615 <Text color="#9A9A9A">◷</Text>
616 </Box>
617 ))}
618 </Box>
619 )}
620 </Box>
621 )
622 })
623}
624types/index.d.ts 51 lines1export type Todo = { id: string; content: string; status: 'pending' | 'in_progress' | 'completed' }
2
3export type SubAgent = {
4 id: string
5 label: string
6 kind: string
7 model: string
8 effort: string
9 steps: number
10 input: number
11 output: number
12 cacheRead: number
13 cacheWrite: number
14 lastContext: number
15 usd: number
16 todos: Todo[]
17 status: 'running' | 'done' | 'failed'
18 startedAt: number
19 endedAt: number | null
20}
21
22export type Turn = {
23 title: string
24 prompt: string
25 isTitled: boolean
26 startedAt: number
27 endedAt: number | null
28 steps: number
29 tools: number
30 tokens: number
31 usd: number
32 activity: string
33 isWorking: boolean
34}
35
36export type Mascot = { color: string; hat: string }
37
38declare module 'claude-code' {
39 interface PluginState {
40 'savvy-progress': {
41 todos: Todo[]
42 agents: SubAgent[]
43 turn: Turn | null
44 mascots: Record<string, Mascot>
45 isCollapsed: boolean
46 isFinishedOpen: boolean
47 isBandHidden: boolean
48 }
49 }
50}
51