SLOPSHOPPER

figma-cost

Read-only pane that shows what Figma design work costs in a session: spend, screens, iterations per screen, failures and tokens by agent. Spends no model…

newpaneguardcommandtimer
v0.2.0no licenseupdated 2026-10-08bekhruzbekswe/modme/figma-cost
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · figma-cost
│ ┃ Figma cost ✕ › fix the failing auth test and add an audit log call │ ┃ ● Spent │ ┃ $0.00 / $0.42 ⏺ Read(src/auth.ts) │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⎿ Read 6 lines │ ┃ ░░░░ ⏺ Update(src/auth.ts) │ ┃ 0% of the session ⎿ 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 │ │ › /figma-cost │ ⎿ figma-cost: Figma cost pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Figma cost
● Spent $0.00 / $0.42 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% of the session
README

figma-cost

A read-only pane that tells the story of Figma design work in a session, top to bottom:

  1. Spent: Figma cost against the whole session's cost.
  2. Bought: screens in Figma and the cost per screen.
  3. Took: iterations per screen (green up to 1.5, amber up to 2.5, red above).
  4. Held back by: failed Figma calls and edits never checked by a screenshot.
  5. Spent by: tokens per agent, named by the task each subagent was given.

Use

  • /figma-cost opens the pane.
  • /figma-cost reset starts the counters again.
  • /figma-cost json prints the raw counters, for checking the numbers.

What it does and does not do

  • It only observes. It never changes a prompt, a model step or a tool call, and it spends no model tokens.
  • After each Figma screenshot it asks Figma one read-only question (which frames does this node show?) through the figma-console MCP server.
  • The session total is the session's own ledger (the figure /cost shows). The Figma share is our token estimate scaled to that ledger, so treat it as good to about 10 percent.
  • A screen is a frame inside a section (or a top-level frame on the page), counted by name, so a rebuilt screen is one screen with more rounds.
  • Numbers live in the session. They are not saved when it ends.
