SLOPSHOPPER

burn

Rate limits, usage credits and spend above the prompt, with a /burn pane for the last 7 days

newpanebandguardcommandtoast
v0.7.0MITupdated 2026-10-07PickleBoxer/burn
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · burn
│ ┃ burn ✕ › fix the failing auth test and add an audit log call │ ┃ Context 97k / 200k 49% │ ┃ ⏺ Read(src/auth.ts) │ ┃ Limits ⎿ Read 6 lines │ ┃ ◔ 5h 31% resets in NaNm ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Last 7 days ⏺ Bash(bun test) │ ┃ Fri 10-03 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⎿ 3 pass, 1 fail │ ┃ Sat 10-04 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ┃ Sun 10-05 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Mon 10-06 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ┃ Tue 10-07 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ✻ Worked for 42s · done 4:20 PM │ ┃ Wed 10-08 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ┃ Thu 10-09 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ › /burn │ ┃ ⎿ burn: ◑ ctx 49% (97k/200k) · ◔ 5h 31% (resets NaNm) · session ≈$ │ ┃ session ≈$0.42 · today ≈$0.00 · 7d ≈$0.00 · │ ┃ month ≈$0.00 │ ┃ │ ┃ [ Refresh ] [ Compact ] ≈ is API-equivalen │ ┃ Only credits are b │ Opus 5.5 ◑ 49% ctx 97k/200k ◔ 31% 5h ↻NaNm ≈$0.42 session ≈$0.00 today ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Opus 5.5 ◑ 49% ctx 97k/200k ◔ 31% 5h ↻NaNm ≈$0.42 session ≈$0.00 today ⟨Claude Code's own drawing⟩
Pane · burn
Context 97k / 200k 49% Limits ◔ 5h 31% resets in NaNm Last 7 days Fri 10-03 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ≈$0.00 Sat 10-04 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ≈$0.00 Sun 10-05 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ≈$0.00 Mon 10-06 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ≈$0.00 Tue 10-07 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ≈$0.00 Wed 10-08 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ≈$0.00 Thu 10-09 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ≈$0.00 session ≈$0.42 · today ≈$0.00 · 7d ≈$0.00 · month ≈$0.00 [ Refresh ] [ Compact ] ≈ is API-equivalent cost, recorded billed.
README

burn

A Claude Code mod that shows your context, rate limits, usage credits and spend above the prompt.

In the Desktop app:

burn in the Claude Desktop app

In the terminal:

 Opus 5.5 › Explore   ◔ 31% ctx 62k/200k   ◑ 59% 5h ↻7m   ◕ 81% 7d ↻8h57m   ● €120.06/120   ≈$3.27 session
  • Band above the prompt: the model and any subagents running under it, context window fill, 5 hour and weekly limit usage with a reset countdown, your usage credits for the month, and this session's cost. Skills loaded this session get a row of their own below it, whether you typed them, Claude invoked them or Claude read their SKILL.md, with a subagent's after › Explore:. The Desktop app draws the rings as SVG, the terminal as glyphs.
  • /burn: opens a pane with what fills the context (as /context counts it), each skill with where and when it loaded, the limits, credits, a bar chart of the last 7 days and the totals. Compact (c) compacts the session, Refresh (r) reloads the figures, and /burn refresh does the same from the prompt.
  • Context warning: a toast once the context passes 85%, suggesting /compact.

Install

Requires Claude Code v2.1.287 or later.

/plugin marketplace add PickleBoxer/claude-plugins
/plugin install burn@pickleboxer

Where the numbers come from

  • Context, rate limits and session cost come from Claude Code itself. The context breakdown is estimated locally, so it costs no request. Limits show after the first request, and only on a Pro or Max subscription.
  • Usage credits are the only billed figure. They come from the same endpoint /status reads, through Claude Code's own credential, which the mod never sees. That endpoint is internal, so if it changes the credits group just disappears.
  • Today, 7 day and month (≈) are API-equivalent cost, not your bill. burn records each session's cost per day on this machine, starting when you install it, so the chart fills in over the first week.

Configuration

terminalIcons picks the terminal's icons: pie (default) draws fill glyphs that work in any font, nerd draws Nerd Font icons for context, the clock, the calendar and your currency. Change it in /config, or in ~/.claude/settings.json:

{
  "pluginConfigs": {
    "burn@pickleboxer": { "terminalIcons": "nerd" }
  }
}

Development

claude --plugin-dir .          # loads the mod and reloads it on save
claude plugin validate .claude-plugin/plugin.json
claude plugin test .

License

MIT

