Shows your Claude plan's session and weekly limits above the prompt, gives Claude a plan_usage tool to check them without a model call, and can pause a…

A Claude Code mod that keeps your Claude plan's limits in view and lets Claude check them itself.

plan_usage tool Claude can call before long or unattended work, and a /plan-usage command for you. Both are answered locally from figures Claude Code already has, with no model call.Tested on macOS. It uses no platform-specific code, but Linux and Windows are untested.
claude plugin marketplace add potterdigital/plan-usage-mod
claude plugin install plan-usage@potterdigital
If the install mentions userConfig options not yet set, that is fine: every setting has a default.
From inside a session, /plugin install plan-usage --marketplace potterdigital/plan-usage-mod asks you to confirm the marketplace, then opens the plugin so you can choose where to install it.
To try it without installing, clone the repository and start a session with claude --plugin-dir ./plan-usage-mod.
| Line shows | Means |
|---|---|
● OK in blue | Under the warning level (80% unless you change it) |
▲ HIGH in orange | At or above the warning level |
■ LIMIT in orange | At or past 100% |
⏸ PAUSED at 96% · continues 4:12 AM CT | The optional pause is holding the session |
as of 2:31 AM CT | The newest figures are more than 5 minutes old |
○ pause off until 4:10 AM CT | You overrode the optional pause for this session window |
Every level has its own icon and word as well as its color, so it reads the same for colorblind users.
To keep the line out of the way until it matters, set show_at: with 50, a limit appears only once it reaches 50%, and the line is empty while neither has.
Until someone on a computer answers it, the line asks: Plan usage: show your limits above the prompt 1: Always 2: From 50% 3: From 80% 4: Other. Type the digit at an empty prompt, or click; Other asks for a whole number from 0 to 100. As with Claude Code's own survey in the same place, a message you start with 1 to 4 at an empty prompt picks that answer while the question is up. The choice is saved as show_at, so /config shows it and can change it, and a choice made while Claude works is saved when the turn ends. The question is not asked in a claude -p run.
In a narrow terminal (under about 105 columns) the line shortens to the time left, as in Session 9% · 3h 55m. What mods that run after this one draw in the same place, Claude Code's own notices included, stays visible below this line.
plan_usage toolClaude sees it as mcp__plan-usage__plan_usage. It takes no input and answers JSON:
{
"limits": [
{
"kind": "five_hour",
"percentUsed": 8,
"resetsAt": "2026-10-04T11:40:00.000Z",
"label": "Session",
"level": "ok",
"resetsAtLocal": "6:40 AM CT",
"resetsIn": "3h 57m"
},
{
"kind": "seven_day",
"percentUsed": 98,
"resetsAt": "2026-10-04T10:00:00.000Z",
"label": "Weekly",
"level": "high",
"resetsAtLocal": "5:00 AM CT",
"resetsIn": "2h 17m"
}
],
"readAtLocal": "2:43 AM CT",
"timeZone": "America/Chicago",
"autoPause": { "isOn": false, "threshold": 95, "pause": null }
}
kind, percentUsed and resetsAt are exactly what Claude Code reports. Other kinds appear when your plan has them, such as seven_day_opus.Thu 9:00 PM CT.limits is empty and a note explains why. If the time_zone setting is not a valid zone, a warning says so./plan-usage prints the same JSON, after the plan-usage: label Claude Code puts on every plugin command's output.Answering costs no model call. Like any tool, its name and description are part of what Claude Code sends with each request, and Claude reads its answer.
To have Claude check before big jobs, add a line like this to your CLAUDE.md: Before starting long or unattended work, call plan_usage and stop if the weekly limit is above 90%.
Claude Code reads your plan limits from each model reply; they are the figures its status line receives. This mod takes them from there (the session.measure event) and makes no network requests of its own.
--plugin-dir folder).The figures are Claude Code's, so they are as current as its last reply. A limit can move from usage elsewhere, such as claude.ai in a browser, before the next reply shows it.
Off by default, and it runs only where a person is at the prompt: in a claude -p run or an SDK host it stays off, because the run ends with its turn and could never continue.
When it is on and the session limit reaches the threshold (95% unless you change it):
Plan usage reset; resume where you left off from the files on disk. Claude reads it with a note that this mod sent it, not you.The check runs whenever new figures arrive during a turn and when each turn starts, so a turn that starts past the threshold, including one you start, a /loop iteration or a queued prompt, is stopped before Claude answers, and the line shows PAUSED. If a turn finishes on its own before anything was refused, nothing is continued later.
You stay in charge. While a pause is in place, a prompt you send yourself cancels it, and the mod does not pause again until that session window resets; the line shows ○ pause off until and the reset time. That counts prompts typed at the terminal, sent through Remote Control, or sent from Slack as the session's owner. A message you type while a turn is still running is queued into that turn and does not cancel anything. /clear, /resume, /branch and closing the session also cancel a waiting continue.
The pause watches the session limit only. If the weekly limit is what runs out, the continuing prompt meets that limit instead.
How this differs from Claude Code's built-in waiting. Claude Code already waits and continues on its own when a usage limit stops a session (autoContinueAtUsageLimit, on by default). That starts once the limit has been hit, wherever the work was. This pause starts a little earlier, at a threshold you pick, so the turn ends between tool calls rather than partway through one, and the session keeps some headroom for you.
Set these in /config, where each one is a row, or in settings.json:
{
"pluginConfigs": {
"plan-usage@potterdigital": {
"options": { "auto_pause": true, "pause_threshold": 90 }
}
}
}
With --plugin-dir, the key is plan-usage@inline.
| Setting | Default | What it does |
|---|---|---|
warn_at | 80 | Percent at which the line turns orange and reads HIGH |
show_at | 0 | Show a limit on the line only from this percent. 0 shows it always. |
time_zone | empty | IANA time zone for reset times, such as America/New_York. Empty uses your computer's. An invalid name falls back to your computer's, with a note in the session and a warning in the tool's answer. |
auto_pause | false | Turns the pause on |
pause_threshold | 95 | Session-limit percent that starts the pause |
resume_prompt | see above | What the session is sent when it continues |
Zones without a short name show as an offset, such as 9:00 AM GMT+1 for London.
claude plugin validate . lists everything the mod hooks and calls. In short:
CLAUDE_CONFIG_DIR environment variable, which keeps readings from different Claude profiles apart. It is told about each prompt you send, like every prompt hook, and uses only who sent it; it does not read, store or forward the text.show_at setting; the last reading and any pause, to the session's own state; and the last reading and whether the first-run question was answered, to this mod's file in Claude Code's plugin store (plugins/store/ in your Claude configuration directory, ~/.claude by default).claude plugin test . # 44 tests, under 3 seconds
claude plugin validate --strict . # what Claude Code will load, warnings as errors
npx prettier --check .
To type-check, load the mod once (claude -p "/plan-usage" --plugin-dir . is enough) so Claude Code writes the type declarations for your version into .claude-plugin/types/, then run npx -p typescript tsc -p .. CI runs the first three; it cannot type-check, because Anthropic's published copy of the declarations predates APIs this mod uses.
See CONTRIBUTING.md and SECURITY.md.
MIT. Not affiliated with Anthropic. Claude and Claude Code are trademarks of Anthropic.
hooks/register.tsx 494 lines1import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
2
3import type { PlanUsageLimit, PlanUsageOnboarding, PlanUsagePause, PlanUsageReading } from '../types'
4import {
5 ORANGE,
6 RESUME_DELAY_MS,
7 describeLimits,
8 formatIn,
9 isReading,
10 labelOf,
11 lookOf,
12 pauseTrigger,
13 resetsAtMs,
14 resolveZone,
15 timeFormat,
16} from './usage'
17import type { TimeFormat } from './usage'
18
19// $.state carries the figures to the band and across a hot reload. Every $.state read within one event
20// sees a single moment, so a timer or a long tool call would read stale values: the module variables
21// below are what the logic trusts, and each change is written through to $.state.
22const READING = { plugin: 'plan-usage', key: 'reading' } as const
23const PAUSE = { plugin: 'plan-usage', key: 'pause' } as const
24const OVERRIDE = { plugin: 'plan-usage', key: 'overrideUntil' } as const
25const ONBOARDING = { plugin: 'plan-usage', key: 'onboarding' } as const
26// Set in $.store once someone has chosen show_at on this machine, so the first-run line shows once.
27const ONBOARDED_KEY = 'onboarded'
28
29const TOOL = 'mcp__plan-usage__plan_usage'
30// How often an idle session looks for a newer reading another session stored. A local file read.
31const SYNC_MS = 60_000
32// Redraws the band so "resets in" counts down.
33const TICK_MS = 30_000
34const STALE_MS = 5 * 60_000
35const BAND_KINDS = ['five_hour', 'seven_day']
36
37let warnAt = 80
38let showAt = 0
39let autoPause = false
40let threshold = 95
41let resumePrompt = ''
42let time: TimeFormat = timeFormat('UTC')
43let zoneWarning = ''
44
45let latest: PlanUsageReading | null = null
46let held: PlanUsagePause | null = null
47// The main turn now running, kept for $.turn.abort; null between turns.
48let turnId: string | null = null
49let toolsRunning = 0
50// Set when the pause refused a tool call: the turn was cut short even if it then ended by itself.
51let wasRefused = false
52// A person sent a prompt during a pause: no pausing again until this session window resets.
53let overrideUntil = 0
54let resumeTimer: Timer | null = null
55
56async function setReading($: EngineInterface, next: PlanUsageReading | null): Promise<void> {
57 latest = next
58 await $.state.set(READING, next)
59}
60
61async function setPause($: EngineInterface, next: PlanUsagePause | null): Promise<void> {
62 held = next
63 await $.state.set(PAUSE, next)
64}
65
66async function setOverride($: EngineInterface, until: number): Promise<void> {
67 overrideUntil = until
68 await $.state.set(OVERRIDE, until)
69}
70
71// Plan limits are per login. The store file is per configuration directory, but a machine can point several
72// CLAUDE_CONFIG_DIRs at one plugins folder, so the key keeps their readings apart.
73async function sharedKey($: EngineInterface): Promise<string> {
74 return `reading:${(await $.env.get('CLAUDE_CONFIG_DIR')) ?? 'default'}`
75}
76
77// Keeps whichever reading is newer, then checks the pause threshold.
78async function adopt($: EngineInterface, next: PlanUsageReading): Promise<void> {
79 if (next.limits.length === 0) return
80 if (latest !== null && latest.readAt > next.readAt) return
81 await setReading($, next)
82 await checkPause($)
83}
84
85function copyLimits(limits: readonly SessionRateLimit[]): PlanUsageLimit[] {
86 return limits.map(({ kind, percentUsed, resetsAt }) =>
87 resetsAt === undefined ? { kind, percentUsed } : { kind, percentUsed, resetsAt },
88 )
89}
90
91// A model reply's figures, as session.measure delivers them: stamped now, and stored so idle sessions on the
92// same login see them.
93async function fromReply($: EngineInterface, limits: readonly SessionRateLimit[]): Promise<void> {
94 const reply: PlanUsageReading = { limits: copyLimits(limits), readAt: await $.clock.now() }
95 await adopt($, reply)
96 await $.store.set(await sharedKey($), reply)
97}
98
99async function fromShared($: EngineInterface): Promise<void> {
100 const stored = await $.store.get(await sharedKey($))
101 if (isReading(stored)) await adopt($, stored)
102}
103
104// The figures of this session's last reply when nothing better is known. They may be old, so they are never
105// stored for other sessions and are dated as far back as they could be: the start of this session.
106async function fromLastReply($: EngineInterface): Promise<void> {
107 if (latest !== null) return
108 const usage = await $.session.usage()
109 if (usage.rateLimits.length > 0) await adopt($, { limits: copyLimits(usage.rateLimits), readAt: usage.startedAt })
110}
111
112async function checkPause($: EngineInterface): Promise<void> {
113 const now = await $.clock.now()
114 if (!autoPause || turnId === null || held !== null || latest === null || now < overrideUntil) return
115 const hit = pauseTrigger(latest.limits, threshold)
116 if (hit === undefined) return
117 const resetsAt = resetsAtMs(hit)
118 if (resetsAt === undefined) {
119 $.ui.log(`session limit at ${hit.percentUsed}% but Claude Code reported no reset time, so no pause`)
120 return
121 }
122 // A reading from a window that has since reset says nothing about the current one.
123 if (resetsAt <= now) return
124 await setPause($, { phase: 'pending', percentUsed: hit.percentUsed, resumeAt: resetsAt + RESUME_DELAY_MS })
125 if (toolsRunning === 0) {
126 await stopTurn($)
127 return
128 }
129 $.ui.toast(`Session limit at ${hit.percentUsed}%: pausing once the running tool call finishes`)
130}
131
132// Ends the running turn and waits for the reset.
133async function stopTurn($: EngineInterface): Promise<void> {
134 if (held === null || held.phase !== 'pending') return
135 // A prompt can cancel the pause while the abort is awaited, so work from this copy.
136 const pause: PlanUsagePause = { ...held, phase: 'waiting' }
137 const stopping = turnId
138 turnId = null
139 await setPause($, pause)
140 if (stopping !== null) {
141 try {
142 await $.turn.abort({ turnId: stopping })
143 } catch (error) {
144 $.ui.log(`could not stop the turn: ${String(error)}`, { to: 'debug' })
145 }
146 }
147 const now = await $.clock.now()
148 $.ui.log(
149 `paused at ${pause.percentUsed}% of the session limit; continuing at ${time.at(pause.resumeAt, now)}. Send a prompt yourself to cancel.`,
150 )
151 await armResume($)
152 // The stopped turn's turn.complete no longer matches turnId, so a first-run choice it held is saved here.
153 if (pendingShowAt !== null) await saveShowAt($, pendingShowAt)
154}
155
156async function armResume($: EngineInterface): Promise<void> {
157 if (held === null || held.phase !== 'waiting') return
158 resumeTimer?.cancel()
159 const wait = Math.max(0, held.resumeAt - (await $.clock.now()))
160 resumeTimer = $.clock.after(wait, () => {
161 void resume($)
162 })
163}
164
165// At the reset plus 2 minutes: if a newer reading moved the reset later, wait again; else continue.
166async function resume($: EngineInterface): Promise<void> {
167 resumeTimer = null
168 await fromShared($)
169 const still = latest === null ? undefined : pauseTrigger(latest.limits, threshold)
170 const movedTo = still === undefined ? undefined : resetsAtMs(still)
171 if (held !== null && movedTo !== undefined && movedTo + RESUME_DELAY_MS > held.resumeAt) {
172 await setPause($, { ...held, resumeAt: movedTo + RESUME_DELAY_MS })
173 await armResume($)
174 return
175 }
176 await setPause($, null)
177 wasRefused = false
178 await $.prompt.submit({ text: resumePrompt })
179}
180
181// Drops a pending or waiting pause without continuing anything.
182async function cancelPause($: EngineInterface): Promise<void> {
183 resumeTimer?.cancel()
184 resumeTimer = null
185 wasRefused = false
186 if (held !== null) await setPause($, null)
187}
188
189// The first-run choice: shown until someone on this computer has saved one.
190let needsChoice = false
191// A choice made while a turn runs, saved when the main turn ends: saving reloads this module.
192let pendingShowAt: number | null = null
193
194async function chooseShowAt($: EngineInterface, percent: number): Promise<void> {
195 showAt = percent
196 await $.state.set(ONBOARDING, null)
197 if (turnId !== null) {
198 pendingShowAt = percent
199 $.ui.toast(`Plan usage: ${shownFrom(percent)}. Saved when this turn ends.`)
200 return
201 }
202 await saveShowAt($, percent)
203}
204
205function shownFrom(percent: number): string {
206 return percent === 0 ? 'always shown' : `shown from ${percent}%`
207}
208
209// Saves show_at where /config shows it. The key is "<plugin name>.<field>", read live under a marketplace
210// install (plan-usage@potterdigital) on Claude Code 2.1.289. Saving reloads the module, so the done flag is
211// written first and taken back if the save is refused.
212async function saveShowAt($: EngineInterface, percent: number): Promise<void> {
213 pendingShowAt = null
214 await $.store.set(ONBOARDED_KEY, true)
215 let refusal: string | undefined
216 try {
217 refusal = (await $.config.set({ key: 'plan-usage.show_at', value: percent })).deny
218 } catch (error) {
219 refusal = String(error)
220 }
221 if (refusal !== undefined) {
222 await $.store.set(ONBOARDED_KEY, false)
223 const retry: PlanUsageOnboarding = { step: 'choose', note: 'Could not save that. Choose again, or set show_at in /config.' }
224 await $.state.set(ONBOARDING, retry)
225 $.ui.log(`show_at was not saved: ${refusal}`)
226 return
227 }
228 needsChoice = false
229 $.ui.toast(`Plan usage: ${shownFrom(percent)}. Change it in /config.`)
230}
231
232async function submitOtherPercent($: EngineInterface, text: string): Promise<void> {
233 const entry = text.trim()
234 const percent = Number(entry)
235 if (!/^\d{1,3}$/.test(entry) || percent > 100) {
236 const retry: PlanUsageOnboarding = { step: 'other', note: 'Enter a whole number from 0 to 100.' }
237 await $.state.set(ONBOARDING, retry)
238 return
239 }
240 await chooseShowAt($, percent)
241}
242
243async function askOtherPercent($: EngineInterface): Promise<void> {
244 const other: PlanUsageOnboarding = { step: 'other' }
245 await $.state.set(ONBOARDING, other)
246}
247
248async function report($: EngineInterface): Promise<string> {
249 await fromShared($)
250 await fromLastReply($)
251 const now = await $.clock.now()
252 const pause = held === null ? null : { ...held, resumeAtLocal: time.at(held.resumeAt, now) }
253 return JSON.stringify(
254 {
255 limits: latest === null ? [] : describeLimits(latest.limits, now, time, warnAt),
256 readAtLocal: latest === null ? null : time.at(latest.readAt, now),
257 timeZone: time.zone,
258 autoPause: { isOn: autoPause, threshold, pause },
259 ...(latest === null
260 ? { note: 'No reading yet: Claude Code reports plan limits with model replies on a claude.ai plan.' }
261 : {}),
262 ...(zoneWarning === '' ? {} : { warning: zoneWarning }),
263 },
264 null,
265 2,
266 )
267}
268
269export const register: Register = (on, options) => {
270 warnAt = typeof options.warn_at === 'number' ? options.warn_at : 80
271 showAt = typeof options.show_at === 'number' ? options.show_at : 0
272 autoPause = options.auto_pause === true
273 threshold = typeof options.pause_threshold === 'number' ? options.pause_threshold : 95
274 resumePrompt =
275 typeof options.resume_prompt === 'string' && options.resume_prompt.trim() !== ''
276 ? options.resume_prompt
277 : 'Plan usage reset; resume where you left off from the files on disk.'
278 const configured = typeof options.time_zone === 'string' ? options.time_zone : ''
279 const resolved = resolveZone(configured)
280 time = timeFormat(resolved.zone)
281 zoneWarning = resolved.isFallback ? `time_zone "${configured}" is not a time zone; showing ${resolved.zone}` : ''
282
283 on('session.start', async ($, e, next) => {
284 // After a hot reload, pick up what the previous copy of this module held.
285 latest = (await $.state.get(READING)).value ?? null
286 held = (await $.state.get(PAUSE)).value ?? null
287 overrideUntil = (await $.state.get(OVERRIDE)).value ?? 0
288 if (zoneWarning !== '') $.ui.log(zoneWarning)
289 if (autoPause && !e.isInteractive) {
290 // A claude -p run or SDK host ends with its turn, so a paused run could never continue.
291 autoPause = false
292 $.ui.log('auto_pause is off in this session: it runs only where a person is at the prompt')
293 }
294 needsChoice = e.isInteractive && (await $.store.get(ONBOARDED_KEY)) !== true
295 if (needsChoice) {
296 const choose: PlanUsageOnboarding = { step: 'choose' }
297 await $.state.set(ONBOARDING, choose)
298 }
299 await fromShared($)
300 await fromLastReply($)
301 $.clock.every(SYNC_MS, () => {
302 void fromShared($)
303 })
304 $.clock.every(TICK_MS, () => $.ui.invalidate('ui.render'))
305 await armResume($)
306 await $.tool.register({
307 name: 'plan_usage',
308 description:
309 "The Claude plan's usage limits right now, answered locally without a model call: one entry per window, with kind (five_hour is the session limit, seven_day the weekly one), percentUsed from 0 to 100, resetsAt as ISO 8601 and resetsAtLocal in the user's time zone. Check it before starting long or unattended work.",
310 inputSchema: { type: 'object', properties: {} },
311 })
312 try {
313 await $.command.register({ name: 'plan-usage', description: 'Show plan usage limits and reset times', immediate: true })
314 } catch (error) {
315 $.ui.log(`/plan-usage not registered: ${String(error)}`, { to: 'debug' })
316 }
317 return next(e)
318 })
319
320 // /clear, /resume and /branch reset $.state and start a different conversation: drop any pending continue.
321 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
322 turnId = null
323 await cancelPause($)
324 if (latest !== null) await $.state.set(READING, latest)
325 if (overrideUntil > 0) await $.state.set(OVERRIDE, overrideUntil)
326 if (pendingShowAt !== null) {
327 await saveShowAt($, pendingShowAt)
328 } else if (needsChoice) {
329 const choose: PlanUsageOnboarding = { step: 'choose' }
330 await $.state.set(ONBOARDING, choose)
331 }
332 return next(e)
333 })
334
335 // A person outranks the pause. A prompt they send that starts its own turn (one typed while a turn runs is
336 // queued into it, and leaves the pause alone) cancels a pause, and pausing stays off until the session window
337 // resets. A prompt sent past the threshold with no pause yet is paused at turn.start like any other, so the
338 // override is always a deliberate second prompt. Origins: the terminal, Remote Control, and the owner on Slack.
339 on('prompt.submit', { origin: { kind: /^(composer|bridge|slack-ping)$/ } }, async ($, e, next) => {
340 if (held !== null && e.turnId === undefined) {
341 await setOverride($, Math.max(overrideUntil, held.resumeAt - RESUME_DELAY_MS))
342 $.ui.toast(`Pause cancelled: you sent a prompt. No pausing until ${time.at(overrideUntil, await $.clock.now())}.`)
343 await cancelPause($)
344 }
345 return next(e)
346 })
347
348 on('session.measure', async ($, e, next) => {
349 if (e.changed.includes('rateLimits')) await fromReply($, e.rateLimits)
350 return next(e)
351 })
352
353 on('turn.start', async ($, e, next) => {
354 // A main turn that never reported turn.complete leaves its id behind; a new turn replaces it.
355 turnId = e.turnId
356 // A reading that crossed the threshold between turns applies to this one.
357 await checkPause($)
358 return next(e)
359 })
360
361 on('turn.complete', async ($, e, next) => {
362 // Subagent turns complete with agentId set and do not end the main turn.
363 if (e.agentId === undefined && e.turnId === turnId) {
364 turnId = null
365 if (held !== null && held.phase === 'pending') {
366 if (wasRefused) {
367 // The pause refused a tool call, so the work stopped early: continue it after the reset.
368 await setPause($, { ...held, phase: 'waiting' })
369 await armResume($)
370 } else {
371 // The turn finished on its own before anything was cut: nothing to continue.
372 await setPause($, null)
373 }
374 }
375 }
376 // Last: saving the first-run choice reloads the module, so the pause above is settled first.
377 if (e.agentId === undefined && pendingShowAt !== null && turnId === null) await saveShowAt($, pendingShowAt)
378 return next(e)
379 })
380
381 on('tool.call', async ($, e, next) => {
382 if (e.tool !== TOOL && held !== null) {
383 wasRefused = true
384 return {
385 deny: `Paused: the plan's session limit is at ${held.percentUsed}%. Stop here; the session continues after the limit resets.`,
386 }
387 }
388 toolsRunning += 1
389 try {
390 return e.tool === TOOL ? { result: await report($) } : await next(e)
391 } finally {
392 toolsRunning -= 1
393 if (toolsRunning === 0) await stopTurn($)
394 }
395 })
396
397 on('command.run', { command: 'plan-usage' }, async $ => ({ text: await report($) }))
398
399 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
400 if (e.props.hasSurvey) return next(e)
401 const current = (await $.state.get(READING)).value ?? null
402 const pausing = (await $.state.get(PAUSE)).value ?? null
403 const pauseOffUntil = (await $.state.get(OVERRIDE)).value ?? 0
404 const onboarding = (await $.state.get(ONBOARDING)).value ?? null
405 const now = await $.clock.now()
406 const { Box, Text } = $.ui.resolve(e)
407 const isNarrow = e.props.bodyColumns < 100
408 // The band is shared: keep what the mods after this one draw (Claude Code's own notes included) under these lines.
409 const theirs = await next(e)
410
411 // The first-run choice, above the usage line. It needs a text field, which the terminal and the desktop app have.
412 let choice = null
413 if (onboarding !== null && (e.surface === 'terminal' || e.surface === 'desktop')) {
414 const { Button, Input } = $.ui.resolve(e)
415 choice =
416 onboarding.step === 'choose' ? (
417 <Box flexDirection="row" columnGap={2}>
418 <Text>Plan usage: show your limits above the prompt</Text>
419 <Button key="show-always" label="Always" hotkey="1" plain onPress={() => chooseShowAt($, 0)} />
420 <Button key="show-50" label="From 50%" hotkey="2" plain onPress={() => chooseShowAt($, 50)} />
421 <Button key="show-80" label="From 80%" hotkey="3" plain onPress={() => chooseShowAt($, 80)} />
422 <Button key="show-other" label="Other" hotkey="4" plain onPress={() => askOtherPercent($)} />
423 {onboarding.note === undefined ? null : <Text>{onboarding.note}</Text>}
424 </Box>
425 ) : (
426 <Box flexDirection="row" columnGap={2}>
427 <Input
428 key="show-at"
429 label="Plan usage: show limits from what percent"
430 placeholder="0 to 100"
431 value=""
432 submitLabel="save"
433 autoFocus
434 onSubmit={(text: string) => submitOtherPercent($, text)}
435 />
436 {onboarding.note === undefined ? null : <Text>{onboarding.note}</Text>}
437 </Box>
438 )
439 }
440
441 let usage = null
442 if (current === null) {
443 if (showAt === 0) usage = <Text wrap="truncate-end">◌ Plan usage: no reading yet (it appears after the first reply)</Text>
444 } else {
445 const shown = BAND_KINDS.flatMap(kind => current.limits.filter(limit => limit.kind === kind && limit.percentUsed >= showAt))
446 const isStale = now - current.readAt > STALE_MS
447 const isOverridden = autoPause && pausing === null && now < pauseOffUntil
448 // Below show_at, with nothing paused or overridden, the usage line steps aside.
449 if (shown.length > 0 || pausing !== null || isOverridden) {
450 usage = (
451 <Box flexDirection="row" columnGap={3}>
452 {pausing === null ? null : (
453 <Text color={ORANGE} bold wrap="truncate-end">
454 ⏸ PAUSED at {pausing.percentUsed}% · continues {time.at(pausing.resumeAt, now)}
455 </Text>
456 )}
457 {shown.map(limit => {
458 const look = lookOf(limit.percentUsed, warnAt)
459 const at = resetsAtMs(limit)
460 const resets =
461 at === undefined
462 ? ''
463 : isNarrow
464 ? ` · ${formatIn(at - now)}`
465 : ` · resets ${time.at(at, now)} (${formatIn(at - now)})`
466 return (
467 <Box key={limit.kind} flexDirection="row" columnGap={1}>
468 <Text color={look.color} bold>
469 {look.icon} {look.word}
470 </Text>
471 <Text wrap="truncate-end">
472 {labelOf(limit.kind)} {limit.percentUsed}%{resets}
473 </Text>
474 </Box>
475 )
476 })}
477 {isOverridden ? <Text wrap="truncate-end">○ pause off until {time.at(pauseOffUntil, now)}</Text> : null}
478 {isStale && shown.length > 0 ? <Text wrap="truncate-end">as of {time.at(current.readAt, now)}</Text> : null}
479 </Box>
480 )
481 }
482 }
483
484 if (choice === null && usage === null) return theirs
485 return (
486 <Box flexDirection="column">
487 {choice}
488 {usage}
489 {theirs}
490 </Box>
491 )
492 })
493}
494hooks/usage.ts 151 lines1// Pure helpers: no `$`, so tests call them directly.
2import type { PlanUsageLimit } from '../types'
3
4export const RESUME_DELAY_MS = 2 * 60_000
5
6// Blue below the warning level, orange at or above it. The icon and the word carry the level too,
7// so it never rests on color alone (red/green colorblind readers included).
8export const BLUE = '#4ea1ff'
9export const ORANGE = '#ff9f1a'
10
11const LABELS: Record<string, string> = {
12 five_hour: 'Session',
13 seven_day: 'Weekly',
14 seven_day_opus: 'Weekly Opus',
15 seven_day_sonnet: 'Weekly Sonnet',
16 spend_limit: 'Spend',
17}
18
19export type Level = 'ok' | 'high' | 'limit'
20
21export interface LevelLook {
22 icon: string
23 word: string
24 color: string
25}
26
27const LOOKS: Record<Level, LevelLook> = {
28 ok: { icon: '●', word: 'OK', color: BLUE },
29 high: { icon: '▲', word: 'HIGH', color: ORANGE },
30 limit: { icon: '■', word: 'LIMIT', color: ORANGE },
31}
32
33export function labelOf(kind: string): string {
34 return LABELS[kind] ?? kind
35}
36
37export function levelOf(percentUsed: number, warnAt: number): Level {
38 if (percentUsed >= 100) return 'limit'
39 return percentUsed >= warnAt ? 'high' : 'ok'
40}
41
42export function lookOf(percentUsed: number, warnAt: number): LevelLook {
43 return LOOKS[levelOf(percentUsed, warnAt)]
44}
45
46// The zone to show times in: the configured IANA name when it is valid, else this computer's.
47export function resolveZone(configured: string): { zone: string; isFallback: boolean } {
48 const system = new Intl.DateTimeFormat().resolvedOptions().timeZone
49 if (configured.trim() === '') return { zone: system, isFallback: false }
50 try {
51 new Intl.DateTimeFormat('en-US', { timeZone: configured })
52 return { zone: configured, isFallback: false }
53 } catch {
54 return { zone: system, isFallback: true }
55 }
56}
57
58export interface TimeFormat {
59 zone: string
60 // "4:10 AM CT" today, "Thu 9:00 PM CT" within six days, "Oct 12 9:00 PM CT" beyond.
61 at: (time: number, now: number) => string
62}
63
64export function timeFormat(zone: string): TimeFormat {
65 const clock = new Intl.DateTimeFormat('en-US', { timeZone: zone, hour: 'numeric', minute: '2-digit' })
66 const weekday = new Intl.DateTimeFormat('en-US', { timeZone: zone, weekday: 'short' })
67 const monthDay = new Intl.DateTimeFormat('en-US', { timeZone: zone, month: 'short', day: 'numeric' })
68 const calendarDay = new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' })
69 return {
70 zone,
71 at: (time, now) => {
72 const label = `${clock.format(time)} ${zoneLabel(zone, time)}`
73 if (calendarDay.format(time) === calendarDay.format(now)) return label
74 return Math.abs(time - now) / 86_400_000 < 6 ? `${weekday.format(time)} ${label}` : `${monthDay.format(time)} ${label}`
75 },
76 }
77}
78
79// "CT" or "ET" where the zone has a short generic name; "GMT+1" style where it does not.
80function zoneLabel(zone: string, time: number): string {
81 const part = (style: 'shortGeneric' | 'short'): string =>
82 new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: style })
83 .formatToParts(time)
84 .find(p => p.type === 'timeZoneName')?.value ?? ''
85 const generic = part('shortGeneric')
86 return /^[A-Z]{2,4}$/.test(generic) ? generic : part('short')
87}
88
89// "1h 12m", "45m", "2d 3h"; "now" once due.
90export function formatIn(ms: number): string {
91 const minutes = Math.round(ms / 60_000)
92 if (minutes <= 0) return 'now'
93 if (minutes < 60) return `${minutes}m`
94 const hours = Math.floor(minutes / 60)
95 if (hours < 24) return `${hours}h ${minutes % 60}m`
96 return `${Math.floor(hours / 24)}d ${hours % 24}h`
97}
98
99export function resetsAtMs(limit: PlanUsageLimit): number | undefined {
100 if (limit.resetsAt === undefined) return undefined
101 const at = Date.parse(limit.resetsAt)
102 return Number.isNaN(at) ? undefined : at
103}
104
105function isRecord(value: unknown): value is Record<string, unknown> {
106 return typeof value === 'object' && value !== null && !Array.isArray(value)
107}
108
109function isLimit(value: unknown): value is PlanUsageLimit {
110 return (
111 isRecord(value) &&
112 typeof value.kind === 'string' &&
113 typeof value.percentUsed === 'number' &&
114 (value.resetsAt === undefined || typeof value.resetsAt === 'string')
115 )
116}
117
118// A reading another session stored; $.store is shared and outlives versions of this mod, so check its shape.
119export function isReading(value: unknown): value is { limits: PlanUsageLimit[]; readAt: number } {
120 return isRecord(value) && Array.isArray(value.limits) && value.limits.every(isLimit) && typeof value.readAt === 'number'
121}
122
123// The session window at or past the threshold.
124export function pauseTrigger(limits: readonly PlanUsageLimit[], threshold: number): PlanUsageLimit | undefined {
125 return limits.find(limit => limit.kind === 'five_hour' && limit.percentUsed >= threshold)
126}
127
128export interface DescribedLimit extends PlanUsageLimit {
129 label: string
130 level: Level
131 resetsAtLocal?: string
132 resetsIn?: string
133}
134
135export function describeLimits(
136 limits: readonly PlanUsageLimit[],
137 now: number,
138 time: TimeFormat,
139 warnAt: number,
140): DescribedLimit[] {
141 return limits.map(limit => {
142 const at = resetsAtMs(limit)
143 const described: DescribedLimit = { ...limit, label: labelOf(limit.kind), level: levelOf(limit.percentUsed, warnAt) }
144 if (at !== undefined) {
145 described.resetsAtLocal = time.at(at, now)
146 described.resetsIn = formatIn(at - now)
147 }
148 return described
149 })
150}
151types/index.d.ts 40 lines1// One plan-limit window as Claude Code reports it: `five_hour` (the session limit), `seven_day` (weekly),
2// the per-model weekly windows, or a gateway's `spend_limit`.
3export interface PlanUsageLimit {
4 kind: string
5 percentUsed: number
6 resetsAt?: string
7}
8
9// The newest figures, from the last model reply this session or another session on the same login saw.
10export interface PlanUsageReading {
11 limits: PlanUsageLimit[]
12 readAt: number
13}
14
15// An auto-pause in progress: `pending` waits for running tool calls, `waiting` waits for the reset.
16export interface PlanUsagePause {
17 phase: 'pending' | 'waiting'
18 percentUsed: number
19 resumeAt: number
20}
21
22// The first-run line that sets show_at; `note` explains a rejected entry.
23export interface PlanUsageOnboarding {
24 step: 'choose' | 'other'
25 note?: string
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'plan-usage': {
31 reading: PlanUsageReading | null
32 pause: PlanUsagePause | null
33 // When a person cancelled a pause: no pausing again before this time (ms since the epoch); 0 when none.
34 overrideUntil: number
35 // The first-run choice of show_at: 'choose' offers the presets, 'other' asks for a percent.
36 onboarding: PlanUsageOnboarding | null
37 }
38 }
39}
40