Never get surprised by a rate limit again. An always-on band above the prompt shows your 5-hour and weekly Claude usage with live bars, reset countdowns and…

See your Claude usage limits before they bite. An always-on band above the Claude Code prompt:

89.1k/1M).Run this in a terminal claude session:
/plugin install limits --marketplace Ryan-LT/claude-mods
Answer y to add the marketplace, then choose the user scope.
claude --plugin-dir ./limits
claude plugin test ./limitshooks/register.tsx 514 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, SessionRateLimit } from 'claude-code'
3
4import type { Context, Limit, Snapshot } from '../types'
5
6const snapshot = atom({ plugin: 'limits', key: 'snapshot' } as const, null)
7const now = atom({ plugin: 'limits', key: 'now' } as const, 0)
8const alerted = atom({ plugin: 'limits', key: 'alerted' } as const, {})
9const refreshing = atom({ plugin: 'limits', key: 'refreshing' } as const, false)
10const context = atom({ plugin: 'limits', key: 'context' } as const, null)
11
12const HOUR = 3_600_000
13const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 168 * HOUR }
14const LABEL: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly', spend_limit: '$' }
15const LONG_LABEL: Record<string, string> = {
16 five_hour: '5-hour',
17 seven_day: 'Weekly',
18 spend_limit: 'Spend',
19}
20const ALERT_AT = [75, 90, 100]
21const MINUTE = 60_000
22/**
23 * Auto refresh runs once per slot of this length, on the clock (:00, :02, :04 …), so its timing is
24 * steady; every open session shares one slot, so only one of them asks.
25 */
26const SLOT = 2 * MINUTE
27/** A main-thread turn this old is taken as over, should its end never have been heard. */
28const BUSY_MAX = 10 * MINUTE
29/** The refresh button's own floor between presses. */
30const PRESS_GAP = 30_000
31const STORE_LATEST = 'latest'
32const STORE_PINGED = 'lastPingAt'
33const STORE_SLOT = 'lastSlot'
34
35// ---------- formatting ----------
36
37const fmtTokens = (n: number): string => {
38 const short = (v: number, unit: string) => `${Number.isInteger(v) ? v : v.toFixed(1)}${unit}`
39 if (n >= 1_000_000) return short(Math.round(n / 100_000) / 10, 'M')
40 if (n >= 1_000) return short(Math.round(n / 100) / 10, 'k')
41 return String(n)
42}
43
44const fmtSpan = (ms: number): string => {
45 if (ms <= 0) return 'now'
46 const mins = Math.round(ms / 60_000)
47 if (mins < 60) return `${mins}m`
48 const hours = Math.floor(mins / 60)
49 if (hours < 24) return mins % 60 ? `${hours}h${mins % 60}m` : `${hours}h`
50 const days = Math.floor(hours / 24)
51 return hours % 24 ? `${days}d${hours % 24}h` : `${days}d`
52}
53
54const fmtPct = (p: number | undefined): string =>
55 p === undefined ? '—' : `${p < 10 && p % 1 !== 0 ? p.toFixed(1) : Math.round(p)}%`
56
57const tone = (p: number | undefined): string =>
58 p === undefined ? 'inactive' : p >= 85 ? 'error' : p >= 60 ? 'warning' : 'success'
59
60const labelOf = (kind: string): string => LABEL[kind] ?? kind.replace(/_/g, ' ').toUpperCase()
61const longLabelOf = (kind: string): string => LONG_LABEL[kind] ?? kind.replace(/_/g, ' ')
62
63const ORDER = ['five_hour', 'seven_day', 'spend_limit']
64const sortLimits = (limits: Limit[]): Limit[] =>
65 [...limits].sort((a, b) => {
66 const ia = ORDER.indexOf(a.kind)
67 const ib = ORDER.indexOf(b.kind)
68 return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib)
69 })
70
71/** How far into the window we are versus how much is used, and where that trends. */
72const paceOf = (limit: Limit, at: number) => {
73 const length = WINDOW_MS[limit.kind]
74 if (!length || !limit.resetsAt) return null
75 const left = Date.parse(limit.resetsAt) - at
76 if (!Number.isFinite(left)) return null
77 const elapsed = Math.min(length, Math.max(0, length - left))
78 const elapsedPct = (elapsed / length) * 100
79 if (elapsed < length * 0.02) return { elapsedPct, delta: 0, runsOutIn: undefined }
80 const rate = limit.percentUsed / elapsed // % per ms
81 const runsOutIn = rate > 0 && limit.percentUsed < 100 ? (100 - limit.percentUsed) / rate : undefined
82 return {
83 elapsedPct,
84 delta: limit.percentUsed - elapsedPct,
85 runsOutIn: runsOutIn !== undefined && runsOutIn < left ? runsOutIn : undefined,
86 }
87}
88
89// ---------- state ----------
90
91/** `isLive`: the figures came from a response just now, so they stamp `checkedAt`. */
92const toSnapshot = (rateLimits: SessionRateLimit[], at: number, isLive = false): Snapshot => ({
93 limits: rateLimits.map(r => ({
94 kind: r.kind,
95 percentUsed: r.percentUsed,
96 resetsAt: r.resetsAt,
97 ...(isLive ? { checkedAt: at } : {}),
98 })),
99 at,
100})
101
102/** A window whose reset time has passed starts over: show it empty, not stale. */
103const current = (l: Limit, at: number): Limit =>
104 l.resetsAt && Date.parse(l.resetsAt) <= at ? { kind: l.kind, percentUsed: 0 } : l
105
106const resetMs = (l: Limit) => (l.resetsAt ? Date.parse(l.resetsAt) : 0)
107
108/**
109 * Which of two readings of one window is current. The later window wins (its reset is later).
110 * Within one window, the latest live reading wins whatever its figure: a plan upgrade drops the
111 * percent mid-window. A figure that may be replayed (one a chat saw hours ago) never beats a live
112 * one; between two such figures the higher wins, since usage inside a window only climbs.
113 */
114const newer = (a: Limit, b: Limit): Limit => {
115 const gap = resetMs(b) - resetMs(a)
116 if (Math.abs(gap) > 30 * MINUTE) return gap > 0 ? b : a
117 const keep = (l: Limit) => ({ ...l, resetsAt: l.resetsAt ?? (l === a ? b : a).resetsAt })
118 if (a.checkedAt !== undefined || b.checkedAt !== undefined) {
119 return (b.checkedAt ?? -1) >= (a.checkedAt ?? -1) ? keep(b) : keep(a)
120 }
121 return b.percentUsed >= a.percentUsed ? keep(b) : a
122}
123
124/**
125 * One response may report only some windows (the weekly one, say, and not the 5-hour), so a
126 * reading updates the windows it names and keeps the others, unless their reset has passed.
127 */
128const merge = (prev: Snapshot | null | undefined, next: Snapshot): Snapshot => {
129 const byKind = new Map((prev?.limits ?? []).map(l => [l.kind, l]))
130 for (const l of next.limits) {
131 const had = byKind.get(l.kind)
132 byKind.set(l.kind, had ? newer(had, l) : l)
133 }
134 const limits = [...byKind.values()].filter(
135 l => next.limits.some(n => n.kind === l.kind) || !l.resetsAt || Date.parse(l.resetsAt) > next.at,
136 )
137 return { limits, at: Math.max(prev?.at ?? 0, next.at) }
138}
139
140const maybeAlert = async ($: EngineInterface, s: Snapshot) => {
141 const seen = await read($, alerted)
142 const next: Record<string, number> = { ...seen }
143 for (const l of s.limits) {
144 const crossed = ALERT_AT.filter(level => l.percentUsed >= level).pop() ?? 0
145 if (crossed > (seen[l.kind] ?? 0)) {
146 $.ui.toast(`${longLabelOf(l.kind)} limit at ${fmtPct(l.percentUsed)}`, { timeoutMs: 6000 })
147 }
148 next[l.kind] = crossed
149 }
150 await update($, alerted, () => next)
151}
152
153/** This session's context window: its own, never shared. */
154const applyContext = async ($: EngineInterface, c: { tokens?: number; window: number; percent?: number }) => {
155 const value: Context = { tokens: c.tokens, window: c.window, percent: c.percent }
156 await update($, context, () => value)
157}
158
159/** Show a reading here, and hand it to every other open session through the store. */
160const apply = async ($: EngineInterface, reading: Snapshot, isShared = true) => {
161 if (reading.limits.length === 0) return
162 const s = merge(await read($, snapshot), reading)
163 await update($, snapshot, () => s)
164 await update($, now, () => Date.now())
165 await maybeAlert($, s)
166 if (isShared) {
167 const latest = (await $.store.get(STORE_LATEST)) as Snapshot | undefined
168 await $.store.set(STORE_LATEST, merge(latest, reading))
169 }
170}
171
172/** Takes up a newer reading another session made. */
173const pullShared = async ($: EngineInterface) => {
174 const latest = (await $.store.get(STORE_LATEST)) as Snapshot | undefined
175 const mine = await read($, snapshot)
176 if (latest?.limits?.length && (!mine || JSON.stringify(merge(mine, latest).limits) !== JSON.stringify(mine.limits) || mine.at < latest.at)) {
177 await apply($, latest, false)
178 }
179}
180
181const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
182const WINDOWS = ['five_hour', 'seven_day']
183
184type UsageWindow = { utilization?: number | null; resets_at?: string | null } | null
185
186/**
187 * The figures claude.ai's usage page shows, asked of Anthropic with this session's login (the engine
188 * sets the credential; the mod never sees it). Null with no claude.ai login: an API key or a gateway.
189 */
190const fetchUsage = async ($: EngineInterface): Promise<SessionRateLimit[] | null> => {
191 const auth = await $.session.authorize()
192 if (auth?.kind !== 'bearer') return null
193 const res = await $.http.fetch(USAGE_URL, { headers: { 'anthropic-beta': 'oauth-2025-04-20' }, auth: auth.handle })
194 if (!res.ok) return null
195 const body = JSON.parse(res.text) as Record<string, UsageWindow>
196 return WINDOWS.flatMap(kind => {
197 const w = body[kind]
198 if (typeof w?.utilization !== 'number') return []
199 const resetsAt = w.resets_at ? new Date(w.resets_at).toISOString() : undefined
200 return [{ kind, percentUsed: Math.round(w.utilization * 10) / 10, ...(resetsAt ? { resetsAt } : {}) }]
201 })
202}
203
204/**
205 * Asks Anthropic for the real figures: no model request, so no tokens, and the slot and press gaps
206 * keep it to one ask per two minutes across every open session.
207 */
208const refresh = async ($: EngineInterface, why: 'press' | 'auto') => {
209 if (await read($, refreshing)) return
210 const at = await $.clock.now()
211 if (why === 'press') {
212 const lastPing = Number((await $.store.get(STORE_PINGED)) ?? 0)
213 if (at - lastPing < PRESS_GAP) return void (await pullShared($))
214 } else {
215 // One ask per clock slot across every session: the first to reach the slot takes it.
216 const slot = Math.floor(at / SLOT)
217 if (Number((await $.store.get(STORE_SLOT)) ?? -1) >= slot) return void (await pullShared($))
218 await $.store.set(STORE_SLOT, slot)
219 }
220 await $.store.set(STORE_PINGED, at)
221 await update($, refreshing, () => true)
222 try {
223 await applyContext($, (await $.session.usage()).context)
224 const limits = await fetchUsage($)
225 if (limits) await apply($, toSnapshot(limits, await $.clock.now(), true))
226 } catch {
227 // offline or refused: the last reading stays, with its age on the button
228 } finally {
229 await update($, refreshing, () => false)
230 }
231}
232
233/** When the running main-thread turn began, or 0: figures that arrive meanwhile ride on its replies. */
234let busySince = 0
235
236const tick = async ($: EngineInterface) => {
237 const at = await $.clock.now()
238 await update($, now, () => at)
239 await pullShared($)
240 // Replies bring figures too, but an idle chat can replay old ones: Anthropic's own answer settles it.
241 await refresh($, 'auto')
242}
243
244/**
245 * The 15-second ticker, started by whichever hook runs first in this module's life: a reload
246 * in an open chat raises no session.start, and without this the bar would never refresh there.
247 */
248let isTicking = false
249
250const ensureTicker = ($: EngineInterface) => {
251 if (isTicking) return
252 isTicking = true
253 $.clock.after(50, () => void tick($).catch(() => {}))
254 $.clock.every(15_000, () => void tick($).catch(() => {}))
255}
256
257// ---------- drawing ----------
258
259/** One limit, reduced to what the band shows. */
260type Meter = {
261 label: string
262 pct: number
263 color: string
264 reset?: string
265 /** Where "now" sits in the window, 0–1: fill past it means ahead of pace. */
266 elapsed?: number
267 runsOut?: string
268 /** Plain grey text after the percent, no icon (the context's token count). */
269 detail?: string
270 alt: string
271}
272
273const HEX: Record<string, string> = {
274 success: '#3fb950',
275 warning: '#d29922',
276 error: '#f85149',
277}
278// Mid-tone grey reads on both light and dark themes (the surface's theme is not exposed).
279const GREY = '#8c959f'
280const FONT = 12.5
281const CH = FONT * 0.62 // monospace advance; every run is pinned to it with textLength
282const BAR_W = 56
283const BAR_H = 4
284const GAP = 22
285const ICON = 11
286
287const toMeter = (raw: Limit, at: number): Meter => {
288 const l = current(raw, at)
289 const pace = paceOf(l, at)
290 const runsOut = pace?.runsOutIn !== undefined ? fmtSpan(pace.runsOutIn) : undefined
291 // On track to run out before the reset: the bar and percent go red, no extra text.
292 const level = runsOut ? 'error' : tone(l.percentUsed)
293 const reset = l.resetsAt ? fmtSpan(Date.parse(l.resetsAt) - at) : undefined
294 return {
295 label: labelOf(l.kind),
296 pct: l.percentUsed,
297 color: level,
298 reset,
299 elapsed: pace ? pace.elapsedPct / 100 : undefined,
300 runsOut,
301 alt:
302 `${longLabelOf(l.kind)} limit ${fmtPct(l.percentUsed)} used` +
303 (reset ? `, resets in ${reset}` : '') +
304 (pace ? `, ${Math.round(pace.elapsedPct)}% of window elapsed` : '') +
305 (runsOut ? `, runs out in about ${runsOut}` : ''),
306 }
307}
308
309const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
310
311/** One run of text at x, exactly `text.length * CH` wide whatever font the surface picks. */
312const run = (x: number, y: number, text: string, fill: string, bold = false) =>
313 `<text x="${x.toFixed(1)}" y="${y}" fill="${fill}"${bold ? ' font-weight="600"' : ''} ` +
314 `textLength="${(text.length * CH).toFixed(1)}" lengthAdjust="spacingAndGlyphs">${esc(text)}</text>`
315
316/** A small clock: "resets in". */
317const clockIcon = (x: number, cy: number, color: string) => {
318 const r = ICON / 2 - 0.75
319 const cx = x + ICON / 2
320 return (
321 `<g fill="none" stroke="${color}" stroke-width="1.3" stroke-linecap="round">` +
322 `<circle cx="${cx.toFixed(1)}" cy="${cy}" r="${r}"/>` +
323 `<path d="M${cx.toFixed(1)} ${cy - r + 2}V${cy}l${(r * 0.55).toFixed(1)} ${(r * 0.45).toFixed(1)}"/></g>`
324 )
325}
326
327const bandSvg = (meters: Meter[]) => {
328 const H = 18
329 const y = 13
330 const by = (H - BAR_H) / 2
331 let x = 0
332 let body = ''
333 meters.forEach((m, i) => {
334 if (i > 0) x += GAP
335 const color = HEX[m.color] ?? GREY
336 body += run(x, y, m.label, GREY)
337 x += m.label.length * CH + 6
338 const fill = Math.min(1, Math.max(0, m.pct / 100)) * BAR_W
339 body += `<rect x="${x.toFixed(1)}" y="${by}" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${GREY}" fill-opacity=".25"/>`
340 if (fill > 0) {
341 body += `<rect x="${x.toFixed(1)}" y="${by}" width="${Math.max(BAR_H, fill).toFixed(1)}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>`
342 }
343 if (m.elapsed !== undefined) {
344 const tx = x + Math.min(BAR_W - 1, Math.max(0, m.elapsed * BAR_W))
345 body += `<rect x="${tx.toFixed(1)}" y="${by - 2}" width="1" height="${BAR_H + 4}" fill="${GREY}"/>`
346 }
347 x += BAR_W + 6
348 const pct = fmtPct(m.pct)
349 body += run(x, y, pct, color, true)
350 x += pct.length * CH
351 if (m.detail) {
352 body += run(x + CH, y, m.detail, GREY)
353 x += (m.detail.length + 1) * CH
354 }
355 if (m.reset) {
356 x += 8
357 body += clockIcon(x, H / 2, GREY)
358 x += ICON + 4
359 body += run(x, y, m.reset, GREY)
360 x += m.reset.length * CH
361 }
362 })
363 const width = Math.ceil(x + 2)
364 return {
365 source:
366 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${H}" viewBox="0 0 ${width} ${H}">` +
367 `<style>text{font-size:${FONT}px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre}</style>${body}</svg>`,
368 width,
369 height: H,
370 }
371}
372
373/** Terminal bar: thin line, with a tick at "now" in the window. */
374const barRuns = (m: Meter, width = 8): { text: string; color: string }[] => {
375 const filled = Math.min(width, Math.max(m.pct > 0 ? 1 : 0, Math.round((m.pct / 100) * width)))
376 const tick = m.elapsed === undefined ? -1 : Math.min(width - 1, Math.floor(m.elapsed * width))
377 const runs: { text: string; color: string }[] = []
378 for (let i = 0; i < width; i++) {
379 const [ch, color] = i === tick ? ['┃', 'inactive'] : i < filled ? ['━', m.color] : ['─', 'inactive']
380 const prev = runs[runs.length - 1]
381 if (prev && prev.color === color && i !== tick && i - 1 !== tick) prev.text += ch
382 else runs.push({ text: ch, color })
383 }
384 return runs
385}
386
387// ---------- hooks ----------
388
389export const register: Register = on => {
390 on('session.start', async ($, e, next) => {
391 const ran = await next(e)
392 $.ui.status(undefined)
393 try {
394 const usage = await $.session.usage()
395 await applyContext($, usage.context)
396 await apply($, toSnapshot(usage.rateLimits, Date.now()))
397 } catch {
398 // usage not available yet; session.measure will fill it in
399 }
400 await update($, refreshing, () => false)
401 ensureTicker($)
402
403 return ran
404 })
405
406 on('session.measure', async ($, e, next) => {
407 ensureTicker($)
408 if (e.changed.includes('context')) await applyContext($, e.context)
409 // Mid-turn, the figures ride on the reply that just came in; idle, they may be an old one replayed.
410 const isLive = busySince > 0 && Date.now() - busySince < BUSY_MAX
411 if (e.changed.includes('rateLimits')) await apply($, toSnapshot(e.rateLimits, Date.now(), isLive))
412
413 return next(e)
414 })
415
416 on('turn.start', ($, e, next) => {
417 ensureTicker($)
418 busySince = Date.now()
419
420 return next(e)
421 })
422
423 on('turn.complete', ($, e, next) => {
424 if (!e.agentId) busySince = 0
425
426 return next(e)
427 })
428
429 on('session.end', async ($, e, next) => {
430 if (e.reason === 'clear') {
431 await update($, alerted, () => ({}))
432 await update($, context, () => null)
433 }
434
435 return next(e)
436 })
437
438 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
439 ensureTicker($)
440 // Another mod's band beneath (quick replies) draws under this one.
441 const below = await next(e)
442 if (e.props.hasSurvey) return below
443 const withBelow = (tree: RenderElement) => {
444 if (below.type === 'engine') return tree
445 const { Box } = $.ui.resolve(e)
446 return (
447 <Box flexDirection="column" rowGap={1}>
448 {tree}
449 {below}
450 </Box>
451 )
452 }
453
454 const s = await read($, snapshot)
455 const at = (await read($, now)) || Date.now()
456 const ctx = await read($, context)
457 const meters = sortLimits(s?.limits ?? []).map(l => toMeter(l, at))
458 if (ctx?.percent !== undefined && ctx.tokens !== undefined) {
459 const detail = `${fmtTokens(ctx.tokens)}/${fmtTokens(ctx.window)}`
460 meters.push({
461 label: 'Context',
462 pct: ctx.percent,
463 color: tone(ctx.percent),
464 detail,
465 alt: `Context ${detail} tokens, ${fmtPct(ctx.percent)} of the window`,
466 })
467 }
468 const isRefreshing = await read($, refreshing)
469 const age = s?.at ? at - s.at : undefined
470 const ageText = isRefreshing ? '…' : age === undefined || age < MINUTE ? 'now' : fmtSpan(age)
471 type Resolved = typeof $.ui.resolve extends (e: never) => infer T ? T : never
472 const refreshButton = (Button: Resolved['Button']) => (
473 <Button key="refresh" label={`⟳ ${ageText}`} onPress={() => void refresh($, 'press')} />
474 )
475
476 if (e.surface !== 'terminal') {
477 const { Box, Button, Svg, Text } = $.ui.resolve(e)
478 if (meters.length === 0) return withBelow(<Text dimColor>Limits show after the first reply</Text>)
479 const band = bandSvg(meters)
480 return withBelow(
481 <Box flexDirection="row" flexWrap="nowrap" alignItems="center" columnGap={2}>
482 <Svg
483 source={band.source}
484 width={band.width}
485 height={band.height}
486 alt={meters.map(m => m.alt).join('; ')}
487 />
488 {refreshButton(Button)}
489 </Box>,
490 )
491 }
492
493 const { Box, Button, Text } = $.ui.resolve(e)
494 if (meters.length === 0) return withBelow(<Text dimColor>Limits show after the first reply</Text>)
495
496 return withBelow(
497 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
498 {meters.map(m => (
499 <Box flexDirection="row">
500 <Text dimColor>{m.label} </Text>
501 {barRuns(m).map(r => (
502 <Text color={r.color}>{r.text}</Text>
503 ))}
504 <Text color={m.color} bold> {fmtPct(m.pct)}</Text>
505 {m.detail ? <Text dimColor> {m.detail}</Text> : null}
506 {m.reset ? <Text dimColor> ◷ {m.reset}</Text> : null}
507 </Box>
508 ))}
509 {refreshButton(Button)}
510 </Box>,
511 )
512 })
513}
514types/index.d.ts 21 lines1/** `checkedAt` (ms): when a live response reported it; absent when it may be an old figure replayed. */
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string; checkedAt?: number }
3
4/** The limits as one API response reported them, and when (ms). */
5export type Snapshot = { limits: Limit[]; at: number }
6
7/** This session's context window, as the last response filled it. */
8export type Context = { tokens?: number; window: number; percent?: number }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'limits': {
13 snapshot: Snapshot | null
14 now: number
15 alerted: Record<string, number>
16 refreshing: boolean
17 context: Context | null
18 }
19 }
20}
21