Your plan's 5-hour and weekly usage limits above the prompt: percent used, time to reset, used today, with warnings at 75% and 90%.

hooks/register.tsx 222 lines1// Limit Meter: your plan's usage limits, live, like a usage counter for
2// Claude Code. One line above the prompt shows the 5-hour and weekly windows
3// (percent used, time to reset, how much of the week you used today), toasts
4// warn at 75%, 90% and 100%, and /limits opens a pane with the details.
5//
6// The readings are the ones Claude Code itself gets back with every response,
7// so they refresh as you work. With an API key there are no plan limits, and
8// only the session cost is shown.
9//
10// LIMIT_METER=status shows it in the status line instead; LIMIT_METER=off
11// hides the line (warnings and /limits keep working).
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register } from 'claude-code'
15
16import type { LimitDayStart, LimitReading } from '../types'
17import { ago, bar, color, crossed, day, label, order, pct, shortLabel, untilReset, when } from './format'
18
19const PANE = 'limit-meter'
20
21const limits = atom({ plugin: 'limit-meter', key: 'limits' } as const, [])
22const usd = atom({ plugin: 'limit-meter', key: 'usd' } as const, null)
23const updatedAt = atom({ plugin: 'limit-meter', key: 'updatedAt' } as const, 0)
24const now = atom({ plugin: 'limit-meter', key: 'now' } as const, 0)
25const dayStart = atom({ plugin: 'limit-meter', key: 'dayStart' } as const, null)
26
27type Display = 'band' | 'status' | 'off'
28// Each Claude Code session loads its own copy of this module, so one variable per module is per session.
29// (Keying a map by $ is not allowed: $ may only be used as $.noun.method(...), and the module refuses to load.)
30let display: Display = 'band'
31
32export const register: Register = on => {
33 on('session.start', async ($, e, next) => {
34 const result = await next(e)
35 const mode = ((await $.env.get('LIMIT_METER')) ?? 'band').toLowerCase()
36 display = mode === 'status' || mode === 'off' ? (mode as Display) : 'band'
37 const saved = await $.store.get('dayStart')
38 if (saved !== undefined && saved !== null) await update($, dayStart, () => saved as LimitDayStart)
39 try {
40 await $.command.register({ name: 'limits', description: 'Show your 5-hour and weekly usage limits' })
41 } catch (error) {
42 $.ui.log(`limit-meter: could not register /limits (${String(error)})`, { to: 'debug' })
43 }
44 await refresh($)
45 // Move the countdowns along, and pick up readings from background work.
46 $.clock.every(60_000, () => refresh($))
47 return result
48 })
49
50 on('turn.complete', async ($, e, next) => {
51 const result = await next(e)
52 await refresh($)
53 return result
54 })
55
56 on('command.run', { command: 'limits' }, async $ => {
57 await refresh($)
58 await $.ui.open({ id: PANE, title: 'Limits', focus: true, closeOnEscape: true })
59 const list = order(await read($, limits))
60 if (list.length === 0) return { text: 'No plan limits reported yet.' }
61 return { text: list.map(l => `${label(l.kind)} ${pct(l.percentUsed)}`).join(' · ') }
62 })
63
64 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
65 const below = await next(e)
66 if (display !== 'band' || e.props.hasSurvey) return below
67 const list = order(await read($, limits))
68 if (list.length === 0) return below
69 const t = await read($, now)
70 const start = await read($, dayStart)
71 const { Box, Text } = $.ui.resolve(e)
72 const roomy = e.props.bodyColumns >= 76
73
74 return (
75 <Box flexDirection="column">
76 {below}
77 <Box key="limit-meter" flexDirection="row" paddingX={1} gap={2}>
78 {list.map(l => {
79 const todayPct = l.kind === 'seven_day' ? today(l, start) : undefined
80 return (
81 <Box key={`lim-${l.kind}`} flexDirection="row">
82 <Text dimColor>{shortLabel(l.kind)} </Text>
83 {roomy && <Text color={color(l.percentUsed)}>{bar(l.percentUsed, 8)} </Text>}
84 <Text color={color(l.percentUsed)} bold>
85 {pct(l.percentUsed)}
86 </Text>
87 {l.resetsAt !== undefined && <Text dimColor> · {untilReset(l.resetsAt, t)}</Text>}
88 {todayPct !== undefined && todayPct > 0 && (
89 <Text dimColor> · today +{pct(todayPct)}</Text>
90 )}
91 </Box>
92 )
93 })}
94 </Box>
95 </Box>
96 )
97 })
98
99 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
100 const { Box, Text, Button } = $.ui.resolve(e)
101 const list = order(await read($, limits))
102 const t = await read($, now)
103 const start = await read($, dayStart)
104 const cost = await read($, usd)
105 const at = await read($, updatedAt)
106 const cells = Math.max(10, Math.min(40, e.props.bodyColumns - 12))
107
108 return (
109 <Box flexDirection="column" width={e.props.bodyColumns}>
110 {list.length === 0 ? (
111 <Box flexDirection="column">
112 <Text bold>No plan limits reported yet.</Text>
113 <Text dimColor>
114 They appear after Claude's first response when you sign in with a Claude subscription (Pro or Max). With an
115 API key there are no plan limits, only the cost below.
116 </Text>
117 </Box>
118 ) : (
119 list.map(l => {
120 const todayPct = l.kind === 'seven_day' ? today(l, start) : undefined
121 return (
122 <Box key={`pane-${l.kind}`} flexDirection="column" marginBottom={1}>
123 <Box flexDirection="row" justifyContent="space-between">
124 <Text bold>{label(l.kind)} limit</Text>
125 <Text color={color(l.percentUsed)} bold>
126 {pct(l.percentUsed)} used
127 </Text>
128 </Box>
129 <Text color={color(l.percentUsed)}>{bar(l.percentUsed, cells)}</Text>
130 {l.resetsAt !== undefined && (
131 <Text dimColor>
132 Resets in {untilReset(l.resetsAt, t)} · {when(l.resetsAt)}
133 </Text>
134 )}
135 {todayPct !== undefined && todayPct > 0 && (
136 <Text dimColor>Used today: +{pct(todayPct)} of the week</Text>
137 )}
138 </Box>
139 )
140 })
141 )}
142 {cost !== null && <Text>This session: ${cost.toFixed(2)}</Text>}
143 <Text dimColor>
144 Updated {at === 0 ? 'never' : ago(at, t)} · from Claude Code's latest response
145 </Text>
146 <Box flexDirection="row" marginTop={1} gap={1}>
147 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => { refresh($).catch(e => $.ui.log(String(e), { to: 'debug' })) }} />
148 <Button key="close" label="Close" hotkey="c" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
149 </Box>
150 </Box>
151 )
152 })
153}
154
155/** How much of the week was used since the first reading of the local day. */
156function today(weekly: LimitReading, start: LimitDayStart | null): number | undefined {
157 if (start === null || start.resetsAt !== weekly.resetsAt) return undefined
158 return Math.max(0, Math.round((weekly.percentUsed - start.percent) * 10) / 10)
159}
160
161async function refresh($: EngineInterface): Promise<void> {
162 const t = await $.clock.now()
163 await update($, now, () => t)
164 let usage
165 try {
166 usage = await $.session.usage()
167 } catch {
168 return
169 }
170 if (usage.cost !== undefined) await update($, usd, () => usage.cost!.usd)
171
172 const fresh: LimitReading[] = usage.rateLimits.map(r => {
173 const reading: LimitReading = { kind: r.kind, percentUsed: r.percentUsed }
174 const resets = r.resetsAt === undefined ? NaN : Date.parse(r.resetsAt)
175 if (!Number.isNaN(resets)) reading.resetsAt = resets
176 return reading
177 })
178 // No reading yet this session (or an API key): keep what we had.
179 if (fresh.length === 0) return
180 await update($, limits, () => fresh)
181 await update($, updatedAt, () => t)
182
183 await trackDay($, fresh, t)
184 await warn($, fresh, t)
185 if (display === 'status') {
186 await $.ui.status(order(fresh).map(l => `${shortLabel(l.kind)} ${pct(l.percentUsed)}`).join(' · '))
187 }
188}
189
190async function trackDay($: EngineInterface, fresh: readonly LimitReading[], t: number) {
191 const weekly = fresh.find(l => l.kind === 'seven_day')
192 if (weekly === undefined) return
193 const current = await read($, dayStart)
194 const date = day(t)
195 if (current !== null && current.date === date && current.resetsAt === weekly.resetsAt) return
196 const start: LimitDayStart = { date, percent: weekly.percentUsed }
197 if (weekly.resetsAt !== undefined) start.resetsAt = weekly.resetsAt
198 await update($, dayStart, () => start)
199 await $.store.set('dayStart', start)
200}
201
202async function warn($: EngineInterface, fresh: readonly LimitReading[], t: number) {
203 const stored = await $.store.get('warned')
204 const warned = new Set(Array.isArray(stored) ? (stored as string[]) : [])
205 let changed = false
206 for (const l of fresh) {
207 const levels = crossed(l.percentUsed)
208 const window = `${l.kind}|${l.resetsAt ?? ''}`
209 const fresher = levels.filter(level => !warned.has(`${window}|${level}`))
210 if (fresher.length === 0) continue
211 for (const level of fresher) warned.add(`${window}|${level}`)
212 changed = true
213 const top = fresher[fresher.length - 1]!
214 const reset = l.resetsAt !== undefined ? ` · resets in ${untilReset(l.resetsAt, t)}` : ''
215 $.ui.toast(
216 top >= 100
217 ? `Limit Meter: ${label(l.kind)} limit reached${reset}`
218 : `Limit Meter: ${label(l.kind)} limit at ${pct(l.percentUsed)}${reset}`,
219 )
220 }
221 if (changed) await $.store.set('warned', [...warned].slice(-60))
222}hooks/format.ts 80 lines1// Pure formatting for Limit Meter: labels, bars, colors and countdowns.
2
3import type { LimitReading } from '../types'
4
5export function label(kind: string): string {
6 if (kind === 'five_hour') return '5-hour'
7 if (kind === 'seven_day') return 'Weekly'
8 if (kind === 'spend_limit') return 'Spend'
9 const m = /^seven_day_(\w+)$/.exec(kind)
10 if (m) return `Weekly ${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)}`
11 return kind.replace(/_/g, ' ')
12}
13
14/** Short label for the one-line band. */
15export function shortLabel(kind: string): string {
16 if (kind === 'five_hour') return '5h'
17 if (kind === 'seven_day') return 'week'
18 if (kind === 'spend_limit') return 'spend'
19 const m = /^seven_day_(\w+)$/.exec(kind)
20 return m ? `week ${m[1]}` : kind.replace(/_/g, ' ')
21}
22
23export function color(percent: number): string {
24 if (percent >= 90) return 'red'
25 if (percent >= 75) return 'magenta'
26 if (percent >= 50) return 'yellow'
27 return 'green'
28}
29
30export function bar(percent: number, cells: number): string {
31 const filled = Math.max(0, Math.min(cells, Math.round((Math.min(percent, 100) / 100) * cells)))
32 return '█'.repeat(filled) + '░'.repeat(cells - filled)
33}
34
35export function pct(percent: number): string {
36 return `${Math.round(percent * 10) / 10}%`
37}
38
39/** "42m", "2h 13m", or past a day "Thu 09:00". */
40export function untilReset(resetsAt: number | undefined, now: number): string {
41 if (resetsAt === undefined) return ''
42 const ms = resetsAt - now
43 if (ms <= 0) return 'resetting'
44 const minutes = Math.ceil(ms / 60_000)
45 if (minutes < 60) return `${minutes}m`
46 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
47 return when(resetsAt)
48}
49
50const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
51const pad = (n: number) => String(n).padStart(2, '0')
52
53/** "Thu 09:00" in local time. */
54export function when(ms: number): string {
55 const d = new Date(ms)
56 return `${DAYS[d.getDay()]} ${pad(d.getHours())}:${pad(d.getMinutes())}`
57}
58
59export function day(ms: number): string {
60 const d = new Date(ms)
61 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
62}
63
64export function ago(ms: number, now: number): string {
65 const s = Math.max(0, Math.round((now - ms) / 1000))
66 if (s < 60) return 'just now'
67 const m = Math.floor(s / 60)
68 return m < 60 ? `${m} min ago` : `${Math.floor(m / 60)}h ${m % 60}m ago`
69}
70
71/** Short windows first: 5-hour, then weekly ones, then the rest. */
72export function order(limits: readonly LimitReading[]): LimitReading[] {
73 const rank = (k: string) => (k === 'five_hour' ? 0 : k === 'seven_day' ? 1 : k.startsWith('seven_day') ? 2 : 3)
74 return [...limits].sort((a, b) => rank(a.kind) - rank(b.kind) || a.kind.localeCompare(b.kind))
75}
76
77/** The warning thresholds a reading has crossed, highest last. */
78export function crossed(percent: number): number[] {
79 return [75, 90, 100].filter(t => percent >= t)
80}types/index.d.ts 26 lines1export type LimitReading = {
2 /** `five_hour`, `seven_day`, `spend_limit`, or another window Claude Code reports. */
3 kind: string
4 /** 0 to 100 (past 100 on an exceeded spend limit). */
5 percentUsed: number
6 /** When the window resets, ms since the epoch. */
7 resetsAt?: number
8}
9
10/** The weekly reading at the start of the local day, for "used today". */
11export type LimitDayStart = { date: string; percent: number; resetsAt?: number }
12
13declare module 'claude-code' {
14 interface PluginState {
15 'limit-meter': {
16 limits: LimitReading[]
17 /** Session cost in US dollars, when the host keeps a ledger. */
18 usd: number | null
19 /** When the readings were last refreshed. */
20 updatedAt: number
21 /** Ticks every minute so the countdowns move. */
22 now: number
23 dayStart: LimitDayStart | null
24 }
25 }
26}