Live session cost odometer above the prompt: a burning fuse, real-world comparisons, threshold alerts

Live session cost odometer above the prompt: a burning fuse, real-world comparisons, threshold alerts.

A band above the prompt with a growing fire bar for session spend, 5-hour and weekly plan-limit bars with reset times, and the cost converted into burritos and McDoubles. /burn opens the full panel with per-turn cost. Alerts fire when you cross a threshold.
Everything burn-meter shows comes from the session's own usage numbers, which Claude Code reports to the mod. It never reads your files, your environment variables or your credentials, never runs a process, and never sends anything off your machine. "Tokens" on this page always means model token counts, not login tokens.
session.start: draws the band and registers /burn.prompt.submit: notes the session's cost at the moment you send a prompt, so it can show what that turn cost. It does not read or change the prompt, and passes it through untouched.turn.complete: adds the finished turn's token counts and cost to the per-turn chart, and shows a toast when spend crosses a threshold.command.run for /burn: opens the pane or toggles the band.ui.render: draws the band above the prompt and the /burn pane.It stores two small values with Claude Code's own plugin storage: your running lifetime spend, and the last cost reading for the current session so a restart does not count the same spend twice.
In a Claude Code session (2.1.287 or later):
/plugin marketplace add OneWave-AI/claude-code-mods
/plugin install burn-meter@claude-code-mods
Or load this folder for one session: claude --plugin-dir ./burn-meter
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| burn-meter | No | No | No | No | No |
Run claude plugin validate ./burn-meter to list every event it hooks and every call it makes. See the repository README for the full security notes.
Part of claude-code-mods by OneWave AI. MIT licensed.
hooks/register.tsx 378 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Burn, ModelTally } from '../types'
5import { FIRE_ROWS, THINGS, barText, compare, crossed, fireCells, heat, lifetimeDelta, modelsLine, money, pct, resetsIn, tallyTurn, tween, windows } from './burn'
6
7const burn = atom({ plugin: 'burn-meter', key: 'burn' } as const, {
8 shown: 0,
9 target: 0,
10 isDemo: false,
11 lifetime: 0,
12 session: -1,
13 week: -1,
14 sessionResets: '',
15 weekResets: '',
16} as Burn)
17const isHidden = atom({ plugin: 'burn-meter', key: 'isHidden' } as const, false)
18const fired = atom({ plugin: 'burn-meter', key: 'fired' } as const, [] as number[])
19const models = atom({ plugin: 'burn-meter', key: 'models' } as const, {} as Record<string, ModelTally>)
20const turns = atom({ plugin: 'burn-meter', key: 'turns' } as const, [] as number[])
21const turnStart = atom({ plugin: 'burn-meter', key: 'turnStart' } as const, 0)
22
23const GOLD = '#c2a87e'
24const PAPER = '#E8E2D6'
25const RED = '#e5484d'
26const ORANGE = '#e8833a'
27
28const DEMO_MS = 20_000
29const DEMO_TOP = 30.5
30const DEMO_HOLD_MS = 6_000
31
32let tweenTimer: { cancel: () => void } | null = null
33let sparkTimer: { cancel: () => void } | null = null
34let sparkSize = 0
35let bandId: string | null = null
36let demoStartedAt = 0
37let demoFired: number[] = []
38let paneTimer: { cancel: () => void } | null = null
39let paneSize = ''
40
41const PANE = 'burn'
42const PANE_FIRE_ROWS = 8
43const TURNS_KEPT = 12
44
45/** Animates the pane's tall fire while the pane is mounted; stops once a blit is refused. */
46const startPaneFire = ($: EngineInterface, columns: number) => {
47 const size = `${columns}x${PANE_FIRE_ROWS}`
48 if (paneTimer && paneSize === size) return
49 paneTimer?.cancel()
50 paneSize = size
51 const timer = $.clock.every(70, () => {
52 void Promise.all([read($, burn), $.clock.now()]).then(([b, now]) =>
53 $.ui
54 .blit({
55 requestId: PANE,
56 key: 'bigfire',
57 cells: fireCells(columns, fill(b), fill(b), now / 1000, PANE_FIRE_ROWS),
58 })
59 .then(r => {
60 if (r.deny && paneTimer === timer) {
61 timer.cancel()
62 paneTimer = null
63 }
64 }),
65 )
66 })
67 paneTimer = timer
68}
69
70const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Burn Meter', focus: true })
71
72type Reading = { sessionId: string; usd: number }
73
74/** Folds a real cost reading into the lifetime total and the target. */
75const readCost = async ($: EngineInterface) => {
76 const usage = await $.session.usage()
77 const usd = usage.cost?.usd ?? 0
78 const sub = windows(usage.rateLimits)
79 const sessionId = await $.session.id()
80 const lastKey = `last:${sessionId}`
81 const last = ((await $.store.get(lastKey)) ?? null) as Reading | null
82 const delta = lifetimeDelta(last, sessionId, usd)
83 const stored = Number((await $.store.get('lifetime')) ?? 0)
84 const lifetime = stored + delta
85 if (delta !== 0 || !last || last.sessionId !== sessionId) {
86 await $.store.set('lifetime', lifetime)
87 await $.store.set(lastKey, { sessionId, usd })
88 }
89 const now = await read($, burn)
90 if (
91 now.session !== sub.session ||
92 now.week !== sub.week ||
93 now.sessionResets !== sub.sessionResets ||
94 now.weekResets !== sub.weekResets
95 ) {
96 await update($, burn, b => ({ ...b, ...sub }))
97 }
98 if (now.isDemo) {
99 if (now.lifetime !== lifetime) await update($, burn, b => ({ ...b, lifetime }))
100 return
101 }
102 if (now.target !== usd || now.lifetime !== lifetime) {
103 await alarm($, now.target, usd, false)
104 await update($, burn, b => ({ ...b, target: usd, lifetime }))
105 }
106 startTween($)
107}
108
109/** Toasts each threshold the spend just crossed, once per session (or per demo). */
110const alarm = async ($: EngineInterface, from: number, to: number, isDemo: boolean) => {
111 const already = isDemo ? demoFired : await read($, fired)
112 const hits = crossed(from, to, already)
113 if (hits.length === 0) return
114 const top = hits[hits.length - 1]!
115 $.ui.toast(`${money(top)} burned this session ${compare(top, top)}`, { timeoutMs: 6000 })
116 if (isDemo) demoFired = [...demoFired, ...hits]
117 else await update($, fired, list => [...list, ...hits])
118}
119
120/** Eases the odometer toward its target, 20 frames a second, and stops once it lands. */
121const startTween = ($: EngineInterface) => {
122 if (tweenTimer) return
123 const timer = $.clock.every(50, () => {
124 void read($, burn).then(async b => {
125 if (b.shown === b.target) {
126 timer.cancel()
127 if (tweenTimer === timer) tweenTimer = null
128 return
129 }
130 await update($, burn, x => ({ ...x, shown: tween(x.shown, x.target) }))
131 })
132 })
133 tweenTimer = timer
134}
135
136/** The bar's fill: the 5-hour window on a subscription, else the spend's heat. */
137const fill = (b: Burn) => (b.isDemo || b.session < 0 ? heat(b.shown) : Math.max(0.02, Math.min(1, b.session / 100)))
138
139/** Animates the fire by blitting the bar while the band is mounted. */
140const startSpark = ($: EngineInterface, columns: number) => {
141 if (sparkTimer && sparkSize === columns) return
142 sparkTimer?.cancel()
143 sparkSize = columns
144 const timer = $.clock.every(80, () => {
145 if (!bandId) return
146 void Promise.all([read($, burn), $.clock.now()]).then(([b, now]) =>
147 $.ui
148 .blit({
149 requestId: bandId!,
150 key: 'fire',
151 cells: fireCells(columns, fill(b), fill(b), now / 1000),
152 })
153 .then(r => {
154 if (r.deny && sparkTimer === timer) {
155 timer.cancel()
156 sparkTimer = null
157 }
158 }),
159 )
160 })
161 sparkTimer = timer
162}
163
164const runDemo = async ($: EngineInterface) => {
165 demoStartedAt = await $.clock.now()
166 demoFired = []
167 await update($, isHidden, () => false)
168 await update($, burn, b => ({ ...b, shown: 0, target: 0, isDemo: true }))
169 const ramp = $.clock.every(100, () => {
170 void $.clock.now().then(async now => {
171 const t = Math.min(1, (now - demoStartedAt) / DEMO_MS)
172 // Slow start, steep finish: how a long agent session actually feels.
173 const target = DEMO_TOP * t * t * (0.35 + 0.65 * t)
174 const before = (await read($, burn)).target
175 await alarm($, before, target, true)
176 await update($, burn, b => ({ ...b, target }))
177 startTween($)
178 if (t >= 1) {
179 ramp.cancel()
180 $.clock.after(DEMO_HOLD_MS, () => {
181 void update($, burn, b => ({ ...b, isDemo: false, shown: 0, target: 0 })).then(() => readCost($))
182 })
183 }
184 })
185 })
186}
187
188export const register: Register = on => {
189 on('session.start', async ($, e, next) => {
190 await $.command.register({
191 name: 'burn',
192 description: 'Burn meter: /burn opens the panel; [panel|show|hide|demo|lifetime]',
193 })
194 void readCost($).catch(() => undefined)
195 $.clock.every(1000, () => void readCost($).catch(() => undefined))
196 // The comparison rotates every six seconds even while the spend sits still.
197 $.clock.every(6000, () => $.ui.invalidate('ui.render'))
198
199 return next(e)
200 })
201
202 on('prompt.submit', async ($, e, next) => {
203 const usd = (await $.session.usage()).cost?.usd ?? 0
204 await update($, turnStart, () => usd)
205 return next(e)
206 })
207
208 on('turn.complete', async ($, e, next) => {
209 const done = await next(e)
210 const usage = done.usage
211 if (usage) await update($, models, m => tallyTurn(m, usage))
212 await readCost($).catch(() => undefined)
213 const usd = (await $.session.usage()).cost?.usd ?? 0
214 const spent = Math.max(0, usd - (await read($, turnStart)))
215 await update($, turns, list => [...list, spent].slice(-TURNS_KEPT))
216 await update($, turnStart, () => usd)
217 return done
218 })
219
220 on('command.run', { command: 'burn' }, async ($, e) => {
221 const arg = e.args.trim().toLowerCase()
222 if (arg === 'demo') {
223 void openPane($)
224 await runDemo($)
225 return { text: 'Burn demo: $0 to $30 in 20 seconds.' }
226 }
227 if (arg === 'hide') {
228 await update($, isHidden, () => true)
229 return { text: 'Burn meter hidden. /burn show brings it back.' }
230 }
231 const b = await read($, burn)
232 if (arg === 'lifetime') {
233 return { text: `Lifetime burn: ${money(b.lifetime)} ${compare(b.lifetime, 2)}.` }
234 }
235 await update($, isHidden, () => false)
236 if (arg === '' || arg === 'panel') void openPane($)
237 const used = modelsLine(await read($, models), 6)
238 const limits = `5h ${pct(b.session)}, week ${pct(b.week)}`
239 return {
240 text: `This session: ${money(b.target)} ${compare(b.target, 0)}. ${limits}. Lifetime: ${money(b.lifetime)}.${used ? `\nModels: ${used}` : ''}`,
241 }
242 })
243
244 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
245 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
246 const b = await read($, burn)
247 if (b.target <= 0 && b.session < 0 && !b.isDemo) return next(e)
248
249 const elements = $.ui.resolve(e)
250 const { Box, Text, Button } = elements
251 const Raster = 'Raster' in elements ? elements.Raster : null
252 const hasFire = Raster !== null && e.surface === 'terminal'
253 const now = await $.clock.now()
254 const cols = Math.max(40, e.props.bodyColumns || 80)
255 const level = fill(b)
256 const color = level > 0.75 ? RED : level > 0.45 ? ORANGE : GOLD
257 const isRolling = b.shown !== b.target
258 const bar = Math.max(12, Math.min(64, cols - 46))
259 const used = modelsLine(await read($, models), cols >= 140 ? 2 : 1)
260 const which = Math.floor(now / 6000) % THINGS.length
261 const sessionLine = `5h ${pct(b.session)} ${resetsIn(b.sessionResets, Date.now())}`.trim()
262 const weekLine = `wk ${pct(b.week)} ${resetsIn(b.weekResets, Date.now())}`.trim()
263
264 if (hasFire) {
265 bandId = e.requestId
266 startSpark($, bar)
267 }
268
269 return (
270 <Box gap={2} alignItems="flex-end">
271 <Box flexDirection="column">
272 <Text color={color} bold>{b.isDemo ? 'DEMO' : 'BURN'}</Text>
273 <Text color={color} bold>{sessionLine}</Text>
274 <Text dimColor>{weekLine}</Text>
275 </Box>
276 {hasFire ? (
277 <Raster key="fire" columns={bar} rows={FIRE_ROWS} cells={fireCells(bar, level, level, now / 1000)} />
278 ) : (
279 <Text color={color}>{barText(bar, level)}</Text>
280 )}
281 <Box flexDirection="column">
282 <Text>
283 <Text color={isRolling ? color : PAPER} bold>{money(b.shown)}</Text>
284 <Text color={GOLD}>{` ${compare(b.shown, which)}`}</Text>
285 </Text>
286 <Text dimColor>{used || 'no model turns yet'}</Text>
287 <Box gap={2}>
288 <Text dimColor>{`lifetime ${money(b.lifetime)}`}</Text>
289 <Button key="hide" label="hide" plain onPress={() => void update($, isHidden, () => true)} />
290 </Box>
291 </Box>
292 </Box>
293 )
294 })
295
296 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
297 const elements = $.ui.resolve(e)
298 const { Box, Text } = elements
299 const Raster = 'Raster' in elements ? elements.Raster : null
300 const hasFire = Raster !== null && e.surface === 'terminal'
301 const b = await read($, burn)
302 const history = await read($, turns)
303 const now = await $.clock.now()
304 const cols = Math.max(40, e.props.bodyColumns || e.viewport?.columns || 80)
305 const width = cols - 2
306 const level = fill(b)
307 const color = level > 0.75 ? RED : level > 0.45 ? ORANGE : GOLD
308 const isRolling = b.shown !== b.target
309 const meter = Math.max(10, Math.min(40, width - 24))
310 const windowBar = (n: number) => (n < 0 ? '' : barText(meter, Math.max(0, Math.min(1, n / 100))))
311 const top = Math.max(0.01, ...history)
312 const turnBar = Math.max(8, Math.min(36, width - 22))
313
314 if (hasFire) startPaneFire($, width)
315
316 return (
317 <Box flexDirection="column" paddingX={1}>
318 <Box justifyContent="space-between">
319 <Text color={color} bold>{b.isDemo ? 'DEMO BURN' : 'SESSION BURN'}</Text>
320 <Text dimColor>{`lifetime ${money(b.lifetime)}`}</Text>
321 </Box>
322 <Text>
323 <Text color={isRolling ? color : PAPER} bold>{money(b.shown)}</Text>
324 <Text color={GOLD}>{` ${compare(b.shown, Math.floor(now / 6000) % THINGS.length)}`}</Text>
325 </Text>
326 <Box marginY={1}>
327 {hasFire ? (
328 <Raster key="bigfire" columns={width} rows={PANE_FIRE_ROWS} cells={fireCells(width, level, level, now / 1000, PANE_FIRE_ROWS)} />
329 ) : (
330 <Text color={color}>{barText(width, level)}</Text>
331 )}
332 </Box>
333
334 {(b.session >= 0 || b.week >= 0) && (
335 <Box flexDirection="column" marginBottom={1}>
336 <Text bold color={PAPER}>Limits</Text>
337 {b.session >= 0 && (
338 <Text>
339 <Text dimColor>{'5 hour '}</Text>
340 <Text color={b.session > 75 ? RED : GOLD}>{windowBar(b.session)}</Text>
341 <Text>{` ${pct(b.session)} `}</Text>
342 <Text dimColor>{resetsIn(b.sessionResets, Date.now())}</Text>
343 </Text>
344 )}
345 {b.week >= 0 && (
346 <Text>
347 <Text dimColor>{'weekly '}</Text>
348 <Text color={b.week > 75 ? RED : GOLD}>{windowBar(b.week)}</Text>
349 <Text>{` ${pct(b.week)} `}</Text>
350 <Text dimColor>{resetsIn(b.weekResets, Date.now())}</Text>
351 </Text>
352 )}
353 </Box>
354 )}
355
356 <Box flexDirection="column" marginBottom={1}>
357 <Text bold color={PAPER}>Per turn</Text>
358 {history.length === 0 && <Text dimColor>No finished turns yet.</Text>}
359 {history.map((usd, i) => (
360 <Text key={`turn-${i}`}>
361 <Text dimColor>{`#${String(i + 1).padStart(2, ' ')} `}</Text>
362 <Text color={usd === top && history.length > 1 ? ORANGE : GOLD}>{barText(turnBar, usd / top)}</Text>
363 <Text>{` ${money(usd)}`}</Text>
364 </Text>
365 ))}
366 </Box>
367
368 <Box flexDirection="column">
369 <Text bold color={PAPER}>That is</Text>
370 {THINGS.map((_, i) => (
371 <Text key={`thing-${i}`} color={GOLD}>{compare(b.shown, i)}</Text>
372 ))}
373 </Box>
374 </Box>
375 )
376 })
377}
378hooks/burn.ts 225 lines1/** Pure helpers for the burn meter: money formatting, comparisons, thresholds and the fuse frame. */
2
3export const THRESHOLDS = [1, 5, 10, 25, 50] as const
4
5type Thing = { one: string; many: string; usd: number }
6
7export const THINGS: readonly Thing[] = [
8 { one: 'burrito', many: 'burritos', usd: 11 },
9 { one: 'McDouble', many: 'McDoubles', usd: 3.19 },
10]
11
12/** Dollars with two decimals (12.34), or four under a cent so the first fractions still move. */
13export const money = (usd: number) =>
14 '$' + (usd > 0 && usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2))
15
16/** `= 1.4 burritos` for the thing at `index`, wrapping. */
17export const compare = (usd: number, index: number) => {
18 const thing = THINGS[((index % THINGS.length) + THINGS.length) % THINGS.length]!
19 const count = usd / thing.usd
20 const shown = count >= 10 ? count.toFixed(0) : count >= 1 ? count.toFixed(1) : count.toFixed(2)
21 return `= ${shown} ${shown === '1.0' || shown === '1' ? thing.one : thing.many}`
22}
23
24/** `842`, `84k`, `1.2M`. */
25export const tokenCount = (n: number) =>
26 n < 1000 ? `${Math.round(n)}` : n < 1e6 ? `${Math.round(n / 1000)}k` : `${(n / 1e6).toFixed(1)}M`
27
28type Tally = { input: number; output: number; turns: number }
29type Usage = {
30 model: string
31 input_tokens: number
32 output_tokens: number
33 cache_read_input_tokens: number
34 cache_creation_input_tokens: number
35}
36
37/** Adds one turn's usage to the per-model tally. */
38export const tallyTurn = (models: Readonly<Record<string, Tally>>, u: Usage) => {
39 const was = models[u.model] ?? { input: 0, output: 0, turns: 0 }
40 const input = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
41 return { ...models, [u.model]: { input: was.input + input, output: was.output + u.output_tokens, turns: was.turns + 1 } }
42}
43
44/** `claude-opus-5-5` -> `opus 5.5`, `claude-haiku-4-5-20251001` -> `haiku 4.5`. */
45export const modelName = (id: string) => {
46 const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?/.exec(id)
47 return m ? `${m[1]} ${m[2]}${m[3] ? `.${m[3]}` : ''}` : id
48}
49
50/** `opus 5.5 1.2M in 84k out · haiku 4.5 ...`, busiest first; '' when none. */
51export const modelsLine = (models: Readonly<Record<string, Tally>>, max = 3) =>
52 Object.entries(models)
53 .sort((a, b) => b[1].output - a[1].output)
54 .slice(0, max)
55 .map(([id, t]) => `${modelName(id)} ${tokenCount(t.input)} in ${tokenCount(t.output)} out`)
56 .join(' · ')
57
58type Limit = { kind: string; percentUsed: number; resetsAt?: string }
59
60/** The 5-hour and 7-day subscription windows out of `rateLimits`; -1 where absent. */
61export const windows = (limits: readonly Limit[]) => {
62 const find = (kind: string) => limits.find(l => l.kind === kind)
63 const s = find('five_hour')
64 const w = find('seven_day')
65 return {
66 session: s ? s.percentUsed : -1,
67 week: w ? w.percentUsed : -1,
68 sessionResets: s?.resetsAt ?? '',
69 weekResets: w?.resetsAt ?? '',
70 }
71}
72
73/** `in 2h 14m`, `in 3d 4h`, `now`; '' when unknown. */
74export const resetsIn = (iso: string, now: number) => {
75 if (!iso) return ''
76 const ms = Date.parse(iso) - now
77 if (!Number.isFinite(ms)) return ''
78 if (ms <= 0) return 'now'
79 const m = Math.floor(ms / 60_000)
80 const d = Math.floor(m / 1440)
81 const h = Math.floor((m % 1440) / 60)
82 return d > 0 ? `in ${d}d ${h}h` : h > 0 ? `in ${h}h ${m % 60}m` : `in ${m}m`
83}
84
85/** `42%`, or `--` with no reading. */
86export const pct = (n: number) => (n < 0 ? '--' : `${Math.round(n)}%`)
87
88/** Thresholds crossed going from `from` to `to` that are not in `fired`. */
89export const crossed = (from: number, to: number, fired: readonly number[]) =>
90 THRESHOLDS.filter(t => from < t && to >= t && !fired.includes(t))
91
92/** The threshold below and above `usd`: the fuse burns from one to the next. */
93export const bracket = (usd: number) => {
94 let low = 0
95 for (const t of THRESHOLDS) {
96 if (usd < t) return { low, high: t }
97 low = t
98 }
99 return { low, high: Math.max(100, Math.ceil(usd / 50) * 50 + 50) }
100}
101
102/** 0..1 of the way through the current bracket. */
103export const burnt = (usd: number) => {
104 const { low, high } = bracket(usd)
105 return Math.max(0, Math.min(1, (usd - low) / (high - low)))
106}
107
108/** 0..1 how hot the session is overall: log-scaled to $100. */
109export const heat = (usd: number) => Math.max(0, Math.min(1, Math.log10(1 + usd) / 2))
110
111/** Steps `shown` toward `target`: an odometer that eases but always lands. */
112export const tween = (shown: number, target: number) => {
113 const gap = target - shown
114 if (Math.abs(gap) < 0.0005) return target
115 const step = gap * 0.22
116 return shown + (Math.abs(step) < 0.0005 ? Math.sign(gap) * 0.0005 : step)
117}
118
119/** How much to add to the lifetime total for a new reading in a session. */
120export const lifetimeDelta = (
121 last: { sessionId: string; usd: number } | null,
122 sessionId: string,
123 usd: number,
124) => {
125 const before = last && last.sessionId === sessionId ? last.usd : 0
126 return usd >= before ? usd - before : usd
127}
128
129// --- the fuse, as Raster cells -------------------------------------------
130
131const DEFAULT = 0x01000000
132const GOLD = 0xc2a87e
133const ORANGE = 0xe8833a
134const RED = 0xe5484d
135const WHITE = 0xfff4e0
136
137const mix = (a: number, b: number, t: number) => {
138 const ch = (shift: number) => Math.round(((a >> shift) & 0xff) * (1 - t) + ((b >> shift) & 0xff) * t)
139 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
140}
141
142/** The flame color for a heat 0..1: gold, then orange, then red. */
143export const flame = (h: number) => (h < 0.5 ? mix(GOLD, ORANGE, h * 2) : mix(ORANGE, RED, (h - 0.5) * 2))
144
145const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
146
147export const toBase64 = (bytes: Uint8Array) => {
148 let out = ''
149 for (let i = 0; i < bytes.length; i += 3) {
150 const a = bytes[i] ?? 0
151 const b = bytes[i + 1] ?? 0
152 const c = bytes[i + 2] ?? 0
153 const n = (a << 16) | (b << 8) | c
154 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
155 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
156 out += i + 2 < bytes.length ? B64[n & 63]! : '='
157 }
158 return out
159}
160
161const YELLOW = 0xffd166
162const EMPTY = 0x2a2724
163
164// ' ▁▂▃▄▅▆▇█' by eighths.
165const EIGHTHS = [0x20, 0x2581, 0x2582, 0x2583, 0x2584, 0x2585, 0x2586, 0x2587, 0x2588]
166
167export const FIRE_ROWS = 3
168
169/** Cheap smooth noise: two beating sines, 0..1. */
170const wobble = (x: number, t: number) =>
171 0.5 + 0.25 * Math.sin(t * 9.1 + x * 1.7) + 0.25 * Math.sin(t * 5.3 - x * 2.9 + Math.sin(t * 2 + x))
172
173/** The flame's color at a height 0..1 inside it: white-hot base, yellow, orange, red tips. */
174const flameAt = (y: number, h: number) => {
175 const tip = mix(ORANGE, RED, Math.min(1, 0.3 + h))
176 return y < 0.25 ? mix(WHITE, YELLOW, y * 4) : y < 0.6 ? mix(YELLOW, ORANGE, (y - 0.25) / 0.35) : mix(ORANGE, tip, (y - 0.6) / 0.4)
177}
178
179/**
180 * The fire bar, `columns` wide and FIRE_ROWS tall: a bar along the bottom row
181 * filled to `fraction`, flames dancing on the filled part (tallest at the
182 * leading edge, taller as `h` rises), sparks drifting off the top.
183 */
184export const fireCells = (columns: number, fraction: number, h: number, t: number, rows: number = FIRE_ROWS) => {
185 const words = new Uint32Array(columns * rows * 3)
186 const filled = Math.max(1, Math.round(fraction * columns))
187 const flameRows = rows - 1
188 const put = (x: number, y: number, glyph: number, fg: number) => {
189 const i = (y * columns + x) * 3
190 words[i] = glyph
191 words[i + 1] = fg
192 words[i + 2] = DEFAULT
193 }
194 for (let x = 0; x < columns; x++) {
195 for (let y = 0; y < flameRows; y++) put(x, y, 0x20, DEFAULT)
196 // The bar.
197 if (x < filled) {
198 const along = filled <= 1 ? 1 : x / (filled - 1)
199 const edge = x === filled - 1
200 const fg = edge && Math.floor(t * 14) % 2 === 0 ? WHITE : mix(GOLD, flame(Math.max(h, along * h)), along)
201 put(x, rows - 1, 0x2588, fg)
202 } else {
203 put(x, rows - 1, 0x2591, EMPTY) // ░
204 continue
205 }
206 // The flames over the filled part, in eighths.
207 const lead = 1 - Math.min(1, (filled - 1 - x) / Math.max(4, filled * 0.6))
208 const height = flameRows * 8 * Math.min(1, (0.35 + 0.4 * h + 0.45 * lead) * (0.35 + 0.9 * wobble(x, t)))
209 for (let y = 0; y < flameRows; y++) {
210 const fromBottom = flameRows - 1 - y
211 const eighths = Math.max(0, Math.min(8, Math.round(height - fromBottom * 8)))
212 if (eighths > 0) put(x, y, EIGHTHS[eighths]!, flameAt(fromBottom / flameRows + eighths / (8 * flameRows), h))
213 }
214 // A spark off the top, now and then.
215 if (height < 9 && wobble(x * 3.1, t * 1.7) > 0.86) put(x, 0, Math.floor(t * 10 + x) % 2 ? 0xb7 : 0x2a, mix(YELLOW, ORANGE, h))
216 }
217 return toBase64(new Uint8Array(words.buffer))
218}
219
220/** The same bar as text, for surfaces without a Raster. */
221export const barText = (columns: number, fraction: number) => {
222 const filled = Math.max(1, Math.round(fraction * columns))
223 return '█'.repeat(filled) + '░'.repeat(Math.max(0, columns - filled))
224}
225types/index.d.ts 24 lines1export type Burn = {
2 /** What the odometer shows right now (tweens toward `target`). */
3 shown: number
4 /** The session's real cost, or the demo ramp while a demo runs. */
5 target: number
6 isDemo: boolean
7 lifetime: number
8 /** Subscription windows, 0..100, or -1 with no reading (API key, before the first response). */
9 session: number
10 week: number
11 /** When each window resets, ISO 8601, or '' unknown. */
12 sessionResets: string
13 weekResets: string
14}
15
16/** One model's tokens this session: input counts cached reads and writes too. */
17export type ModelTally = { input: number; output: number; turns: number }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'burn-meter': { burn: Burn; isHidden: boolean; fired: number[]; turns: number[]; turnStart: number; models: Record<string, ModelTally> }
22 }
23}
24