A band above the prompt: context window fill, plan usage limits and session cost, a Recap button, and a per-day cost record read with /spend.

A Claude Code mod that puts your usage above the prompt box, so you stop opening the context popover to check it.

A drawing of the band with sample values, made from the mod's own drawing code.
A 19-second walkthrough is in docs/demo.mp4. It is an animated mock of the desktop app, drawn in video/demo.html and rendered by video/render.mjs, not a screen recording.
One row above the prompt:
A percentage turns amber at 60% and red at 85%.
It also keeps a quiet record of cost per day. Type /spend to list the total for each of the last 14 days, summed across every session that has the mod loaded.
The Claude Code documentation asks for 2.1.287 or later for mods.
To try it in one session:
git clone https://github.com/justerification/claude-code-context-meter
claude --plugin-dir ./claude-code-context-meter
To load it in every session, including the desktop app, add the folder to the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/path/to/claude-code-context-meter",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
The second line makes the desktop app reload the mod when you edit its files. Both are read when a session starts.
The pills cost nothing: they read figures Claude Code already has. Each press of Recap is one extra model call over the session's transcript, mostly served from the prompt cache. Nothing is spent until you press it.
~/.claude/mods-data/context-meter/cost/, one small file per session per day.Everything is in hooks/register.tsx. The colours are in HUES, the thresholds in tone, the pills in pillsOf, and the Recap question in WHERE_PROMPT. Check an edit with:
claude plugin validate .
This is an independent project. It is not affiliated with or endorsed by Anthropic; Claude and Claude Code are named only to say what the mod works with.
MIT.
hooks/register.tsx 362 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Reading, Where } from '../types'
5
6const reading = atom({ plugin: 'context-meter', key: 'reading' } as const, null)
7
8// Asked only when the button is pressed: each ask is a model call over the session's transcript.
9const IDLE: Where = { status: 'idle', lines: [] }
10const where = atom({ plugin: 'context-meter', key: 'where' } as const, IDLE)
11const WHERE_PROMPT = [
12 'Do not continue the work. Answer in exactly three short lines of plain text, no markdown, each under 110 characters:',
13 'Now: where the work in this session stands',
14 'Waiting on you: what needs the user to decide, check or do, or "nothing"',
15 'Next: the next step',
16].join('\n')
17
18const ask = async ($: EngineInterface) => {
19 await update($, where, () => ({ status: 'asking', lines: [] }))
20 const reply = await $.model.fork({ prompt: WHERE_PROMPT })
21 const lines = reply.isAnswered
22 ? reply.text.split('\n').map(line => line.trim()).filter(line => line !== '').slice(0, 3)
23 : [`No summary: ${reply.reason}`]
24 await update($, where, () => ({ status: 'ready', lines }))
25}
26
27// Each session keeps its own file under the day's folder; a day's total is the sum of that folder.
28
29const dayOf = (ms: number) => {
30 const d = new Date(ms)
31 const two = (n: number) => String(n).padStart(2, '0')
32
33 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`
34}
35
36const ledger = async ($: EngineInterface) => `${(await $.env.get('HOME')) ?? ''}/.claude/mods-data/context-meter/cost`
37
38// A session's entry for one day: its running cost when the day began for it, and its running cost now.
39// `offset` carries what was spent before the session's own ledger started over (a /clear).
40type Entry = { start: number; now: number; offset: number }
41
42const entryIn = async ($: EngineInterface, path: string): Promise<Entry | undefined> => {
43 const text = await $.fs.read(path).catch(() => '')
44
45 try {
46 const entry = JSON.parse(text)
47
48 return typeof entry === 'object' && entry !== null && typeof entry.now === 'number' ? entry : undefined
49 } catch {
50 return undefined
51 }
52}
53
54const totalOf = async ($: EngineInterface, day: string) => {
55 const dir = `${await ledger($)}/${day}`
56 const entries = await $.fs.list(dir).catch(() => [])
57 let total = 0
58
59 for (const { name } of entries) {
60 const entry = await entryIn($, `${dir}/${name}`)
61 total += entry === undefined ? 0 : entry.now - entry.start
62 }
63
64 return total
65}
66
67// Writes the session's running cost into today's entry. The write states a total, never adds a
68// difference, so a reload or a restart that repeats it changes nothing.
69const count = async ($: EngineInterface, usd: number | undefined) => {
70 if (usd === undefined) {
71 return
72 }
73
74 const at = await $.clock.now()
75 const id = await $.session.id()
76 const root = await ledger($)
77 const file = `${root}/${dayOf(at)}/${id}.json`
78 let entry = await entryIn($, file)
79
80 if (entry === undefined) {
81 const before = await entryIn($, `${root}/${dayOf(at - 86_400_000)}/${id}.json`)
82 const { startedAt } = await $.session.usage()
83 // Carried over from yesterday; else a session begun today starts the day at zero; else its
84 // earlier days are unknown and the day starts from what it has spent so far.
85 entry =
86 before !== undefined
87 ? { start: before.now, now: before.now, offset: before.offset }
88 : dayOf(startedAt) === dayOf(at)
89 ? { start: 0, now: 0, offset: 0 }
90 : { start: usd, now: usd, offset: 0 }
91 }
92
93 // A running cost below the last one seen means the session's ledger started over.
94 const offset = entry.offset + usd < entry.now ? entry.now : entry.offset
95 await $.fs.write(file, JSON.stringify({ start: entry.start, now: offset + usd, offset }))
96}
97
98const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'Spend' }
99
100const compact = (n: number) =>
101 n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : n >= 100_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
102
103const tone = (percent: number) => (percent >= 85 ? 'error' : percent >= 60 ? 'warning' : 'success')
104
105const label = (kind: string) => {
106 if (LIMIT_LABELS[kind] !== undefined) {
107 return LIMIT_LABELS[kind]
108 }
109
110 const model = kind.replace(/^(seven_day|five_hour)_?/, '').replace(/_/g, ' ')
111 const window = kind.startsWith('five_hour') ? '5h' : kind.startsWith('seven_day') ? '7d' : ''
112
113 return `${window} ${model.charAt(0).toUpperCase()}${model.slice(1)}`.trim()
114}
115
116const hueOf = (kind: string) => (kind.startsWith('five_hour') ? 'blue' : kind.startsWith('seven_day') ? 'purple' : 'grey')
117
118const iconOf = (kind: string) => (kind.startsWith('five_hour') ? 'clock' : kind.startsWith('seven_day') ? 'calendar' : 'coin')
119
120const until = (resetsAt: string | undefined, now: number) => {
121 const ms = resetsAt === undefined ? NaN : Date.parse(resetsAt) - now
122
123 if (!(ms > 0)) {
124 return undefined
125 }
126
127 const minutes = Math.round(ms / 60_000)
128 const days = Math.floor(minutes / 1440)
129 const hours = Math.floor((minutes % 1440) / 60)
130
131 return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
132}
133
134// The desktop band: one SVG of pills, each a label, a bar, a percentage and a detail.
135const CHAR = 7.25
136const BAR = 48
137const PILL_H = 24
138// One colour per pill, mid-tones that read on a light and a dark window.
139const HUES = { green: '#2e8b4d', blue: '#2f6fd0', purple: '#7a54c7', grey: '#7b7f87', yellow: '#9a7300', orange: '#c2571a' }
140const TONES = { success: '#3f9d5c', warning: '#c98a12', error: '#d9483b' }
141
142// 16px line icons, drawn in the pill's own colour.
143const ICONS = {
144 gauge: '<path d="M3 11.5a5 5 0 1 1 10 0"/><path d="M8 11.5l2.4-3.6"/>',
145 clock: '<circle cx="8" cy="8" r="5.5"/><path d="M8 5v3.2l2 1.3"/>',
146 calendar: '<rect x="2.5" y="3.5" width="11" height="10" rx="2"/><path d="M2.5 7h11M5.5 2v3M10.5 2v3"/>',
147 coin: '<circle cx="8" cy="8" r="5.5"/><path d="M9.7 6.4c-.5-.7-3.2-.9-3.2.5 0 1.5 3.2.8 3.2 2.3 0 1.3-2.8 1.3-3.4.5M8 4.6v6.8"/>',
148}
149
150type Pill = { name: string; icon: keyof typeof ICONS; hue: keyof typeof HUES; percent?: number; detail?: string; hasBar?: boolean }
151
152const drawPills = (pills: Pill[]) => {
153 let x = 0
154 let body = ''
155
156 for (const pill of pills) {
157 const start = x
158 const hue = HUES[pill.hue]
159 let inner = ''
160 let cx = start + 12
161 const text = (s: string, fill: string, weight = 400, opacity = 1) => {
162 inner += `<text x="${cx}" y="16.5" fill="${fill}" fill-opacity="${opacity}" font-weight="${weight}">${s}</text>`
163 cx += s.length * CHAR + 8
164 }
165
166 inner += `<g transform="translate(${cx} 4)" fill="none" stroke="${hue}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">${ICONS[pill.icon]}</g>`
167 cx += 16 + 6
168
169 // A pill whose icon says what it is carries no name.
170 if (pill.name !== '') {
171 text(pill.name, hue, 600)
172 }
173
174 if (pill.percent !== undefined) {
175 // The pill's own colour until the figure needs attention, then amber, then red.
176 const level = tone(pill.percent)
177 const fill = level === 'success' ? hue : TONES[level]
178 const width = Math.max(pill.percent > 0 ? 3 : 0, (Math.min(100, pill.percent) / 100) * BAR)
179
180 if (pill.hasBar !== false) {
181 inner += `<rect x="${cx}" y="9.5" width="${BAR}" height="5" rx="2.5" fill="${hue}" fill-opacity="0.22"/>`
182 inner += `<rect x="${cx}" y="9.5" width="${width}" height="5" rx="2.5" fill="${fill}"/>`
183 cx += BAR + 8
184 }
185
186 text(`${Math.round(pill.percent)}%`, fill, 700)
187 }
188
189 if (pill.detail !== undefined) {
190 if (pill.percent !== undefined) {
191 inner += `<rect x="${cx}" y="6" width="1" height="12" fill="${hue}" fill-opacity="0.35"/>`
192 cx += 9
193 text(pill.detail, hue, 400, 0.8)
194 } else {
195 text(pill.detail, hue, 700)
196 }
197 }
198
199 const width = cx - 8 + 12 - start
200 body += `<rect x="${start}" y="0" width="${width}" height="${PILL_H}" rx="12" fill="${hue}" fill-opacity="0.13"/>${inner}`
201 x = start + width + 8
202 }
203
204 const total = Math.max(1, x - 8)
205
206 return {
207 width: total,
208 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="${PILL_H}" viewBox="0 0 ${total} ${PILL_H}" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="12">${body}</svg>`,
209 }
210}
211
212const pillsOf = (now: Reading, at: number): Pill[] => [
213 {
214 name: 'Context',
215 icon: 'gauge',
216 hue: 'green',
217 percent: now.percent ?? 0,
218 detail: now.tokens === undefined ? 'no reading yet' : `${compact(now.tokens)} / ${compact(now.window)}`,
219 },
220 ...now.limits.map((limit: Limit) => ({
221 name: label(limit.kind),
222 icon: iconOf(limit.kind),
223 hue: hueOf(limit.kind),
224 // The plan windows are glance figures: percentage and reset only.
225 hasBar: false,
226 percent: limit.percentUsed,
227 detail: until(limit.resetsAt, at),
228 })),
229 ...(now.usd === undefined ? [] : [{ name: '', icon: 'coin' as const, hue: 'yellow' as const, detail: `$${now.usd.toFixed(2)}` }]),
230]
231
232// The summary's three parts, each drawn as a chip in the pills' style ahead of its line.
233const PARTS = [
234 { match: /^now\b/i, name: 'Now', hue: 'blue', icon: '<circle cx="8" cy="8" r="5.5"/><circle cx="8" cy="8" r="1.4"/>' },
235 { match: /^waiting\b/i, name: 'Waiting on you', hue: 'orange', icon: '<path d="M4.5 2.5h7M4.5 13.5h7M5.2 2.5c0 3.2 5.6 3.4 5.6 5.5s-5.6 2.3-5.6 5.5M10.8 2.5c0 3.2-5.6 3.4-5.6 5.5s5.6 2.3 5.6 5.5"/>' },
236 { match: /^next\b/i, name: 'Next', hue: 'green', icon: '<path d="M3 8h9.5M9 4.5L12.5 8 9 11.5"/>' },
237] as const
238const CHIP_W = 150
239
240const drawChip = (name: string, hue: string, icon: string) =>
241 `<svg xmlns="http://www.w3.org/2000/svg" width="${CHIP_W}" height="${PILL_H}" viewBox="0 0 ${CHIP_W} ${PILL_H}" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="12">` +
242 `<rect width="${CHIP_W}" height="${PILL_H}" rx="12" fill="${hue}" fill-opacity="0.13"/>` +
243 `<g transform="translate(12 4)" fill="none" stroke="${hue}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">${icon}</g>` +
244 `<text x="34" y="16.5" fill="${hue}" font-weight="700">${name}</text></svg>`
245
246// Splits "Now: the work stands here" into its part and its text; an unlabelled line gets no chip.
247const partOf = (line: string) => {
248 const colon = line.indexOf(':')
249 const part = colon === -1 ? undefined : PARTS.find(p => p.match.test(line.slice(0, colon).trim()))
250
251 return part === undefined ? { text: line } : { part, text: line.slice(colon + 1).trim() }
252}
253
254export const register: Register = on => {
255 on('session.start', async ($, e, next) => {
256 const { context, rateLimits, cost } = await $.session.usage()
257 const first: Reading = { ...context, limits: rateLimits, usd: cost?.usd }
258 await update($, reading, () => first)
259 await count($, cost?.usd)
260 await $.command.register({ name: 'spend', description: 'Show the cost of Claude Code sessions per day, last 14 days' })
261 // The countdowns read the clock while drawing, so they need a redraw between turns.
262 $.clock.every(20_000, () => $.ui.invalidate('ui.render'))
263
264 return next(e)
265 })
266
267 // A summary describes the turn it was asked in; the next prompt makes it stale.
268 on('prompt.submit', async ($, e, next) => {
269 await update($, where, () => IDLE)
270
271 return next(e)
272 })
273
274 on('session.measure', async ($, e, next) => {
275 const latest: Reading = { ...e.context, limits: e.rateLimits, usd: e.cost?.usd }
276 await update($, reading, () => latest)
277 await count($, e.cost?.usd)
278
279 return next(e)
280 })
281
282 on('command.run', { command: 'spend' }, async $ => {
283 const days = (await $.fs.list(await ledger($)).catch(() => [])).map(entry => entry.name).sort().slice(-14)
284 const rows = []
285
286 for (const day of days) {
287 rows.push(`${day} $${(await totalOf($, day)).toFixed(2)}`)
288 }
289
290 return { text: rows.length === 0 ? 'No spend recorded yet.' : rows.join('\n') }
291 })
292
293 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
294 const now = await read($, reading)
295
296 if (e.props.hasSurvey || now === null) {
297 return next(e)
298 }
299
300 const pills = pillsOf(now, await $.clock.now())
301
302 if (e.surface === 'desktop') {
303 const { Box, Button, Svg, Text } = $.ui.resolve(e)
304 const asked = (await read($, where)) ?? IDLE
305 const { source, width } = drawPills(pills)
306 const alt = pills.map(p => [p.name || 'Cost', p.percent === undefined ? '' : `${Math.round(p.percent)}%`, p.detail ?? ''].join(' ')).join(', ')
307
308 return (
309 <Box flexDirection="column">
310 <Box alignItems="center" gap={1} flexWrap="wrap">
311 <Svg source={source} alt={alt} width={width} height={PILL_H} />
312 <Box flexShrink={0}>
313 {asked.status === 'ready' ? (
314 <Button key="hide" label="Hide" onPress={() => update($, where, () => IDLE)} />
315 ) : (
316 <Button key="where" label={asked.status === 'asking' ? 'Asking…' : 'Recap'} onPress={() => ask($)} />
317 )}
318 </Box>
319 </Box>
320 {asked.lines.map((line, i) => {
321 const { part, text } = partOf(line)
322
323 return (
324 <Box key={`where-${i}`} alignItems="center" gap={1} marginTop={1}>
325 {part !== undefined && (
326 <Svg source={drawChip(part.name, HUES[part.hue], part.icon)} alt={part.name} width={CHIP_W} height={PILL_H} />
327 )}
328 <Text color={part === undefined ? undefined : HUES[part.hue]}>{text}</Text>
329 </Box>
330 )
331 })}
332 </Box>
333 )
334 }
335
336 const { Box, Text } = $.ui.resolve(e)
337 const CELLS = 12
338
339 return (
340 <Box gap={3}>
341 {pills.map(pill => {
342 const filled = Math.min(CELLS, Math.round(((pill.percent ?? 0) / 100) * CELLS))
343
344 return (
345 <Text key={pill.name}>
346 <Text dimColor>{pill.name} </Text>
347 {pill.percent !== undefined && (
348 <Text>
349 {pill.hasBar !== false && <Text color={tone(pill.percent)}>{'█'.repeat(filled)}</Text>}
350 {pill.hasBar !== false && <Text dimColor>{'░'.repeat(CELLS - filled)} </Text>}
351 <Text color={tone(pill.percent)}>{Math.round(pill.percent)}% </Text>
352 </Text>
353 )}
354 <Text dimColor>{pill.detail ?? ''}</Text>
355 </Text>
356 )
357 })}
358 </Box>
359 )
360 })
361}
362types/index.d.ts 13 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Reading = { tokens?: number; window: number; percent?: number; limits: Limit[]; usd?: number }
4
5// The on-demand summary of where the session stands.
6export type Where = { status: 'idle' | 'asking' | 'ready'; lines: string[] }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'context-meter': { reading: Reading | null; where: Where }
11 }
12}
13