SLOPSHOPPER

Burn Meter

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

newpanebandcommandtoastprompt
★ 2v0.1.0MITupdated 2026-10-02OneWave-AI/claude-code-mods/burn-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · burn-meter
│ ┃ Burn Meter ✕ › fix the failing auth test and add an audit log call │ ┃ SESSION BURN lifetime $0.42 │ ┃ $0.00 = 0.00 McDoubles ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ▅ ⎿ Added 2 lines, removed 1 line │ ┃ ▂ ▅ ▂ █▂▆▃ ⏺ Bash(bun test) │ ┃ █ █ █▅█ ████ ⎿ 3 pass, 1 fail │ ┃ ▃ █▆ █▁▄███▇████ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Limits › /burn │ ┃ 5 hour █████████░░░░░░░░░░░░░░░░░░░░░ 31% ⎿ burn-meter: This session: $0.42 = 0.04 burritos. 5h 31%, week -- │ ┃ ⎿ burn-meter: Models: opus 5.5 97k in 1k out │ ┃ Per turn │ ┃ # 1 █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ┃ $0.00 │ ┃ │ ┃ That is │ ┃ = 0.00 burritos │ ┃ = 0.00 McDoubles │ BURN ▁· ▂ *▂ ▂ ▄▃▄▁ $0.00 = 0.00 McDoubles 5h 31% ▅▄█▆▅▄█▅▆███▆████ opus 5.5 97k in 1k out wk -- █████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ lifetime $0.42 hide ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
BURN ▁· ▂ *▂ ▂ ▄▃▄▁ $0.00 = 0.00 McDoubles 5h 31% ▅▄█▆▅▄█▅▆███▆████ opus 5.5 97k in 1k out wk -- █████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ lifetime $0.42 hide
Pane · Burn Meter
SESSION BURN lifetime $0.42 $0.00 = 0.00 McDoubles ▅ ▂ ▅ ▂ █▂▆▃ █ █ █▅█ ████ ▃ █▆ █▁▄███▇████ Limits 5 hour █████████░░░░░░░░░░░░░░░░░░░░░ 31% Per turn # 1 █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ $0.00 That is = 0.00 burritos = 0.00 McDoubles
README

burn-meter

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

burn-meter

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.

What the hooks do

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

What it reaches

ModNetworkRuns processesFilesCalls a modelSends data anywhere
burn-meterNoNoNoNoNo

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.

Source 3 files
hooks/register.tsx 378 lines
1import { 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}
378
hooks/burn.ts 225 lines
1/** 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}
225
types/index.d.ts 24 lines
1export 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