Progress bar above the prompt and a live subagent pane with pixel crab mascots, model, tokens and estimated cost.

My Claude Code mods, as a plugin marketplace (alex-mods). One folder per mod under plugins/.
| Mod | What it does |
|---|---|
| savvy-progress | Ember progress bar above the prompt for a batch of subagents, and an Agents pane: crab mascot per agent, live status, per-agent progress bar, model, tokens, estimated cost, elapsed time. |
| file-tree | Files pane with the project tree: files Claude reads (blue), modifies (orange) or creates (purple) light up with a shimmer, and turn green once committed, in every repo they live in. |
| tool-skins | Reskins the transcript's tool rows: an icon and a palette colour per tool (dracula skin from kevinvn1709/vscode-dracula-color-theme), the row's result still drawn by Claude Code. |
| cache-tax | Prompt-cache state in the status line; stops a message that would re-read the whole conversation on a cold cache and shows what it costs; /keepwarm keeps the cache warm while you are away. |
claude plugin marketplace add E:/WS/claude-mods
claude plugin install savvy-progress@alex-mods --scope user
claude plugin install file-tree@alex-mods --scope user
claude plugin install cache-tax@alex-mods --scope user
claude plugin install tool-skins@alex-mods --scope user
Installed this way, Claude Code reads the plugin from this folder, so an edit here reaches a session with /reload-plugins (no reinstall, no version bump).
/plugin install savvy-progress --marketplace Fraktalis/claude-mods
Then update later with claude plugin update savvy-progress@alex-mods after pushing a new version.
claude plugin validate plugins/savvy-progress
claude plugin test plugins/savvy-progress
Bump version in the mod's .claude-plugin/plugin.json for each release that GitHub installs should pick up.
/savvy opens the Agents pane; /savvy-skin <type|*> <crab|developer|designer|researcher|architect|ghost> picks a mascot.progress tool (mcp__savvy-progress__progress); each subagent is asked to report done/total/step with it. The main loop can also call it, with title to name the batch.hooks/register.tsx, PRICES); edit it when prices change./tree opens the Files pane (it also opens by itself on the first file Claude edits or creates); /tree-clear forgets what was touched.git commit run by Claude re-checks git status in every repository holding a modified or new file; commits made in a terminal are picked up on the next prompt.node_modules, .git and the like are listed dimmed and stay closed. Touched only hides the rest. Files outside the session's folder are listed under Elsewhere.🔥 cache 42m while warm, 🧊 cache cold · next msg ≈ $2.40 once the TTL has passed since the main conversation's last request.hooks/register.ts (PRICES), same table as savvy-progress./cache shows the details; /keepwarm on|off sends a one-line ping ~1m30 before expiry, at most maxPings times in a row (never while Claude works or while you type). Each ping costs a cache read and a one-word reply.ttl (1h or 5m), minTokens, maxPings. The TTL is not detected: switch it to 5m in usage overage.ToolUse): icon + tool name in its palette colour, the call's argument (last 3 path segments, or the one-line command), … running, ✗ error, Interrupted. The result under the row (ToolResult: diffs, Bash output) is still Claude Code's own./skin lists skins, /skin <name> switches, /skin off hands the rows back; the choice is kept across sessions. dracula is on by default.dracula comes from themes/dracula-color-theme.json of github.com/kevinvn1709/vscode-dracula-color-theme, a Darcula-style palette despite its name: keyword #CC7832 → Bash, function #FFC66D → Edit, string #6A8759 → Write, number #6897BB → Read, type #4EC9B0 → Grep/Glob, property #9876AA → Agent, info #6796e6 → web tools, debug #b267e6 → MCP tools, on #212122.THEMES (hooks/register.tsx). The name claude-skins is reserved for Anthropic, hence tool-skins.hooks/register.tsx 602 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, StateDollar } from 'claude-code'
3
4import type { AgentRow, AgentStatus, Batch, Report, Task, TaskStatus } from '../types'
5
6const PANE = 'savvy-agents'
7const PROGRESS_TOOL = 'mcp__savvy-progress__progress'
8
9const tasks = atom({ plugin: 'savvy-progress', key: 'tasks' } as const, {})
10const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, {})
11const skins = atom({ plugin: 'savvy-progress', key: 'skins' } as const, {})
12const batch = atom({ plugin: 'savvy-progress', key: 'batch' } as const, null)
13const mainReport = atom({ plugin: 'savvy-progress', key: 'mainReport' } as const, null)
14const finishedCollapsed = atom({ plugin: 'savvy-progress', key: 'finishedCollapsed' } as const, false)
15const lastPrompt = atom({ plugin: 'savvy-progress', key: 'lastPrompt' } as const, '')
16
17// ---------- pure helpers (tested in savvy.test.ts) ----------
18
19export type Usage = {
20 input_tokens: number
21 output_tokens: number
22 cache_read_input_tokens: number
23 cache_creation_input_tokens: number
24}
25
26/** $ per million tokens: input, output, cache read. Cache writes bill at 1.25x input. */
27export type Price = { input: number; output: number; cacheRead: number }
28
29// ponytail: first-party list prices (claude-api skill table, 2026-10-06); edit here when prices change
30const PRICES: [RegExp, Price][] = [
31 [/fable|mythos/, { input: 10, output: 50, cacheRead: 0.25 }],
32 [/opus-5-5/, { input: 4, output: 20, cacheRead: 0.2 }],
33 [/opus/, { input: 5, output: 25, cacheRead: 0.5 }],
34 [/sonnet-5/, { input: 2, output: 10, cacheRead: 0.2 }],
35 [/sonnet/, { input: 3, output: 15, cacheRead: 0.3 }],
36 [/haiku-5/, { input: 0.1, output: 0.5, cacheRead: 0.01 }],
37 [/haiku/, { input: 1, output: 5, cacheRead: 0.1 }],
38]
39
40export const priceOf = (model: string): Price => PRICES.find(([re]) => re.test(model))?.[1] ?? PRICES[1]![1]
41
42/** What one request cost, in dollars, at the answering model's list price. */
43export const costUsd = (u: Usage, model: string) => {
44 const p = priceOf(model)
45 return (
46 (u.input_tokens * p.input +
47 u.cache_creation_input_tokens * p.input * 1.25 +
48 u.cache_read_input_tokens * p.cacheRead +
49 u.output_tokens * p.output) /
50 1_000_000
51 )
52}
53
54/** Tokens the request added: new input, cache writes and output; cache re-reads are not new. */
55export const newTokens = (u: Usage) => u.input_tokens + u.cache_creation_input_tokens + u.output_tokens
56
57export const promptTokens = (u: Usage) => u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
58
59export const tally = (list: Task[]) => ({
60 done: list.filter(t => t.status === 'completed').length,
61 total: list.length,
62 active: list.find(t => t.status === 'in_progress')?.subject,
63})
64
65export const bar = (done: number, total: number, width = 20) => {
66 const n = total ? Math.round((done / total) * width) : 0
67 return '█'.repeat(n) + '░'.repeat(width - n)
68}
69
70export const shortModel = (m: string) =>
71 (m || '?')
72 .replace(/^claude-/, '')
73 .replace(/-\d{8}$/, '')
74 .replace(/^(\w+)-(\d+)-(\d+)$/, (_, n: string, a: string, b: string) => `${n[0]!.toUpperCase()}${n.slice(1)} ${a}.${b}`)
75
76export const money = (usd: number) => (usd < 0.01 ? '≈<$0.01' : `≈$${usd.toFixed(2)}`)
77export const kTokens = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
78export const clock = (ms: number) => {
79 const s = Math.max(0, Math.round(ms / 1000))
80 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
81}
82
83/** Where an agent stands, 0..1, or null when nothing measurable was reported. */
84export const fraction = (a: Pick<AgentRow, 'status' | 'report'>, own?: { done: number; total: number }) => {
85 if (a.status === 'done' || a.status === 'failed') return 1
86 if (a.report && a.report.total > 0) return Math.min(1, a.report.done / a.report.total)
87 if (own && own.total > 0) return own.done / own.total
88 return null
89}
90
91export const titleOf = (prompt: string) => {
92 const first = prompt.replace(/^\/\S+\s*/, '').split(/[.:\n!?]/)[0]?.trim() ?? ''
93 if (!first) return 'Agents'
94 return first.length > 28 ? `${first.slice(0, 27)}…` : first
95}
96
97// ---------- pixel crabs ----------
98// 10x8 grid: two hat rows + six body rows. b body, e eye, h hat.
99
100export type Skin = { body: number; eye: number; hat?: number; hatRows?: [string, string]; role: string; color: string }
101
102export const SKINS = {
103 crab: { body: 0xd97757, eye: 0x1a1a1a, role: 'crab', color: '#d97757' },
104 developer: { body: 0xd97757, eye: 0x1a1a1a, hat: 0x3fb950, hatRows: ['...hhhh...', '..hhhhhhhh'], role: 'builder', color: '#3fb950' },
105 designer: { body: 0xd97757, eye: 0x1a1a1a, hat: 0xf2f2f2, hatRows: ['..hhhhhh..', '...hhhh...'], role: 'designer', color: '#4c9aff' },
106 researcher: { body: 0xd97757, eye: 0x1a1a1a, hat: 0xb07d48, hatRows: ['...hhhh...', '.hhhhhhhh.'], role: 'scout', color: '#e3a33b' },
107 architect: { body: 0xd97757, eye: 0x1a1a1a, hat: 0xf2c94c, hatRows: ['..hhhhhh..', '.hhhhhhhh.'], role: 'planner', color: '#f2c94c' },
108 ghost: { body: 0x8b949e, eye: 0x30363d, role: 'failed', color: '#f85149' },
109} satisfies Record<string, Skin>
110
111const skinOf = (name: string): Skin => (SKINS as Record<string, Skin>)[name] ?? SKINS.crab
112
113// ponytail: default skin per built-in agent type; /savvy-skin overrides
114const TYPE_SKIN: Record<string, string> = { Explore: 'researcher', Plan: 'architect', 'general-purpose': 'developer' }
115
116const BODY = ['.bbbbbbbb.', '.bebbbbeb.', 'bbbbbbbbbb', '.bbbbbbbb.', '.bbbbbbbb.', '.b.b..b.b.']
117
118export const pixels = (skin: Skin): (number | null)[][] =>
119 [...(skin.hatRows ?? ['..........', '..........']), ...BODY].map(r =>
120 [...r].map(c => (c === 'b' ? skin.body : c === 'e' ? skin.eye : c === 'h' ? skin.hat ?? null : null)),
121 )
122
123const DEFAULT = 0x01000000
124
125export const rasterCells = (px: (number | null)[][]) => {
126 const words: number[] = []
127 for (let y = 0; y < px.length; y += 2) {
128 const row = px[y] ?? []
129 for (let x = 0; x < row.length; x++) {
130 const top = row[x] ?? null
131 const bottom = px[y + 1]?.[x] ?? null
132 if (top === null && bottom === null) words.push(0x20, DEFAULT, DEFAULT)
133 else if (top === null) words.push(0x2584, bottom ?? DEFAULT, DEFAULT)
134 else words.push(0x2580, top, bottom ?? DEFAULT)
135 }
136 }
137 // toBase64 is in the mod runtime, not in the es2023 lib typings
138 return (new Uint8Array(Uint32Array.from(words).buffer) as Uint8Array & { toBase64(): string }).toBase64()
139}
140
141const hex = (c: number) => '#' + c.toString(16).padStart(6, '0')
142
143export const svgCrab = (px: (number | null)[][], opts: { scale?: number; bob?: boolean; dim?: boolean } = {}) => {
144 const s = opts.scale ?? 3
145 const rects = px
146 .flatMap((row, y) =>
147 row.map((c, x) => (c === null ? '' : `<rect x="${x * s}" y="${y * s + s}" width="${s}" height="${s}" fill="${hex(c)}"/>`)),
148 )
149 .join('')
150 const bob = opts.bob
151 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -${s};0 0" dur="0.7s" repeatCount="indefinite"/>`
152 : ''
153 return (
154 `<svg xmlns="http://www.w3.org/2000/svg" width="${10 * s}" height="${9 * s}" viewBox="0 0 ${10 * s} ${9 * s}" shape-rendering="crispEdges">` +
155 `<g opacity="${opts.dim ? 0.45 : 1}">${rects}${bob}</g></svg>`
156 )
157}
158
159// ---------- status glyphs (fallback where a surface has no Client) ----------
160
161export const STATUS = {
162 waiting: { glyph: '◌', color: 'subtle' },
163 running: { glyph: '●', color: '#e8743b' },
164 done: { glyph: '✓', color: 'success' },
165 failed: { glyph: '✗', color: 'error' },
166} satisfies Record<AgentStatus, { glyph: string; color: string }>
167
168const BAR_COLOR: Record<AgentStatus, string | null> = { waiting: null, running: null, done: '#3fb950', failed: '#f85149' }
169
170// ---------- state helpers ----------
171
172function setTasks($: StateDollar, owner: string, fn: (t: Record<string, Task>) => Record<string, Task>) {
173 return update($, tasks, all => ({ ...all, [owner]: fn(all[owner] ?? {}) }))
174}
175
176function patchAgent($: StateDollar, id: string, fn: (a: AgentRow) => Partial<AgentRow>) {
177 return update($, agents, all => (all[id] ? { ...all, [id]: { ...all[id], ...fn(all[id]) } } : all))
178}
179
180const isLive = (a: AgentRow) => a.status === 'running' || a.status === 'waiting'
181
182const NOTE =
183 `\n\n[Progress reporting] If the tool ${PROGRESS_TOOL} is available, call it once at the start with ` +
184 `total = the number of steps you plan and done = 0, then after each step with the new done count and ` +
185 `a 2-4 word step label. One call per step, no more.`
186
187/** Marks the batch ended (and toasts once) when none of its agents is live. */
188async function closeBatchIfDone($: EngineInterface, now: number) {
189 const all = await read($, agents)
190 const b = await read($, batch)
191 if (!b || b.endedAt !== undefined || !b.ids.every(id => !all[id] || !isLive(all[id]!))) return
192 await update($, batch, cur => (cur ? { ...cur, endedAt: now } : cur))
193 if (!b.hidden) $.ui.toast(`🦀 ${b.title}: ${b.ids.length} agent${b.ids.length > 1 ? 's' : ''} finished`)
194}
195
196// ---------- hooks ----------
197
198export const register: Register = on => {
199 // a subagent can finish before agent.spawn has written its row: keep the outcome until the row lands
200 const early = new Map<string, { status: AgentStatus; endedAt: number }>()
201
202 on('session.start', async ($, e, next) => {
203 await $.command.register({ name: 'savvy', description: 'Open the Savvy Progress agents pane' })
204 await $.command.register({
205 name: 'savvy-skin',
206 description: 'Set a crab skin for an agent type: /savvy-skin <type|*> <skin>',
207 argumentHint: `<type|*> <${Object.keys(SKINS).join('|')}>`,
208 })
209 await $.tool.register({
210 name: 'progress',
211 description:
212 'Report progress on your current multi-step job so the person sees a live progress bar. ' +
213 'Call with total steps and done = 0 at the start, then after each step. Optional title names the whole job (main loop only).',
214 inputSchema: {
215 type: 'object',
216 properties: {
217 done: { type: 'integer', minimum: 0 },
218 total: { type: 'integer', minimum: 1 },
219 step: { type: 'string', description: '2-4 word label of the current step' },
220 title: { type: 'string', description: 'Short name of the whole job' },
221 },
222 required: ['done', 'total'],
223 },
224 isDeferred: false,
225 })
226 const saved = (await $.store.get('skins')) as Record<string, string> | undefined
227 if (saved) await update($, skins, () => saved)
228 return next(e)
229 })
230
231 on('command.run', { command: 'savvy' }, async $ => {
232 await $.ui.open({ id: PANE, title: 'Agents' })
233 return { text: 'Savvy Progress pane opened.' }
234 })
235
236 on('command.run', { command: 'savvy-skin' }, async ($, e) => {
237 const [type, skin] = e.args.trim().split(/\s+/)
238 if (!type || !skin || !(SKINS as Record<string, Skin>)[skin]) {
239 return { text: `Usage: /savvy-skin <type|*> <skin>. Skins: ${Object.keys(SKINS).join(', ')}` }
240 }
241 const map = { ...(await read($, skins)), [type]: skin }
242 await update($, skins, () => map)
243 await $.store.set('skins', map)
244 return { text: `Agents of type ${type} now wear the ${skin} skin.` }
245 })
246
247 on('prompt.submit', async ($, e, next) => {
248 if (e.text.trim()) await update($, lastPrompt, () => e.text)
249 return next(e)
250 })
251
252 on('tool.call', async ($, e, next) => {
253 const owner = e.agentId ?? 'main'
254
255 if ((e.tool as string) === PROGRESS_TOOL) {
256 const input = e as unknown as { done?: number; total?: number; step?: string; title?: string }
257 const report: Report = { done: Math.max(0, input.done ?? 0), total: Math.max(1, input.total ?? 1), step: input.step }
258 if (e.agentId) {
259 await patchAgent($, e.agentId, a => ({ report, tools: a.tools + 1 }))
260 } else {
261 await update($, mainReport, () => report)
262 if (input.title) await update($, batch, b => (b ? { ...b, title: input.title! } : b))
263 }
264 return { result: 'Progress noted.' }
265 }
266
267 if (e.agentId) void patchAgent($, e.agentId, a => ({ tools: a.tools + 1 }))
268
269 if (e.tool === 'TodoWrite') {
270 await setTasks($, owner, () =>
271 Object.fromEntries(e.todos.map((t, i) => [`todo${i}`, { subject: t.activeForm || t.content, status: t.status }])),
272 )
273 return next(e)
274 }
275 if (e.tool === 'TaskUpdate' && e.status) {
276 const status = e.status
277 await setTasks($, owner, list => {
278 const { [e.taskId]: old, ...rest } = list
279 if (status === 'deleted') return rest
280 if (!old) return list
281 return { ...list, [e.taskId]: { subject: e.activeForm ?? e.subject ?? old.subject, status: status as TaskStatus } }
282 })
283 return next(e)
284 }
285 if (e.tool === 'TaskCreate') {
286 const subject = e.activeForm ?? e.subject
287 const ran = await next(e)
288 const id = (ran as { result?: { task?: { id?: string } } }).result?.task?.id
289 if (id) await setTasks($, owner, list => ({ ...list, [id]: { subject, status: 'pending' } }))
290 return ran
291 }
292 return next(e)
293 })
294
295 on('agent.spawn', async ($, e, next) => {
296 const ran = await next(e.workflow ? e : { ...e, prompt: e.prompt + NOTE })
297 if (!ran.agentId) return ran
298 const now = await $.clock.now()
299 const row: AgentRow = {
300 id: ran.agentId,
301 description: e.description,
302 type: e.subagentType,
303 model: ran.model,
304 status: 'waiting',
305 steps: 0,
306 tools: 0,
307 usd: 0,
308 tokens: 0,
309 ctx: 0,
310 startedAt: now,
311 }
312 const prompt = await read($, lastPrompt)
313 // one atomic update: parallel spawns all land in the same batch (update retries on a version miss)
314 let isFresh = false
315 let nextBatch: Batch = { id: now, title: titleOf(prompt), startedAt: now, ids: [row.id], hidden: false }
316 await update($, batch, cur => {
317 isFresh = !cur || cur.endedAt !== undefined
318 nextBatch = isFresh
319 ? { id: now, title: titleOf(prompt), startedAt: now, ids: [row.id], hidden: false }
320 : { id: cur!.id, title: cur!.title, startedAt: cur!.startedAt, ids: [...cur!.ids, row.id], hidden: false }
321 return nextBatch
322 })
323 if (isFresh) await update($, mainReport, () => null)
324 await update($, agents, list => {
325 // ponytail: keep this batch's agents + the 6 most recent older finished ones
326 const older = Object.values(list)
327 .filter(a => !nextBatch.ids.includes(a.id) && !isLive(a))
328 .sort((a, b) => b.startedAt - a.startedAt)
329 .slice(6)
330 .map(a => a.id)
331 const kept = Object.fromEntries(Object.entries(list).filter(([id]) => !older.includes(id)))
332 return { ...kept, [row.id]: row }
333 })
334 const done = early.get(row.id)
335 if (done) {
336 early.delete(row.id)
337 await patchAgent($, row.id, () => done)
338 await closeBatchIfDone($, done.endedAt)
339 }
340 void $.ui.open({ id: PANE, title: 'Agents' })
341 return ran
342 })
343
344 on('turn.step', async function* ($, e, next) {
345 if (e.agentId) {
346 const effort = e.effort === undefined ? undefined : String(e.effort)
347 const id = e.agentId
348 // a step after an early finish means the agent was resumed: forget the stored outcome
349 early.delete(id)
350 let resumed = false
351 // a step means the agent is working: waiting -> running, and a finished agent that is resumed goes back to Running
352 await update($, agents, all => {
353 const a = all[id]
354 if (!a) return all
355 if (a.status === 'running') return effort && effort !== a.effort ? { ...all, [id]: { ...a, effort } } : all
356 resumed = a.status === 'done' || a.status === 'failed'
357 const { endedAt: _ended, ...rest } = a
358 const back: AgentRow = { ...rest, status: 'running', effort: effort ?? a.effort }
359 return { ...all, [id]: back }
360 })
361 if (resumed) {
362 await update($, batch, cur => {
363 if (!cur || !cur.ids.includes(id) || cur.endedAt === undefined) return cur
364 const { endedAt: _e, ...open } = cur
365 return open
366 })
367 }
368 }
369 const r = yield* next(e)
370 if (r.usage) {
371 if (e.agentId) {
372 const model = r.usage.model || e.model
373 const usd = costUsd(r.usage, model)
374 const t = newTokens(r.usage)
375 const ctx = promptTokens(r.usage)
376 await patchAgent($, e.agentId, a => ({ steps: a.steps + 1, usd: (a.usd ?? 0) + usd, tokens: a.tokens + t, ctx, model: model || a.model }))
377 }
378 }
379 return r
380 })
381
382 on('turn.complete', async ($, e, next) => {
383 if (e.agentId) {
384 const now = await $.clock.now()
385 const status: AgentStatus = e.reason === 'answer' ? 'done' : 'failed'
386 if (!(await read($, agents))[e.agentId]) early.set(e.agentId, { status, endedAt: now })
387 await patchAgent($, e.agentId, a => ({
388 status,
389 endedAt: now,
390 report: a.report && e.reason === 'answer' ? { ...a.report, done: a.report.total } : a.report,
391 }))
392 // the row may have landed meanwhile and taken the outcome above: then nothing waits in `early`
393 if ((await read($, agents))[e.agentId]) early.delete(e.agentId)
394 await closeBatchIfDone($, now)
395 }
396 return next(e)
397 })
398
399 // ---------- band above the prompt ----------
400
401 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
402 const b = await read($, batch)
403 if (e.props.hasSurvey || !b || b.hidden) return next(e)
404 const all = await read($, agents)
405 const list = b.ids.map(id => all[id]).filter((a): a is AgentRow => !!a)
406 const live = list.filter(isLive)
407 // ponytail: the band stays up after a batch ends until dismissed, so the result is seen
408 const ownTasks = await read($, tasks)
409 const skinMap = await read($, skins)
410 const mr = await read($, mainReport)
411
412 const segs = list.map(a => {
413 const own = tally(Object.values(ownTasks[a.id] ?? {}))
414 const skin = skinOf(skinMap[a.type] ?? skinMap['*'] ?? TYPE_SKIN[a.type] ?? 'crab')
415 return { frac: fraction(a, own), color: BAR_COLOR[a.status] ?? skin.color, running: a.status === 'running' }
416 })
417 const finished = list.length - live.length
418 const known = segs.map(s => s.frac ?? 0)
419 const frac = mr ? mr.done / mr.total : known.reduce((s, f) => s + f, 0) / Math.max(1, list.length)
420 const activeStep = live.map(a => a.report?.step).find(Boolean)
421 const label = mr ? `${mr.step ?? 'Step'} ${mr.done}/${mr.total}` : `${activeStep ?? (live.length ? 'Working' : 'Done')} ${finished}/${list.length}`
422 const pct = `${Math.round(frac * 100)}%`
423 const overall: AgentStatus = live.length ? (live.some(a => a.status === 'running') ? 'running' : 'waiting') : list.some(a => a.status === 'failed') ? 'failed' : 'done'
424 const hide = () => update($, batch, cur => (cur ? { ...cur, hidden: true } : cur))
425 const openPane = () => void $.ui.open({ id: PANE, title: 'Agents' })
426
427 // ponytail: animation lives in Clients (surface-side, kept across redraws), so redraws never restart it
428 if (e.surface === 'terminal' || e.surface === 'desktop') {
429 const { Box, Text, Button, Client } = $.ui.resolve(e)
430 const barW = Math.max(16, Math.min(44, (e.props.bodyColumns ?? 80) - b.title.length - 26))
431 return (
432 <Box flexDirection="row" gap={1} alignItems="center">
433 <Client key="band-dot" module="./dot.tsx" props={{ status: overall }} />
434 <Button key="agents" plain label={b.title} onPress={openPane} />
435 <Client
436 key="band-bar"
437 module="./globalbar.tsx"
438 props={{ frac, label: `${label} · ${pct}`, live: live.length > 0, failed: overall === 'failed', width: barW }}
439 width={barW}
440 />
441 <Text color="#d97757">🦀</Text>
442 <Text>×{list.length}</Text>
443 <Button key="hide" plain label="✕" role="dismiss" onPress={hide} />
444 </Box>
445 )
446 }
447 const { Text } = $.ui.resolve(e)
448 return (
449 <Text>
450 {STATUS[overall].glyph} {b.title} · {label} · {pct}
451 </Text>
452 )
453 })
454
455 // ---------- agents pane ----------
456
457 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
458 const { Box, Text, Button } = $.ui.resolve(e)
459 const all = await read($, agents)
460 const list = Object.values(all)
461 if (list.length === 0) return <Text dimColor>No subagents yet this session.</Text>
462
463 const b = await read($, batch)
464 const allTasks = await read($, tasks)
465 const skinMap = await read($, skins)
466 const collapsed = await read($, finishedCollapsed)
467 const usage = await $.session.usage()
468 const window = usage.context?.window || 200_000
469 const now = await $.clock.now()
470 const cols = e.props.bodyColumns ?? 40
471 const costOf = (a: AgentRow) => a.usd ?? 0
472
473 const inBatch = b ? list.filter(a => b.ids.includes(a.id)) : list
474 const statCost = inBatch.reduce((s, a) => s + costOf(a), 0)
475 const statTokens = inBatch.reduce((s, a) => s + a.tokens, 0)
476 const statTime = b ? (b.endedAt ?? now) - b.startedAt : 0
477
478 const live = list.filter(isLive).sort((x, y) => x.startedAt - y.startedAt)
479 const finished = list.filter(a => !isLive(a)).sort((x, y) => (y.endedAt ?? 0) - (x.endedAt ?? 0))
480
481
482 const mascot = (a: AgentRow, skin: Skin) => {
483 const px = pixels(a.status === 'failed' ? SKINS.ghost : skin)
484 if (e.surface === 'terminal') {
485 const { Raster } = $.ui.resolve(e)
486 return <Raster key={`crab-${a.id}`} columns={10} rows={4} cells={rasterCells(px)} />
487 }
488 const { Svg } = $.ui.resolve(e)
489 // static image: no sandboxed frame, nothing to restart on redraw
490 return <Svg source={svgCrab(px, { dim: a.status === 'waiting' })} alt={`${skin.role} crab`} width={30} height={27} />
491 }
492
493 const statusMark = (a: AgentRow) => {
494 if (e.surface === 'terminal' || e.surface === 'desktop') {
495 const { Client } = $.ui.resolve(e)
496 return <Client key={`dot-${a.id}`} module="./dot.tsx" props={{ status: a.status }} />
497 }
498 return <Text color={STATUS[a.status].color}>{STATUS[a.status].glyph}</Text>
499 }
500
501 const timer = (key: string, elapsed: number, running: boolean, bold?: boolean) => {
502 if (e.surface === 'terminal' || e.surface === 'desktop') {
503 const { Client } = $.ui.resolve(e)
504 return <Client key={key} module="./timer.tsx" props={{ elapsed, running, bold: !!bold, dim: !bold }} />
505 }
506 return (
507 <Text bold={bold} dimColor={!bold}>
508 {clock(elapsed)}
509 </Text>
510 )
511 }
512
513 const progressBar = (a: AgentRow, frac: number | null, color: string) => {
514 if (e.surface === 'terminal' || e.surface === 'desktop') {
515 const { Client } = $.ui.resolve(e)
516 // ponytail: pane width minus the crab (10) and gaps; a fixed width keeps the Client's size stable
517 const width = Math.max(8, Math.min(60, cols - 13))
518 return <Client key={`bar-${a.id}`} module="./agentbar.tsx" props={{ frac, color, status: a.status, width }} width={width} />
519 }
520 const w = Math.max(8, Math.min(28, cols - 14))
521 return <Text color={color}>{frac === null ? '━'.repeat(w) : bar(Math.round(frac * w), w, w)}</Text>
522 }
523
524 const row = (a: AgentRow) => {
525 const skin = skinOf(skinMap[a.type] ?? skinMap['*'] ?? TYPE_SKIN[a.type] ?? 'crab')
526 const own = tally(Object.values(allTasks[a.id] ?? {}))
527 const frac = fraction(a, own)
528 const color = BAR_COLOR[a.status] ?? skin.color
529 const count = a.report ? `${Math.min(a.report.done, a.report.total)}/${a.report.total}` : own.total ? `${own.done}/${own.total}` : `${a.steps} steps`
530 const step = a.report?.step ?? own.active ?? (a.status === 'waiting' ? 'Waiting' : a.status === 'running' ? `${a.tools} tools` : '')
531 const ctxPct = a.ctx ? `ctx ${Math.max(1, Math.round((a.ctx / window) * 100))}% · ` : ''
532 return (
533 <Box key={a.id} flexDirection="row" gap={1} marginBottom={1}>
534 {mascot(a, skin)}
535 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
536 <Box flexDirection="row" justifyContent="space-between" gap={1}>
537 <Text bold dimColor={a.status === 'waiting'} wrap="truncate-end">
538 {a.description}
539 </Text>
540 {statusMark(a)}
541 </Box>
542 <Text wrap="truncate-end">
543 <Text color={skin.color}>{skin.role}</Text>
544 <Text dimColor>
545 {' '}
546 {shortModel(a.model)}
547 {a.effort ? ` · ${a.effort}` : ''}
548 </Text>
549 </Text>
550 <Box flexDirection={cols < 56 ? 'column' : 'row'} justifyContent="space-between" columnGap={1}>
551 <Text wrap="truncate-end">
552 {count}
553 {step ? <Text dimColor> · {step}</Text> : null}
554 </Text>
555 <Box flexDirection="row" gap={1}>
556 <Text dimColor wrap="truncate-end">
557 {ctxPct}
558 {kTokens(a.tokens)} {money(costOf(a))}
559 </Text>
560 {timer(`t-${a.id}`, (a.endedAt ?? now) - a.startedAt, isLive(a))}
561 </Box>
562 </Box>
563 {progressBar(a, frac, color)}
564 </Box>
565 </Box>
566 )
567 }
568
569 const stat = (label: string, value: string | ReturnType<typeof timer>) => (
570 <Box key={label} flexDirection="column" flexGrow={1} borderStyle="round" borderColor="subtle" paddingX={1}>
571 <Text dimColor>{label}</Text>
572 {typeof value === 'string' ? <Text bold>{value}</Text> : value}
573 </Box>
574 )
575
576 const toggle = () => update($, finishedCollapsed, v => !v)
577
578 return (
579 <Box flexDirection="column" gap={1}>
580 <Text bold>{b?.title ?? 'Agents'}</Text>
581 <Box flexDirection="row" gap={1}>
582 {stat('Cost', money(statCost))}
583 {stat('Tokens', kTokens(statTokens))}
584 {stat('Time', timer('stat-time', statTime, live.length > 0, true))}
585 </Box>
586 {live.length > 0 && (
587 <Box flexDirection="column">
588 <Text dimColor>Running · {live.length}</Text>
589 {live.map(row)}
590 </Box>
591 )}
592 {finished.length > 0 && (
593 <Box flexDirection="column">
594 <Button key="finished" plain label={`${collapsed ? '▸' : '▾'} Finished · ${finished.length}`} onPress={toggle} />
595 {!collapsed && finished.map(row)}
596 </Box>
597 )}
598 </Box>
599 )
600 })
601}
602hooks/dot.tsx 39 lines1import type { ClientModule } from 'claude-code'
2
3import { mix, ORANGE } from './fx'
4
5type Status = 'waiting' | 'running' | 'done' | 'failed'
6type Props = { status: Status }
7type Ref = { t: number; status: Status }
8type State = { r: Ref }
9
10/** Status mark: pulsing ● running, breathing ◌ waiting, ✓ done, ✗ failed. */
11const Dot: ClientModule<Props, State> = (props, surface) => {
12 const { Text } = surface.elements
13 let st = surface.state
14 if (!st) {
15 const r: Ref = { t: 0, status: props.status }
16 st = { r }
17 surface.setState(st)
18 surface.every(120, () => {
19 r.t += 1
20 if (r.status === 'running' || r.status === 'waiting') surface.setState({ r })
21 })
22 }
23 const r = st.r
24 r.status = props.status
25 if (r.status === 'running') {
26 const k = 0.5 + 0.5 * Math.sin(r.t / 2)
27 return <Text color={mix('#5a2a12', ORANGE, 0.35 + 0.65 * k)}>●</Text>
28 }
29 if (r.status === 'waiting') {
30 // same glyph every frame (a rotating glyph changes width in a proportional font)
31 const k = 0.5 + 0.5 * Math.sin(r.t / 3)
32 return <Text color={mix('#484f58', '#8b949e', k)}>◌</Text>
33 }
34 if (r.status === 'done') return <Text color="#3fb950">✓</Text>
35 return <Text color="#f85149">✗</Text>
36}
37
38export default Dot
39hooks/globalbar.tsx 101 lines1import type { ClientModule } from 'claude-code'
2
3import { ease, mix, ORANGE, TRACK } from './fx'
4
5type Props = { frac: number; label: string; live: boolean; failed: boolean; width: number }
6type Ref = { target: number; shown: number; t: number; live: boolean; failed: boolean }
7type State = { r: Ref }
8
9const EMBER_BG = '#3b1a0a'
10const EMBER_HI = '#ffb070'
11// figure space: as wide as a digit in any font, so blank cells match the label's cells
12const FILL = ' '
13
14// ponytail: fixed embers (offset, speed, size); add more if the field looks sparse on wide bars
15const EMBERS = [
16 [0.05, 0.11, 1.6],
17 [0.27, 0.07, 2.2],
18 [0.48, 0.13, 1.3],
19 [0.66, 0.09, 1.9],
20 [0.83, 0.06, 2.6],
21 [0.94, 0.12, 1.4],
22] as const
23
24/**
25 * The global bar. Every cell is the same character (a space) and only its
26 * background colour changes, so the bar never changes width. The filled part
27 * glows orange and embers drift right through it; the fill eases toward its target.
28 */
29const GlobalBar: ClientModule<Props, State> = (props, surface) => {
30 const { Box, Text } = surface.elements
31 let st = surface.state
32 if (!st) {
33 const r: Ref = { target: props.frac, shown: 0, t: 0, live: props.live, failed: props.failed }
34 st = { r }
35 surface.setState(st)
36 surface.every(80, () => {
37 const settled = r.shown === r.target
38 r.shown = ease(r.shown, r.target, 0.06)
39 r.t += 1
40 if (r.live || !settled) surface.setState({ r })
41 })
42 }
43 const r = st.r
44 r.target = Math.max(0, Math.min(1, props.frac))
45 r.live = props.live
46 r.failed = props.failed
47
48 const w = Math.max(10, props.width)
49 const filled = r.shown * w
50 const full = Math.floor(filled)
51 const edge = filled - full
52 // split by code point, capped to the bar, so an emoji in a step label never breaks the cell count
53 const label = [...` ${props.label} `].slice(0, Math.max(10, props.width))
54 const ls = Math.max(0, Math.floor((w - label.length) / 2))
55 const accent = r.failed ? '#f85149' : ORANGE
56 const sec = r.t * 0.08
57
58 const glow = (i: number) => {
59 // a slow breathing base plus soft embers drifting right inside the filled span
60 let g = 0.25 + 0.1 * Math.sin(sec * 1.3 + i * 0.45)
61 if (r.live && full > 0) {
62 for (const [off, speed, size] of EMBERS) {
63 const x = (off * full + sec * speed * full) % Math.max(1, full)
64 const d = Math.abs(i - x)
65 g += Math.max(0, 1 - d / size) * 0.75
66 }
67 }
68 return Math.min(1, g)
69 }
70
71 const cells = []
72 for (let i = 0; i < w; i++) {
73 const li = i - ls
74 let bg: string
75 if (i < full) bg = mix(mix(EMBER_BG, accent, 0.55), EMBER_HI, glow(i))
76 else if (i === full && edge > 0) bg = mix(TRACK, mix(EMBER_BG, accent, 0.55), edge)
77 else bg = TRACK
78 if (li >= 0 && li < label.length) {
79 // the label sits on the bar itself, its cells only a little darker for contrast
80 cells.push(
81 <Text key={`c${i}`} color="#ffffff" backgroundColor={mix(bg, '#000000', 0.3)} bold>
82 {label[li]}
83 </Text>,
84 )
85 continue
86 }
87 cells.push(
88 <Text key={`c${i}`} backgroundColor={bg}>
89 {FILL}
90 </Text>,
91 )
92 }
93 return (
94 <Box flexDirection="row" flexShrink={0}>
95 {cells}
96 </Box>
97 )
98}
99
100export default GlobalBar
101hooks/timer.tsx 40 lines1import type { ClientModule } from 'claude-code'
2
3type Props = { elapsed: number; running: boolean; bold?: boolean; dim?: boolean }
4type Ref = { base: number; elapsed: number; running: boolean }
5type State = { r: Ref }
6
7const fmt = (ms: number) => {
8 const s = Math.max(0, Math.round(ms / 1000))
9 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
10}
11
12/** An elapsed-time label that ticks on the surface's own clock, so the hooks never redraw for it. */
13const Timer: ClientModule<Props, State> = (props, surface) => {
14 const { Text } = surface.elements
15 let st = surface.state
16 if (!st) {
17 const r: Ref = { base: Date.now() - props.elapsed, elapsed: props.elapsed, running: props.running }
18 st = { r }
19 surface.setState(st)
20 surface.every(1000, () => {
21 if (r.running) surface.setState({ r })
22 })
23 }
24 const r = st.r
25 if (props.elapsed !== r.elapsed) {
26 // a redraw brought a fresher reading from the hooks: rebase on it
27 r.elapsed = props.elapsed
28 r.base = Date.now() - props.elapsed
29 }
30 r.running = props.running
31 const ms = r.running ? Date.now() - r.base : r.elapsed
32 return (
33 <Text bold={props.bold} dimColor={props.dim}>
34 {fmt(ms)}
35 </Text>
36 )
37}
38
39export default Timer
40hooks/agentbar.tsx 76 lines1import type { ClientModule } from 'claude-code'
2
3import { ease, mix, TRACK } from './fx'
4
5type Status = 'waiting' | 'running' | 'done' | 'failed'
6type Props = { frac: number | null; color: string; status: Status; width: number }
7type Ref = { target: number | null; shown: number; t: number; status: Status; flash: number; color: string }
8type State = { r: Ref }
9
10/**
11 * One agent's bar: the fill eases toward its fraction; unknown progress shows a
12 * comet sweeping the track; finishing sweeps to full and flashes once.
13 */
14const AgentBar: ClientModule<Props, State> = (props, surface) => {
15 const { Box, Text } = surface.elements
16 let st = surface.state
17 if (!st) {
18 const r: Ref = { target: props.frac, shown: props.frac ?? 0, t: 0, status: props.status, flash: 0, color: props.color }
19 st = { r }
20 surface.setState(st)
21 surface.every(50, () => {
22 const goal = r.target ?? 0
23 const moving = r.target !== null && r.shown !== goal
24 if (r.target !== null) r.shown = ease(r.shown, goal, 0.1)
25 r.flash = Math.max(0, r.flash - 0.06)
26 r.t += 1
27 if (moving || r.flash > 0 || (r.target === null && r.status === 'running')) surface.setState({ r })
28 })
29 }
30 const r = st.r
31 if (props.status !== r.status && (props.status === 'done' || props.status === 'failed')) r.flash = 1
32 r.status = props.status
33 r.target = props.frac
34 r.color = props.color
35
36 // the width comes from the hooks, never from the region: drawing to the region's size fed back into it
37 const w = Math.max(6, props.width)
38 const base = r.flash > 0 ? mix(r.color, '#ffffff', r.flash * 0.7) : r.color
39 const cells = []
40
41 if (r.target === null) {
42 // comet: a bright head with a fading tail, sweeping while running
43 const head = r.status === 'running' ? ((r.t * 0.7) % (w + 8)) - 4 : -99
44 for (let i = 0; i < w; i++) {
45 const d = head - i
46 const b = d >= 0 && d < 6 ? 1 - d / 6 : d < 0 && d > -1.5 ? 0.6 : 0
47 cells.push(
48 <Text key={`c${i}`} color={b > 0 ? mix(TRACK, base, b) : TRACK}>
49 ━
50 </Text>,
51 )
52 }
53 } else {
54 const filled = r.shown * w
55 const full = Math.floor(filled)
56 const edge = filled - full
57 for (let i = 0; i < w; i++) {
58 // one glyph everywhere, only colours move: the bar never changes width
59 const sheen = i < full && r.status === 'running' ? Math.max(0, 1 - Math.abs(((r.t * 0.5) % (w + 10)) - 5 - i) / 3) * 0.35 : 0
60 const color = i < full ? mix(base, '#ffffff', sheen) : i === full ? mix(TRACK, base, edge) : TRACK
61 cells.push(
62 <Text key={`c${i}`} color={color}>
63 ━
64 </Text>,
65 )
66 }
67 }
68 return (
69 <Box flexDirection="row" flexShrink={0}>
70 {cells}
71 </Box>
72 )
73}
74
75export default AgentBar
76hooks/fx.ts 32 lines1// Shared by the surface modules (Clients). Plain functions, no `$`.
2
3const rgb = (c: string) => {
4 const n = parseInt(c.slice(1, 7), 16)
5 return [(n >> 16) & 255, (n >> 8) & 255, n & 255] as const
6}
7
8/** a→b by t (0..1), both '#rrggbb'. */
9export const mix = (a: string, b: string, t: number) => {
10 const k = Math.max(0, Math.min(1, t))
11 const [r1, g1, b1] = rgb(a)
12 const [r2, g2, b2] = rgb(b)
13 const h = (x: number, y: number) => Math.round(x + (y - x) * k).toString(16).padStart(2, '0')
14 return `#${h(r1, r2)}${h(g1, g2)}${h(b1, b2)}`
15}
16
17/** Deterministic noise in 0..1 for a cell and a time bucket. */
18export const noise = (x: number, y: number) => {
19 let h = (x * 374761393 + y * 668265263) | 0
20 h = Math.imul(h ^ (h >>> 13), 1274126177)
21 return ((h ^ (h >>> 16)) >>> 0) / 4294967295
22}
23
24/** Ease `shown` toward `target`; snaps when close. */
25export const ease = (shown: number, target: number, k = 0.09) => {
26 const next = shown + (target - shown) * k
27 return Math.abs(target - next) < 0.002 ? target : next
28}
29
30export const TRACK = '#3d444d'
31export const ORANGE = '#e8743b'
32types/index.d.ts 46 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2export type Task = { subject: string; status: TaskStatus }
3
4/** What a loop reported through the progress tool. */
5export type Report = { done: number; total: number; step?: string }
6
7export type AgentStatus = 'waiting' | 'running' | 'done' | 'failed'
8
9export type AgentRow = {
10 id: string
11 description: string
12 type: string
13 model: string
14 effort?: string
15 status: AgentStatus
16 steps: number
17 tools: number
18 /** Dollars at the model's list price, summed over the agent's requests. */
19 usd: number
20 tokens: number
21 /** Prompt size of the last step, for the ctx % figure. */
22 ctx: number
23 startedAt: number
24 endedAt?: number
25 report?: Report
26}
27
28export type Batch = { id: number; title: string; startedAt: number; endedAt?: number; ids: string[]; hidden: boolean }
29
30declare module 'claude-code' {
31 interface PluginState {
32 'savvy-progress': {
33 /** Task lists by owner loop: 'main' or a subagent id, then by task id. */
34 tasks: Record<string, Record<string, Task>>
35 agents: Record<string, AgentRow>
36 /** Skin name by agent type ('*' is the fallback). */
37 skins: Record<string, string>
38 batch: Batch | null
39 mainReport: Report | null
40 finishedCollapsed: boolean
41 /** The main loop's last prompt, the batch title when nothing names one. */
42 lastPrompt: string
43 }
44 }
45}
46