SLOPSHOPPER

agents-panel

A pane of every subagent in the session: model, effort, context, tokens, estimated cost and time, with a crab per agent role on desktop. Works with any agent…

newpanecommandstatustimeragents
v0.1.0MITupdated 2026-10-09Blind3y3Design/agents-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agents-panel
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet this session. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents-panel │ ⎿ agents-panel: Agents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
No subagents yet this session.
README

agents-panel

A Claude Code mod: a pane showing every subagent in the session, whatever its type and whatever started it (the Agent tool, a skill, a workflow, a nested subagent). Adapted from the agents panel in savvy-progress, without the savvy-flow workflow.

Each row shows:

  • what the agent is doing (its description) and its status: ● running, ✓ done, ✗ failed
  • agent type · model · effort
  • context: a fill bar plus % of the model's window and tokens in use (yellow from 50%, red from 80%)
  • tokens summed over all its requests · ≈cost · time

On desktop each row is drawn as a card with a pixel crab dressed for the agent's role, walking while it runs:

  • pirate: Explore agents
  • engineer (a software developer in headphones and glasses, typing on a laptop): development work, i.e. an agent type or task mentioning implement, build, fix, test, refactor, code, debug, deploy and the like
  • designer (beret, palette, brush): design work, i.e. design, Figma, UI/UX, mockup, layout, CSS, style, prototype and the like
  • plain crab: everything else

The agent type is checked before the task description, so an implementer stays an engineer even when its task mentions a layout. The crab's accent and the model name take the model family's colour (Fable violet, Opus orange, Sonnet blue, Haiku green). The terminal draws the same data as text rows.

The header totals cost, tokens and wall time across all subagents. Nested subagents are marked ↳. Finished agents collapse under Finished, and Clear removes them. While any agent runs, the status line shows ▣ N running · ≈$cost.

Using it

  • /agents-panel shows or hides the pane.
  • designWords and engineerWords (comma-separated) add words to the designer and engineer rules, e.g. storyboard, motion or terraform.
  • The autoOpen option (on by default) opens the pane when a subagent starts while none were running. If you close it mid-batch, it stays closed.

Sprites and pane layout adapted from savvy-progress (its crab is Pixel Clawd from DockCrab). Cost is an estimate from token counts at list prices. Effort appears after the agent's first model request.

Install

From any Claude Code terminal session:

/plugin install agents-panel --marketplace Blind3y3Design/agents-panel

Answer y to add the marketplace, then pick a scope (user makes it available in every session). Or from a shell:

claude plugin marketplace add Blind3y3Design/agents-panel
claude plugin install agents-panel@agents-panel --scope user

Developing

claude plugin validate . checks the manifest and hooks; claude plugin test . runs the tests. To run a working copy, claude --plugin-dir ..

License

MIT. The pane layout and crab sprites are adapted from savvy-progress by johnnyvizz (MIT); see LICENSE.

