Right-hand pane with live token usage, context window, cost, plan limits and lifetime totals

A Claude Code mod that docks a Usage pane on the right with live stats for the session you're in, plus totals kept across every session.
Subagent tokens are counted in the totals but not as replies.
At the prompt of a Claude Code terminal session, including a cloud session:
/plugin install usage-panel --marketplace catflinger5000-netizen/claude-usage-panel
Answer y to add the marketplace, then press Enter to pick the user scope. The pane opens straight away, and in every session after that.
From a shell:
claude plugin install usage-panel --marketplace catflinger5000-netizen/claude-usage-panel
In the desktop app, where /plugin doesn't run: click + next to the prompt box, then Plugins → Add marketplace with catflinger5000-netizen/claude-usage-panel, then Add plugin.
This repository is private, so the install only works where git already has a GitHub login that can read it (an SSH key, or gh auth login then gh auth setup-git).
| Command | What it does |
|---|---|
/usage-panel | Opens the pane if you closed it |
/usage-panel credits 12.78 | Sets your usage-credit balance (copy it from Settings → Usage → Current balance) |
/usage-panel credits | Shows the estimated balance left |
Claude Code doesn't give mods the credit balance, so you enter it once and the pane counts down from there. Auto-reload top-ups aren't seen, so re-enter the balance after one.
On first run, the all-time totals are seeded from baseline.json. It ships as zeros; put in your own past numbers if you want the totals to include history from before you installed the mod.
hooks/register.tsx the mod: hooks, slash command and pane
types/index.d.ts state types
tests/usage.test.tsx testshooks/register.tsx 430 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Credits, Lifetime, Limit, Live, Tokens } from '../types'
5
6const PANE = 'usage'
7const TITLE = 'Usage'
8const STORE_KEY = 'lifetime'
9const CREDITS_KEY = 'credits'
10// Shown in /usage-panel's reply, so a cloud session's copy can be told apart from an older one.
11const VERSION = '0.2.4'
12
13const ZERO: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
14
15const live = atom({ plugin: 'usage-panel', key: 'live' } as const, {
16 ...ZERO,
17 messages: 0,
18 replies: 0,
19 turns: 0,
20 lastTurnTokens: 0,
21 model: '',
22 contextWindow: 0,
23 costUsd: 0,
24 limits: [],
25 startedAt: 0,
26 isCounted: false,
27} as Live)
28
29const lifetime = atom({ plugin: 'usage-panel', key: 'lifetime' } as const, {
30 ...ZERO,
31 messages: 0,
32 replies: 0,
33 sessions: 0,
34 costUsd: 0,
35 since: '',
36} as Lifetime)
37
38// Claude Code does not give mods the credit balance, so the person enters it once
39// (/usage-panel credits 12.78) and the pane counts down from it.
40const credits = atom({ plugin: 'usage-panel', key: 'credits' } as const, null as Credits | null)
41
42const total = (t: Tokens) => t.input + t.output + t.cacheRead + t.cacheWrite
43
44function short(n: number): string {
45 if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`
46 if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
47 if (n >= 1e4) return `${(n / 1e3).toFixed(1)}K`
48 return String(Math.round(n))
49}
50
51function usd(n: number): string {
52 if (n >= 1000) return `$${Math.round(n).toLocaleString('en-US')}`
53 return n > 0 && n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`
54}
55
56function fillColor(percent: number): string {
57 return percent >= 90 ? 'error' : percent >= 75 ? 'warning' : 'rate_limit_fill'
58}
59
60function resetText(iso?: string): string {
61 if (!iso) return ''
62 const at = new Date(iso)
63 const ms = at.getTime() - Date.now()
64 if (!(ms > 0)) return 'Resetting now'
65 const time = at.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }).replace(':00', '').replace(' ', '').toLowerCase()
66 const sameDay = at.toDateString() === new Date().toDateString()
67 const when = sameDay ? time : `${at.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}, ${time}`
68 const h = Math.floor(ms / 3_600_000)
69 const m = Math.floor((ms % 3_600_000) / 60_000)
70 const left = h >= 24 ? `${Math.floor(h / 24)}d ${h % 24}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
71 return `Resets ${when} · in ${left}`
72}
73
74// The same titles Claude's /usage screen uses.
75function limitTitle(kind: string): string {
76 if (kind === 'five_hour') return 'Current session'
77 if (kind === 'seven_day') return 'Current week (all models)'
78 if (kind === 'spend_limit') return 'Monthly spend limit'
79 return kind.replace(/_/g, ' ').replace(/^./, c => c.toUpperCase())
80}
81
82function duration(ms: number): string {
83 const minutes = Math.max(0, Math.round(ms / 60_000))
84 return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`
85}
86
87const toLimits = (rateLimits: Limit[]): Limit[] =>
88 rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
89
90// Adds a delta to the lifetime totals kept across sessions. Re-reads the store
91// first so two open sessions do not overwrite each other's counts.
92type Counter = Exclude<keyof Lifetime, 'since' | 'sessionCosts' | 'costSince'>
93
94async function bump($: EngineInterface, delta: Partial<Record<Counter, number>>) {
95 const stored = ((await $.store.get(STORE_KEY)) as Lifetime | undefined) ?? (await read($, lifetime))
96 const next: Lifetime = { ...stored }
97 for (const k of Object.keys(delta) as Counter[]) {
98 next[k] = (stored[k] ?? 0) + (delta[k] ?? 0)
99 }
100 await $.store.set(STORE_KEY, next)
101 await update($, lifetime, () => next)
102}
103
104// The all-time cost is the sum of each session's own total, as /cost reports it, so
105// reloads, restarts and missed measurements cannot lose or double-count any of it.
106async function recordSessionCost($: EngineInterface, usd: number) {
107 const id = await $.session.id()
108 const stored = ((await $.store.get(STORE_KEY)) as Lifetime | undefined) ?? (await read($, lifetime))
109 // Totals counted before v0.2.1 were increments that cannot be split by session; they restart here.
110 const costs = stored.sessionCosts ?? {}
111 if ((costs[id] ?? -1) >= usd && stored.sessionCosts) return
112 const sessionCosts = { ...costs, [id]: Math.max(costs[id] ?? 0, usd) }
113 const next: Lifetime = {
114 ...stored,
115 sessionCosts,
116 costUsd: Object.values(sessionCosts).reduce((a, b) => a + b, 0),
117 costSince: stored.sessionCosts ? stored.costSince : new Date().toISOString(),
118 }
119 await $.store.set(STORE_KEY, next)
120 await update($, lifetime, () => next)
121}
122
123// Credits pay for usage only while a plan limit is used up; then the session's
124// cost, at API rates, is what they are charged.
125const onCredits = (limits: Limit[]) =>
126 limits.some(l => (l.kind === 'five_hour' || l.kind === 'seven_day') && l.percentUsed >= 100)
127
128async function spendCredits($: EngineInterface, usd: number) {
129 const stored = (await $.store.get(CREDITS_KEY)) as Credits | undefined
130 if (!stored) return
131 const next = { ...stored, spentUsd: stored.spentUsd + usd }
132 await $.store.set(CREDITS_KEY, next)
133 await update($, credits, () => next)
134}
135
136async function loadLifetime($: EngineInterface): Promise<Lifetime> {
137 const stored = (await $.store.get(STORE_KEY)) as Lifetime | undefined
138 if (stored) return stored
139 // First run: seed from the transcripts counted when the mod was made.
140 let seed: Partial<Lifetime> = {}
141 try {
142 seed = JSON.parse(await $.fs.read(`${$.plugin.root}/baseline.json`))
143 } catch {}
144 const first: Lifetime = {
145 input: seed.input ?? 0,
146 output: seed.output ?? 0,
147 cacheRead: seed.cacheRead ?? 0,
148 cacheWrite: seed.cacheWrite ?? 0,
149 messages: seed.messages ?? 0,
150 replies: seed.replies ?? 0,
151 sessions: seed.sessions ?? 0,
152 costUsd: 0,
153 since: new Date().toISOString(),
154 }
155 await $.store.set(STORE_KEY, first)
156 return first
157}
158
159// The pane's main figures as plain lines, for /usage-panel where no pane can be drawn.
160async function summary($: EngineInterface): Promise<string> {
161 const s = await read($, live)
162 const life = await read($, lifetime)
163 const wallet = await read($, credits)
164 const lines = [
165 s.contextPercent === undefined
166 ? 'Context window: waiting for the first reply'
167 : `Context window: ${s.contextPercent}% (${short(s.contextTokens ?? 0)} of ${short(s.contextWindow)} tokens)`,
168 ...s.limits.map(l => `${limitTitle(l.kind)}: ${l.percentUsed}% used${l.resetsAt ? ` · ${resetText(l.resetsAt)}` : ''}`),
169 ]
170 if (wallet) lines.push(`Usage credits (est.): ${usd(Math.max(0, wallet.balanceUsd - wallet.spentUsd))}`)
171 lines.push(
172 `Cost: ${usd(s.costUsd)} this session · ${usd(life.costUsd)} all time`,
173 `Tokens: ${short(total(s))} this session · ${short(total(life))} all time`,
174 `Messages: ${s.messages} · Replies: ${s.replies} · Sessions: ${short(life.sessions)}`,
175 )
176 if (s.model !== '') lines.push(`Model: ${s.model.replace(/^claude-/, '')}`)
177 return lines.join('\n')
178}
179
180export const register: Register = on => {
181 on('session.start', async ($, e, next) => {
182 const result = await next(e)
183
184 await $.command.register({
185 name: 'usage-panel',
186 description: 'Open the usage stats pane, or set your usage-credit balance',
187 argumentHint: '[credits <balance>]',
188 })
189 const savedCredits = (await $.store.get(CREDITS_KEY)) as Credits | undefined
190 await update($, credits, () => savedCredits ?? null)
191
192 const loaded = await loadLifetime($)
193 await update($, lifetime, () => loaded)
194
195 const now = await read($, live)
196 if (!now.isCounted) {
197 const usage = await $.session.usage()
198 await update($, live, l => ({
199 ...l,
200 isCounted: true,
201 startedAt: usage.startedAt,
202 contextTokens: usage.context.tokens,
203 contextWindow: usage.context.window,
204 contextPercent: usage.context.percent,
205 costUsd: usage.cost?.usd ?? 0,
206 limits: toLimits(usage.rateLimits),
207 }))
208 await bump($, { sessions: 1 })
209 }
210 const opening = await $.session.usage()
211 if (opening.cost) await recordSessionCost($, opening.cost.usd)
212
213 void $.ui.open({ id: PANE, title: TITLE })
214
215 return result
216 })
217
218 on('command.run', { command: 'usage-panel' }, async ($, e) => {
219 const set = /^\s*credits\s+\$?(\d+(?:\.\d{1,2})?)\s*$/i.exec(e.args)
220 if (set) {
221 const next: Credits = { balanceUsd: Number(set[1]), setAt: new Date().toISOString(), spentUsd: 0 }
222 await $.store.set(CREDITS_KEY, next)
223 await update($, credits, () => next)
224 return { text: `Usage-credit balance set to ${usd(next.balanceUsd)}. The pane counts down from here as credits are spent.` }
225 }
226 if (/^\s*credits\s*$/i.test(e.args)) {
227 const c = await read($, credits)
228 return {
229 text: c
230 ? `About ${usd(Math.max(0, c.balanceUsd - c.spentUsd))} left (${usd(c.balanceUsd)} entered ${c.setAt.slice(0, 10)}, ${usd(c.spentUsd)} spent since).`
231 : 'No balance set. Copy it from Settings → Usage → Current balance: /usage-panel credits 12.78',
232 }
233 }
234 const opened = await $.ui.open({ id: PANE, title: TITLE })
235
236 // The figures always come as text too: a cloud session's page can count the pane as
237 // placed and still not show it, and a -p run draws no panes at all.
238 const note = opened.isPlaced ? 'Usage pane opened.' : `The pane can't be shown here (${opened.reason}).`
239 return { text: `${await summary($)}\n\n${note} (usage-panel ${VERSION})` }
240 })
241
242 on('prompt.submit', async ($, e, next) => {
243 const result = await next(e)
244 if (!e.isMeta) {
245 try {
246 await update($, live, l => ({ ...l, messages: l.messages + 1 }))
247 await bump($, { messages: 1 })
248 } catch {}
249 }
250
251 return result
252 })
253
254 on('turn.complete', async ($, e, next) => {
255 const result = await next(e)
256 const u = e.usage
257 if (u) {
258 const delta: Tokens = {
259 input: u.input_tokens,
260 output: u.output_tokens,
261 cacheRead: u.cache_read_input_tokens,
262 cacheWrite: u.cache_creation_input_tokens,
263 }
264 const isMain = !e.agentId
265 await update($, live, l => ({
266 ...l,
267 input: l.input + delta.input,
268 output: l.output + delta.output,
269 cacheRead: l.cacheRead + delta.cacheRead,
270 cacheWrite: l.cacheWrite + delta.cacheWrite,
271 turns: l.turns + (isMain ? 1 : 0),
272 replies: l.replies + (isMain ? 1 : 0),
273 lastTurnTokens: isMain ? total(delta) : l.lastTurnTokens,
274 model: isMain ? u.model : l.model,
275 }))
276 await bump($, { ...delta, replies: isMain ? 1 : 0 })
277 }
278
279 return result
280 })
281
282 on('session.measure', async ($, e, next) => {
283 const before = await read($, live)
284 const cost = e.cost?.usd ?? before.costUsd
285 const spent = cost - before.costUsd
286 if (spent > 0 && onCredits(toLimits(e.rateLimits))) await spendCredits($, spent)
287 await update($, live, l => ({
288 ...l,
289 contextTokens: e.context.tokens,
290 contextWindow: e.context.window,
291 contextPercent: e.context.percent,
292 costUsd: cost,
293 limits: toLimits(e.rateLimits),
294 }))
295 if (e.cost) await recordSessionCost($, e.cost.usd)
296
297 return next(e)
298 })
299
300 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
301 const { Box, Text } = $.ui.resolve(e)
302 const s = await read($, live)
303 const life = await read($, lifetime)
304 const wallet = await read($, credits)
305
306 const heading = (text: string) => (
307 <Box marginTop={1}>
308 <Text bold color="claude">
309 {text}
310 </Text>
311 </Box>
312 )
313 const row = (label: string, value: string, color?: string) => (
314 <Box flexDirection="row" justifyContent="space-between">
315 <Text dimColor>{label}</Text>
316 <Text color={color} tabular>
317 {value}
318 </Text>
319 </Box>
320 )
321 // Claude's usage bar: a solid fill on a rate_limit_empty track, the figure to its right.
322 const meter = (percent: number, label: string) => {
323 const filled = Math.round(Math.max(0, Math.min(100, percent)))
324 return (
325 <Box flexDirection="row" alignItems="center" gap={1}>
326 <Box flexGrow={1} height={1} backgroundColor="rate_limit_empty">
327 {filled > 0 && <Box width={`${filled}%`} height={1} backgroundColor={fillColor(percent)} />}
328 </Box>
329 <Text tabular>{label}</Text>
330 </Box>
331 )
332 }
333 const note = (text: string) => (
334 <Text dimColor wrap="wrap">
335 {text}
336 </Text>
337 )
338 // Token table: label, this session, all time, in right-aligned columns.
339 const cell = (text: string, bold?: boolean, dim?: boolean) => (
340 <Box width={9} justifyContent="flex-end">
341 <Text tabular bold={bold} dimColor={dim}>
342 {text}
343 </Text>
344 </Box>
345 )
346 const tokenRow = (label: string, session: number, all: number, bold?: boolean) => (
347 <Box flexDirection="row" justifyContent="space-between">
348 <Text dimColor={!bold} bold={bold}>
349 {label}
350 </Text>
351 <Box flexDirection="row">
352 {cell(short(session), bold)}
353 {cell(short(all), bold)}
354 </Box>
355 </Box>
356 )
357
358 const pct = s.contextPercent ?? 0
359 const plan = s.limits.filter(l => l.kind !== 'spend_limit')
360 const spend = s.limits.find(l => l.kind === 'spend_limit')
361 const perReply = s.replies > 0 ? s.costUsd / s.replies : 0
362 const costedSessions = Object.keys(life.sessionCosts ?? {}).length
363 const perSession = costedSessions > 0 ? life.costUsd / costedSessions : 0
364
365 return (
366 <Box flexDirection="column" paddingX={1}>
367 {heading('Context window')}
368 {s.contextPercent === undefined ? (
369 note('Waiting for the first reply.')
370 ) : (
371 <Box flexDirection="column">
372 {meter(pct, `${pct}%`)}
373 {note(`${short(s.contextTokens ?? 0)} of ${short(s.contextWindow)} tokens · ${short(Math.max(0, s.contextWindow - (s.contextTokens ?? 0)))} left`)}
374 </Box>
375 )}
376
377 {plan.length === 0 && heading('Plan usage')}
378 {plan.length === 0 && note('No limit reading yet.')}
379 {plan.map(l => (
380 <Box flexDirection="column" key={l.kind}>
381 {heading(limitTitle(l.kind))}
382 {meter(l.percentUsed, `${l.percentUsed}% used`)}
383 {l.resetsAt && note(resetText(l.resetsAt))}
384 </Box>
385 ))}
386
387 {heading('Usage credits')}
388 {onCredits(s.limits) && <Text color="warning">● Paying from usage credits now</Text>}
389 {wallet && row('Balance (est.)', usd(Math.max(0, wallet.balanceUsd - wallet.spentUsd)), 'claude')}
390 {wallet && row('Spent since set', usd(wallet.spentUsd))}
391 {wallet &&
392 note(`${usd(wallet.balanceUsd)} entered ${wallet.setAt.slice(0, 10)}, less what was spent while a limit was used up. Auto-reload top-ups are not seen: update it with /usage-panel credits <balance>.`)}
393 {!wallet && note('Copy your balance from Settings → Usage → Current balance, then run /usage-panel credits 12.78. The pane counts down from it.')}
394 {spend && (
395 <Box flexDirection="column">
396 {meter(spend.percentUsed, `${spend.percentUsed}% used`)}
397 {note(`${limitTitle(spend.kind)}${spend.resetsAt ? ` · ${resetText(spend.resetsAt)}` : ''}`)}
398 </Box>
399 )}
400
401 {heading('Cost')}
402 {row('This session', usd(s.costUsd), 'claude')}
403 {row('Per reply (avg)', usd(perReply))}
404 {row('All time', usd(life.costUsd))}
405 {row('Per session (avg)', usd(perSession))}
406 {note(`At API rates${life.costSince ? `; all time counts sessions since ${life.costSince.slice(0, 10)}` : ''}. On a Pro or Max plan you pay the plan price, plus any usage credits.`)}
407
408 {heading('Tokens')}
409 <Box flexDirection="row" justifyContent="flex-end">
410 {cell('Session', false, true)}
411 {cell('All time', false, true)}
412 </Box>
413 {tokenRow('Input', s.input, life.input)}
414 {tokenRow('Output', s.output, life.output)}
415 {tokenRow('Cache read', s.cacheRead, life.cacheRead)}
416 {tokenRow('Cache write', s.cacheWrite, life.cacheWrite)}
417 {tokenRow('Total', total(s), total(life), true)}
418
419 {heading('Activity')}
420 {row('Your messages', `${s.messages} · ${short(life.messages)} all time`)}
421 {row('Claude replies', `${s.replies} · ${short(life.replies)} all time`)}
422 {row('Last reply', `${short(s.lastTurnTokens)} tokens`)}
423 {row('Sessions', short(life.sessions))}
424 {s.startedAt > 0 && row('Running for', duration(Date.now() - s.startedAt))}
425 {s.model !== '' && row('Model', s.model.replace(/^claude-/, ''))}
426 </Box>
427 )
428 })
429}
430types/index.d.ts 44 lines1export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
2
3export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
4
5export type Live = Tokens & {
6 messages: number
7 replies: number
8 turns: number
9 lastTurnTokens: number
10 model: string
11 contextTokens?: number
12 contextWindow: number
13 contextPercent?: number
14 costUsd: number
15 limits: Limit[]
16 startedAt: number
17 isCounted: boolean
18}
19
20export type Lifetime = Tokens & {
21 messages: number
22 replies: number
23 sessions: number
24 costUsd: number
25 since: string
26 /** Each session's own running cost, by session id; costUsd is their sum. */
27 sessionCosts?: Record<string, number>
28 /** When per-session cost counting began. */
29 costSince?: string
30}
31
32/** The usage-credit balance the person entered, and what has been paid from credits since. */
33export type Credits = {
34 balanceUsd: number
35 setAt: string
36 spentUsd: number
37}
38
39declare module 'claude-code' {
40 interface PluginState {
41 'usage-panel': { live: Live; lifetime: Lifetime; credits: Credits | null }
42 }
43}
44