A fuel gauge above the prompt: context window fill, tokens in and out, dollars spent, dollars per hour, your plan's rate-limit window, and a sparkline of the…

A fuel gauge above the prompt.
▮▮▮▮▮▮▯▯▯▯▯▯ 49% 97k/200k $0.42 $0.84/h 5h 31% ⇅ 97k/1.5k ▁▃▂█ ×
| Figure | Where it comes from |
|---|---|
| bar and percent | $.session.usage().context: the same numbers as the status line's used_percentage |
| tokens | input tokens of the last response over the model's context window |
$0.42 | the session's cost ledger, when the host keeps one |
$0.84/h | cost divided by hours since the session started |
5h 31% | the tightest rate-limit window your plan reports |
⇅ 97k/1.5k | the last turn's input and output tokens |
| sparkline | input tokens of the last 16 turns |
The bar turns yellow at 50%, magenta at 75%, red at 90%, and once the window passes warnAt (default 80) a toast suggests compacting.
/burn prints the same figures as text, which also works in claude -p.
| Hook | What it does |
|---|---|
session.start | Registers /burn and takes a first reading. |
turn.complete | Records the turn's token counts and takes a reading. Subagent turns are skipped. |
session.measure | Takes a reading when the host reports new figures. |
ui.render on AbovePrompt | Draws the gauge. Hides itself while a survey is up, and after you press ×. |
The band is one line; on a terminal narrower than 70 columns it drops the rate, the plan window and the sparkline.
claude plugin marketplace add ryx2/slopshopper
claude plugin install burn-rate@slopshopper
Tested with Claude Code 2.1.289. claude plugin validate and claude plugin test pass. The mod calls no model and sends nothing anywhere.
hooks/register.tsx 159 lines1// burn-rate: a fuel gauge above the prompt.
2//
3// session.start, turn.complete, session.measure: take a reading of the
4// context window, the cost and the rate limits from $.session.usage(),
5// the same figures the status line shows. turn.complete also records the
6// turn's token counts for the sparkline.
7// ui.render (AbovePrompt): one line: a bar for the window, the percent and
8// tokens, dollars and dollars per hour, the plan's window, the last turns.
9// /burn prints the same figures as text, for a -p run or a narrow terminal.
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14import type { Reading, TurnStat } from '../types'
15
16const HISTORY = 16
17const BARS = '▁▂▃▄▅▆▇█'
18
19const reading = atom({ plugin: 'burn-rate', key: 'reading' } as const, null)
20const turns = atom({ plugin: 'burn-rate', key: 'turns' } as const, [])
21const isHidden = atom({ plugin: 'burn-rate', key: 'isHidden' } as const, false)
22const hasWarned = atom({ plugin: 'burn-rate', key: 'hasWarned' } as const, false)
23
24export const register: Register = (on, options) => {
25 const warnAt = typeof options.warnAt === 'number' ? options.warnAt : 80
26
27 on('session.start', async ($, e, next) => {
28 const result = await next(e)
29 try {
30 await $.command.register({ name: 'burn', description: 'Context, tokens, cost and burn rate for this session' })
31 } catch {
32 // the name is taken; the band still draws
33 }
34 await takeReading($, warnAt)
35 return result
36 })
37
38 on('turn.complete', async ($, e, next) => {
39 const result = await next(e)
40 if (e.agentId !== undefined) return result
41 const usage = e.usage
42 if (usage) {
43 const stat: TurnStat = { input: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens, output: usage.output_tokens, durationMs: e.durationMs }
44 await update($, turns, list => [...list, stat].slice(-HISTORY))
45 }
46 await takeReading($, warnAt)
47 return result
48 })
49
50 on('session.measure', async ($, e, next) => {
51 await takeReading($, warnAt)
52 return next(e)
53 })
54
55 on('command.run', { command: 'burn' }, async $ => {
56 const r = await read($, reading)
57 const list = await read($, turns)
58 if (r === null) return { text: 'burn-rate: no reading yet; send a prompt first.' }
59 return { text: summary(r, list) }
60 })
61
62 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
63 const r = await read($, reading)
64 if (e.props.hasSurvey || r === null || (await read($, isHidden))) return next(e)
65 const list = await read($, turns)
66 const { Box, Text, Button } = $.ui.resolve(e)
67 const color = colorFor(r.percent)
68 const wide = e.props.bodyColumns >= 110
69 const narrow = e.props.bodyColumns < 70
70 return (
71 <Box flexDirection="row" columnGap={2} paddingX={1}>
72 <Text color={color}>{bar(r.percent, narrow ? 8 : 12)}</Text>
73 <Text color={color} bold>
74 {r.percent}%
75 </Text>
76 <Text dimColor>
77 {short(r.tokens)}/{short(r.window)}
78 </Text>
79 {r.usd !== null && <Text>${r.usd.toFixed(2)}</Text>}
80 {r.usdPerHour !== null && !narrow && <Text dimColor>${r.usdPerHour.toFixed(2)}/h</Text>}
81 {r.limit !== null && !narrow && (
82 <Text color={r.limit.percentUsed >= 90 ? 'red' : r.limit.percentUsed >= 70 ? 'yellow' : undefined} dimColor={r.limit.percentUsed < 70}>
83 {limitLabel(r.limit.kind)} {r.limit.percentUsed}%
84 </Text>
85 )}
86 {list.length > 0 && !narrow && (
87 <Text dimColor>
88 ⇅ {short(list[list.length - 1]!.input)}/{short(list[list.length - 1]!.output)}
89 </Text>
90 )}
91 {list.length > 1 && wide && <Text color={color}>{sparkline(list)}</Text>}
92 {r.percent >= warnAt && <Text color="red">compaction soon</Text>}
93 <Button key="hide" label="×" plain dimColor onPress={() => update($, isHidden, () => true)} />
94 </Box>
95 )
96 })
97}
98
99async function takeReading($: EngineInterface, warnAt: number): Promise<void> {
100 try {
101 const usage = await $.session.usage()
102 if (!usage.context || !usage.context.window) return
103 const tokens = usage.context.tokens ?? 0
104 const percent = Math.round(usage.context.percent ?? (tokens / usage.context.window) * 100)
105 const at = await $.clock.now()
106 const hours = Math.max(1 / 60, (at - usage.startedAt) / 3_600_000)
107 const usd = usage.cost ? usage.cost.usd : null
108 const tightest = usage.rateLimits.reduce<{ kind: string; percentUsed: number } | null>((best, w) => (best === null || w.percentUsed > best.percentUsed ? { kind: w.kind, percentUsed: Math.round(w.percentUsed) } : best), null)
109 const next: Reading = { tokens, window: usage.context.window, percent, usd, usdPerHour: usd === null ? null : usd / hours, limit: tightest, at }
110 await update($, reading, () => next)
111 if (percent >= warnAt && !(await read($, hasWarned))) {
112 await update($, hasWarned, () => true)
113 $.ui.toast(`Context is ${percent}% full. /compact soon, or start a fresh session for unrelated work.`)
114 }
115 } catch {
116 // no reading this time; the band keeps the last one
117 }
118}
119
120function colorFor(percent: number): string {
121 if (percent >= 90) return 'red'
122 if (percent >= 75) return 'magenta'
123 if (percent >= 50) return 'yellow'
124 return 'green'
125}
126
127function bar(percent: number, cells: number): string {
128 const full = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells)
129 return '▮'.repeat(full) + '▯'.repeat(cells - full)
130}
131
132function sparkline(list: readonly TurnStat[]): string {
133 const top = Math.max(1, ...list.map(t => t.input))
134 return list.map(t => BARS[Math.min(BARS.length - 1, Math.floor((t.input / top) * (BARS.length - 1)))]).join('')
135}
136
137function limitLabel(kind: string): string {
138 return kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind.replace(/_/g, ' ')
139}
140
141function short(n: number): string {
142 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
143 if (n >= 10_000) return `${Math.round(n / 1000)}k`
144 if (n >= 1_000) return `${(n / 1000).toFixed(1)}k`
145 return String(n)
146}
147
148function summary(r: Reading, list: readonly TurnStat[]): string {
149 const lines = [`context ${r.percent}% (${short(r.tokens)} of ${short(r.window)})`]
150 if (r.usd !== null) lines.push(`cost $${r.usd.toFixed(2)}${r.usdPerHour !== null ? ` · $${r.usdPerHour.toFixed(2)}/h` : ''}`)
151 if (r.limit !== null) lines.push(`${limitLabel(r.limit.kind)} window ${r.limit.percentUsed}% used`)
152 if (list.length) {
153 const totalIn = list.reduce((n, t) => n + t.input, 0)
154 const totalOut = list.reduce((n, t) => n + t.output, 0)
155 lines.push(`${list.length} turn${list.length === 1 ? '' : 's'} recorded · ${short(totalIn)} in, ${short(totalOut)} out · last turns ${sparkline(list)}`)
156 }
157 return lines.join('\n')
158}
159tests/register.test.ts 83 lines1import { describe, expect, mock, test } from 'claude-code/testing'
2
3const BAND = {
4 plugin: 'burn-rate',
5 component: 'AbovePrompt',
6 requestId: 'band',
7 viewport: { columns: 140, rows: 40 },
8 props: { hasSurvey: false, isWorking: false, maxRows: 8, bodyColumns: 120, scroll: { offset: 0, bodyRows: 8 }, view: {} },
9} as const
10
11const usage = (percent: number, usd = 0.42) => ({
12 value: {
13 startedAt: 0,
14 context: { tokens: Math.round((percent / 100) * 200_000), window: 200_000, percent },
15 rateLimits: [{ kind: 'five_hour', percentUsed: 31, resetsAt: 10_000_000 }],
16 cost: { usd },
17 },
18})
19
20describe('register', () => {
21 test('draws the gauge after a turn and /burn prints the figures', async ($, on) => {
22 const clock = mock.clock(on, { now: 1_800_000 })
23 on('session.usage', () => usage(49))
24 on('turn.complete', () => ({ text: '' }))
25 on('ui.render', () => ({ type: 'Text', props: {}, children: ['engine'] }))
26
27 await $.turn.complete({
28 turnId: 't1',
29 answer: 'done',
30 durationMs: 4000,
31 isAborted: false,
32 usage: { input_tokens: 2000, output_tokens: 500, cache_read_input_tokens: 90_000, cache_creation_input_tokens: 4000, model: 'claude-test' },
33 })
34 void clock
35
36 for (const surface of ['terminal', 'desktop'] as const) {
37 const ui = await $.ui.mount({ ...BAND, surface })
38 expect(await ui.find({ type: 'Text', text: '49%' })).toBeDefined()
39 expect(await ui.find({ type: 'Text', text: /\$0\.42/ })).toBeDefined()
40 expect(await ui.find({ type: 'Text', text: /5h 31%/ })).toBeDefined()
41 await ui.unmount()
42 }
43
44 const answer = await $.command.run({ command: 'burn', args: '' })
45 expect(answer.text).toContain('context 49%')
46 expect(answer.text).toContain('cost $0.42')
47 expect(answer.text).toContain('1 turn recorded')
48 })
49
50 test('warns once when the window passes the threshold', async ($, on) => {
51 mock.clock(on, { now: 1_800_000 })
52 let percent = 60
53 on('session.usage', () => usage(percent))
54 on('turn.complete', () => ({ text: '' }))
55 const toasts: string[] = []
56 on('ui.toast', ($, e) => {
57 toasts.push(e.text)
58 return { value: undefined }
59 })
60 const turn = () => $.turn.complete({ turnId: 't', answer: '', durationMs: 1, isAborted: false, usage: null })
61 await turn()
62 expect(toasts).toEqual([])
63 percent = 85
64 await turn()
65 await turn()
66 expect(toasts.length).toBe(1)
67 expect(toasts[0]).toContain('85%')
68 })
69
70 test('the × button hides the band', async ($, on) => {
71 mock.clock(on, { now: 1_800_000 })
72 on('session.usage', () => usage(20))
73 on('turn.complete', () => ({ text: '' }))
74 on('ui.render', () => ({ type: 'Text', props: {}, children: ['engine'] }))
75 await $.turn.complete({ turnId: 't', answer: '', durationMs: 1, isAborted: false, usage: null })
76 const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
77 expect(await ui.find({ type: 'Text', text: '20%' })).toBeDefined()
78 await ui.press({ key: 'hide' })
79 expect(await ui.find({ type: 'Text', text: '20%' })).toBeUndefined()
80 await ui.unmount()
81 })
82})
83types/index.d.ts 35 lines1export type Reading = {
2 /** Input tokens the last response was answered over. */
3 tokens: number
4 /** The model's context window, in tokens. */
5 window: number
6 /** tokens over window, 0 to 100. */
7 percent: number
8 /** Dollars spent this session, when the host keeps a ledger. */
9 usd: number | null
10 /** Dollars per hour since the session started. */
11 usdPerHour: number | null
12 /** The tightest rate-limit window, when the account has one. */
13 limit: { kind: string; percentUsed: number } | null
14 /** When the reading was taken, in clock milliseconds. */
15 at: number
16}
17
18export type TurnStat = {
19 /** Uncached + cached input tokens of the turn's requests. */
20 input: number
21 output: number
22 durationMs: number
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'burn-rate': {
28 reading: Reading | null
29 turns: TurnStat[]
30 isHidden: boolean
31 hasWarned: boolean
32 }
33 }
34}
35README.md 40 lines1# burn-rate
2
3A fuel gauge above the prompt.
4
5```text
6 ▮▮▮▮▮▮▯▯▯▯▯▯ 49% 97k/200k $0.42 $0.84/h 5h 31% ⇅ 97k/1.5k ▁▃▂█ ×
7```
8
9| Figure | Where it comes from |
10| --- | --- |
11| bar and percent | `$.session.usage().context`: the same numbers as the status line's `used_percentage` |
12| tokens | input tokens of the last response over the model's context window |
13| `$0.42` | the session's cost ledger, when the host keeps one |
14| `$0.84/h` | cost divided by hours since the session started |
15| `5h 31%` | the tightest rate-limit window your plan reports |
16| `⇅ 97k/1.5k` | the last turn's input and output tokens |
17| sparkline | input tokens of the last 16 turns |
18
19The bar turns yellow at 50%, magenta at 75%, red at 90%, and once the window passes `warnAt` (default 80) a toast suggests compacting.
20
21`/burn` prints the same figures as text, which also works in `claude -p`.
22
23| Hook | What it does |
24| --- | --- |
25| `session.start` | Registers `/burn` and takes a first reading. |
26| `turn.complete` | Records the turn's token counts and takes a reading. Subagent turns are skipped. |
27| `session.measure` | Takes a reading when the host reports new figures. |
28| `ui.render` on `AbovePrompt` | Draws the gauge. Hides itself while a survey is up, and after you press `×`. |
29
30The band is one line; on a terminal narrower than 70 columns it drops the rate, the plan window and the sparkline.
31
32## Install
33
34```bash
35claude plugin marketplace add ryx2/slopshopper
36claude plugin install burn-rate@slopshopper
37```
38
39Tested with Claude Code 2.1.289. `claude plugin validate` and `claude plugin test` pass. The mod calls no model and sends nothing anywhere.
40