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…

A read-only pane that tells the story of Figma design work in a session, top to bottom:
/figma-cost opens the pane./figma-cost reset starts the counters again./figma-cost json prints the raw counters, for checking the numbers./cost shows). The Figma share is our token estimate scaled to that ledger, so treat it as good to about 10 percent.hooks/register.tsx 249 lines1import { 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}
249hooks/lib.ts 409 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
409types/index.d.ts 43 lines1export 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