Shows your claude.ai plan limits (session, weekly, Fable, extra usage) in a pane, read from the usage widget's usage_data.json

A macOS desktop widget that shows your claude.ai plan limits at a glance, so you see a limit coming before you hit it.
Bars turn amber at 75% and red at 90%. A pixel-art mascot reacts to your session usage: calm when you're idle, sweating at 75%, panicking at 90%, asleep once you hit the limit.
Works with Chrome, Arc, Brave, Edge, Vivaldi, Chromium, Firefox, Zen, LibreWolf and Floorp. On any other browser, you paste your login key once.
Unofficial. This project is not affiliated with Anthropic. It reads the same internal endpoint the claude.ai settings page uses, which can change without notice.
Browser cookies (Chrome, Arc, Firefox, Zen, …) ──┐
├─▶ fetch_usage.py ──▶ claude.ai usage API
macOS Keychain (saved login) ───────────────────┘ │
▼
usage_data.json
│
┌──────────────┴──────────────┐
▼ ▼
Übersicht widget (JSX) widget.py (tkinter)
fetch_usage.py needs is your claude.ai login cookie (sessionKey).--setup, because reading them shows a macOS password dialog.curl_cffi to look like a real browser so Cloudflare lets it through, and writes the result to usage_data.json.Your login never leaves your machine except to talk to claude.ai.
| macOS | Uses Keychain and launchd |
| Python 3.10+ | Check with python3 --version |
| A browser logged in to claude.ai | See Supported browsers |
| Übersicht | Optional, only for the desktop widget |
git clone https://github.com/sdezubiria/claude-usage-widget.git ~/claude-usage-widget
cd ~/claude-usage-widget
pip3 install -r requirements.txt
Log in to claude.ai in your browser, then run:
python3 fetch_usage.py --setup
It lists the claude.ai logins it found, tests the most recent one, and saves it to your Keychain:
claude.ai logins found in your browsers:
chrome Profile 4 last used 2026-09-15 13:10
zen yr00ybqc.Default (release) last used 2026-09-12 09:02
Testing…
session=55% weekly=54%
Setup complete. Saved to the macOS Keychain.
If your login is in Chrome, Arc, Brave, Edge, Vivaldi or Chromium, macOS asks for your login keychain password so setup can read "*Browser* Safe Storage". This only happens during --setup. The background fetcher never shows this dialog.
If no login is found (Safari, for example), setup asks you to paste your sessionKey instead. See Other browsers.
Install the background fetcher as a launchd agent. It starts at login and refreshes every 5 minutes:
sed -e "s|__PYTHON__|$(which python3)|" -e "s|__REPO__|$PWD|g" \
launchd/com.claudeusagewidget.fetch.plist \
> ~/Library/LaunchAgents/com.claudeusagewidget.fetch.plist
launchctl load ~/Library/LaunchAgents/com.claudeusagewidget.fetch.plist
If you'd rather not install a login agent, run ./start.sh instead. The fetcher then runs until you log out.
Option A: Übersicht (desktop widget)
~/claude-usage-widget, open ubersicht/claude-usage.widget/index.jsx and set DATA_FILE to your usage_data.json path.bash ln -s "$PWD/ubersicht/claude-usage.widget" \ ~/Library/Application\ Support/Übersicht/widgets/claude-usage.widget ``Option B: floating window
python3 widget.py
Drag the window to move it, double-click to reload, right-click to quit.
Option C: inside Claude Code
The mod/ folder is a Claude Code mod that draws the same widget, mascot included, in a pane beside the conversation, and keeps a one-line summary under the prompt. Install it from your clone so it reads this repo's usage_data.json:
claude plugin marketplace add "$PWD"
claude plugin install usage-meter@usage-widget --scope user
It loads in every new session. /usage-pane reopens the pane after you close it. To read a usage_data.json somewhere else, set the dataFile option with /plugin configure usage-meter@usage-widget.
| Family | Browsers | Notes |
|---|---|---|
| Chromium | Chrome, Arc, Brave, Edge, Vivaldi, Chromium | Cookies are encrypted with a key in your Keychain, so macOS asks for your password. Read only during --setup: when the login expires, log in again and re-run --setup |
| Firefox | Firefox, Zen, LibreWolf, Floorp | Read directly with no prompts. Expired logins renew automatically |
| Other | Safari, Orion, anything else | Paste your key once (below) |
Every profile in every supported browser is checked, and the most recently used claude.ai login wins. To use only one browser:
python3 fetch_usage.py --setup --browser chrome
For the background fetcher, set CLAUDE_BROWSER=chrome instead. To see where logins were found without reading them:
python3 fetch_usage.py --browsers
sessionKey.python3 fetch_usage.py --setup and paste it when asked.Pasted keys can't be renewed automatically. When the widget says your session expired, repeat these steps.
The Übersicht widget has a pixel-art robot whose mood follows your session usage:
| Session | Mood | Animation |
|---|---|---|
| under 25% | idle | Slow breathing, blinking |
| 25–74% | working | Walks in place, swings arms |
| 75–89% | hustling | Double speed, sweat drop |
| 90–99% | panicking | Frantic, shaking, sweating |
| 100% | resting | Eyes shut, floating z's |
Animations turn off if Reduce motion is on in macOS settings. To change the thresholds, edit moodFor() in index.jsx.
| What | How |
|---|---|
| Use one browser only | --browser NAME on the command line, or CLAUDE_BROWSER=NAME. Names: chrome arc brave edge vivaldi chromium firefox zen librewolf floorp |
| Fetch interval | python3 fetch_usage.py --loop 120 (seconds). Default is 300. |
| Name under the mascot | SIGNATURE at the top of index.jsx. Set it to "" to hide it. |
| Colors | The C dict in widget.py, or the inline styles in index.jsx. |
| Manual values | python3 set_usage.py asks for each value. Handy for testing the UI. |
usage_data.jsonBoth widgets read this file:
{
"plan": "Max",
"session": { "percent": 55, "resets_in": "1h 18m" },
"weekly": { "percent": 54, "resets_in": "7h 18m" },
"fable": { "percent": 92, "resets_in": "7h 18m", "available": true },
"extra": { "enabled": true, "spent": 0.0, "percent": 0, "limit": 12.0, "resets": "1st of month" },
"updated": "2026-09-15T11:41:48"
}
The widget shows old numbers, or "updated Xh ago" keeps growing
Check the log:
tail fetch.log
Session expired: log in to claude.ai in your browser again. Firefox-family logins are picked up on the next run. For Chromium browsers, also run python3 fetch_usage.py --setup.Blocked by Cloudflare: usually temporary. It clears up on a later run.No claude.ai login found: run python3 fetch_usage.py --browsers to see what was detected. If your browser isn't listed, paste your key (see Other browsers).The "Chrome Safe Storage" password dialog rejects your password or keeps coming back
This dialog only appears during --setup, once per Chromium browser, and each approval covers that single read.
If your Mac password is rejected, your login keychain password no longer matches your Mac password. That's common after a password reset. Click Cancel: setup skips that browser and moves on. Then either:
Always Allow would stop future prompts, but it also lets any program that uses the security tool read that browser's key.
The Übersicht widget is blank
Open http://127.0.0.1:41416 in a browser and check the console for build errors. If that page doesn't load at all, quit and reopen Übersicht.
The Fable bar is missing
The row only appears when the API reports a Fable limit for your plan.
Restart the background fetcher
launchctl kickstart -k gui/$(id -u)/com.claudeusagewidget.fetch
fetch_usage.py fetches usage and writes usage_data.json
browser_cookies.py finds claude.ai logins in installed browsers
widget.py floating tkinter widget
set_usage.py enter values by hand
start.sh run the fetcher without launchd
launchd/ login agent template
ubersicht/ Übersicht desktop widget, with the mascot
mod/ Claude Code mod: the widget as a pane in Claude Code
extension/ experimental Zen/Firefox extension (needs a local receiver on :8765, not included)
hooks/register.tsx 249 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Snapshot, Usage } from '../types'
5
6// Same data and thresholds as the Übersicht widget: reads usage_data.json,
7// which fetch_usage.py (launchd) rewrites every 5 minutes. No network here.
8
9const PANE = 'usage-meter'
10const REFRESH_MS = 60_000
11
12const snapshot = atom({ plugin: 'usage-meter', key: 'snapshot' } as const, null)
13const problem = atom({ plugin: 'usage-meter', key: 'problem' } as const, null)
14
15type Mood = { name: 'idle' | 'work' | 'hustle' | 'panic' | 'sleep'; label: string }
16
17function moodFor(pct: number): Mood {
18 if (pct >= 100) return { name: 'sleep', label: 'limit hit · resting' }
19 if (pct >= 90) return { name: 'panic', label: 'panicking' }
20 if (pct >= 75) return { name: 'hustle', label: 'hustling' }
21 if (pct >= 25) return { name: 'work', label: 'working' }
22 return { name: 'idle', label: 'idle' }
23}
24
25function barColor(pct: number) {
26 if (pct >= 90) return 'error'
27 if (pct >= 75) return 'warning'
28 return 'suggestion'
29}
30
31function ago(ms: number) {
32 const s = ms / 1000
33 if (s < 60) return 'just now'
34 if (s < 3600) return `${Math.floor(s / 60)}m ago`
35 return `${Math.floor(s / 3600)}h ago`
36}
37
38function statusLine(d: Usage) {
39 const parts = [`Session ${d.session?.percent ?? 0}%`, `Weekly ${d.weekly?.percent ?? 0}%`]
40 if (d.fable?.available) parts.push(`Fable ${d.fable.percent}%`)
41 if (d.extra?.enabled) parts.push(`Extra $${d.extra.spent.toFixed(2)}/$${d.extra.limit.toFixed(0)}`)
42 return parts.join(' · ')
43}
44
45// The widget's pixel mascot, as an SVG string. CSS animations need isInteractive.
46function robotSvg(mood: Mood) {
47 const c = '#E07050'
48 const d = '#1C1E21'
49 const r = (x: number, y: number, w: number, h: number, fill: string, cls = '') =>
50 `<rect${cls ? ` class="${cls}"` : ''} x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"/>`
51 const css = `
52 .b{overflow:visible;--bob:.8s;--walk:.4s;--swing:.8s}
53 .b .bob{animation:bob var(--bob) steps(1) infinite}
54 .b .legA{animation:up var(--walk) steps(1) infinite}
55 .b .legB{animation:upB var(--walk) steps(1) infinite}
56 .b .armL{animation:down var(--swing) steps(1) infinite}
57 .b .armR{animation:downB var(--swing) steps(1) infinite}
58 .b .eyes{animation:blink 4s steps(1) infinite;transform-box:fill-box;transform-origin:center}
59 .b .sweat,.b .z{opacity:0}
60 .idle{--bob:1.6s}
61 .idle .legA,.idle .legB,.idle .armL,.idle .armR{animation:none}
62 .hustle{--bob:.4s;--walk:.2s;--swing:.4s}
63 .panic{--bob:.2s;--walk:.1s;--swing:.2s}
64 .panic .shake{animation:shake .3s steps(1) infinite}
65 .hustle .sweat{animation:sweat 1.2s steps(4) infinite}
66 .panic .sweat{animation:sweat .6s steps(4) infinite}
67 .sleep{--bob:2.4s}
68 .sleep .legA,.sleep .legB,.sleep .armL,.sleep .armR{animation:none}
69 .sleep .eyes{animation:none;transform:scaleY(.1)}
70 .sleep .z{animation:zzz 2.4s steps(6) infinite}
71 .sleep .z2{animation-delay:.8s}.sleep .z3{animation-delay:1.6s}
72 @keyframes bob{0%{transform:translateY(0)}50%{transform:translateY(-1px)}}
73 @keyframes up{0%{transform:translateY(0)}50%{transform:translateY(-1px)}}
74 @keyframes upB{0%{transform:translateY(-1px)}50%{transform:translateY(0)}}
75 @keyframes down{0%{transform:translateY(0)}50%{transform:translateY(1px)}}
76 @keyframes downB{0%{transform:translateY(1px)}50%{transform:translateY(0)}}
77 @keyframes blink{0%,94%{transform:scaleY(1)}95%,98%{transform:scaleY(.1)}}
78 @keyframes shake{0%{transform:translateX(0)}25%{transform:translateX(-1px)}50%{transform:translateX(0)}75%{transform:translateX(1px)}}
79 @keyframes sweat{0%{opacity:1;transform:translateY(0)}100%{opacity:0;transform:translateY(4px)}}
80 @keyframes zzz{0%{opacity:0;transform:translate(0,0)}20%{opacity:1}100%{opacity:0;transform:translate(2px,-4px)}}
81 @media (prefers-reduced-motion:reduce){.b *{animation:none!important}}`
82 return (
83 `<svg xmlns="http://www.w3.org/2000/svg" class="b ${mood.name}" width="96" height="72" viewBox="-1 -2 16 12" shape-rendering="crispEdges">` +
84 `<style>${css}</style>` +
85 r(12, 1, 1, 1, '#7DD3FC', 'sweat') +
86 r(13, 1, 1, 1, '#C8C9CA', 'z') +
87 r(13, 1, 1, 1, '#C8C9CA', 'z z2') +
88 r(13, 1, 1, 1, '#C8C9CA', 'z z3') +
89 `<g class="shake"><g class="bob">` +
90 r(3, 1, 8, 6, c) +
91 `<g class="eyes">${r(4, 2, 1, 1, d)}${r(9, 2, 1, 1, d)}</g>` +
92 r(1, 3, 2, 2, c, 'armL') +
93 r(11, 3, 2, 2, c, 'armR') +
94 `<g class="legA">${r(3, 7, 1, 2, c)}${r(8, 7, 1, 2, c)}</g>` +
95 `<g class="legB">${r(5, 7, 1, 2, c)}${r(10, 7, 1, 2, c)}</g>` +
96 `</g></g></svg>`
97 )
98}
99
100// Folders added with `claude plugin marketplace add <folder>`: installing from a
101// clone makes the clone one, and the session may run a cache copy of the mod.
102async function marketplaceFolders($: EngineInterface, home: string) {
103 try {
104 const known = JSON.parse(await $.fs.read(`${home}/.claude/plugins/known_marketplaces.json`)) as Record<
105 string,
106 { source?: { source?: string; path?: string } }
107 >
108 return Object.values(known)
109 .filter(m => m.source?.source === 'directory' && m.source.path)
110 .map(m => m.source!.path!)
111 } catch {
112 return []
113 }
114}
115
116// The setting when given. Otherwise the repo this mod ships in: beside the mod
117// when it runs from the clone, else the clone it was installed from; then the
118// README's default clone location.
119async function findDataFile($: EngineInterface, configured: string) {
120 if (configured) return configured
121 const home = await $.env.get('HOME')
122 const candidates = [`${$.plugin.root}/../usage_data.json`]
123 if (home) {
124 for (const folder of await marketplaceFolders($, home)) candidates.push(`${folder}/usage_data.json`)
125 candidates.push(`${home}/claude-usage-widget/usage_data.json`)
126 }
127 for (const path of candidates) {
128 if (await $.fs.exists(path)) return path
129 }
130 return candidates[candidates.length - 1] ?? candidates[0]!
131}
132
133// Reads the file into state and pins the one-line summary under the prompt;
134// answers what went wrong, or null.
135async function refresh($: EngineInterface, configured: string): Promise<string | null> {
136 const dataFile = await findDataFile($, configured)
137 try {
138 const [text, stat, now] = await Promise.all([
139 $.fs.read(dataFile),
140 $.fs.stat(dataFile),
141 $.clock.now(),
142 ])
143 const data = JSON.parse(text) as Usage
144 await update($, snapshot, () => ({ data, updatedMs: stat.mtimeMs, checkedMs: now }))
145 await update($, problem, () => null)
146 $.ui.status(statusLine(data))
147 return null
148 } catch {
149 await update($, problem, () => `Can't read ${dataFile}. Set its path with /plugin configure usage-meter@usage-widget.`)
150 $.ui.status(undefined)
151 return `Can't read ${dataFile}`
152 }
153}
154
155export const register: Register = (on, options) => {
156 const dataFile = String(options.dataFile ?? '')
157
158 on('session.start', async ($, e, next) => {
159 await $.command.register({
160 name: 'usage-pane',
161 description: 'Show your claude.ai plan limits (session, weekly, Fable, extra usage)',
162 })
163 await refresh($, dataFile)
164 $.clock.every(REFRESH_MS, () => void refresh($, dataFile))
165 void $.ui.open({ id: PANE, title: 'Usage' })
166
167 return next(e)
168 })
169
170 on('command.run', { command: 'usage-pane' }, async $ => {
171 const failed = await refresh($, dataFile)
172 await $.ui.open({ id: PANE, title: 'Usage' })
173
174 return { text: failed ?? 'Usage pane opened.' }
175 })
176
177 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
178 const els = $.ui.resolve(e)
179 const { Box, Text } = els
180 const snap = await read($, snapshot)
181 const err = await read($, problem)
182
183 if (snap === null) {
184 return (
185 <Box flexDirection="column">
186 <Text color="error">{err ?? 'Loading…'}</Text>
187 <Text dimColor>Run fetch_usage.py --setup in the widget repo.</Text>
188 </Box>
189 )
190 }
191
192 const d = snap.data
193 const session = d.session ?? { percent: 0, resets_in: '—' }
194 const weekly = d.weekly ?? { percent: 0, resets_in: '—' }
195 const extra = d.extra
196 const mood = moodFor(session.percent)
197 const cols = Math.max(12, e.props.bodyColumns)
198 const barWidth = Math.max(6, Math.min(30, cols - 11))
199
200 const Row = (label: string, limit: Limit, sub: string, right?: JSX.Element) => {
201 const pct = Math.min(Math.max(limit.percent || 0, 0), 100)
202 const filled = Math.round((pct / 100) * barWidth)
203 return (
204 <Box flexDirection="column" marginBottom={1}>
205 <Box justifyContent="space-between">
206 <Text color="claude" bold>{label}</Text>
207 {right ?? <Text> </Text>}
208 </Box>
209 <Text>
210 <Text color={barColor(pct)}>{'█'.repeat(filled)}</Text>
211 <Text color="subtle">{'░'.repeat(barWidth - filled)}</Text>
212 <Text> {String(limit.percent || 0).padStart(3)}%</Text>
213 </Text>
214 <Text dimColor wrap="truncate-end">{sub}</Text>
215 </Box>
216 )
217 }
218
219 return (
220 <Box flexDirection="column">
221 <Box justifyContent="space-between" marginBottom={1}>
222 <Text color="claude" bold>Claude usage</Text>
223 <Text dimColor>{d.plan ?? '—'}</Text>
224 </Box>
225 {e.surface !== 'terminal' && 'Svg' in els && (
226 <Box flexDirection="column" alignItems="center" marginBottom={1}>
227 <els.Svg source={robotSvg(mood)} alt={`Mascot: ${mood.label}`} width={96} height={72} isInteractive />
228 <Text dimColor>{mood.label}</Text>
229 </Box>
230 )}
231 {Row('SESSION', session, `Resets in ${session.resets_in}`)}
232 {Row('WEEKLY', weekly, `Resets in ${weekly.resets_in}`)}
233 {d.fable?.available && Row('FABLE', d.fable, `Weekly · Resets in ${d.fable.resets_in}`)}
234 {extra &&
235 Row(
236 'EXTRA USAGE',
237 { percent: extra.percent, resets_in: extra.resets },
238 `$${extra.spent.toFixed(2)} of $${extra.limit.toFixed(0)} monthly · Resets ${extra.resets}`,
239 <Text color={extra.enabled ? 'success' : 'subtle'}>{extra.enabled ? '● ON' : '○ OFF'}</Text>,
240 )}
241 <Text dimColor>
242 ↻ updated {ago(snap.checkedMs - snap.updatedMs)}
243 {err ? ` · ${err}` : ''}
244 </Text>
245 </Box>
246 )
247 })
248}
249types/index.d.ts 27 lines1export type Limit = { percent: number; resets_in: string; available?: boolean }
2
3export type Extra = {
4 enabled: boolean
5 spent: number
6 percent: number
7 limit: number
8 resets: string
9}
10
11export type Usage = {
12 plan?: string
13 session?: Limit
14 weekly?: Limit
15 fable?: Limit
16 extra?: Extra
17}
18
19// What the pane draws: the file's contents, when it was written, and when it was last read.
20export type Snapshot = { data: Usage; updatedMs: number; checkedMs: number }
21
22declare module 'claude-code' {
23 interface PluginState {
24 'usage-meter': { snapshot: Snapshot | null; problem: string | null }
25 }
26}
27