Your 5-hour and weekly limits, with when each resets, in the footer next to the model picker.

What it does: keeps your 5-hour and weekly limits in the footer next to the model picker, with the time left until each resets, so you never need the usage button.
Where you see it: the footer, right of the prompt:
5h 22% 3h27 · w 76%
45m under an hour). The weekly window shows no countdown here, to keep the line short; the pace panel has its reset./pace there./pace (the pace mod).Where the numbers come from:
~/.claude/mods-data/limits/readings.json, so only one session asks the endpoint at a time, at most every 3 minutes. If the endpoint fails, the file says why (error) and the mod waits 10 minutes before asking again.Not shown: the Fable weekly limit (the usage panel's per-model line).
Cost: no model calls. One small request to the usage endpoint every few minutes, shared by all your sessions.
hooks/register.tsx 227 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { FooterPart, LimitWindow, LimitsFile, LimitsReading } from '../types'
5
6// Your 5-hour and weekly limits, with when the 5-hour one resets, in the footer
7// next to the model picker, so the usage button is never needed:
8// 5h 22% 3h12 · w 76%
9// The words 5h and w are dim grey, the numbers bright, the % bold, and a dim
10// dot sets the two windows apart. A % from 85% up and a countdown under 30
11// minutes turn red. The 5-hour window says how long until it resets; the weekly
12// one shows no countdown (the pace panel has it).
13// Plain text, on one line, alike on the desktop and the terminal: the desktop
14// app draws neither an SVG nor a surface module in that slot.
15// The figures come from the account endpoint the usage button reads (your
16// login rides along, attached by the engine; the mod never sees it), and from
17// each reply's own rate-limit headers in between. Every open session shares
18// one reading in ~/.claude/mods-data/limits/readings.json, so only one asks the
19// endpoint at a time; its short history is what the pace mod works from.
20
21// How often each session looks at the shared reading.
22const TICK_MS = 60_000
23// A reading or a check younger than this stands: no session asks again.
24const FRESH_MS = 3 * 60_000
25// After the endpoint fails, wait this long before asking again.
26const BACKOFF_MS = 10 * 60_000
27// History for the pace mod: 8 days, a reading at most every 2 minutes unless it moved.
28const HISTORY_MS = 8 * 24 * 3_600_000
29const HISTORY_MAX = 4000
30const HISTORY_EVERY_MS = 2 * 60_000
31
32// The words are grey and the numbers keep the bright text color; a % from 85%
33// up and a countdown under 30 minutes turn red.
34const GREY = '#9ca3af'
35const RED = '#f87171'
36// The desktop folds any run of whitespace (even no-break spaces) into one; a braille
37// blank is not whitespace to it, so it keeps the room between a % and its countdown
38// and around the dot between the windows.
39const PAD = '\u2800'
40const HOT_PCT = 85
41const SOON_MS = 30 * 60_000
42// The 5-hour countdown shades with the time left: green past 3 hours (a window
43// just begun), orange from 1 to 3 hours, red under 1 hour (nearly reset).
44const GREEN = '#4ade80'
45const ORANGE = '#fb923c'
46const fiveColor = (msLeft: number | null) => (msLeft === null ? undefined : msLeft > 3 * 3_600_000 ? GREEN : msLeft > 3_600_000 ? ORANGE : RED)
47// The weekly countdown only turns red, under SOON_MS.
48const resetColor = (label: string, msLeft: number | null) => (label === '5h' ? fiveColor(msLeft) : msLeft !== null && msLeft < SOON_MS ? RED : undefined)
49
50const reading = atom({ plugin: 'limits', key: 'reading' } as const, null)
51
52const EMPTY: LimitsFile = { latest: null, history: [], checkedAt: 0, error: null, errorAt: null }
53
54const dataFile = async ($: EngineInterface) => {
55 // CLAUDE_CONFIG_DIR (set when Claude's folder lives on another drive) wins over ~/.claude
56 const dir = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'}/.claude`).replace(/\\/g, '/')
57 return `${dir}/mods-data/limits/readings.json`
58}
59
60const load = async ($: EngineInterface): Promise<LimitsFile> => {
61 try {
62 return { ...EMPTY, ...(JSON.parse(await $.fs.read(await dataFile($))) as Partial<LimitsFile>) }
63 } catch {
64 return { ...EMPTY }
65 }
66}
67
68const save = async ($: EngineInterface, file: LimitsFile) => $.fs.write(await dataFile($), JSON.stringify(file))
69
70// The countdown to a reset: 3h27, 45m.
71// No "in" and no trailing m past an hour: the footer slot is narrow. Resets
72// land a fraction of a second before the minute (03:59:59.7), so it is rounded
73// to the nearest minute.
74const countdownOf = (iso: string | null, now: number) => {
75 const t = iso === null ? NaN : Date.parse(iso)
76 if (!Number.isFinite(t) || t <= now) return null
77 const m = Math.round((t - now) / 60_000)
78 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
79}
80
81// A window whose reset time has passed has started over.
82const current = (w: LimitWindow | null, now: number): LimitWindow | null => {
83 if (!w) return null
84 const t = w.resetsAt === null ? NaN : Date.parse(w.resetsAt)
85 return Number.isFinite(t) && t <= now ? { pct: 0, resetsAt: null } : w
86}
87
88const partOf = (label: string, w: LimitWindow, reset: string | null, now: number): FooterPart => {
89 const t = w.resetsAt === null ? NaN : Date.parse(w.resetsAt)
90 return { label, pct: Math.round(w.pct), reset, msLeft: Number.isFinite(t) ? t - now : null }
91}
92
93const footerParts = (r: LimitsReading, now: number) => {
94 const five = current(r.five, now)
95 const week = current(r.week, now)
96 return [five && partOf('5h', five, countdownOf(five.resetsAt, now), now), week && partOf('w', week, null, now)].filter(
97 (p): p is FooterPart => p !== null,
98 )
99}
100
101// The endpoint's window: { utilization: 0-100, resets_at: ISO }.
102const windowOf = (raw: unknown): LimitWindow | null => {
103 const w = raw as { utilization?: unknown; resets_at?: unknown } | null | undefined
104 if (!w || typeof w.utilization !== 'number' || !Number.isFinite(w.utilization)) return null
105 return { pct: Math.round(w.utilization * 10) / 10, resetsAt: typeof w.resets_at === 'string' ? w.resets_at : null }
106}
107
108// A reply's rate-limit headers, as the engine reads them.
109const fromHeaders = (list: readonly SessionRateLimit[], at: number, before: LimitsReading | null): LimitsReading | null => {
110 const pick = (kind: string, prev: LimitWindow | null) => {
111 const l = list.find(x => x.kind === kind)
112 return l ? { pct: l.percentUsed, resetsAt: l.resetsAt ?? prev?.resetsAt ?? null } : prev
113 }
114 const five = pick('five_hour', before?.five ?? null)
115 const week = pick('seven_day', before?.week ?? null)
116 return five || week ? { at, five, week } : null
117}
118
119const isSame = (a: LimitWindow | null, b: LimitWindow | null) => a?.pct === b?.pct && a?.resetsAt === b?.resetsAt
120
121const show = async ($: EngineInterface, r: LimitsReading | null) => {
122 const cur = await read($, reading)
123 if (r && (!cur || cur.at !== r.at || !isSame(cur.five, r.five) || !isSame(cur.week, r.week))) await update($, reading, () => r)
124}
125
126// Saves a new reading for every session (and the pace mod's history), then shows it.
127const record = async ($: EngineInterface, r: LimitsReading, file: LimitsFile, error: string | null) => {
128 const last = file.history.at(-1)
129 const hasMoved = !last || !isSame(last.five, r.five) || !isSame(last.week, r.week)
130 const history = hasMoved || r.at - last.at >= HISTORY_EVERY_MS ? [...file.history, r] : file.history
131 await save($, {
132 latest: r,
133 history: history.filter(h => h.at >= r.at - HISTORY_MS).slice(-HISTORY_MAX),
134 checkedAt: file.checkedAt,
135 error,
136 errorAt: error === null ? null : file.errorAt,
137 })
138 await show($, r)
139}
140
141// Asks the account endpoint, unless a session did lately or it failed lately.
142const refresh = async ($: EngineInterface) => {
143 const file = await load($)
144 const now = await $.clock.now()
145 const isFresh = file.latest !== null && now - file.latest.at < FRESH_MS
146 const isRecent = now - file.checkedAt < FRESH_MS
147 const isBackingOff = file.errorAt !== null && now - file.errorAt < BACKOFF_MS
148 if (isFresh || isRecent || isBackingOff) return show($, file.latest)
149 // Claimed first, so the other sessions leave this check to this one.
150 await save($, { ...file, checkedAt: now })
151 const fail = async (error: string) => {
152 await save($, { ...file, checkedAt: now, error, errorAt: now })
153 await show($, file.latest)
154 }
155 const auth = await $.session.authorize().catch(() => null)
156 if (!auth) return fail('no Anthropic login to read the plan limits with')
157 try {
158 const base = ((await $.env.get('ANTHROPIC_BASE_URL')) ?? 'https://api.anthropic.com').replace(/\/+$/, '')
159 const res = await $.http.fetch(`${base}/api/oauth/usage`, { auth: auth.handle, headers: { 'anthropic-beta': 'oauth-2025-04-20' } })
160 if (!res.ok) return fail(`the usage endpoint answered ${res.status}`)
161 const body = JSON.parse(res.text) as Record<string, unknown>
162 const fresh = { at: now, five: windowOf(body.five_hour), week: windowOf(body.seven_day) }
163 if (!fresh.five && !fresh.week) return fail('the usage endpoint named no 5-hour or weekly window')
164 await record($, fresh, { ...file, checkedAt: now }, null)
165 } catch (err) {
166 await fail(`the usage endpoint could not be read: ${err instanceof Error ? err.message : String(err)}`)
167 }
168}
169
170export const register: Register = on => {
171 on('session.start', async ($, e, next) => {
172 const started = await next(e)
173 const file = await load($)
174 await show($, file.latest)
175 // This session's own reading, when it has had a reply already (a resumed one).
176 const own = fromHeaders((await $.session.usage()).rateLimits, await $.clock.now(), file.latest)
177 if (own) await record($, own, file, file.error)
178 void refresh($).catch(() => undefined)
179 // Another session's reading, a due check, and a reset time passing all show within a minute.
180 $.clock.every(TICK_MS, () => {
181 void refresh($).catch(() => undefined)
182 $.ui.invalidate('ui.render')
183 })
184 return started
185 })
186
187 // Each reply's headers: free, and fresher than the last check.
188 on('session.measure', async ($, e, next) => {
189 if (e.changed.includes('rateLimits')) {
190 const file = await load($)
191 const r = fromHeaders(e.rateLimits, await $.clock.now(), file.latest)
192 if (r) await record($, r, file, file.error)
193 }
194 return next(e)
195 })
196
197 // The footer's mode slot, right of the prompt by the model picker.
198 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
199 const r = await read($, reading)
200 const parts = r ? footerParts(r, await $.clock.now()) : []
201 if (parts.length === 0) return next(e)
202 const below = await next(e)
203 const { Box, Text } = $.ui.resolve(e)
204 // The same on the terminal and the desktop. The desktop folds any run of
205 // spaces (even no-break ones) into one and ignores a Box's gap, so the
206 // windows are set apart by a dim dot, not by spaces. Then whatever the mods
207 // beneath add (the timings button, the pace P).
208 return (
209 <Box flexDirection="row" gap={1}>
210 <Box key="limits:line" flexShrink={0}>
211 <Text wrap="truncate-end">
212 {parts.flatMap((p, i) => [
213 ...(i > 0 ? [<Text key={`limits:${p.label}:dot`} color={GREY}>{` ${PAD}·${PAD} `}</Text>] : []),
214 <Text key={`limits:${p.label}:label`} color={GREY}>{`${p.label} `}</Text>,
215 <Text key={`limits:${p.label}:pct`} bold {...(p.pct >= HOT_PCT ? { color: RED } : {})}>{`${p.pct}%`}</Text>,
216 ...(p.reset !== null
217 ? [<Text key={`limits:${p.label}:reset`} {...(resetColor(p.label, p.msLeft) ? { color: resetColor(p.label, p.msLeft) } : {})}>{`${PAD}${p.reset}`}</Text>]
218 : []),
219 ])}
220 </Text>
221 </Box>
222 {below}
223 </Box>
224 )
225 })
226}
227types/index.d.ts 30 lines1/** One usage window: how full it is (0-100) and when it resets (ISO 8601), as the account reports it. */
2export type LimitWindow = { pct: number; resetsAt: string | null }
3
4/** The account's 5-hour and weekly windows at one moment (`at`, ms). */
5export type LimitsReading = { at: number; five: LimitWindow | null; week: LimitWindow | null }
6
7/**
8 * What every open session shares in ~/.claude/mods-data/limits/readings.json:
9 * the latest reading, a short history (the pace mod's input), and when the
10 * account endpoint was last asked and what went wrong, if anything.
11 */
12export type LimitsFile = {
13 latest: LimitsReading | null
14 history: LimitsReading[]
15 checkedAt: number
16 error: string | null
17 errorAt: number | null
18}
19
20/** One window as the footer draws it; `reset` the countdown to its reset ("1h17"), null when it isn't shown; `msLeft` the time to the reset in ms, shown or not. */
21export type FooterPart = { label: string; pct: number; reset: string | null; msLeft: number | null }
22
23declare module 'claude-code' {
24 interface PluginState {
25 limits: {
26 reading: LimitsReading | null
27 }
28 }
29}
30