Source 3 files
hooks/register.tsx 659 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput, SessionUsage } from 'claude-code'
3
4import type { ContextRow, Credits, Day, Skill, Usage } from '../types'
5import {
6  bar,
7  clockTime,
8  contextLevel,
9  creditsLabel,
10  creditsPercent,
11  currencySymbol,
12  daysAgo,
13  glyph,
14  lastSevenDays,
15  level,
16  limitLabel,
17  localDate,
18  modelName,
19  money,
20  parseCredits,
21  resetsIn,
22  skillFromPath,
23  tokens,
24  totals,
25} from './format'
26
27const PANE = 'burn'
28const REFRESH_MS = 10 * 60 * 1000
29const TICK_MS = 60 * 1000
30const KEEP_DAYS = 40
31const LABEL_COLUMNS = 10
32const PRICE_COLUMNS = 10
33const WARN_PERCENT = 85
34const ROW_COLUMNS = 18
35const TOKEN_COLUMNS = 8
36const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
37
38const usageAtom = atom({ plugin: 'burn', key: 'usage' } as const, null)
39const daysAtom = atom({ plugin: 'burn', key: 'days' } as const, [])
40const creditsAtom = atom({ plugin: 'burn', key: 'credits' } as const, null)
41const recordedAtom = atom({ plugin: 'burn', key: 'recordedUsd' } as const, null)
42const nowAtom = atom({ plugin: 'burn', key: 'now' } as const, 0)
43const breakdownAtom = atom({ plugin: 'burn', key: 'breakdown' } as const, null)
44const hasWarnedAtom = atom({ plugin: 'burn', key: 'hasWarned' } as const, false)
45const agentAtom = atom({ plugin: 'burn', key: 'agent' } as const, null)
46const skillsAtom = atom({ plugin: 'burn', key: 'skills' } as const, [])
47
48const HEX = { green: '#4caf50', yellow: '#d9a520', red: '#e5534b' }
49const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
50
51function toUsage(source: SessionUsage | SessionMeasureInput): Usage {
52  return {
53    limits: source.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
54    sessionUsd: source.cost?.usd ?? null,
55    context: {
56      tokens: source.context.tokens ?? null,
57      window: source.context.window,
58      percent: source.context.percent ?? null,
59    },
60  }
61}
62
63// What each band group measures, drawn as an icon inside Desktop's ring
64type Kind = 'context' | 'five_hour' | 'seven_day' | 'credits'
65
66// Nerd Font md-robot, shown before the model
67const NERD_AGENT = '\u{F06A9}'
68
69// Nerd Font md-flash, shown before the loaded skills
70const NERD_SKILLS = '\u{F0241}'
71
72// Nerd Font glyphs: md-brain, fa-clock, fa-calendar, and the currency signs
73const NERD: Record<Exclude<Kind, 'credits'>, string> = { context: '\u{F09D1}', five_hour: '\uF017', seven_day: '\uF073' }
74const NERD_CURRENCY: Record<string, string> = { EUR: '\uF153', USD: '\uF155', GBP: '\uF154' }
75
76function nerdIcon(kind: Kind | null, currency: string): string | null {
77  if (kind === 'credits') {
78    return NERD_CURRENCY[currency] ?? '\uF0D6'
79  }
80
81  return kind ? NERD[kind] : null
82}
83
84function kindOf(limitKind: string): Kind | null {
85  return limitKind === 'five_hour' || limitKind === 'seven_day' ? limitKind : null
86}
87
88// Icons drawn in the middle of a 24 by 24 ring, in the ring's color
89function innerIcon(kind: Kind | null, hex: string, currency: string): string {
90  const stroke = `fill="none" stroke="${hex}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"`
91
92  switch (kind) {
93    case 'context':
94      return `<path d="M8.5 9h7M8.5 12h7M8.5 15h7" ${stroke}/>`
95    case 'five_hour':
96      return `<circle cx="12" cy="12" r="4.2" ${stroke}/><path d="M12 9.8V12l1.6 1.1" ${stroke}/>`
97    case 'seven_day':
98      return `<rect x="8" y="8.6" width="8" height="7.4" rx="1.4" ${stroke}/><path d="M8 11h8M10.3 7.6v1.8M13.7 7.6v1.8" ${stroke}/>`
99    case 'credits':
100      return `<text x="12" y="15.4" text-anchor="middle" font-family="-apple-system, system-ui, sans-serif" font-size="9.5" font-weight="700" fill="${hex}">${currency}</text>`
101    default:
102      return ''
103  }
104}
105
106function ring(percent: number, color: keyof typeof HEX, kind: Kind | null, currency = '$'): string {
107  const r = 10
108  const length = 2 * Math.PI * r
109  const filled = (Math.min(100, percent) / 100) * length
110
111  return (
112    `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">` +
113    `<circle cx="12" cy="12" r="${r}" fill="none" stroke="#444" stroke-width="2.2"/>` +
114    `<circle cx="12" cy="12" r="${r}" fill="none" stroke="${HEX[color]}" stroke-width="2.2" ` +
115    `stroke-linecap="round" stroke-dasharray="${filled} ${length}" transform="rotate(-90 12 12)"/>` +
116    innerIcon(kind, HEX[color], currency) +
117    `</svg>`
118  )
119}
120
121function weekday(date: string): string {
122  const [y, m, d] = date.split('-').map(Number)
123
124  return WEEKDAYS[new Date(y ?? 0, (m ?? 1) - 1, d ?? 1).getDay()] ?? ''
125}
126
127// Moves the clock the countdowns read, which redraws the band and the pane
128async function tick($: EngineInterface): Promise<void> {
129  const now = await $.clock.now()
130  await update($, nowAtom, () => now)
131}
132
133// Daily totals live in $.store as `day:YYYY-MM-DD`, shared by every session on the machine
134async function loadDays($: EngineInterface): Promise<void> {
135  const oldest = daysAgo(await $.clock.now(), KEEP_DAYS)
136  const days: Day[] = []
137
138  for (const key of await $.store.keys()) {
139    if (!key.startsWith('day:')) {
140      continue
141    }
142
143    const date = key.slice(4)
144
145    if (date < oldest) {
146      await $.store.delete(key)
147    } else {
148      days.push({ date, usd: Number((await $.store.get(key)) ?? 0) })
149    }
150  }
151
152  await update($, daysAtom, () => days)
153}
154
155// Adds what the session spent since the last measurement to today's total
156async function record($: EngineInterface, sessionUsd: number | null): Promise<void> {
157  const recorded = await read($, recordedAtom)
158
159  if (sessionUsd === null) {
160    return
161  }
162
163  // First reading, or /clear started the session's cost over
164  if (recorded === null || sessionUsd < recorded) {
165    await update($, recordedAtom, () => sessionUsd)
166
167    return
168  }
169
170  const delta = sessionUsd - recorded
171
172  if (delta <= 0) {
173    return
174  }
175
176  await update($, recordedAtom, () => sessionUsd)
177  const key = `day:${localDate(await $.clock.now())}`
178  await $.store.set(key, Number((await $.store.get(key)) ?? 0) + delta)
179  await loadDays($)
180}
181
182// Usage credits come from the endpoint /status reads. It's internal, so any failure hides them.
183async function loadCredits($: EngineInterface): Promise<void> {
184  let credits: Credits | null = null
185
186  try {
187    const auth = await $.session.authorize()
188
189    if (auth) {
190      const res = await $.http.fetch(USAGE_URL, { auth: auth.handle, headers: { 'anthropic-beta': 'oauth-2025-04-20' } })
191      credits = res.ok ? parseCredits(res.text) : null
192    }
193  } catch {
194    credits = null
195  }
196
197  await update($, creditsAtom, () => credits)
198}
199
200// The /context rows, estimated locally, so it costs no request
201async function loadBreakdown($: EngineInterface): Promise<void> {
202  const { context } = await $.session.usage({ breakdown: 'summary' })
203  const rows: ContextRow[] = []
204
205  for (const category of context.breakdown?.categories ?? []) {
206    if (category.kind !== 'deferred' && category.tokens > 0) {
207      rows.push({ name: category.name, tokens: category.tokens, kind: category.kind })
208    }
209  }
210
211  await update($, breakdownAtom, () => rows)
212}
213
214// Warns once when the context nearly fills up, and again after it has been compacted
215async function warn($: EngineInterface, percent: number | null): Promise<void> {
216  const hasWarned = await read($, hasWarnedAtom)
217
218  if (percent === null) {
219    return
220  }
221
222  if (percent >= WARN_PERCENT && !hasWarned) {
223    await update($, hasWarnedAtom, () => true)
224    $.ui.toast(`Context is ${percent}% full. Run /compact, or press Compact in /burn.`)
225  } else if (percent < 50 && hasWarned) {
226    await update($, hasWarnedAtom, () => false)
227  }
228}
229
230// The main loop's model and the subagents running under it. No event says when one ends,
231// so measurements and spawns re-read the list.
232async function loadAgent($: EngineInterface): Promise<void> {
233  const model = await $.session.model()
234  const subagents = (await $.agent.list())
235    .filter(agent => agent.status === 'running')
236    .map(agent => agent.type)
237
238  await update($, agentAtom, () => ({ model, subagents: [...new Set(subagents)] }))
239}
240
241// The first load of a skill in each loop is the one kept
242async function addSkills($: EngineInterface, added: Skill[]): Promise<void> {
243  await update($, skillsAtom, skills => [
244    ...skills,
245    ...added.filter(
246      (skill, i) =>
247        !skills.some(known => known.name === skill.name && known.agent === skill.agent) &&
248        added.findIndex(other => other.name === skill.name && other.agent === skill.agent) === i,
249    ),
250  ])
251}
252
253// Skills, legacy .claude/commands and plugin skills (namespaced plugin:name) expand into a
254// prompt. Built-ins, MCP prompts and commands a plugin registers in code (/burn) don't.
255async function isSkillCommand($: EngineInterface, name: string): Promise<boolean> {
256  const command = (await $.command.list()).find(command => command.name === name)
257
258  return command?.source === 'user' || (command?.source === 'plugin' && command.name.includes(':'))
259}
260
261async function agentType($: EngineInterface, agentId: string | undefined): Promise<string | null> {
262  if (agentId === undefined) {
263    return null
264  }
265
266  return (await $.agent.list()).find(agent => agent.id === agentId)?.type ?? 'subagent'
267}
268
269// A resumed session starts with no skills, so read the Skill tool calls and SKILL.md reads it made.
270// Skills typed as /name don't show up as tool calls and are missed here.
271async function loadSkills($: EngineInterface): Promise<void> {
272  const messages = await $.session.messages()
273
274  if (!Array.isArray(messages)) {
275    return
276  }
277
278  const skills = messages
279    .flatMap(message => message.toolUses)
280    .flatMap((use): Skill[] => {
281      if (use.tool === 'Skill' && typeof use.input.skill === 'string') {
282        return [{ name: use.input.skill, agent: null, at: null }]
283      }
284
285      const name = use.tool === 'Read' && typeof use.input.file_path === 'string' ? skillFromPath(use.input.file_path) : null
286
287      return name === null ? [] : [{ name, agent: null, at: null }]
288    })
289
290  await addSkills($, skills)
291}
292
293// The main loop's skills, then each subagent's: `commit  tdd   › Explore: pong`
294function skillsLine(skills: Skill[]): string {
295  const main = skills.filter(skill => skill.agent === null).map(skill => skill.name)
296  const agents = [...new Set(skills.flatMap(skill => (skill.agent === null ? [] : [skill.agent])))].map(
297    agent => `› ${agent}: ${skills.filter(skill => skill.agent === agent).map(skill => skill.name).join('  ')}`,
298  )
299
300  return [main.join('  '), ...agents].filter(Boolean).join('   ')
301}
302
303async function compact($: EngineInterface): Promise<void> {
304  try {
305    const result = await $.session.compact()
306
307    if (result.skip) {
308      $.ui.toast(`Not compacted: ${result.skip}`)
309    }
310  } catch {
311    $.ui.toast("Can't compact while Claude is working")
312  }
313}
314
315async function refresh($: EngineInterface): Promise<void> {
316  await loadDays($)
317  await loadCredits($)
318}
319
320async function summary($: EngineInterface): Promise<string> {
321  const usage = await read($, usageAtom)
322  const credits = await read($, creditsAtom)
323  const now = await $.clock.now()
324  const sum = totals(usage?.sessionUsd ?? null, await read($, daysAtom), now)
325  const limits = (usage?.limits ?? []).map(limit => {
326    const resets = resetsIn(limit.resetsAt, now)
327
328    return `${glyph(limit.percentUsed)} ${limitLabel(limit.kind)} ${limit.percentUsed}%${resets ? ` (resets ${resets})` : ''}`
329  })
330
331  const context = usage?.context.percent != null
332    ? [`${glyph(usage.context.percent)} ctx ${usage.context.percent}% (${tokens(usage.context.tokens ?? 0)}/${tokens(usage.context.window)})`]
333    : []
334
335  return [
336    ...context,
337    ...limits,
338    ...(credits ? [`${glyph(creditsPercent(credits))} ${creditsLabel(credits)} credits`] : []),
339    `session ${money(sum.session)}`,
340    `today ${money(sum.today)}`,
341    `7d ${money(sum.week)}`,
342    `month ${money(sum.month)}`,
343  ].join(' · ')
344}
345
346export const register: Register = (on, options) => {
347  const isNerd = options.terminalIcons === 'nerd'
348
349  on('session.start', async ($, e, next) => {
350    await $.command.register({
351      name: 'burn',
352      description: 'Show rate limits, usage credits and spend for the last 7 days',
353    })
354
355    const usage = toUsage(await $.session.usage())
356    await update($, usageAtom, () => usage)
357    await tick($)
358
359    // A resumed session's earlier cost was recorded by the process that ran it
360    if ((await read($, recordedAtom)) === null) {
361      await update($, recordedAtom, () => usage.sessionUsd ?? 0)
362    }
363
364    await loadDays($)
365    await loadAgent($)
366    await loadSkills($)
367    void loadCredits($)
368    $.clock.every(REFRESH_MS, () => void refresh($))
369    $.clock.every(TICK_MS, () => void tick($))
370
371    return next(e)
372  })
373
374  on('session.measure', async ($, e, next) => {
375    const usage = toUsage(e)
376    await update($, usageAtom, () => usage)
377    await record($, usage.sessionUsd)
378    await warn($, usage.context.percent)
379    await loadAgent($)
380
381    if (e.changed.includes('context')) {
382      await loadBreakdown($)
383    }
384
385    return next(e)
386  })
387
388  on('agent.spawn', async ($, e, next) => {
389    const result = await next(e)
390    await loadAgent($)
391
392    return result
393  })
394
395  // skill.prompt would cover every load, but the built-in security plugin routes it past
396  // the user tier, so typed skills are read from command.run and Claude's from the Skill tool.
397  // A skill loaded by reading its SKILL.md is read from the Read tool; reads through Bash stay
398  // invisible. Preloads into a subagent's frontmatter show in none of these and are missed.
399  on('command.run', async ($, e, next) => {
400    const result = await next(e)
401
402    if (await isSkillCommand($, e.command)) {
403      await addSkills($, [{ name: e.command, agent: null, at: await $.clock.now() }])
404    }
405
406    return result
407  })
408
409  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
410    const result = await next(e)
411
412    if (!result.deny && !result.isError && typeof e.skill === 'string') {
413      await addSkills($, [{ name: e.skill, agent: await agentType($, e.agentId), at: await $.clock.now() }])
414    }
415
416    return result
417  })
418
419  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
420    const result = await next(e)
421    const name = typeof e.file_path === 'string' ? skillFromPath(e.file_path) : null
422
423    if (!result.deny && !result.isError && name !== null) {
424      await addSkills($, [{ name, agent: await agentType($, e.agentId), at: await $.clock.now() }])
425    }
426
427    return result
428  })
429
430  on('command.run', { command: 'burn' }, async ($, e) => {
431    if (e.args.trim() === 'refresh') {
432      await refresh($)
433
434      return { text: await summary($) }
435    }
436
437    await loadBreakdown($)
438    await $.ui.open({ id: PANE, title: 'burn', closeOnEscape: true })
439
440    // Shown where nothing draws, such as the VS Code panel or claude -p
441    return { text: await summary($) }
442  })
443
444  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
445    const { Box, Button, Text } = $.ui.resolve(e)
446    const usage = await read($, usageAtom)
447    const credits = await read($, creditsAtom)
448    const stored = await read($, daysAtom)
449    const now = (await read($, nowAtom)) || (await $.clock.now())
450    const sum = totals(usage?.sessionUsd ?? null, stored, now)
451    const today = localDate(now)
452    const days = lastSevenDays(stored, now)
453    const max = Math.max(...days.map(day => day.usd), 0)
454    const barWidth = Math.max(8, Math.min(40, e.props.bodyColumns - LABEL_COLUMNS - PRICE_COLUMNS - 2))
455    const since = stored.map(day => day.date).sort()[0]
456    const context = usage?.context
457    const rows = (await read($, breakdownAtom)) ?? []
458    const rowBarWidth = Math.max(8, Math.min(40, e.props.bodyColumns - ROW_COLUMNS - TOKEN_COLUMNS - 2))
459    const skills = await read($, skillsAtom)
460
461    return (
462      <Box flexDirection="column" gap={1}>
463        {context && (
464          <Box flexDirection="column">
465            <Text bold>
466              Context{' '}
467              <Text dimColor>
468                {context.tokens === null ? 'no reading yet' : `${tokens(context.tokens)} / ${tokens(context.window)}`}
469              </Text>
470              {context.percent !== null && <Text color={contextLevel(context.percent)}> {context.percent}%</Text>}
471            </Text>
472            {rows.map(row => (
473              <Box key={row.name} flexDirection="row" gap={1}>
474                <Box width={ROW_COLUMNS} flexShrink={0}>
475                  <Text dimColor={row.kind !== 'used'} wrap="truncate">
476                    {row.name}
477                  </Text>
478                </Box>
479                <Box flexGrow={1} flexShrink={1} overflow="hidden">
480                  <Text color={row.kind === 'used' ? 'cyan' : undefined} dimColor={row.kind !== 'used'} wrap="truncate">
481                    {bar(row.tokens, context.window, rowBarWidth)}
482                  </Text>
483                </Box>
484                <Box width={TOKEN_COLUMNS} flexShrink={0} justifyContent="flex-end">
485                  <Text dimColor={row.kind !== 'used'} wrap="truncate">
486                    {tokens(row.tokens)}
487                  </Text>
488                </Box>
489              </Box>
490            ))}
491          </Box>
492        )}
493
494        {skills.length > 0 && (
495          <Box flexDirection="column">
496            <Text bold>Skills</Text>
497            {skills.map(skill => (
498              <Box key={`skill-${skill.agent ?? 'main'}-${skill.name}`} flexDirection="row" gap={1}>
499                <Box width={ROW_COLUMNS} flexShrink={0}>
500                  <Text wrap="truncate">{skill.name}</Text>
501                </Box>
502                <Box width={ROW_COLUMNS} flexShrink={0}>
503                  <Text dimColor wrap="truncate">
504                    {skill.agent ?? 'main'}
505                  </Text>
506                </Box>
507                <Text dimColor>{skill.at === null ? 'resumed' : clockTime(skill.at)}</Text>
508              </Box>
509            ))}
510          </Box>
511        )}
512
513        <Box flexDirection="column">
514          <Text bold>Limits</Text>
515          {(usage?.limits ?? []).length === 0 && <Text dimColor>No reading yet (needs a subscription and one request)</Text>}
516          {(usage?.limits ?? []).map(limit => {
517            const resets = resetsIn(limit.resetsAt, now)
518
519            return (
520              <Text key={limit.kind}>
521                <Text color={level(limit.percentUsed)}>{glyph(limit.percentUsed)}</Text> {limitLabel(limit.kind).padEnd(8)}
522                <Text bold>{String(limit.percentUsed).padStart(5)}%</Text>
523                <Text dimColor>{resets ? `  resets in ${resets}` : ''}</Text>
524              </Text>
525            )
526          })}
527          {credits && (
528            <Text key="credits">
529              <Text color={level(creditsPercent(credits))}>{glyph(creditsPercent(credits))}</Text> {'credits'.padEnd(8)}
530              <Text bold>{String(creditsPercent(credits)).padStart(5)}%</Text>
531              <Text dimColor>  {creditsLabel(credits)} this month</Text>
532            </Text>
533          )}
534        </Box>
535
536        <Box flexDirection="column">
537          <Text bold>Last 7 days</Text>
538          {/* Fixed label and price columns, so the bars line up whatever the amounts are */}
539          {days.map(day => (
540            <Box key={day.date} flexDirection="row" gap={1}>
541              <Box width={LABEL_COLUMNS} flexShrink={0}>
542                <Text bold={day.date === today} wrap="truncate">
543                  {weekday(day.date)} {day.date.slice(5)}
544                </Text>
545              </Box>
546              <Box key={`day-${day.date}`} flexGrow={1} flexShrink={1} overflow="hidden">
547                <Text color="green" wrap="truncate">
548                  {bar(day.usd, max, barWidth)}
549                </Text>
550              </Box>
551              <Box width={PRICE_COLUMNS} flexShrink={0} justifyContent="flex-end">
552                <Text bold={day.date === today} wrap="truncate">
553                  {money(day.usd)}
554                </Text>
555              </Box>
556            </Box>
557          ))}
558        </Box>
559
560        <Text>
561          session <Text bold>{money(sum.session)}</Text> · today <Text bold>{money(sum.today)}</Text> · 7d{' '}
562          <Text bold>{money(sum.week)}</Text> · month <Text bold>{money(sum.month)}</Text>
563        </Text>
564
565        <Box flexDirection="row" gap={2}>
566          <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
567          <Button key="compact" label="Compact" hotkey="c" onPress={() => void compact($)} />
568          <Text dimColor>
569            ≈ is API-equivalent cost, recorded by burn{since ? ` since ${since}` : ''}. Only credits are billed.
570          </Text>
571        </Box>
572      </Box>
573    )
574  })
575
576  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
577    const usage = await read($, usageAtom)
578    const credits = await read($, creditsAtom)
579    const now = (await read($, nowAtom)) || (await $.clock.now())
580
581    if (e.props.hasSurvey || !usage) {
582      return next(e)
583    }
584
585    const agent = await read($, agentAtom)
586    const skills = await read($, skillsAtom)
587    const elements = $.ui.resolve(e)
588    const { Box, Text } = elements
589    const isCompact = e.props.bodyColumns < 100
590    const sum = totals(usage.sessionUsd, await read($, daysAtom), now)
591
592    // Svg draws only on Desktop, so the terminal gets a pie glyph instead
593    const icon = (percent: number, kind: Kind | null, color = level(percent)) =>
594      e.surface === 'desktop' && 'Svg' in elements ? (
595        <elements.Svg source={ring(percent, color, kind, currencySymbol(credits?.currency ?? 'USD'))} alt={`${percent}%`} width={22} height={22} />
596      ) : (
597        <Text color={color}>{(isNerd ? nerdIcon(kind, credits?.currency ?? 'USD') : null) ?? glyph(percent)}</Text>
598      )
599    const context = usage.context
600    const below = await next(e)
601
602    return (
603      <Box flexDirection="column">
604        <Box flexDirection="row" gap={3} paddingLeft={1}>
605          {agent && (
606            <Box key="agent" flexDirection="row" gap={1}>
607              {isNerd && e.surface !== 'desktop' && <Text color="magenta">{NERD_AGENT}</Text>}
608              <Text bold>{modelName(agent.model, isCompact)}</Text>
609              {agent.subagents.length > 0 && <Text dimColor>› {agent.subagents.join(', ')}</Text>}
610            </Box>
611          )}
612          {context.percent !== null && (
613            <Box key="context" flexDirection="row" gap={1}>
614              {icon(context.percent, 'context', contextLevel(context.percent))}
615              <Text bold>{context.percent}%</Text>
616              <Text dimColor>
617                ctx{!isCompact && context.tokens !== null ? ` ${tokens(context.tokens)}/${tokens(context.window)}` : ''}
618              </Text>
619            </Box>
620          )}
621          {usage.limits.map(limit => {
622            const resets = resetsIn(limit.resetsAt, now)
623
624            return (
625              <Box key={limit.kind} flexDirection="row" gap={1}>
626                {icon(limit.percentUsed, kindOf(limit.kind))}
627                <Text bold>{limit.percentUsed}%</Text>
628                <Text dimColor>
629                  {limitLabel(limit.kind)}
630                  {resets && !isCompact ? ` ↻${resets}` : ''}
631                </Text>
632              </Box>
633            )
634          })}
635          {credits && (
636            <Box key="credits" flexDirection="row" gap={1}>
637              {icon(creditsPercent(credits), 'credits')}
638              <Text bold>{creditsLabel(credits, true)}</Text>
639            </Box>
640          )}
641          <Box key="spend" flexDirection="row" gap={1}>
642            <Text dimColor>{money(sum.session)} session</Text>
643            {!credits && !isCompact && <Text dimColor>{money(sum.today)} today</Text>}
644          </Box>
645        </Box>
646        {skills.length > 0 && (
647          <Box key="skills" flexDirection="row" gap={1} paddingLeft={1}>
648            {isNerd && e.surface !== 'desktop' ? <Text color="yellow">{NERD_SKILLS}</Text> : <Text dimColor>skills</Text>}
649            <Text dimColor wrap="truncate">
650              {skillsLine(skills)}
651            </Text>
652          </Box>
653        )}
654        {below}
655      </Box>
656    )
657  })
658}
659
hooks/format.ts 164 lines
1import type { Credits, Day, Skill } from '../types'
2
3const DAY_MS = 24 * 60 * 60 * 1000
4
5export function localDate(ms: number): string {
6  const d = new Date(ms)
7  const pad = (n: number) => String(n).padStart(2, '0')
8
9  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
10}
11
12export function daysAgo(now: number, days: number): string {
13  return localDate(now - days * DAY_MS)
14}
15
16// The `extra_usage` block of /api/oauth/usage, or null when it's off or its shape changed
17export function parseCredits(body: string): Credits | null {
18  const extra = (JSON.parse(body) as { extra_usage?: Record<string, unknown> | null }).extra_usage
19
20  if (!extra?.is_enabled || typeof extra.used_credits !== 'number' || typeof extra.monthly_limit !== 'number') {
21    return null
22  }
23
24  return {
25    used: extra.used_credits,
26    limit: extra.monthly_limit,
27    currency: String(extra.currency ?? 'USD'),
28    decimals: Number(extra.decimal_places ?? 2),
29  }
30}
31
32export function lastSevenDays(days: Day[], now: number): Day[] {
33  const byDate = new Map(days.map(day => [day.date, day.usd]))
34
35  return Array.from({ length: 7 }, (_, i) => {
36    const date = daysAgo(now, 6 - i)
37
38    return { date, usd: byDate.get(date) ?? 0 }
39  })
40}
41
42export type Totals = { session: number | null; today: number; week: number; month: number }
43
44export function totals(sessionUsd: number | null, days: Day[], now: number): Totals {
45  const today = localDate(now)
46  const sum = (list: Day[]) => list.reduce((total, day) => total + day.usd, 0)
47
48  return {
49    session: sessionUsd,
50    today: sum(days.filter(day => day.date === today)),
51    week: sum(lastSevenDays(days, now)),
52    month: sum(days.filter(day => day.date.startsWith(today.slice(0, 7)))),
53  }
54}
55
56export function money(usd: number | null): string {
57  return usd === null ? '–' : `≈$${usd.toFixed(2)}`
58}
59
60const SYMBOLS: Record<string, string> = { EUR: '€', USD: '$', GBP: '£' }
61
62// `€120.06 / €120`, or `€120.06/120` where room is short
63export function currencySymbol(currency: string): string {
64  return SYMBOLS[currency] ?? currency
65}
66
67export function creditsLabel(credits: Credits, isShort = false): string {
68  const symbol = SYMBOLS[credits.currency] ?? `${credits.currency} `
69  const amount = (minor: number, digits: number) => `${symbol}${(minor / 10 ** credits.decimals).toFixed(digits)}`
70  const isWhole = credits.limit % 10 ** credits.decimals === 0
71  const limit = amount(credits.limit, isWhole ? 0 : credits.decimals)
72
73  return isShort
74    ? `${amount(credits.used, credits.decimals)}/${limit.slice(symbol.length)}`
75    : `${amount(credits.used, credits.decimals)} / ${limit}`
76}
77
78export function creditsPercent(credits: Credits): number {
79  return credits.limit > 0 ? Math.round((credits.used / credits.limit) * 100) : 0
80}
81
82export function resetsIn(resetsAt: string | undefined, now: number): string | null {
83  if (!resetsAt) {
84    return null
85  }
86
87  const minutes = Math.max(0, Math.round((Date.parse(resetsAt) - now) / 60000))
88  const days = Math.floor(minutes / 1440)
89  const hours = Math.floor((minutes % 1440) / 60)
90  const rest = minutes % 60
91
92  if (days > 0) {
93    return `${days}d${hours}h`
94  }
95
96  return hours > 0 ? `${hours}h${rest}m` : `${rest}m`
97}
98
99export function limitLabel(kind: string): string {
100  return { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }[kind] ?? kind
101}
102
103export function level(percent: number): 'green' | 'yellow' | 'red' {
104  if (percent >= 80) {
105    return 'red'
106  }
107
108  return percent >= 50 ? 'yellow' : 'green'
109}
110
111// Context gets stricter thresholds, as answers get worse well before the window is full
112export function contextLevel(percent: number): 'green' | 'yellow' | 'red' {
113  if (percent >= 80) {
114    return 'red'
115  }
116
117  return percent >= 60 ? 'yellow' : 'green'
118}
119
120export function tokens(count: number): string {
121  if (count >= 1_000_000) {
122    return `${Number((count / 1_000_000).toFixed(1))}M`
123  }
124
125  return count >= 1000 ? `${Math.round(count / 1000)}k` : String(count)
126}
127
128export function glyph(percent: number): string {
129  return ['○', '◔', '◑', '◕', '●'][Math.min(4, Math.round(percent / 25))] ?? '○'
130}
131
132export function bar(usd: number, max: number, width: number): string {
133  const filled = max > 0 ? Math.round((usd / max) * width) : 0
134
135  return '█'.repeat(filled) + '░'.repeat(width - filled)
136}
137
138// `claude-opus-5-5[1m]` reads as `Opus 5.5`, or `Opus` when space is short. Anything else shows as given.
139export function modelName(model: string, isShort = false): string {
140  const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?/.exec(model)
141
142  const [, name, major, minor] = match ?? []
143
144  if (!name) {
145    return model
146  }
147
148  const family = name.charAt(0).toUpperCase() + name.slice(1)
149
150  return isShort ? family : `${family} ${major}${minor ? `.${minor}` : ''}`
151}
152
153// `14:05` in local time
154export function clockTime(ms: number): string {
155  const d = new Date(ms)
156
157  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
158}
159
160// The skill a path loads when Claude reads it: skills/<name>/SKILL.md, on either separator
161export function skillFromPath(path: string): string | null {
162  return path.match(/[\\/]skills[\\/]([^\\/]+)[\\/]SKILL\.md$/)?.[1] ?? null
163}
164
types/index.d.ts 40 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Context = { tokens: number | null; window: number; percent: number | null }
4
5export type Usage = { limits: Limit[]; sessionUsd: number | null; context: Context }
6
7// One row of the /context breakdown
8export type ContextRow = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' }
9
10// The main loop's model and the types of the subagents running now
11export type Agent = { model: string; subagents: string[] }
12
13// A skill loaded this session. agent is the subagent type that loaded it, null for the main
14// loop. at is null when it came from a resumed transcript.
15export type Skill = { name: string; agent: string | null; at: number | null }
16
17export type Day = { date: string; usd: number }
18
19// Usage credits, in minor units such as cents
20export type Credits = { used: number; limit: number; currency: string; decimals: number }
21
22declare module 'claude-code' {
23  interface PluginState {
24    burn: {
25      usage: Usage | null
26      days: Day[]
27      credits: Credits | null
28      // Session cost already added to the daily totals
29      recordedUsd: number | null
30      now: number
31      breakdown: ContextRow[] | null
32      // Whether this session already warned that the context is nearly full
33      hasWarned: boolean
34      agent: Agent | null
35      // The skills loaded this session, in the order they loaded
36      skills: Skill[]
37    }
38  }
39}
40