SLOPSHOPPER

savvy-progress

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

newpanebandguardcommandmodel
v0.3.1no licenseupdated 2026-10-12Isaacg1000/isaac-mods/plugins/savvy-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-progress
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ Agents │ ┃ ⏺ Read(src/auth.ts) │ ┃ Cost ⎿ Read 6 lines │ ┃ ≈$0.00 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Collapse ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ No agents yet. Helpers Claude starts show up │ ┃ here. Restyle their crabs with /crab-style. ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /savvy │ ⎿ savvy-progress: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
Agents Cost ≈$0.00 Collapse No agents yet. Helpers Claude starts show up here. Restyle their crabs with /crab-style.
README

isaac-mods

Eight mods for Claude Code (terminal and the desktop app's Code tab). Requires Claude Code 2.1.287 or newer.

ModWhat it doesCommands
savvy-progressProgress 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-skinsThemed icons and colors for tool calls (Tokyo Night, Nord, Dracula, Gruvbox, Solarized, Catppuccin, Rose Pine)/skin <theme>
you-should-knowAfter a long reply, a Haiku side agent flags costs, risks and actions you might miss/ysk on, /ysk off, /ysk focus <text>
file-treeA panel showing which files Claude is reading or editing; turns green once committed/files
cache-taxWarns before a prompt reloads a cold prompt cache, with a token and cost estimate/keepwarm, /cachetax ttl <minutes>
blast-radiusStops rm -r, Remove-Item -Recurse, git clean -f, git reset --hard and similar, lists the files at stake, and waits for Proceed or Cancelautomatic
reflectSpots corrections in your prompts and offers to save them as rules in CLAUDE.md/reflect <rule>
terminal-browserA 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.

Install

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

Developing

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.

Source 2 files
hooks/register.tsx 624 lines
1import { 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}
624
types/index.d.ts 51 lines
1export 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