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

A Claude Code mod that shows your context, rate limits, usage credits and spend above the prompt.
In the 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
› 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./compact.Requires Claude Code v2.1.287 or later.
/plugin marketplace add PickleBoxer/claude-plugins
/plugin install burn@pickleboxer
/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.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" }
}
}
claude --plugin-dir . # loads the mod and reloads it on save
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
MIT
hooks/register.tsx 659 lines1import { 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}
659hooks/format.ts 164 lines1import 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}
164types/index.d.ts 40 lines1export 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