SLOPSHOPPER

usage-panel

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

newpanecommandprocess
v0.1.0no licenseupdated 2026-10-26Eskladoscla/claude-usage-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-panel
│ ┃ Usage and tokens ✕ › fix the failing auth test and add an audit log call │ ┃ Session (5h) 31% │ ┃ ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Used/Expected: calculating… ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Weekly (7 days) — ⏺ Bash(bun test) │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⎿ 3 pass, 1 fail │ ┃ Used/Expected: calculating… │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Fable limit — │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ✻ Worked for 42s · done 4:20 PM │ ┃ Used/Expected: calculating… │ ┃ › /usage-panel │ ┃ JSON Parse error: Unexpected EOF ⎿ usage-panel: Usage panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Usage and tokens
Session (5h) 31% ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Used/Expected: calculating… Weekly (7 days) — ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Used/Expected: calculating… Fable limit — ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Used/Expected: calculating… JSON Parse error: Unexpected EOF
README

usage-panel

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

Install

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.

Usage

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.

How it works

  • Percentages: every time Claude Code reports your limits and one of them changes, the mod appends a line to ~/.claude/usage-ledger/samples/<session>.jsonl.
  • Tokens: scripts/ledger.py reads your local transcripts (~/.claude/projects/) incrementally and sums the tokens per window into ~/.claude/usage-ledger/ledger.db (SQLite).
  • Expected: weighted tokens ÷ the percentage reached in the last window that hit the cap. It is an estimate: Anthropic does not publish the quota formula, and the weights per token type (~/.claude/usage-ledger/weights.json, created on first use and editable) are taken from the ratio of API prices.
  • Usage outside this machine (claude.ai, another computer) raises the percentage without leaving tokens here, so the capacity comes out low for those windows.

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.

Detailed report

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

Tests

python3 -I -m unittest discover -s tests   # ledger.py
claude plugin test .                       # the mod
Source 2 files
hooks/register.tsx 332 lines
1import { 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}
332
types/index.d.ts 10 lines
1export 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