Source 3 files
hooks/register.tsx 249 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { bridgeWords, calibrate, isEditCode, normalize, parseScreens, screenLookupCode, seeScreens, trim, add, agentShares, bar, empty, fresh, isBridgeError, isFigma, isRead, isShot, iterations, mega, money, cardSvg, percent, rateColor, segments, stepsOf, tokens } from './lib'
5
6const PANE = 'figma-cost'
7const stats = atom({ plugin: 'figma-cost', key: 'stats' } as const, fresh())
8const ACCENT = ['#4B55C6', '#8A92E0', '#C9CDF2', '#9CA3AF', '#D1D5DB']
9
10export const register: Register = on => {
11  on('session.start', async ($, e, next) => {
12    await $.command.register({ name: 'figma-cost', description: 'Show what Figma design work costs in this session' })
13    try {
14      const usd = await $.session.usage().then(u => u.cost?.usd ?? null, () => null)
15      await update($, stats, saved => { const s = normalize(saved); return s.costBase === null && usd !== null ? { ...s, costBase: usd, estAtBase: s.all.cost } : s })
16    } catch { /* optional */ }
17    return next(e)
18  })
19
20  on('command.run', { command: 'figma-cost' }, async ($, e) => {
21    if (String((e as { args?: string }).args ?? '').trim() === 'json') {
22      const raw = normalize(await read($, stats))
23      const usd = await $.session.usage().then(u => u.cost?.usd ?? null, () => null)
24      return { text: JSON.stringify({ usd, ...raw }, null, 1) }
25    }
26    if (String((e as { args?: string }).args ?? '').trim() === 'reset') {
27      const usd = await $.session.usage().then(u => u.cost?.usd ?? null, () => null)
28      await update($, stats, () => ({ ...fresh(), costBase: usd, estAtBase: 0 }))
29      return { text: 'Figma cost counters reset.' }
30    }
31    await $.ui.open({ id: PANE, title: 'Figma cost' })
32    return { text: 'Figma cost pane opened.' }
33  })
34
35  const triedNames = new Map<string, number>()
36  const safeText = (v: unknown): string => {
37    try { return typeof v === 'string' ? v : JSON.stringify(v ?? '') } catch { return '' }
38  }
39
40  // Every step is filed under its agent; a step that asked for a Figma tool also counts as Figma work.
41  // Counting is best effort: nothing here may throw into the model step.
42  on('turn.step', async function* ($, e, next) {
43    // First sight of the ledger: remember where it stood before this step was paid for.
44    try {
45      const before = normalize(await read($, stats))
46      if (before.costBase === null) {
47        const usd = await $.session.usage().then(u => u.cost?.usd ?? null, () => null)
48        if (usd !== null) await update($, stats, saved => { const s = normalize(saved); return s.costBase === null ? { ...s, costBase: usd, estAtBase: s.all.cost } : s })
49      }
50    } catch { /* optional */ }
51    const r = yield* next(e)
52    try {
53      const u = r.usage
54      if (!u) return r
55      const hasFigma = (r.toolUses ?? []).some(t => isFigma(String(t.name)))
56      const who = e.agentId ?? 'main'
57      await update($, stats, saved => {
58        const s = normalize(saved)
59        return {
60          ...s,
61          all: add(s.all, u),
62          figma: hasFigma ? add(s.figma, u) : s.figma,
63          byAgent: trim({ ...s.byAgent, [who]: add(s.byAgent[who] ?? empty(), u) }, 100),
64        }
65      })
66      // Name any agent we have not named yet from the task it was given, trying a few times at most.
67      const now = normalize(await read($, stats))
68      const missing = Object.keys(now.byAgent).filter(id => id !== 'main' && !now.names[id] && (triedNames.get(id) ?? 0) < 3)
69      if (missing.length > 0) {
70        for (const id of missing) triedNames.set(id, (triedNames.get(id) ?? 0) + 1)
71        const known = await $.agent.list()
72        const found: Record<string, string> = {}
73        for (const a of known) if (missing.includes(a.id) && a.description) found[a.id] = a.description
74        if (Object.keys(found).length > 0) await update($, stats, saved => { const s = normalize(saved); return { ...s, names: trim({ ...s.names, ...found }, 100) } })
75      }
76    } catch { /* a counter is never worth failing a model step */ }
77    return r
78  })
79
80  on('tool.call', async ($, e, next) => {
81    const tool = String(e.tool)
82    if (!isFigma(tool)) return next(e)
83    const input = e as Record<string, unknown>
84    const node = String(input.nodeId ?? input.node_id ?? '')
85    const name = tool.replace(/^mcp__.*?__/, '')
86    try {
87      await update($, stats, saved => {
88        const s = normalize(saved)
89        return { ...s, figmaTools: { ...s.figmaTools, [name]: (s.figmaTools[name] ?? 0) + 1 } }
90      })
91    } catch { /* counting must never block a Figma call */ }
92    const ran = await next(e)
93    try {
94      const r = ran as { isError?: boolean; deny?: string; text?: string; result?: unknown }
95      if (r.deny) return ran
96      const said = `${r.text ?? ''} ${safeText(r.result)}`.slice(0, 4000)
97      const down = bridgeWords.test(said)
98      if (r.isError || down) {
99        await update($, stats, saved => {
100          const s = normalize(saved)
101          return { ...s, figmaErrors: s.figmaErrors + (r.isError ? 1 : 0), bridgeDown: down ? true : s.bridgeDown }
102        })
103        return ran
104      }
105      // A successful call: the bridge is up. An edit moves the edit counter; a screenshot closes a round for the screens it shows.
106      if (isShot(tool)) {
107        let found: Array<{ id: string; name: string; parent?: string }> | null = null
108        if (/^[0-9:;-]+$/.test(node)) {
109          try {
110            // Read-only question to Figma itself, no model involved: which screens does this node show?
111            const lookup = $.mcp.call('figma-console', 'figma_execute', { code: screenLookupCode(node), timeout: 4000 })
112            // Never hold up the agent's own call for more than a moment.
113            const patience = new Promise<null>(done => { $.clock.after(3000, () => done(null)) })
114            found = parseScreens(await Promise.race([lookup, patience]))
115          } catch { found = null }
116        }
117        await update($, stats, saved => {
118          const s = { ...normalize(saved), bridgeDown: false }
119          // Lookup failed or the node is not a screen: fall back to the node itself so nothing is silently dropped.
120          const isSection = !!found && found.length > 0 && found.every(f => f.parent === node)
121          return seeScreens(s, found && found.length > 0 ? found : node ? [{ id: node, name: `#${node}` }] : [], isSection ? node : undefined)
122        })
123      } else {
124        const edit = name === 'figma_execute' ? isEditCode(String(input.code ?? '')) : !isRead(tool)
125        await update($, stats, saved => {
126          const s = normalize(saved)
127          return edit ? { ...s, bridgeDown: false, editSeq: s.editSeq + 1, writesSinceShot: s.writesSinceShot + 1 } : s.bridgeDown ? { ...s, bridgeDown: false } : s
128        })
129      }
130    } catch { /* same */ }
131    return ran
132  }).catch(($, e, next) => next(e))
133
134  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
135    const s = calibrate(normalize(await read($, stats)), await $.session.usage().then(u => u.cost?.usd ?? null, () => null))
136    if (e.surface !== 'terminal') {
137      const { Box, Svg } = $.ui.resolve(e) as unknown as { Box: any; Svg: any }
138      const card = cardSvg(s)
139      return (
140        <Box flexDirection="column" paddingX={1} paddingY={1}>
141          <Svg source={card.source} alt={card.alt} />
142        </Box>
143      )
144    }
145    const { Box, Text } = $.ui.resolve(e)
146    const cols = Math.max(30, Math.min(60, (e.props as { bodyColumns?: number }).bodyColumns ?? 44) - 2)
147    const barWidth = Math.max(16, cols - 8)
148
149    const it = iterations(s)
150    const dim = s.bridgeDown
151    const pct = percent(s.figma.cost, s.all.cost)
152    const spent = bar(s.figma.cost, s.all.cost, barWidth)
153
154    // One step of the story: a node, a lead word, then lines hung off the rail.
155    const Step = ({ lead, last, children }: { lead: string; last?: boolean; children?: unknown }) => (
156      <Box flexDirection="column">
157        <Text><Text color="#4B55C6">●</Text><Text dimColor> {lead}</Text></Text>
158        {children}
159        {!last && <Text dimColor>│</Text>}
160      </Box>
161    )
162    const Line = ({ last, children }: { last?: boolean; children?: unknown }) => (
163      <Box flexDirection="row">
164        <Text dimColor>{last ? '  ' : '│ '}</Text>
165        <Box flexDirection="row">{children}</Box>
166      </Box>
167    )
168    const steps = stepsOf(s)
169    const has = (n: string) => (steps as string[]).includes(n)
170    const showMore = steps.length > 1
171    const agents = agentShares(s)
172    const widths = segments(agents, barWidth)
173
174    return (
175      <Box flexDirection="column" paddingX={1}>
176        {dim && <Text color="error">● Not connected</Text>}
177        {dim && <Text> </Text>}
178
179        <Step lead="Spent" last={!showMore}>
180          <Line last={!showMore}>
181            <Text bold dimColor={dim}>{money(s.figma.cost)}</Text>
182            <Text dimColor> / {money(s.all.cost)}</Text>
183          </Line>
184          <Line last={!showMore}>
185            <Text color="#4B55C6" dimColor={dim}>{'█'.repeat(spent.filled)}</Text>
186            <Text dimColor>{'░'.repeat(spent.rest)}</Text>
187          </Line>
188          <Line last={!showMore}>
189            <Text dimColor>{pct}% of the session{dim ? ', last known' : ''}</Text>
190          </Line>
191        </Step>
192
193        {has('Bought') && (
194          <Step lead="Bought">
195            <Line><Text bold>{it.screens} screens</Text></Line>
196            <Line><Text dimColor>{money(s.figma.cost / it.screens)} per screen</Text></Line>
197          </Step>
198        )}
199
200        {has('Took') && (
201          <Step lead="Took">
202            <Line>
203              <Text bold color={rateColor(it.perScreen)}>{it.perScreen.toFixed(1)}</Text>
204              <Text bold> iterations per screen</Text>
205            </Line>
206          </Step>
207        )}
208
209        {has('Held back by') && (
210          <Step lead="Held back by">
211            {s.figmaErrors > 0 && (
212              <Line>
213                <Text bold>{s.figmaErrors} failed {s.figmaErrors === 1 ? 'call' : 'calls'}</Text>
214              </Line>
215            )}
216            {s.writesSinceShot > 0 && (
217              <Line>
218                {s.figmaErrors > 0
219                  ? <Text dimColor>{s.writesSinceShot} {s.writesSinceShot === 1 ? 'edit' : 'edits'} never checked</Text>
220                  : <Text bold>{s.writesSinceShot} {s.writesSinceShot === 1 ? 'edit' : 'edits'} never checked</Text>}
221              </Line>
222            )}
223          </Step>
224        )}
225
226        {has('Spent by') && agents.length > 0 && (
227          <Step lead="Spent by" last>
228            <Line last><Text bold>{mega(tokens(s.all))} tokens</Text></Line>
229            <Line last>
230              {agents.map((a, i) => (
231                <Text color={ACCENT[i] ?? '#D1D5DB'}>{'█'.repeat(widths[i] ?? 0)}</Text>
232              ))}
233            </Line>
234            {agents.map((a, i) => (
235              <Line last>
236                <Text color={ACCENT[i] ?? '#D1D5DB'}>■ </Text>
237                <Text>{a.name.padEnd(26)}</Text>
238                <Text dimColor>{mega(a.tokens).padStart(6)}</Text>
239                <Text bold>{`${a.share}%`.padStart(5)}</Text>
240              </Line>
241            ))}
242          </Step>
243        )}
244
245      </Box>
246    )
247  })
248}
249
hooks/lib.ts 409 lines
1import type { Bucket, Screen, Stats } from '../types'
2
3// USD per million tokens: input, output, cache read, cache write. Only used to split the real session cost between
4// Figma work and the rest (see `calibrate`); the session total itself comes from the session's own ledger.
5const PRICES: Array<[RegExp, [number, number, number, number]]> = [
6  [/opus-4(-1)?(-\d{8})?$/i, [15, 75, 1.5, 18.75]],
7  [/opus/i, [5, 25, 0.5, 6.25]],
8  [/sonnet/i, [3, 15, 0.3, 3.75]],
9  [/haiku/i, [1, 5, 0.1, 1.25]],
10]
11const FALLBACK: [number, number, number, number] = [5, 25, 0.5, 6.25]
12
13export type Usage = {
14  model: string
15  input_tokens: number
16  output_tokens: number
17  cache_read_input_tokens: number
18  cache_creation_input_tokens: number
19}
20
21/** A usable number: finite and not negative, else 0. */
22export const num = (x: unknown): number => (typeof x === 'number' && Number.isFinite(x) && x > 0 ? x : 0)
23
24export const empty = (): Bucket => ({ steps: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 })
25
26export const fresh = (): Stats => ({
27  startedAt: Date.now(),
28  all: empty(),
29  figma: empty(),
30  byAgent: {},
31  names: {},
32  figmaTools: {},
33  figmaErrors: 0,
34  screens: {},
35  editSeq: 0,
36  writesSinceShot: 0,
37  bridgeDown: false,
38  costBase: null,
39  estAtBase: 0,
40})
41
42export const costOf = (u: Usage): number => {
43  const p = PRICES.find(([re]) => re.test(String(u.model ?? '')))?.[1] ?? FALLBACK
44  return (num(u.input_tokens) * p[0] + num(u.output_tokens) * p[1] + num(u.cache_read_input_tokens) * p[2] + num(u.cache_creation_input_tokens) * p[3]) / 1e6
45}
46
47export const add = (b: Bucket, u: Usage): Bucket => ({
48  steps: num(b.steps) + 1,
49  input: num(b.input) + num(u.input_tokens),
50  output: num(b.output) + num(u.output_tokens),
51  cacheRead: num(b.cacheRead) + num(u.cache_read_input_tokens),
52  cacheWrite: num(b.cacheWrite) + num(u.cache_creation_input_tokens),
53  cost: num(b.cost) + costOf(u),
54})
55
56export const isFigma = (tool: string): boolean => /figma/i.test(tool)
57export const isShot = (tool: string): boolean => /screenshot/i.test(tool)
58export const isRead = (tool: string): boolean => /(_get_|_list_|_search_|_lint_|_audit_|status|diagnose|_ds_|_navigate|_reconnect|_reload|_focus|_capture|_clear_console|_watch_)/i.test(tool)
59export const isBridgeError = (text: string): boolean => /not connected|no active file|no files connected|desktop bridge|no plugin|unable to retrieve|no figma file open/i.test(text)
60
61export const tokens = (b: Bucket): number => num(b.input) + num(b.output) + num(b.cacheRead) + num(b.cacheWrite)
62
63export const money = (n: number): string => `$${num(n).toFixed(2)}`
64export const mega = (x: number): string => {
65  const n = num(x)
66  return n >= 999_500 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}K` : `${Math.round(n)}`
67}
68
69/**
70 * Screens are the frames Figma says exist (looked up by name when a screenshot is taken). Iterations per screen is
71 * build-and-check rounds over screens: 1.0 means every screen was right after its first check.
72 */
73export const iterations = (s: Stats): { screens: number; looks: number; cycles: number; perScreen: number } => {
74  const rows = Object.values(s.screens ?? {})
75  const cycles = rows.reduce((a, r) => a + Math.max(1, num(r.cycles)), 0)
76  const looks = rows.reduce((a, r) => a + num(r.looks), 0)
77  return { screens: rows.length, looks, cycles, perScreen: rows.length ? cycles / rows.length : 0 }
78}
79
80/**
81 * Record that a screenshot showed these screens. When the shot was of a whole section (`section` given), screens that
82 * used to live in it and are no longer there (renamed, deleted, rebuilt under another name) are dropped, so the count
83 * follows Figma and not the history.
84 */
85export const seeScreens = (s: Stats, found: Array<{ id: string; name: string; parent?: string }>, section?: string): Stats => {
86  let screens = { ...s.screens }
87  if (section) {
88    const names = new Set(found.map(f => f.name))
89    screens = Object.fromEntries(Object.entries(screens).filter(([name, r]) => r.parent !== section || names.has(name)))
90  }
91  for (const f of found) {
92    const prev = screens[f.name]
93    const edited = !prev || s.editSeq > prev.checkedAt
94    screens[f.name] = {
95      cycles: (prev?.cycles ?? 0) + (edited ? 1 : 0),
96      looks: (prev?.looks ?? 0) + 1,
97      ids: prev && prev.ids.includes(f.id) ? prev.ids : [...(prev?.ids ?? []), f.id].slice(-10),
98      checkedAt: s.editSeq,
99      parent: f.parent ?? prev?.parent ?? '',
100    }
101  }
102  return { ...s, screens: trim(screens, 200), writesSinceShot: 0 }
103}
104
105/** Does this Figma script change the file? Reads (get, find, list) do not count as edits. */
106export const isEditCode = (code: string): boolean =>
107  /(figma\.create|\.remove\(|\.appendChild\(|\.insertChild\(|\.clone\(|\.resize(WithoutConstraints)?\(|\.(characters|fills|strokes|effects|name|x|y|opacity|cornerRadius|layoutMode|itemSpacing|visible|layoutGrow|layoutSizingHorizontal|layoutSizingVertical|primaryAxisAlignItems|counterAxisAlignItems|paddingLeft|paddingRight|paddingTop|paddingBottom|fontName|fontSize|letterSpacing|arcData)\s*=[^=]|\.set[A-Z]\w*\(|\.combineAsVariants)/.test(code)
108
109/** Words Figma tools use when the bridge is not there, in either an error or a "successful" status reply. */
110export const bridgeWords = /unable to retrieve|no plugin connected|plugin is not connected|no active file|no files connected|desktop bridge may need|no figma file open|not connected/i
111
112/** The read-only script the mod sends to learn which screens a screenshotted node shows. */
113export const screenLookupCode = (id: string): string =>
114  `const n = await figma.getNodeByIdAsync(${JSON.stringify(id)}); if (!n) return []; const info = t => ({ id: t.id, name: t.name, parent: t.parent ? t.parent.id : '' }); ` +
115  `if (n.type === 'SECTION') return n.children.filter(c => c.type === 'FRAME').map(info); ` +
116  `if (n.type === 'FRAME' && n.parent && (n.parent.type === 'SECTION' || n.parent.type === 'PAGE')) return [info(n)]; return [];`
117
118/** Pull the list of screens out of an MCP result. */
119export const parseScreens = (res: unknown): Array<{ id: string; name: string; parent?: string }> | null => {
120  try {
121    const blocks = ((res as { content?: Array<{ type?: string; text?: string }> })?.content ?? []).filter(b => b.type === 'text')
122    for (const b of blocks) {
123      const j = JSON.parse(b.text ?? '')
124      const list = j?.result
125      if (Array.isArray(list)) return list.filter((x: any) => x && typeof x.name === 'string' && typeof x.id === 'string').slice(0, 100).map((x: any) => ({ id: x.id, name: x.name, parent: typeof x.parent === 'string' ? x.parent : '' }))
126    }
127  } catch { /* fall through */ }
128  return null
129}
130
131/** Colour of the iterations number: green up to 1.5, amber up to 2.5, red above. */
132export const rateColor = (perScreen: number): 'success' | 'warning' | 'error' =>
133  perScreen <= 1.5 ? 'success' : perScreen <= 2.5 ? 'warning' : 'error'
134
135export const percent = (part: number, whole: number): number => (num(whole) > 0 ? Math.min(100, Math.round((num(part) / num(whole)) * 100)) : 0)
136
137export const bar = (v: number, m: number, width: number): { filled: number; rest: number } => {
138  const value = num(v)
139  const max = num(m)
140  const filled = max > 0 ? Math.min(width, Math.max(value > 0 ? 1 : 0, Math.round((value / max) * width))) : 0
141  return { filled, rest: width - filled }
142}
143
144/** What an agent is doing in a few words: its task description, else its id. */
145export const agentLabel = (s: Stats, id: string): string => {
146  if (id === 'main') return 'main'
147  const said = clean(((s.names ?? {})[id] ?? '')).trim().toLowerCase()
148  if (!said) return `subagent ${[...id].slice(0, 6).join('')}`
149  return clip(said, 26)
150}
151
152/** Strip characters XML cannot carry. */
153export const clean = (t: string): string => String(t ?? '').replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, ' ').replace(/[\r\n\t]+/g, ' ')
154
155/**
156 * Cut text to a width budget (ASCII counts 1, anything else 2), on a whole code point, and on a word boundary when
157 * there is one: "review figma-cost mod…", never "review figma-cost mod c…".
158 */
159export const clip = (text: string, budget: number): string => {
160  const chars = [...text]
161  const out: string[] = []
162  let used = 0
163  for (const ch of chars) {
164    const w = ch.charCodeAt(0) < 0x250 ? 1 : 2
165    if (used + w > budget) break
166    out.push(ch)
167    used += w
168  }
169  if (out.length === chars.length) return text
170  // room for the ellipsis
171  while (out.length > 0 && used + 1 > budget) { const last = out.pop() as string; used -= last.charCodeAt(0) < 0x250 ? 1 : 2 }
172  const next = chars[out.length]
173  const atBoundary = next === undefined || /\s/.test(next)
174  let cut = out
175  if (!atBoundary) {
176    const at = out.map(c => /\s/.test(c)).lastIndexOf(true)
177    if (at > 0) cut = out.slice(0, at)
178  }
179  return `${cut.join('').replace(/[\s,;:.\-–—]+$/, '')}…`
180}
181
182/** Agents ranked by tokens, the tail folded into "others", with whole-number shares that add to 100. */
183export const agentShares = (s: Stats, keep = 4): Array<{ name: string; tokens: number; share: number }> => {
184  const rows = Object.entries(s.byAgent ?? {})
185    .map(([id, b]) => ({ name: agentLabel(s, id), tokens: tokens(b ?? empty()) }))
186    .sort((a, b) => b.tokens - a.tokens)
187  const head = rows.slice(0, keep)
188  const tail = rows.slice(keep).reduce((a, r) => a + r.tokens, 0)
189  if (tail > 0) head.push({ name: 'others', tokens: tail })
190  const total = head.reduce((a, r) => a + r.tokens, 0)
191  const shares = head.map(r => ({ ...r, share: percent(r.tokens, total) }))
192  const drift = 100 - shares.reduce((a, r) => a + r.share, 0)
193  if (total > 0 && drift !== 0) {
194    const biggest = shares.reduce((m, r, i) => (r.share > (shares[m]?.share ?? -1) ? i : m), 0)
195    if (shares[biggest]) shares[biggest].share += drift
196  }
197  return shares
198}
199
200/** Widths for a segmented bar: every agent with tokens gets at least one cell, the total is exactly `width`. */
201export const segments = (shares: Array<{ tokens: number }>, width: number): number[] => {
202  const total = shares.reduce((a, r) => a + r.tokens, 0)
203  if (total <= 0) return shares.map(() => 0)
204  const raw = shares.map(r => Math.max(r.tokens > 0 ? 1 : 0, Math.round((r.tokens / total) * width)))
205  let diff = width - raw.reduce((a, b) => a + b, 0)
206  let i = 0
207  while (diff !== 0 && i < 1000) {
208    const k = i++ % raw.length
209    if (diff > 0) { raw[k] += 1; diff -= 1 } else if (raw[k] > 1) { raw[k] -= 1; diff += 1 }
210  }
211  return raw
212}
213
214export type StepName = 'Spent' | 'Bought' | 'Took' | 'Held back by' | 'Spent by'
215
216/** Which steps of the story the pane tells, in order. */
217export const stepsOf = (s: Stats): StepName[] => {
218  const calls = Object.values(s.figmaTools ?? {}).reduce((a, b) => a + num(b), 0)
219  const hasFigma = calls > 0 || num(s.figma?.steps) > 0
220  const out: StepName[] = ['Spent']
221  if (!hasFigma || s.bridgeDown) return out
222  const { screens } = iterations(s)
223  if (screens > 0) out.push('Bought', 'Took')
224  if (num(s.figmaErrors) > 0 || num(s.writesSinceShot) > 0) out.push('Held back by')
225  if (Object.keys(s.byAgent ?? {}).length > 0) out.push('Spent by')
226  return out
227}
228
229// ---------------------------------------------------------------- desktop card (SVG)
230
231const esc = (t: string): string => clean(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
232const f1 = (n: number): string => n.toFixed(1)
233
234const CSS = `
235:root{--t:#14171F;--s:#6B7280;--a:#4B55C6;--trk:#E9EBF0;--rail:#D9DCE3;--g:#1F7A4D;--w:#A8590B;--r:#B3261E;--c0:#4B55C6;--c1:#8A92E0;--c2:#C9CDF2;--c3:#9CA3AF;--c4:#D1D5DB}
236@media (prefers-color-scheme: dark){:root{--t:#ECEEF3;--s:#9AA1AE;--a:#8A92E0;--trk:#2A2E38;--rail:#3A3F4B;--g:#4ADE80;--w:#F5B045;--r:#F87171;--c0:#8A92E0;--c1:#6B74D6;--c2:#444B99;--c3:#6B7280;--c4:#4B5563}}
237text{font-family:Geist,Inter,-apple-system,system-ui,sans-serif}
238.t{fill:var(--t)}.s{fill:var(--s)}.a{fill:var(--a)}.trk{fill:var(--trk)}.rail{stroke:var(--rail)}
239.g{fill:var(--g)}.w{fill:var(--w)}.r{fill:var(--r)}
240.b{font-weight:600}.m{font-weight:500}
241`
242
243/** The whole card as one SVG, drawn like the Figma design. Returns the markup and a plain-words summary. */
244export const cardSvg = (saved: Stats): { source: string; alt: string; height: number } => {
245  const s = normalize(saved)
246  const W = 320
247  const X = 26
248  const steps = stepsOf(s)
249  const it = iterations(s)
250  const dim = s.bridgeDown
251  const pct = percent(s.figma.cost, s.all.cost)
252  const parts: string[] = []
253  const dots: number[] = []
254  const alt: string[] = [`Figma cost this session: ${money(s.figma.cost)} of ${money(s.all.cost)}`]
255
256  // The pane already carries the title; only a problem earns a line of its own.
257  if (dim) parts.push(`<circle cx="3" cy="9" r="3" style="fill:var(--r)"/><text x="12" y="13" class="r m" font-size="12">Not connected</text>`)
258
259  let y = dim ? 46 : 6
260  let bottom = y
261  const lead = (name: string) => {
262    dots.push(y + 6)
263    parts.push(`<text x="${X}" y="${y + 10}" class="s m" font-size="12">${esc(name)}</text>`)
264  }
265
266  // Spent
267  const body: string[] = []
268  lead('Spent')
269  const barW = W - X
270  const fill = Math.min(barW, Math.max(s.figma.cost > 0 ? 4 : 0, Math.round((pct / 100) * barW)))
271  const costText = `${money(s.figma.cost)} / ${money(s.all.cost)}`
272  const [big, small] = costText.length > 17 ? [26, 13] : costText.length > 14 ? [32, 16] : [38, 19]
273  body.push(
274    `<text x="${X}" y="${y + 48}" class="t b" font-size="${big}" letter-spacing="-1.5">${esc(money(s.figma.cost))}<tspan class="s" font-weight="400" font-size="${small}" letter-spacing="-0.5" dx="8">/ ${esc(money(s.all.cost))}</tspan></text>`,
275    `<rect x="${X}" y="${y + 62}" width="${barW}" height="6" rx="3" class="trk"/>`,
276    fill > 0 ? `<rect x="${X}" y="${y + 62}" width="${fill}" height="6" rx="3" class="a"/>` : '',
277    `<text x="${X}" y="${y + 90}" class="s" font-size="12">${pct}% of the session${dim ? ', last known' : ''}</text>`,
278  )
279  bottom = y + 94
280  y += 92 + 28
281
282  if (steps.includes('Bought')) {
283    lead('Bought')
284    body.push(
285      `<text x="${X}" y="${y + 38}" class="t b" font-size="20" letter-spacing="-0.4">${it.screens} ${it.screens === 1 ? 'screen' : 'screens'}</text>`,
286      `<text x="${X}" y="${y + 58}" class="s" font-size="13">${esc(money(s.figma.cost / it.screens))} per screen</text>`,
287    )
288    alt.push(`${it.screens} screens`)
289    bottom = y + 62
290    y += 60 + 28
291  }
292
293  if (steps.includes('Took')) {
294    lead('Took')
295    const cls = { success: 'g', warning: 'w', error: 'r' }[rateColor(it.perScreen)]
296    body.push(`<text x="${X}" y="${y + 38}" class="t b" font-size="20" letter-spacing="-0.4"><tspan class="${cls}">${f1(it.perScreen)}</tspan> iterations per screen</text>`)
297    alt.push(`${f1(it.perScreen)} iterations per screen`)
298    bottom = y + 46
299    y += 42 + 28
300  }
301
302  if (steps.includes('Held back by')) {
303    lead('Held back by')
304    const failed = `${s.figmaErrors} failed ${s.figmaErrors === 1 ? 'call' : 'calls'}`
305    const unchecked = `${s.writesSinceShot} ${s.writesSinceShot === 1 ? 'edit' : 'edits'} never checked`
306    if (s.figmaErrors > 0) {
307      body.push(`<text x="${X}" y="${y + 38}" class="t b" font-size="20" letter-spacing="-0.4">${failed}</text>`)
308      if (s.writesSinceShot > 0) body.push(`<text x="${X}" y="${y + 58}" class="s" font-size="13">${unchecked}</text>`)
309      bottom = y + (s.writesSinceShot > 0 ? 62 : 46)
310      y += (s.writesSinceShot > 0 ? 60 : 42) + 28
311    } else {
312      body.push(`<text x="${X}" y="${y + 38}" class="t b" font-size="20" letter-spacing="-0.4">${unchecked}</text>`)
313      bottom = y + 46
314      y += 42 + 28
315    }
316    alt.push(s.figmaErrors > 0 ? failed : unchecked)
317  }
318
319  if (steps.includes('Spent by')) {
320    lead('Spent by')
321    const agents = agentShares(s)
322    const widths = segments(agents, Math.max(agents.length, barW - 3 * (agents.length - 1)))
323    body.push(`<text x="${X}" y="${y + 38}" class="t b" font-size="20" letter-spacing="-0.4">${esc(mega(tokens(s.all)))} tokens</text>`)
324    let x = X
325    agents.forEach((a, i) => {
326      const w = widths[i] ?? 0
327      const r = Math.min(6, w / 2)
328      const left = Math.min(i === 0 ? r : 2, w / 2)
329      const right = Math.min(i === agents.length - 1 ? r : 2, w / 2)
330      const top = y + 54
331      const h = 12
332      // rounded-rect with independent left/right radii
333      body.push(
334        `<path d="M${x + left},${top} H${x + w - right} Q${x + w},${top} ${x + w},${top + right} V${top + h - right} Q${x + w},${top + h} ${x + w - right},${top + h} H${x + left} Q${x},${top + h} ${x},${top + h - left} V${top + left} Q${x},${top} ${x + left},${top} Z" style="fill:var(--c${Math.min(i, 4)})"/>`,
335      )
336      x += w + 3
337    })
338    agents.forEach((a, i) => {
339      const ry = y + 92 + i * 22
340      body.push(
341        `<rect x="${X}" y="${ry - 8}" width="8" height="8" rx="2" style="fill:var(--c${Math.min(i, 4)})"/>`,
342        `<text x="${X + 18}" y="${ry}" class="t" font-size="13">${esc(a.name)}</text>`,
343        `<text x="${W - 46}" y="${ry}" text-anchor="end" class="s" font-size="13">${esc(mega(a.tokens))}</text>`,
344        `<text x="${W}" y="${ry}" text-anchor="end" class="t m" font-size="13">${a.share}%</text>`,
345      )
346    })
347    bottom = y + 92 + (agents.length - 1) * 22 + 6
348    y += 92 + agents.length * 22
349  }
350
351  const height = Math.ceil(bottom + 4)
352  const railLine =
353    dots.length > 1 ? `<line x1="6" y1="${dots[0]}" x2="6" y2="${dots[dots.length - 1]}" class="rail" stroke-width="2" stroke-linecap="round"/>` : ''
354  const dotMarks = dots.map(cy => `<circle cx="6" cy="${cy}" r="5" class="a"/>`).join('')
355  const source =
356    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${height}" width="${W}" height="${height}"><style>${CSS}</style>` +
357    `<g${dim ? ' opacity="0.45"' : ''}>${railLine}${dotMarks}${body.join('')}</g>${parts.join('')}</svg>`
358  return { source, alt: alt.join(', '), height }
359}
360
361/** Numbers saved by an older version of this mod lack newer fields: fill them in. */
362export const normalize = (saved: Partial<Stats> | null | undefined): Stats => {
363  const base = fresh()
364  const o = (saved ?? {}) as Partial<Stats>
365  const bucket = (b: unknown): Bucket => ({ ...empty(), ...((b && typeof b === 'object' ? b : {}) as Partial<Bucket>) })
366  const dict = <T,>(d: unknown, each: (v: unknown) => T): Record<string, T> =>
367    Object.fromEntries(Object.entries(d && typeof d === 'object' ? (d as object) : {}).map(([k, v]) => [k, each(v)]))
368  return {
369    startedAt: num(o.startedAt) || base.startedAt,
370    all: bucket(o.all),
371    figma: bucket(o.figma),
372    byAgent: dict(o.byAgent, bucket),
373    names: dict(o.names, v => String(v ?? '')),
374    figmaTools: dict(o.figmaTools, num),
375    figmaErrors: num(o.figmaErrors),
376    screens: dict(o.screens, (v): Screen => {
377      const r = (v && typeof v === 'object' ? v : {}) as Partial<Screen>
378      return { cycles: num(r.cycles), looks: num(r.looks), ids: Array.isArray(r.ids) ? r.ids.map(String).slice(-10) : [], checkedAt: typeof r.checkedAt === 'number' ? r.checkedAt : -1, parent: typeof r.parent === 'string' ? r.parent : '' }
379    }),
380    editSeq: num(o.editSeq),
381    writesSinceShot: num(o.writesSinceShot),
382    bridgeDown: o.bridgeDown === true,
383    // A base without the estimate it was taken against cannot be used: drop it, a new one is taken at the next step.
384    costBase: typeof o.costBase === 'number' && Number.isFinite(o.costBase) && typeof o.estAtBase === 'number' ? o.costBase : null,
385    estAtBase: typeof o.estAtBase === 'number' && Number.isFinite(o.estAtBase) ? o.estAtBase : 0,
386  }
387}
388
389/** Keep the maps that grow with the session within a fixed size: oldest entries go first. */
390export const trim = <T,>(d: Record<string, T>, max: number): Record<string, T> => {
391  const keys = Object.keys(d)
392  if (keys.length <= max) return d
393  return Object.fromEntries(keys.slice(keys.length - max).map(k => [k, d[k] as T]))
394}
395
396/**
397 * Put real dollars on the numbers. The session's own ledger (`usd`) is exact; our token-based estimate is only used to
398 * say what share of it was Figma work. Without a ledger reading the estimates stand as they are.
399 */
400export const calibrate = (s: Stats, usd: number | null | undefined): Stats => {
401  if (typeof usd !== 'number' || !Number.isFinite(usd) || usd <= 0) return s
402  // Our estimate and the ledger are compared over the same stretch: from the moment the ledger was first read.
403  const sinceEst = s.all.cost - s.estAtBase
404  const sinceUsd = s.costBase === null ? 0 : usd - s.costBase
405  // Short stretches are noisy (the ledger and our counters move at slightly different moments), so wait for $1 of estimate.
406  const k = sinceEst >= 1 && sinceUsd > 0 ? Math.min(2, Math.max(0.4, sinceUsd / sinceEst)) : 1
407  return { ...s, all: { ...s.all, cost: usd }, figma: { ...s.figma, cost: s.figma.cost * k } }
408}
409
types/index.d.ts 43 lines
1export type Bucket = { steps: number; input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }
2export type Screen = {
3  /** build-and-check rounds: a screenshot taken after edits since its last one */
4  cycles: number
5  /** every screenshot that showed it */
6  looks: number
7  /** ids the screen has had; more than one means it was rebuilt */
8  ids: string[]
9  /** value of editSeq when it was last looked at */
10  checkedAt: number
11  /** id of the section it lives in, so a screen that left the section can be dropped */
12  parent: string
13}
14export type Stats = {
15  startedAt: number
16  all: Bucket
17  figma: Bucket
18  /** every model step, filed under the agent that made it */
19  byAgent: Record<string, Bucket>
20  /** agent id to the short task description it was spawned with */
21  names: Record<string, string>
22  figmaTools: Record<string, number>
23  figmaErrors: number
24  /** screens as Figma names them: a frame inside a section. Keyed by name so a rebuilt screen is still the same screen. */
25  screens: Record<string, Screen>
26  /** counts every successful edit; a screen is "checked" when a screenshot follows edits it has not seen */
27  editSeq: number
28  /** Figma writes since the last screenshot */
29  writesSinceShot: number
30  /** the last Figma call failed because the bridge plugin is not connected */
31  bridgeDown: boolean
32  /** the session's real cost (USD, as /cost totals it) when counting began; null until first seen */
33  costBase: number | null
34  /** our own estimate (all.cost) at the moment costBase was read, so both are measured over the same stretch */
35  estAtBase: number
36}
37
38declare module 'claude-code' {
39  interface PluginState {
40    'figma-cost': { stats: Stats }
41  }
42}
43