Claude Code usage limits panel (5-hour, weekly, Fable) with the tokens used in each window and an estimate of its capacity

A Claude Code mod that opens a pane with your subscription's usage limits (5-hour window, 7-day weekly and Fable weekly): the percentage, a bar, when each window resets and the tokens used in it, together with an estimate of how many it allows.
Session (5h) 42%
████████████████░░░░░░░░░░░░░░░░░░░░░░░░
Resets in 3 h
Used/Expected: 18.4 M / 43.1 M
You need python3 on your PATH (it only uses the standard library).
From the prompt of a Claude Code session in the terminal:
/plugin install usage-panel --marketplace Eskladoscla/claude-usage-panel
Answer y to add the marketplace and pick the scope (user scope, for all your sessions). From the shell:
claude plugin install usage-panel --marketplace Eskladoscla/claude-usage-panel
In the desktop app: + next to the message box → Plugins → Add marketplace with Eskladoscla/claude-usage-panel → Add plugin.
Type /usage-panel in a session to open the pane. The percentages arrive after the first turn; the tokens are recalculated at most once per minute.
~/.claude/usage-ledger/samples/<session>.jsonl.scripts/ledger.py reads your local transcripts (~/.claude/projects/) incrementally and sums the tokens per window into ~/.claude/usage-ledger/ledger.db (SQLite).~/.claude/usage-ledger/weights.json, created on first use and editable) are taken from the ratio of API prices.Everything stays on your machine: the mod uses no network and not your session token, never blocks anything and, if it fails, it logs to ~/.claude/usage-ledger/errors.log and the session carries on.
From the installed plugin's folder (--base changes the data folder):
python3 -I scripts/ledger.py report --ingest # every window
python3 -I scripts/ledger.py report --window five_hour # just one
python3 -I -m unittest discover -s tests # ledger.py
claude plugin test . # the modhooks/register.tsx 332 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, WindowTokens } from '../types'
5
6// usage-panel: the three limit windows (5h, weekly, Fable) with their bar and, below it, the tokens
7// used in the current window, summed across every project on this machine. The tokens are counted by
8// scripts/ledger.py (`window` subcommand) from the local transcripts, with its data in
9// ~/.claude/usage-ledger; usage in the claude.ai chat does not show up. The percentage samples are
10// recorded by the ledger below. Observe-only: it never blocks or throws.
11
12// Sample ledger: records every reading of the account's usage limits (5-hour window, weekly,
13// weekly per model…) so that scripts/ledger.py can estimate how many tokens each window allows.
14// Observe-only: never blocks, never throws, no network.
15//
16// Each session writes its own file samples/<sessionId>.jsonl: $.fs has no "append",
17// and a single shared file with read-and-rewrite would lose lines when two sessions
18// write at the same time.
19
20type RawLimit = { kind: string; percentUsed: number; resetsAt?: string; [k: string]: unknown }
21
22// Our own labels (the mod API provides no display_name). An unknown kind is still
23// recorded, with its name as the label.
24const LABELS: Record<string, string> = {
25 five_hour: 'Session (5h)',
26 seven_day: 'Weekly (all models)',
27 seven_day_overage_included: 'Fable limit',
28 seven_day_opus: 'Weekly Opus',
29 seven_day_sonnet: 'Weekly Sonnet',
30 seven_day_cowork: 'Weekly Cowork',
31 seven_day_omelette: 'Weekly omelette',
32 seven_day_oauth_apps: 'Weekly OAuth apps',
33 spend_limit: 'Spend limit',
34}
35const KNOWN = new Set(['kind', 'percentUsed', 'resetsAt'])
36const MAX_BYTES = 3 * 1024 * 1024 // $.fs.read rejects anything above 4 MiB
37const MAX_ERROR_LINES = 200
38
39type St = {
40 last: Map<string, string> // kind → signature of the last sample written
41 base?: string
42 file?: string
43 content?: string // in-memory copy of this session's file
44 cwd: string
45 sessionId: string
46 queue: Promise<void>
47 plan?: string | null
48}
49
50async function dir($, st: St): Promise<string> {
51 if (!st.base) {
52 let home = ''
53 try {
54 home = (await $.env.get('HOME')) ?? ''
55 } catch {}
56 if (!home) throw new Error('HOME is not set')
57 st.base = `${home}/.claude/usage-ledger`
58 }
59 return st.base
60}
61
62async function logError($, st: St, where: string, err: unknown) {
63 try {
64 const path = `${await dir($, st)}/errors.log`
65 let old = ''
66 try {
67 old = await $.fs.read(path)
68 } catch {}
69 const msg = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
70 const lines = (old + `${new Date().toISOString()} [${where}] ${msg}\n`).split('\n').filter(Boolean)
71 await $.fs.write(path, lines.slice(-MAX_ERROR_LINES).join('\n') + '\n')
72 } catch {}
73}
74
75// Account plan (Pro, Max x5, Max x20) read from ~/.claude.json once per session; null if unknown.
76async function plan($, st: St): Promise<string | null> {
77 if (st.plan === undefined) {
78 st.plan = null
79 try {
80 const home = await $.env.get('HOME')
81 if (!home) return st.plan
82 const acc = JSON.parse(await $.fs.read(`${home}/.claude.json`))?.oauthAccount ?? {}
83 const tier = String(acc.organizationRateLimitTier || acc.userRateLimitTier || '')
84 st.plan = tier.includes('max_20x') ? 'Max x20' : tier.includes('max_5x') ? 'Max x5' : tier.includes('pro') ? 'Pro' : tier || null
85 } catch {}
86 }
87 return st.plan
88}
89
90async function persist($, st: St, rows: string[]) {
91 if (!st.file) {
92 st.file = `${await dir($, st)}/samples/${st.sessionId || 'no-session'}.jsonl`
93 try {
94 st.content = await $.fs.read(st.file)
95 } catch {
96 st.content = ''
97 }
98 }
99 if ((st.content ?? '').length > MAX_BYTES) {
100 st.file = st.file.replace(/(\.\d+)?\.jsonl$/, `.${Date.now()}.jsonl`)
101 st.content = ''
102 }
103 const next = (st.content ?? '') + rows.join('')
104 await $.fs.write(st.file, next)
105 st.content = next
106}
107
108async function record($, st: St, limits: readonly RawLimit[] | undefined, source: string) {
109 try {
110 if (!limits?.length) return
111 const ts = new Date(await $.clock.now()).toISOString()
112 if (!st.sessionId) {
113 try {
114 st.sessionId = await $.session.id()
115 } catch {}
116 }
117 if (!st.cwd) {
118 try {
119 st.cwd = await $.session.cwd()
120 } catch {}
121 }
122 const rows: string[] = []
123 const sigs: [string, string][] = []
124 for (const l of limits) {
125 if (!l || typeof l.kind !== 'string') continue
126 const sig = `${l.percentUsed}|${l.resetsAt ?? ''}`
127 if (st.last.get(l.kind) === sig) continue
128 sigs.push([l.kind, sig])
129 const extra: Record<string, unknown> = {}
130 for (const [k, v] of Object.entries(l)) if (!KNOWN.has(k)) extra[k] = v
131 const label = typeof extra.label === 'string' ? extra.label : (LABELS[l.kind] ?? l.kind)
132 rows.push(
133 JSON.stringify({
134 ts,
135 sessionId: st.sessionId,
136 cwd: st.cwd,
137 project: st.cwd.split('/').filter(Boolean).pop() ?? '',
138 source,
139 kind: l.kind,
140 label,
141 percentUsed: l.percentUsed,
142 resetsAt: l.resetsAt ?? null,
143 plan: await plan($, st),
144 ...(Object.keys(extra).length ? { extra } : {}),
145 }) + '\n',
146 )
147 }
148 if (!rows.length) return
149 // Serialized writes: two back-to-back measurements do not clobber the file.
150 const run = st.queue.then(() => persist($, st, rows))
151 st.queue = run.catch(() => {})
152 try {
153 await run
154 for (const [k, sig] of sigs) st.last.set(k, sig) // only what was written counts as "already seen"
155 } catch (err) {
156 await logError($, st, 'persist', err)
157 }
158 } catch (err) {
159 await logError($, st, source, err)
160 }
161}
162
163// Session state; the panel module calls these two functions from its hooks
164// (a mod cannot register the same event twice).
165const st: St = { last: new Map(), cwd: '', sessionId: '', queue: Promise.resolve() }
166
167// Initial sample, after the engine's session.start.
168export async function ledgerStart($: EngineInterface, cwd: string | undefined) {
169 try {
170 st.cwd = cwd ?? ''
171 const usage = await $.session.usage()
172 await record($, st, usage?.rateLimits, 'start')
173 } catch (err) {
174 await logError($, st, 'start', err)
175 }
176}
177
178export async function ledgerMeasure($: EngineInterface, limits: readonly RawLimit[] | undefined) {
179 await record($, st, limits, 'measure')
180}
181
182// Panel
183
184const PANE = 'usage-panel'
185const limits = atom({ plugin: 'usage-panel', key: 'limits' } as const, [] as Limit[])
186const tokens = atom({ plugin: 'usage-panel', key: 'tokens' } as const, {} as Record<string, WindowTokens>)
187const updatedAt = atom({ plugin: 'usage-panel', key: 'updatedAt' } as const, 0)
188const error = atom({ plugin: 'usage-panel', key: 'error' } as const, '')
189
190const WINDOWS: readonly { kind: string; label: string }[] = [
191 { kind: 'five_hour', label: 'Session (5h)' },
192 { kind: 'seven_day', label: 'Weekly (7 days)' },
193 { kind: 'seven_day_overage_included', label: 'Fable limit' },
194]
195const MIN_INTERVAL_MS = 60_000 // ingestion takes a few seconds: at most once per minute
196
197let running = false
198let lastRun = 0
199
200async function refresh($: EngineInterface, force = false) {
201 const now = Date.now()
202 if (running || (!force && now - lastRun < MIN_INTERVAL_MS)) return
203 running = true
204 lastRun = now
205 try {
206 const ls = await read($, limits)
207 const resets = ls.filter((l) => l.resetsAt).map((l) => `${l.kind}=${l.resetsAt}`)
208 if (resets.length === 0) return
209 const home = await $.env.get('HOME')
210 if (!home) throw new Error('HOME is not set')
211 const r = await $.process.run(
212 ['/usr/bin/env', 'python3', '-I', `${$.plugin.root}/scripts/ledger.py`, 'window', '--ingest', ...resets],
213 { cwd: home, timeoutMs: 120_000 },
214 )
215 if (r.exitCode !== 0) {
216 await update($, error, () => `ledger.py exited with ${r.exitCode}`)
217 return
218 }
219 const parsed = JSON.parse(r.stdout) as Record<string, WindowTokens>
220 await update($, tokens, () => parsed)
221 await update($, updatedAt, () => Date.now())
222 await update($, error, () => '')
223 } catch (err) {
224 try {
225 await update($, error, () => (err instanceof Error ? err.message : String(err)).slice(0, 120))
226 } catch {}
227 } finally {
228 running = false
229 }
230}
231
232async function setLimits($: EngineInterface, rl: readonly Limit[] | undefined) {
233 if (!rl || rl.length === 0) return
234 await update($, limits, () => rl.map((l) => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })))
235}
236
237function fmtTokens(n: number): string {
238 if (n >= 1e9) return `${(n / 1e9).toFixed(2)} B`
239 if (n >= 1e6) return `${(n / 1e6).toFixed(1)} M`
240 if (n >= 1e3) return `${(n / 1e3).toFixed(1)} k`
241 return String(n)
242}
243
244function fmtReset(iso: string | undefined, now: number): string {
245 if (!iso) return ''
246 const ms = Date.parse(iso) - now
247 if (!Number.isFinite(ms)) return ''
248 if (ms <= 0) return 'Resets now'
249 const h = ms / 3_600_000
250 return h >= 24 ? `Resets in ${Math.round(h / 24)} d` : h >= 1 ? `Resets in ${Math.round(h)} h` : `Resets in ${Math.max(1, Math.round(ms / 60_000))} min`
251}
252
253// Full and empty cells of the bar; the full part is drawn in orange.
254function bar(pct: number, width: number): { full: string; empty: string } {
255 const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
256 return { full: '█'.repeat(full), empty: '░'.repeat(width - full) }
257}
258
259const ORANGE = '#f97316'
260
261export const register: Register = (on) => {
262 on('session.start', async ($, e, next) => {
263 await $.command.register({ name: 'usage-panel', description: 'Opens the usage limits panel with the tokens used' })
264 // The pane only opens on request, with /usage-panel.
265 try {
266 await setLimits($, (await $.session.usage())?.rateLimits)
267 } catch {}
268 void refresh($, true)
269 const result = await next(e)
270 await ledgerStart($, e.cwd)
271 return result
272 })
273
274 on('command.run', { command: 'usage-panel' }, async ($) => {
275 await $.ui.open({ id: PANE, title: 'Usage and tokens' })
276 void refresh($, true)
277 return { text: 'Usage panel opened.' }
278 })
279
280 on('session.measure', async ($, e, next) => {
281 await ledgerMeasure($, e.rateLimits)
282 try {
283 await setLimits($, e.rateLimits)
284 void refresh($)
285 } catch {}
286 return next(e)
287 })
288
289 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
290 const { Box, Text } = $.ui.resolve(e)
291 const ls = await read($, limits)
292 const tk = await read($, tokens)
293 const at = await read($, updatedAt)
294 const err = await read($, error)
295 const now = Date.now()
296 const width = Math.max(10, Math.min(40, (e.viewport?.columns ?? 40) - 4))
297
298 return (
299 <Box flexDirection="column">
300 {ls.length === 0 && <Text dimColor>No limit data yet (it arrives after the first turn).</Text>}
301 {WINDOWS.map((w) => {
302 const l = ls.find((x) => x.kind === w.kind)
303 const t = tk[w.kind]
304 return (
305 <Box flexDirection="column" marginBottom={1}>
306 <Text>
307 {w.label} {l ? `${l.percentUsed}%` : '—'}
308 </Text>
309 <Text>
310 <Text color={ORANGE}>{bar(l?.percentUsed ?? 0, width).full}</Text>
311 {bar(l?.percentUsed ?? 0, width).empty}
312 </Text>
313 {l?.resetsAt && <Text dimColor>{fmtReset(l.resetsAt, now)}</Text>}
314 {t?.cap && (
315 <Text dimColor>
316 Last cap: {new Date(t.cap.hit_ts * 1000).toLocaleDateString('en-US', { month: '2-digit', day: '2-digit' })}
317 {t.cap.plan ? ` · ${t.cap.plan}` : ''}
318 </Text>
319 )}
320 <Text dimColor>
321 {t ? `Used/Expected: ${fmtTokens(t.raw)} / ${t.cap ? fmtTokens(t.cap.raw) : '—'}` : 'Used/Expected: calculating…'}
322 </Text>
323 </Box>
324 )
325 })}
326 {at > 0 && <Text dimColor>Tokens as of {new Date(at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}</Text>}
327 {err && <Text color="red">{err}</Text>}
328 </Box>
329 )
330 })
331}
332types/index.d.ts 10 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Cap = { raw: number; weighted: number; hit_ts: number; plan: string | null }
3export type WindowTokens = { raw: number; weighted: number; cap: Cap | null }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'usage-panel': { limits: Limit[]; tokens: Record<string, WindowTokens>; updatedAt: number; error: string }
8 }
9}
10