Source 4 files
hooks/register.tsx 275 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Panel } from '../types'
5
6import { HEADER_H, ROW_H, agentAlt, agentSvg, headerSvg, paneWidth, roleOf, wordsRe } from './drawing'
7import { colorOf, costOf, ctxOf, fmtCost, fmtTime, fmtTokens, modelName, statsOf, tokensOf, totals, windowOf } from './format'
8
9export { roleOf } from './drawing'
10export { costOf, fmtCost, fmtTime, fmtTokens, modelName } from './format'
11
12// Every subagent of the session, whatever its type or the workflow that started
13// it: model, effort, context, tokens, estimated cost and time.
14
15const agents = atom({ plugin: 'agents-panel', key: 'agents' } as const, [])
16const panel = atom({ plugin: 'agents-panel', key: 'panel' } as const, { isDoneCollapsed: false })
17const now = atom({ plugin: 'agents-panel', key: 'now' } as const, 0)
18
19const PANE = 'agents-panel'
20const COMMAND = 'agents-panel'
21
22const bar = (pct: number, width: number): string => {
23  const filled = Math.round((width * Math.max(0, Math.min(100, pct))) / 100)
24  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
25}
26
27const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗' }
28
29async function openPane($: EngineInterface): Promise<void> {
30  const at = await $.clock.now()
31  await update($, now, () => at)
32  await $.ui.open({ id: PANE, title: 'Agents' })
33}
34
35async function togglePane($: EngineInterface): Promise<boolean> {
36  if ((await $.ui.panes()).some(p => p.id === PANE)) {
37    await $.ui.close({ id: PANE })
38    return false
39  }
40  await openPane($)
41  return true
42}
43
44// While anything runs: `▣ 2 running · ≈$0.31` in the status line.
45async function showStatus($: EngineInterface): Promise<void> {
46  const list = await read($, agents)
47  const running = list.filter(a => a.status === 'running')
48  if (!running.length) return $.ui.status(undefined)
49  const cost = list.reduce((s, a) => s + a.costUsd, 0)
50  $.ui.status(`▣ ${running.length} running · ≈${fmtCost(cost)}`)
51}
52
53export const register: Register = (on, options) => {
54  on('session.start', async ($, e, next) => {
55    const started = await next(e)
56    await $.command.register({
57      name: COMMAND,
58      description: 'Show or hide the subagents pane: model, effort, context, tokens, cost and time',
59    })
60    // Ticks running agents' clocks; quiet when nothing runs.
61    $.clock.every(1000, () => {
62      void (async () => {
63        const list = await read($, agents)
64        if (!list.some(a => a.status === 'running')) return
65        const at = await $.clock.now()
66        await update($, now, () => at)
67      })()
68    })
69    return started
70  })
71
72  on('command.run', { command: COMMAND }, async $ => {
73    const isOpen = await togglePane($)
74    return { text: isOpen ? 'Agents pane opened.' : 'Agents pane closed.' }
75  })
76
77  on('agent.spawn', async ($, e, next) => {
78    const started = await next(e)
79    if (started.deny !== undefined) return started
80
81    const at = await $.clock.now()
82    const before = await read($, agents)
83    const run: AgentRun = {
84      id: started.agentId ?? e.tool_use_id,
85      agentId: started.agentId,
86      parentAgentId: e.parentAgentId,
87      type: e.subagentType,
88      description: e.description,
89      model: started.model,
90      status: 'running',
91      startedAt: at,
92      contextTokens: 0,
93      contextMax: windowOf(started.model),
94      tokens: 0,
95      costUsd: 0,
96      steps: 0,
97    }
98    await update($, agents, list => [...list.filter(a => a.id !== run.id), run].slice(-200))
99    await update($, now, () => at)
100    await showStatus($)
101
102    // Opens with the first of a batch; closed mid-batch, it stays closed.
103    if (options.autoOpen !== false && !before.some(a => a.status === 'running')) {
104      if (!(await $.ui.panes()).some(p => p.id === PANE)) void openPane($)
105    }
106    return started
107  }).catch(($, e, next) => next(e))
108
109  // Each model request of a subagent: live context, effort, tokens and cost.
110  on('turn.step', async function* ($, e, next) {
111    const result = yield* next(e)
112    const agentId = e.agentId
113    const usage = result.usage
114    if (!agentId || !usage) return result
115
116    const model = usage.model || e.model
117    await update($, agents, list =>
118      list.map(a =>
119        a.agentId !== agentId
120          ? a
121          : {
122              ...a,
123              model,
124              effort: typeof e.effort === 'string' ? e.effort : typeof e.effort === 'number' ? String(e.effort) : a.effort,
125              contextTokens: tokensOf(usage),
126              contextMax: windowOf(model),
127              tokens: a.tokens + tokensOf(usage),
128              costUsd: a.costUsd + costOf(model, usage),
129              steps: a.steps + 1,
130            },
131      ),
132    )
133    await showStatus($)
134    return result
135  })
136
137  on('turn.complete', async ($, e, next) => {
138    const agentId = e.agentId
139    if (agentId === undefined) return next(e)
140    const at = await $.clock.now()
141    await update($, agents, list =>
142      list.map(a => {
143        if (a.agentId !== agentId) return a
144        // A run whose steps went unseen still gets the turn's own sum.
145        const fallback = a.steps === 0 ? e.usage : undefined
146        return {
147          ...a,
148          status: (e.reason === 'answer' ? 'done' : 'failed') as AgentRun['status'],
149          endedAt: at,
150          ...(fallback
151            ? {
152                model: fallback.model || a.model,
153                tokens: tokensOf(fallback),
154                costUsd: costOf(fallback.model || a.model, fallback),
155              }
156            : {}),
157        }
158      }),
159    )
160    await update($, now, () => at)
161    await showStatus($)
162    return next(e)
163  })
164
165  const words = { design: wordsRe(options.designWords), engineer: wordsRe(options.engineerWords) }
166
167  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
168    const ui = $.ui.resolve(e)
169    const { Box, Text, Button } = ui
170    const list = await read($, agents)
171    const p: Panel = await read($, panel)
172    const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
173
174    const running = list.filter(a => a.status === 'running').reverse()
175    const finished = list.filter(a => a.status !== 'running').reverse()
176    const t = totals(list, at)
177    const cols = Math.max(24, e.props.bodyColumns || 40)
178    const barW = Math.max(6, Math.min(20, cols - 34))
179    const isNested = (a: AgentRun) => a.parentAgentId !== undefined
180
181    const row = (a: AgentRun) => {
182      const color = colorOf(a.model)
183      const ctx = ctxOf(a)
184      const pad = isNested(a) ? '    ' : '  '
185      return (
186        <Box key={a.id} flexDirection="column" marginBottom={1}>
187          <Text wrap="truncate-end">
188            {isNested(a) ? <Text dimColor>↳ </Text> : ''}
189            <Text color={color}>▣ </Text>
190            <Text bold>{a.description || a.type}</Text>{' '}
191            <Text color={a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : color}>
192              {STATUS_GLYPH[a.status]}
193            </Text>
194          </Text>
195          <Text dimColor wrap="truncate-end">
196            {pad}
197            {a.type} · <Text color={color}>{modelName(a.model)}</Text>
198            {a.effort ? ` · ${a.effort}` : ''}
199          </Text>
200          <Text wrap="truncate-end">
201            {pad}
202            <Text color={ctx >= 80 ? 'red' : ctx >= 50 ? 'yellow' : color}>{bar(ctx, barW)}</Text>
203            <Text dimColor> {statsOf(a, at)}</Text>
204          </Text>
205        </Box>
206      )
207    }
208
209    if (list.length === 0) {
210      return <Text dimColor>No subagents yet this session.</Text>
211    }
212
213    const finishedToggle = finished.length > 0 && (
214      <Box flexDirection="row" gap={2}>
215        <Button
216          key="ap-done"
217          label={`${p.isDoneCollapsed ? '▸' : '▾'} Finished · ${finished.length}`}
218          plain
219          onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
220        />
221        <Button
222          key="ap-clear"
223          label="Clear"
224          plain
225          onPress={() => update($, agents, prev => prev.filter(a => a.status === 'running'))}
226        />
227      </Box>
228    )
229
230    // Desktop: each row one SVG, the crab dressed for the agent's role.
231    if (e.surface === 'desktop' && 'Svg' in ui) {
232      const { Svg } = ui
233      const W = paneWidth(e.props.bodyColumns)
234      const svgRow = (a: AgentRun) => {
235        const role = roleOf(a.type, a.description, words)
236        return <Svg key={a.id} source={agentSvg(W, a, at, role)} alt={agentAlt(a, at, role)} width={W} height={ROW_H} />
237      }
238      return (
239        <Box flexDirection="column" gap={1}>
240          <Svg
241            key="ap-header"
242            source={headerSvg(W, t)}
243            alt={`≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} tokens, ${running.length} running, ${finished.length} finished`}
244            width={W}
245            height={HEADER_H}
246          />
247          <Box flexDirection="column">
248            {running.length > 0 && <Text dimColor>Running · {running.length}</Text>}
249            {running.map(svgRow)}
250          </Box>
251          {finishedToggle}
252          {!p.isDoneCollapsed && finished.length > 0 && <Box flexDirection="column">{finished.map(svgRow)}</Box>}
253        </Box>
254      )
255    }
256
257    return (
258      <Box flexDirection="column">
259        <Text bold>
260          ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} tokens · {fmtTime(t.time)}
261        </Text>
262        <Text dimColor>
263          {running.length} running · {finished.length} finished
264        </Text>
265        <Box flexDirection="column" marginTop={1}>
266          {running.length > 0 && <Text dimColor>Running · {running.length}</Text>}
267          {running.map(row)}
268          {finishedToggle}
269          {!p.isDoneCollapsed && finished.map(row)}
270        </Box>
271      </Box>
272    )
273  })
274}
275
hooks/drawing.ts 230 lines
1import type { AgentRun } from '../types'
2
3import { colorOf, ctxOf, fmtCost, fmtTime, fmtTokens, modelName, statsOf } from './format'
4import type { Totals } from './format'
5
6// The desktop pane: each row one SVG, with a pixel crab dressed for the agent's
7// role. Sprites and layout adapted from savvy-progress (JohnnyVizz/claude-kit),
8// whose crab is Pixel Clawd from DockCrab.
9
10export type Role = 'crab' | 'pirate' | 'engineer' | 'designer'
11
12// Words that mark a role in an agent's type or task description; the type wins.
13const DESIGN = /\b(design\w*|figma|ui|ux|visual\w*|mock-?ups?|wireframe\w*|layouts?|typograph\w*|colou?rs?|css|styl\w*|themes?|icons?|brand\w*|accessib\w*|a11y|prototyp\w*)\b/i
14const ENGINEER =
15  /\b(implement\w*|engineer\w*|develop\w*|code|coding|build\w*|fix\w*|bugs?|debug\w*|refactor\w*|tests?|testing|tdd|features?|migrat\w*|deploy\w*|compil\w*|typecheck\w*|lint\w*|api|endpoints?|scripts?|ship\w*|merge\w*)\b/i
16
17export const wordsRe = (extra: unknown): RegExp | null => {
18  if (typeof extra !== 'string' || !extra.trim()) return null
19  const words = extra
20    .split(',')
21    .map(w => w.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
22    .filter(Boolean)
23  return words.length ? new RegExp(`\\b(${words.join('|')})\\b`, 'i') : null
24}
25
26export type RoleWords = { design?: RegExp | null; engineer?: RegExp | null }
27
28const matches = (text: string, base: RegExp, extra?: RegExp | null): boolean =>
29  base.test(text) || (extra ? extra.test(text) : false)
30
31export const roleOf = (type: string, description: string, words: RoleWords = {}): Role => {
32  // `my-plugin:implementer` names the same agent as `implementer`.
33  const bare = type.replace(/^.*:/, '').replace(/[-_]/g, ' ')
34  if (/^explore$/i.test(bare.trim())) return 'pirate'
35  if (matches(bare, DESIGN, words.design)) return 'designer'
36  if (matches(bare, ENGINEER, words.engineer)) return 'engineer'
37  if (matches(description, DESIGN, words.design)) return 'designer'
38  if (matches(description, ENGINEER, words.engineer)) return 'engineer'
39  return 'crab'
40}
41
42export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
43
44export const xml = (s: string): string =>
45  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
46
47// Rough advance of system UI text, in em; good enough to fit a line.
48const charEm = (ch: string): number =>
49  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
50
51const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
52
53// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
54export const fitText = (s: string, size: number, maxW: number): string => {
55  if (textWidth(s, size) <= maxW) return s
56  let out = ''
57  for (const ch of s) {
58    if (textWidth(out + ch + '…', size) > maxW) break
59    out += ch
60  }
61  return out + '…'
62}
63
64const PANE_CSS = `<style>
65.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
66@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
67.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
68@media (prefers-reduced-motion: reduce){.live{animation:none}}
69</style>`
70
71// A 24×18 crab on a 30×28 grid. The body keeps the brand clay; the model
72// family's color lives in the costume's accent.
73const CLAY = '#D97757'
74const INK = '#1F1E1D'
75
76// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
77// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
78type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
79
80const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
81  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
82
83// `armCls` lets a raised claw travel with the prop it holds.
84const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
85  f(7, 10, 16, 12, CLAY)
86  f(3, 14, 4, 4, CLAY)
87  f(23, 14 + armFront, 4, 4, CLAY, armCls)
88  f(9, 12, 2, 2, INK)
89  f(19, 12, 2, 2, INK)
90  f(7, 22, 2, 4, CLAY, 'la')
91  f(17, 22, 2, 4, CLAY, 'la')
92  f(11, 22, 2, 4, CLAY, 'lb')
93  f(21, 22, 2, 4, CLAY, 'lb')
94}
95
96// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
97// once-a-second redraw of a running row restarts them in phase. Every crab walks;
98// each costume adds its prop's own motion on top.
99const CRAB_CSS = `<style>
100.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
101.run .bd{animation:bob .5s steps(1) infinite -.125s}
102.run g{transform-box:fill-box}
103@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
104.c-engineer.run .typ{transform-origin:0 50%;animation:type 1s steps(1) infinite}
105.c-engineer.run .cur{animation:blink .5s steps(1) infinite}
106.c-engineer.run .tap{animation:st .25s steps(1) infinite}
107@keyframes type{0%{transform:scaleX(.17)}25%{transform:scaleX(.5)}50%{transform:scaleX(.83)}75%{transform:scaleX(1)}}
108@keyframes blink{50%{opacity:0}}
109.c-pirate.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
110@keyframes fence{50%{transform:rotate(25deg)}}
111.c-designer.run .it{transform-origin:50% 100%;animation:dab .5s ease-in-out infinite}
112.c-designer.run .tip{animation:hue 2s steps(1) infinite}
113@keyframes dab{50%{transform:rotate(-22deg) translateY(1px)}}
114@keyframes hue{25%{fill:#378ADD}50%{fill:#1D9E75}75%{fill:#F5C542}}
115@media (prefers-reduced-motion: reduce){.run,.run g,.run rect{animation:none!important}}
116</style>`
117
118const COSTUMES: Record<Role, (f: Fill, accent: string) => void> = {
119  crab: f => crabBody(f),
120  // Pirate scouting the code; the cutlass fences.
121  pirate: f => {
122    crabBody(f)
123    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
124    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
125    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
126  },
127  // Software developer in headphones and glasses, at an open laptop; the code
128  // line types out, the cursor blinks and a claw taps the keys.
129  engineer: (f, accent) => {
130    crabBody(f, 0, 'tap')
131    stamp(f, 6, 6, ['...hhhhhhhhhhhh...', '.hh............hh.', 'h................h'], { h: '#3A3A3C' })
132    stamp(f, 5, 9, ['cc', 'ca', 'ca', 'cc'], { c: '#3A3A3C', a: accent })
133    stamp(f, 23, 9, ['cc', 'ac', 'ac', 'cc'], { c: '#3A3A3C', a: accent })
134    stamp(f, 8, 11, ['kkkk......kkkk', 'k..kkkkkkkk..k', 'k..k......k..k', 'kkkk......kkkk'], { k: '#2B2B2E' })
135    stamp(
136      f,
137      9,
138      15,
139      ['fffffffffffff', 'fdgggdbbddddf', 'fdddddddddddf', 'fdbbbdgggdddf', 'fdddddddddddf', 'fffffffffffff'],
140      { f: '#8E929A', d: '#1F2430', g: '#5FBF8F', b: '#6FA8DC' },
141    )
142    f(11, 17, 6, 1, accent, 'typ')
143    f(11, 19, 2, 1, '#F8F6F1', 'cur')
144    f(7, 21, 17, 1, '#C9CCD2')
145  },
146  // Designer in a beret, palette in one claw; the brush dabs and changes color.
147  designer: (f, accent) => {
148    crabBody(f, -4, 'it')
149    stamp(f, 6, 4, ['........s.........', '....bbbbbbbbbb....', '..bbbbbbbbbbbbbb..', '.bbbbbbbbbbbbbbbbb', '..aaaaaaaaaaaaaa..'], { b: '#3A3550', s: '#3A3550', a: accent })
150    stamp(f, 0, 12, ['.ppp.', 'prpgp', 'ppbpp', '.pp..'], { p: '#E8D3A8', r: '#D0453F', g: '#1D9E75', b: '#378ADD' })
151    f(25, 4, 1, 6, '#A0703F', 'it'); f(25, 3, 1, 1, '#C9CCD2', 'it')
152    f(24, 1, 3, 2, '#D0453F', 'tip')
153  },
154}
155
156const CRAB_SCALE = 1.1
157
158// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
159// legs stay outside it and step on their own. The brush tip rides with the brush.
160export const crab = (x: number, y: number, role: Role, accent: string, isWalking = false, scale = CRAB_SCALE): string => {
161  const groups = new Map<string, string[]>([['bd', []]])
162  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
163    if (!groups.has(cls)) groups.set(cls, [])
164    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
165  }
166  COSTUMES[role](f, accent)
167  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
168  const tip = groups.get('tip') ?? []
169  const props = [...groups.keys()].filter(k => !['bd', 'la', 'lb', 'tip'].includes(k))
170  const prop = (cls: string) =>
171    cls === 'it' && tip.length
172      ? `<g class="it">${(groups.get('it') ?? []).join('')}${tip.map(r => r.replace('<rect', '<rect class="tip"')).join('')}</g>`
173      : group(cls)
174  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(prop).join('')}</g>`
175  return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g class="c-${role}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
176}
177
178const statusMark = (x: number, y: number, status: string, color: string): string => {
179  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
180  if (status === 'done')
181    return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
182  return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
183}
184
185const svg = (W: number, H: number, body: string): string =>
186  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
187
188export const paneWidth = (bodyColumns: number | undefined): number =>
189  Math.max(240, Math.min(900, (bodyColumns || 40) * 8 - 8))
190
191export const HEADER_H = 44
192export const ROW_H = 66
193
194export const headerSvg = (W: number, t: Totals): string => {
195  const gap = 6
196  const tw = (W - gap * 2) / 3
197  const tile = (i: number, k: string, v: string) =>
198    `<rect class="tile" x="${i * (tw + gap)}" y="0" width="${tw}" height="40" rx="8"/>
199<text class="s" x="${i * (tw + gap) + 9}" y="16" font-family="${FONT}" font-size="11">${k}</text>
200<text class="t" x="${i * (tw + gap) + 9}" y="33" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${xml(fitText(v, 15, tw - 18))}</text>`
201  return svg(W, HEADER_H, `${tile(0, 'Cost', '≈' + fmtCost(t.cost))}${tile(1, 'Tokens', fmtTokens(t.tokens))}${tile(2, 'Time', fmtTime(t.time))}`)
202}
203
204export const agentSvg = (W: number, a: AgentRun, at: number, role: Role): string => {
205  const color = colorOf(a.model)
206  const ctx = ctxOf(a)
207  const indent = a.parentAgentId !== undefined ? 14 : 0
208  const x = indent + 42
209  const textW = W - x - 22
210  const barColor = ctx >= 80 ? '#D0453F' : ctx >= 50 ? '#BA7517' : color
211  const model = modelName(a.model)
212  const after = `${a.effort ? `  ·  ${a.effort}` : ''}${a.status === 'failed' ? '  ·  error' : ''}`
213  const typeText = fitText(a.type, 11, Math.max(40, textW * 0.45))
214  return svg(
215    W,
216    ROW_H,
217    `${indent ? `<text class="m" x="0" y="30" font-family="${FONT}" font-size="12">↳</text>` : ''}
218${crab(indent, 14, role, color, a.status === 'running')}
219<text class="t" x="${x}" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
220<text x="${x}" y="34" font-family="${FONT}" font-size="11"><tspan class="s">${xml(typeText)}  ·  </tspan><tspan fill="${color}" font-weight="600">${xml(model)}</tspan><tspan class="s">${xml(after)}</tspan></text>
221<text class="s" x="${x}" y="49" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(statsOf(a, at), 11, textW))}</text>
222<rect class="k" x="${x}" y="55" width="${textW}" height="4" rx="2"/><rect x="${x}" y="55" width="${Math.round((textW * ctx) / 100)}" height="4" rx="2" fill="${barColor}"/>
223${statusMark(W - 8, 16, a.status, color)}
224<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
225  )
226}
227
228export const agentAlt = (a: AgentRun, at: number, role: Role): string =>
229  `${a.description || a.type} (${role}): ${a.type}, ${modelName(a.model)}${a.effort ? ` ${a.effort}` : ''}, ${a.status}, ${statsOf(a, at)}`
230
hooks/format.ts 86 lines
1import type { AgentRun } from '../types'
2
3// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
4// The engine reports tokens, not money, so cost is an estimate.
5const PRICES: [RegExp, [number, number, number, number]][] = [
6  [/fable|mythos/, [10, 50, 0.25, 12.5]],
7  [/opus-5-5/, [4, 20, 0.2, 5]],
8  [/opus/, [5, 25, 0.5, 6.25]],
9  [/sonnet/, [2, 10, 0.2, 2.5]],
10  [/haiku/, [1, 5, 0.1, 1.25]],
11]
12
13export type Usage = {
14  input_tokens: number
15  output_tokens: number
16  cache_read_input_tokens: number
17  cache_creation_input_tokens: number
18}
19
20const priceOf = (model: string): [number, number, number, number] =>
21  PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
22
23export const costOf = (model: string, u: Usage): number => {
24  const [i, o, r, w] = priceOf(model)
25  return (
26    ((u.input_tokens || 0) * i +
27      (u.output_tokens || 0) * o +
28      (u.cache_read_input_tokens || 0) * r +
29      (u.cache_creation_input_tokens || 0) * w) /
30    1e6
31  )
32}
33
34export const tokensOf = (u: Usage): number =>
35  (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
36
37export const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
38
39const FAMILY_COLOR: [RegExp, string][] = [
40  [/fable|mythos/i, '#7F77DD'],
41  [/opus/i, '#D85A30'],
42  [/sonnet/i, '#378ADD'],
43  [/haiku/i, '#1D9E75'],
44]
45
46export const colorOf = (model: string): string => FAMILY_COLOR.find(([re]) => re.test(model))?.[1] ?? '#888780'
47
48export const modelName = (id: string): string => {
49  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
50  const [, family = '', major = '', minor] = m ?? []
51  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
52  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
53}
54
55export const fmtTokens = (n: number): string =>
56  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
57
58export const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
59
60export const fmtTime = (ms: number): string => {
61  const s = Math.max(0, Math.round(ms / 1000))
62  const h = Math.floor(s / 3600)
63  const m = Math.floor((s % 3600) / 60)
64  const ss = String(s % 60).padStart(2, '0')
65  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
66}
67
68export const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
69
70export const ctxOf = (a: AgentRun): number =>
71  a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0
72
73export type Totals = { cost: number; tokens: number; time: number }
74
75export const totals = (list: AgentRun[], at: number): Totals => {
76  const cost = list.reduce((s, a) => s + a.costUsd, 0)
77  const tokens = list.reduce((s, a) => s + a.tokens, 0)
78  const start = Math.min(...list.map(a => a.startedAt))
79  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
80  return { cost, tokens, time: list.length ? end - start : 0 }
81}
82
83// The stats a row shows after its context bar.
84export const statsOf = (a: AgentRun, at: number): string =>
85  `ctx ${ctxOf(a)}% (${fmtTokens(a.contextTokens)}) · ${fmtTokens(a.tokens)} tok · ≈${fmtCost(a.costUsd)} · ${fmtTime(elapsed(a, at))}`
86
types/index.d.ts 31 lines
1export type AgentStatus = 'running' | 'done' | 'failed'
2
3export type AgentRun = {
4  id: string
5  agentId?: string
6  /** The subagent that spawned this one, when it is nested. */
7  parentAgentId?: string
8  type: string
9  description: string
10  model: string
11  effort?: string
12  status: AgentStatus
13  startedAt: number
14  endedAt?: number
15  /** What the last request carried: the context in use now. */
16  contextTokens: number
17  contextMax: number
18  /** Every request's tokens, summed. */
19  tokens: number
20  costUsd: number
21  steps: number
22}
23
24export type Panel = { isDoneCollapsed: boolean }
25
26declare module 'claude-code' {
27  interface PluginState {
28    'agents-panel': { agents: AgentRun[]; panel: Panel; now: number }
29  }
30}
31