Live pane with tokens read, written and the context window fill

A Claude Code mod that opens a pane with the tokens read and written in the current session and over the week (across all projects), the estimated cost at API prices, the context window fill, and the usage limits (5h, weekly) with the time left until each reset.
At the prompt of a Claude Code terminal session:
/plugin install token-meter --marketplace Dumorro/token-meter
Answer y to add the marketplace and pick the user scope. The pane opens when a session starts; reopen it with /tokens.
jq on the PATH. It reads the transcripts under ~/.claude/projects to total the session (subagents included) and the week. Without jq, the pane shows only what it counted since the mod loaded and the totals saved from earlier sessions.hooks/pricing.ts, not what a plan bills. Models missing from the table are listed as "unpriced" instead of being counted as free.claude plugin validate .
claude plugin test .hooks/register.tsx 335 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, SessionContextUsage } from 'claude-code'
3
4import type { Limit, Source, Totals, Week, Window } from '../types'
5
6import { costOf } from './pricing'
7import type { ModelUsage } from './pricing'
8import { AGGREGATE, EMPTY, summarize } from './usage'
9
10const PANE = 'token-meter'
11const WEEK_MS = 7 * 86_400_000
12const WEEK_EVERY_MS = 10 * 60_000
13
14const totals = atom({ plugin: 'token-meter', key: 'totals' } as const, EMPTY)
15const week = atom({ plugin: 'token-meter', key: 'week' } as const, null as Week | null)
16const source = atom({ plugin: 'token-meter', key: 'source' } as const, 'live' as Source)
17const window = atom({ plugin: 'token-meter', key: 'window' } as const, null)
18const usd = atom({ plugin: 'token-meter', key: 'usd' } as const, null)
19const limits = atom({ plugin: 'token-meter', key: 'limits' } as const, [] as Limit[])
20
21const STORE_PREFIX = 'session:'
22const STORE_WEEK = 'week'
23const STORE_KEEP = 200
24
25const projectKey = (dir: string) => dir.replace(/[^A-Za-z0-9]/g, '-')
26const projectsDir = async ($: EngineInterface) => `${(await $.env.get('HOME')) ?? ''}/.claude/projects`
27
28// Saved totals from before usd existed lack it; treat them as tokens without a price.
29const withDefaults = (t: Partial<Totals>): Totals => ({ ...EMPTY, ...t, unpricedModels: t.unpricedModels ?? [] })
30
31async function transcriptFiles($: EngineInterface, sessionId: string): Promise<string[]> {
32 const projects = await projectsDir($)
33 const hasTranscript = (name: string) => $.fs.exists(`${projects}/${name}/${sessionId}.jsonl`)
34 // The transcript lives under the launch directory's key; search every project only when it moved.
35 let name: string | undefined
36 for (const guess of [projectKey(await $.session.root()), projectKey(await $.session.cwd())]) {
37 if (await hasTranscript(guess)) {
38 name = guess
39 break
40 }
41 }
42 if (name === undefined) {
43 for (const entry of await $.fs.list(projects)) {
44 if (entry.kind === 'dir' && (await hasTranscript(entry.name))) {
45 name = entry.name
46 break
47 }
48 }
49 }
50 if (name === undefined) return []
51 const dir = `${projects}/${name}`
52 const files = [`${dir}/${sessionId}.jsonl`]
53 const subagents = `${dir}/${sessionId}/subagents`
54 if (await $.fs.exists(subagents)) {
55 for (const f of await $.fs.list(subagents)) {
56 if (f.kind === 'file' && f.name.endsWith('.jsonl')) files.push(`${subagents}/${f.name}`)
57 }
58 }
59
60 return files
61}
62
63async function scanSession($: EngineInterface): Promise<Totals | null> {
64 const files = await transcriptFiles($, await $.session.id())
65 if (files.length === 0) return null
66 const r = await $.process.run(['jq', '-ncR', '--arg', 'since', '', AGGREGATE, ...files], { timeoutMs: 60_000 })
67 if (r.exitCode !== 0) return null
68
69 return summarize(JSON.parse(r.stdout) as ModelUsage[])
70}
71
72// Every session of every project since the start of the weekly limit window: files touched in the
73// window, assistant lines only (grep), then the same aggregate filtered by timestamp.
74async function scanWeek($: EngineInterface, since: number, now: number): Promise<Totals | null> {
75 const minutes = String(Math.ceil((now - since) / 60_000) + 60)
76 const script =
77 'find "$1" -name "*.jsonl" -mmin "-$2" -print0 | xargs -0 grep -h -F \'"type":"assistant"\' | jq -ncR --arg since "$3" "$4"'
78 const r = await $.process.run(
79 ['sh', '-c', script, 'sh', await projectsDir($), minutes, new Date(since).toISOString(), AGGREGATE],
80 { timeoutMs: 120_000 },
81 )
82 if (r.exitCode !== 0) return null
83
84 return summarize(JSON.parse(r.stdout) as ModelUsage[])
85}
86
87async function saveSession($: EngineInterface, t: Totals) {
88 await $.store.set(`${STORE_PREFIX}${await $.session.id()}`, t)
89 const keys = (await $.store.keys()).filter(k => k.startsWith(STORE_PREFIX))
90 for (const old of keys.slice(0, Math.max(0, keys.length - STORE_KEEP))) await $.store.delete(old)
91}
92
93let isScanning = false
94let isScanningWeek = false
95
96async function refreshSession($: EngineInterface) {
97 if (isScanning) return
98 isScanning = true
99 try {
100 const t = await scanSession($)
101 if (t) {
102 await update($, totals, () => t)
103 await update($, source, () => 'transcript' as Source)
104 await saveSession($, t)
105 }
106 } catch {
107 // keep what is shown; the next turn retries
108 } finally {
109 isScanning = false
110 }
111}
112
113// The weekly limit's own window when the API reported one, else the last seven days.
114async function weekStart($: EngineInterface, now: number) {
115 const resetsAt = (await read($, limits)).find(l => l.kind === 'seven_day')?.resetsAt
116 const reset = resetsAt === undefined ? NaN : Date.parse(resetsAt)
117
118 return Number.isNaN(reset) ? now - WEEK_MS : reset - WEEK_MS
119}
120
121async function refreshWeek($: EngineInterface) {
122 if (isScanningWeek) return
123 isScanningWeek = true
124 try {
125 const now = await $.clock.now()
126 const since = await weekStart($, now)
127 const t = await scanWeek($, since, now)
128 if (t) {
129 const w: Week = { since, at: now, totals: t }
130 await update($, week, () => w)
131 await $.store.set(STORE_WEEK, w)
132 }
133 } catch {
134 // keep what is shown; the next period retries
135 } finally {
136 isScanningWeek = false
137 }
138}
139
140const fmt = (n: number) =>
141 n >= 1_000_000_000
142 ? `${(n / 1_000_000_000).toFixed(2)}B`
143 : n >= 1_000_000
144 ? `${(n / 1_000_000).toFixed(2)}M`
145 : n >= 1_000
146 ? `${(n / 1_000).toFixed(1)}k`
147 : `${n}`
148
149const money = (n: number) => `$${n.toFixed(2)}`
150
151const ddmm = (ms: number) => {
152 const d = new Date(ms)
153 const two = (n: number) => String(n).padStart(2, '0')
154
155 return `${two(d.getDate())}/${two(d.getMonth() + 1)} ${two(d.getHours())}:${two(d.getMinutes())}`
156}
157
158const LIMIT_LABEL: Record<string, string> = {
159 five_hour: '5h window',
160 seven_day: 'Weekly',
161 spend_limit: 'Spend',
162}
163
164const until = (resetsAt: string | undefined, now: number) => {
165 const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
166 if (Number.isNaN(at)) return ''
167 const min = Math.max(0, Math.round((at - now) / 60_000))
168 const d = Math.floor(min / 1440)
169 const h = Math.floor((min % 1440) / 60)
170 const m = min % 60
171
172 return ` · resets in ${d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`}`
173}
174
175const limitColor = (used: number) => (used >= 85 ? 'red' : used >= 60 ? 'yellow' : 'green')
176
177const toWindow = (c: SessionContextUsage): Window => ({
178 tokens: c.tokens ?? null,
179 size: c.window,
180 percent: c.percent ?? null,
181})
182
183export const register: Register = on => {
184 on('session.start', async ($, e, next) => {
185 await $.command.register({
186 name: 'tokens',
187 description: 'Open the token meter pane',
188 })
189 const saved = (await $.store.get(`${STORE_PREFIX}${await $.session.id()}`)) as Partial<Totals> | undefined
190 if (saved) {
191 await update($, totals, () => withDefaults(saved))
192 await update($, source, () => 'saved' as Source)
193 }
194 const savedWeek = (await $.store.get(STORE_WEEK)) as Week | undefined
195 if (savedWeek) await update($, week, () => ({ ...savedWeek, totals: withDefaults(savedWeek.totals) }))
196 const usage = await $.session.usage()
197 await update($, window, () => toWindow(usage.context))
198 await update($, usd, () => usage.cost?.usd ?? null)
199 await update($, limits, () => usage.rateLimits)
200 void refreshSession($)
201 void refreshWeek($)
202 $.clock.every(WEEK_EVERY_MS, () => void refreshWeek($))
203 void $.ui.open({ id: PANE, title: 'Tokens' })
204
205 return next(e)
206 })
207
208 on('command.run', { command: 'tokens' }, async $ => {
209 await $.ui.open({ id: PANE, title: 'Tokens' })
210
211 return { text: 'Token meter pane opened.' }
212 })
213
214 on('turn.complete', async ($, e, next) => {
215 const u = e.usage
216 if (u) {
217 const turnUsd =
218 costOf({
219 model: u.model,
220 fast: false,
221 input: u.input_tokens,
222 cacheRead: u.cache_read_input_tokens,
223 cacheWrite: u.cache_creation_input_tokens,
224 cacheWrite1h: 0,
225 output: u.output_tokens,
226 responses: 1,
227 }) ?? 0
228 const t = await update($, totals, t => ({
229 ...t,
230 input: t.input + u.input_tokens,
231 cacheRead: t.cacheRead + u.cache_read_input_tokens,
232 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
233 output: t.output + u.output_tokens,
234 turns: t.turns + 1,
235 usd: t.usd + turnUsd,
236 }))
237 await saveSession($, t)
238 }
239 const ran = await next(e)
240 if (e.agentId === undefined) void refreshSession($)
241
242 return ran
243 })
244
245 on('session.measure', async ($, e, next) => {
246 await update($, window, () => toWindow(e.context))
247 await update($, usd, () => e.cost?.usd ?? null)
248 await update($, limits, () => e.rateLimits)
249
250 return next(e)
251 })
252
253 on('session.end', async ($, e, next) => {
254 if (e.reason === 'clear') {
255 await update($, totals, () => EMPTY)
256 await update($, source, () => 'live' as Source)
257 await update($, window, () => null)
258 }
259
260 return next(e)
261 })
262
263 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
264 const { Box, Text } = $.ui.resolve(e)
265 const t = await read($, totals)
266 const wk = await read($, week)
267 const from = await read($, source)
268 const w = await read($, window)
269 const cost = await read($, usd)
270 const rate = await read($, limits)
271 const now = await $.clock.now()
272
273 const block = (x: Totals, note: RenderChildren) => {
274 const readTotal = x.input + x.cacheRead + x.cacheWrite
275
276 return (
277 <Box flexDirection="column">
278 <Text> Read {fmt(readTotal)}</Text>
279 <Text dimColor> uncached {fmt(x.input)}</Text>
280 <Text dimColor> cache read {fmt(x.cacheRead)}</Text>
281 <Text dimColor> cache write {fmt(x.cacheWrite)}</Text>
282 <Text> Written {fmt(x.output)}</Text>
283 <Text> Total {fmt(readTotal + x.output)}</Text>
284 <Text color="cyan"> Cost {money(x.usd)} (estimated, API pricing)</Text>
285 {x.unpricedModels.length > 0 && (
286 <Text dimColor> unpriced: {x.unpricedModels.join(', ')}</Text>
287 )}
288 <Text dimColor>
289 {' '}
290 {x.turns} API responses · {note}
291 </Text>
292 </Box>
293 )
294 }
295
296 return (
297 <Box flexDirection="column">
298 <Text bold>Current session</Text>
299 {block(
300 t,
301 `includes subagents · ${from === 'transcript' ? 'transcript' : from === 'saved' ? 'saved' : 'since the mod loaded'}`,
302 )}
303 <Text> </Text>
304 <Text bold>Weekly{wk ? ` (since ${ddmm(wk.since)})` : ''}</Text>
305 {wk ? (
306 block(wk.totals, `all projects · updated ${ddmm(wk.at)}`)
307 ) : (
308 <Text dimColor> calculating…</Text>
309 )}
310 <Text> </Text>
311 <Text bold>Context window</Text>
312 {w === null || w.tokens === null ? (
313 <Text dimColor> waiting for first response</Text>
314 ) : (
315 <Text color={(w.percent ?? 0) >= 85 ? 'red' : (w.percent ?? 0) >= 60 ? 'yellow' : 'green'}>
316 {' '}
317 {fmt(w.tokens)} / {fmt(w.size)} ({w.percent ?? 0}%)
318 </Text>
319 )}
320 {cost !== null && <Text dimColor> /cost for this process {money(cost)}</Text>}
321 <Text> </Text>
322 <Text bold>Limits</Text>
323 {rate.length === 0 && <Text dimColor> no reading yet</Text>}
324 {rate.map(l => (
325 <Text color={limitColor(l.percentUsed)}>
326 {' '}
327 {LIMIT_LABEL[l.kind] ?? l.kind}: {Math.max(0, Math.round((100 - l.percentUsed) * 10) / 10)}% free (
328 {l.percentUsed}% used){until(l.resetsAt, now)}
329 </Text>
330 ))}
331 </Box>
332 )
333 })
334}
335hooks/pricing.ts 54 lines1// US$ per million tokens, Anthropic first-party list prices (claude-api skill, cached 2026-09-25).
2// Cache writes are 1.25x input (5 min) and 2x input (1 h); cache reads are 0.1x input except
3// where a model sets its own rate.
4type Rate = { input: number; output: number; cacheRead: number }
5
6const RATES: [prefix: string, rate: Rate][] = [
7 ['claude-fable-5-1', { input: 10, output: 50, cacheRead: 0.25 }],
8 ['claude-mythos-5-1', { input: 10, output: 50, cacheRead: 0.25 }],
9 ['claude-fable-5', { input: 10, output: 50, cacheRead: 1 }],
10 ['claude-mythos-5', { input: 10, output: 50, cacheRead: 1 }],
11 ['claude-opus-5-5', { input: 4, output: 20, cacheRead: 0.2 }],
12 ['claude-opus-5', { input: 5, output: 25, cacheRead: 0.5 }],
13 ['claude-opus-4-8', { input: 5, output: 25, cacheRead: 0.5 }],
14 ['claude-opus-4-7', { input: 5, output: 25, cacheRead: 0.5 }],
15 ['claude-opus-4-6', { input: 5, output: 25, cacheRead: 0.5 }],
16 ['claude-sonnet-5-5', { input: 2, output: 10, cacheRead: 0.2 }],
17 ['claude-sonnet-5', { input: 2, output: 10, cacheRead: 0.2 }],
18 ['claude-sonnet-4-6', { input: 3, output: 15, cacheRead: 0.3 }],
19 ['claude-haiku-4-5', { input: 1, output: 5, cacheRead: 0.1 }],
20]
21
22// Longest prefix first, so claude-opus-5-5 never matches claude-opus-5.
23const BY_LENGTH = [...RATES].sort((a, b) => b[0].length - a[0].length)
24
25export const rateOf = (model: string): Rate | undefined =>
26 BY_LENGTH.find(([prefix]) => model.startsWith(prefix))?.[1]
27
28export type ModelUsage = {
29 model: string
30 fast: boolean
31 input: number
32 cacheRead: number
33 cacheWrite: number
34 cacheWrite1h: number
35 output: number
36 responses: number
37}
38
39// Estimated cost of one model's usage; undefined when the model has no known price.
40export function costOf(u: ModelUsage): number | undefined {
41 const r = rateOf(u.model)
42 if (!r) return undefined
43 const write5m = Math.max(0, u.cacheWrite - u.cacheWrite1h)
44 const usd =
45 (u.input * r.input +
46 u.cacheRead * r.cacheRead +
47 write5m * r.input * 1.25 +
48 u.cacheWrite1h * r.input * 2 +
49 u.output * r.output) /
50 1_000_000
51
52 return u.fast ? usd * 2 : usd
53}
54hooks/usage.ts 46 lines1import type { Totals } from '../types'
2
3import { costOf } from './pricing'
4import type { ModelUsage } from './pricing'
5
6// Every assistant record carries its API response's usage, repeated once per content block:
7// dedupe by message id, keep records at or after $since, and sum per model and speed.
8// Lines a writer left half-written are skipped.
9export const AGGREGATE = [
10 '[inputs | fromjson? | select(.type == "assistant" and .message.usage != null and (.timestamp // "") >= $since)',
11 '| .message as $m | $m.usage as $u',
12 '| {k: ($m.id // .requestId // ""), m: ($m.model // "?"), f: (($u.speed // "standard") == "fast"),',
13 'in: ($u.input_tokens // 0), cr: ($u.cache_read_input_tokens // 0),',
14 'w1: ($u.cache_creation.ephemeral_1h_input_tokens // 0), w: ($u.cache_creation_input_tokens // 0),',
15 'out: ($u.output_tokens // 0)}]',
16 '| unique_by(.k) | group_by([.m, .f])',
17 '| map({model: .[0].m, fast: .[0].f, input: (map(.in) | add), cacheRead: (map(.cr) | add),',
18 'cacheWrite: (map(.w) | add), cacheWrite1h: (map(.w1) | add), output: (map(.out) | add), responses: length})',
19].join(' ')
20
21export const EMPTY: Totals = {
22 input: 0,
23 cacheRead: 0,
24 cacheWrite: 0,
25 output: 0,
26 turns: 0,
27 usd: 0,
28 unpricedModels: [],
29}
30
31export function summarize(rows: readonly ModelUsage[]): Totals {
32 const t: Totals = { ...EMPTY, unpricedModels: [] }
33 for (const r of rows) {
34 t.input += r.input
35 t.cacheRead += r.cacheRead
36 t.cacheWrite += r.cacheWrite
37 t.output += r.output
38 t.turns += r.responses
39 const usd = costOf(r)
40 if (usd !== undefined) t.usd += usd
41 else if (r.input + r.cacheRead + r.cacheWrite + r.output > 0) t.unpricedModels.push(r.model)
42 }
43
44 return t
45}
46types/index.d.ts 31 lines1export type Totals = {
2 input: number
3 cacheRead: number
4 cacheWrite: number
5 output: number
6 turns: number
7 usd: number
8 unpricedModels: string[]
9}
10
11export type Week = { since: number; at: number; totals: Totals }
12
13export type Source = 'transcript' | 'saved' | 'live'
14
15export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
16
17export type Window = { tokens: number | null; size: number; percent: number | null }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'token-meter': {
22 totals: Totals
23 week: Week | null
24 source: Source
25 window: Window | null
26 usd: number | null
27 limits: Limit[]
28 }
29 }
30}
31