Know your Claude limits before they hit you (Live band above the Claude Code prompt): 5 Hour and Weekly usage (whole account) with a live usage forecast…

Know your Claude limits before they hit you. A live band above the Claude Code prompt that shows your plan limits, forecasts whether you'll run out before they reset, and shows how full your context window is. Compact in one click, or let it compact automatically.
The 5 Hour and Weekly limits are your whole Claude account's, including what you use in Claude chat and Cowork. The band itself shows in Claude Code: the desktop app and the terminal.
Live from the moment a session opens. Many usage mods stay blank until Claude replies, because they only read the figures each reply carries. This one asks Anthropic's usage service straight away, for free (it uses no tokens and doesn't count toward your limits), so your limits are there as soon as the band appears, and it keeps them current every few seconds while you work.
Type /quota to turn the band off and on.

One row that still shows the time left until each limit resets:

[!TIP] Never hit a full context again: Auto compact, on by one click. With Auto compact on, the band compacts your session once the context reaches 30% (the default; set 15 to 99). A long context costs more on every reply, so compacting early keeps replies cheaper and your limits lasting longer. Auto compact never interrupts a reply. Want to compact right now? The Compact button runs
/compactin one click.Each session keeps its own Auto compact setting, even after a restart, so you can run them differently side by side:
- Building something big? Set Auto compact higher (say 70%) or turn it off, so Claude keeps the whole picture. Press Compact yourself at a good stopping point. (With it off, Claude Code's built-in compaction still steps in when the context is nearly full.)
- Everyday sessions? One click turns Auto compact on at 30%. They stay lean, and your 5 Hour and Weekly limits last longer.
5 Hour and Weekly limits
On course to run out, it warns you and says when you'll hit the limit:


Context window
/context counts it, split into Messages, Tools and Other.Compacting
/compact in one click.If the session is already past your % when you turn it on, it asks first:


/compact, or Claude Code's own), then takes over.Works in light and dark themes and at every width down to the narrowest. Collapsed or expanded is shared across all sessions.
[!NOTE] MORE SCREENSHOTS
Early in a 5 hour window:
Running out on the Weekly limit:
Light theme Narrowest window
[!NOTE] In the desktop app, the band appears once a session has started. The brand-new Welcome back screen has no session yet, so no mod can draw there. Send your first message and it appears.
In the terminal, ▲/▼ expands and collapses it. The [-] next to it is Claude Code's own control and hides the band; ctrl+x ctrl+a brings it back.

Collapsed:

Needs Claude Code 2.1.287 or later, signed in with a Pro or Max plan.
Desktop app: in the Code tab, send this as a message, allow the claude plugin commands if asked, then quit and reopen the app:
Install the usage-quota plugin from the GitHub marketplace anantraghunath/claude-code-usage-quota-mod
Terminal: inside claude, run these one at a time:
/plugin marketplace add anantraghunath/claude-code-usage-quota-mod
/plugin install usage-quota@claude-code-usage-quota-mod
/reload-plugins
The band appears once the plugins reload. If it doesn't, restart Claude Code.
Either way installs it for both the desktop app and the terminal.
[!IMPORTANT] Installed before v0.1.5? The plugin was renamed from
claude-code-usage-quotatousage-quota, because Claude Code reserves names starting withclaude-for Anthropic's own plugins. Updating won't pick up the new name, so remove the old one and install again. This gets you the latest version, v0.1.6.Desktop app: in the Code tab, send this as a message:
Run these one at a time: claude plugin uninstall claude-code-usage-quota@claude-code-usage-quota-mod, then claude plugin marketplace update claude-code-usage-quota-mod, then claude plugin install usage-quota@claude-code-usage-quota-modTerminal: run these in your shell:
claude plugin uninstall claude-code-usage-quota@claude-code-usage-quota-modclaude plugin marketplace update claude-code-usage-quota-modclaude plugin install usage-quota@claude-code-usage-quota-modThen restart. Your Auto compact settings and learned pace carry over.
Update: ask Claude, then restart:
Update the usage-quota plugin from its marketplace
Uninstall: in the desktop app, ask Claude:
Remove the claude-code-usage-quota-mod plugin marketplace
or in the terminal:
/plugin marketplace remove claude-code-usage-quota-mod
then restart. This removes it from both. Just want it out of sight? /quota hides it without uninstalling.
claude plugin marketplace add anantraghunath/claude-code-usage-quota-mod; claude plugin install usage-quota@claude-code-usage-quota-mod
To update:
claude plugin marketplace update claude-code-usage-quota-mod; claude plugin update usage-quota@claude-code-usage-quota-mod
To uninstall:
claude plugin marketplace remove claude-code-usage-quota-mod
Everything runs on your machine, with the same access Claude Code has, like any mod, so read the code before installing any mod you don't trust. It never sees your credentials and sends nothing anywhere except the one request below.
https://api.anthropic.com/api/oauth/usage, Anthropic's usage service, for your 5 Hour and Weekly limits. It goes through your existing Claude login: Claude Code attaches the credential, and the mod never sees it. That is the only host it contacts. The www.w3.org address in the code is only the namespace of the drawn bars' SVG images; nothing is sent there./compact when you press Compact, or when Auto compact reaches your %. It adds /quota, which turns the band off and on.reg query for AppsUseLightTheme on Windows, defaults read -g AppleInterfaceStyle on macOS.config.json for its theme setting. Once, after the v0.1.5 rename, it copies the old plugin's saved settings from ~/.claude/plugins/store.ui.press, ui.focus) and edits to the prompt (prompt.edit) only set a % you typed in the band, then pass on unchanged. Turns starting and ending tell it when a reply is running, so Auto compact never interrupts one.First release: I'd love to hear how it works for you. Open an issue for bugs or ideas. Pull requests welcome.
To work on it, clone the repo, then load it from the folder, or run its tests:
claude --plugin-dir ./claude-code-usage-quota-mod
claude plugin test ./claude-code-usage-quota-mod
If you find it useful, a ⭐ helps others find it.
Inspired by I'm liking the new mods feature on r/ClaudeCode. Thanks to u/itsxzy for sharing the original prompt that started this project.
MIT © 2026 Anant Raghunath
hooks/register.tsx 1428 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { AutoCompact, Category, Hold, Limit, PaceOf, Snapshot } from '../types'
5
6const snapshot = atom({ plugin: 'usage-quota', key: 'snapshot' } as const, null)
7const isOn = atom({ plugin: 'usage-quota', key: 'isOn' } as const, true)
8const isCollapsed = atom({ plugin: 'usage-quota', key: 'isCollapsed' } as const, false)
9const autoCompact = atom({ plugin: 'usage-quota', key: 'autoCompact' } as const, { isOn: false, at: null } as AutoCompact)
10const fieldTick = atom({ plugin: 'usage-quota', key: 'fieldTick' } as const, 0)
11// the % field's text while typing is cleaned (digits only, 3 at most); null: the set %
12const fieldText = atom({ plugin: 'usage-quota', key: 'fieldText' } as const, null as string | null)
13const theme = atom({ plugin: 'usage-quota', key: 'theme' } as const, 'dark' as 'dark' | 'light')
14const autoAsk = atom({ plugin: 'usage-quota', key: 'autoAsk' } as const, null as { at: number; percent: number } | null)
15// what holds auto compact back, and in which chat: kept in state so a reload (an
16// update, /reload-plugins) keeps it, where a module variable would start over
17const hold = atom({ plugin: 'usage-quota', key: 'hold' } as const, { chat: '', waits: false, stuck: false } as Hold)
18
19// two palettes, the desktop's dark and light themes; the band draws in the one the
20// app shows (Theme), set at the start of every draw so all colours below follow it
21type Palette = {
22 muted: string; green: string; greenText: string; amber: string; red: string; free: string; buffer: string; track: string
23 palette: string[]; named: Record<string, string>; switchOff: string; switchOn: string; switchLit: string; compactLit: string
24}
25const DARK: Palette = {
26 muted: '#8b90a0', green: '#4ade80', greenText: '#4ade80', amber: '#fbbf24', red: '#f87171',
27 free: '#2d3140', buffer: '#4b5163', track: '#2d3140',
28 palette: ['#a78bfa', '#60a5fa', '#f472b6', '#34d399', '#fbbf24', '#fb923c', '#22d3ee', '#c084fc'],
29 named: {
30 Tools: '#a78bfa', Other: '#8b90a0', 'System prompt': '#a78bfa', 'System tools': '#60a5fa', 'MCP tools': '#f472b6',
31 'Custom agents': '#34d399', 'Memory files': '#fbbf24', Skills: '#fb923c', Messages: '#22d3ee',
32 },
33 switchOff: '#4b5163', switchOn: '#4ade80', switchLit: '#8b919c', compactLit: '#5a606e',
34}
35// light: the same hues, deep enough to read on the pale band; the tracks pale
36const LIGHT: Palette = {
37 muted: '#5b6070', green: '#22b856', greenText: '#16a34a', amber: '#ea8a0c', red: '#ef4444',
38 free: '#ffffff', buffer: '#b9bec9', track: '#ffffff',
39 palette: ['#8b5cf6', '#3b82f6', '#ec4899', '#10b981', '#f59e0b', '#f97316', '#0ea5e9', '#a855f7'],
40 named: {
41 Tools: '#8b5cf6', Other: '#5b6070', 'System prompt': '#8b5cf6', 'System tools': '#3b82f6', 'MCP tools': '#ec4899',
42 'Custom agents': '#10b981', 'Memory files': '#f59e0b', Skills: '#f97316', Messages: '#0ea5e9',
43 },
44 switchOff: '#b4b9c4', switchOn: '#22c55e', switchLit: '#c3c7cf', compactLit: '#d6d9df',
45}
46let MUTED = DARK.muted
47let GREEN = DARK.green
48let GREEN_TEXT = DARK.greenText
49let AMBER = DARK.amber
50let RED = DARK.red
51let FREE = DARK.free
52let BUFFER = DARK.buffer
53let TRACK = DARK.track
54let PALETTE = DARK.palette
55let NAMED = DARK.named
56let SWITCH_OFF = DARK.switchOff
57let SWITCH_ON = DARK.switchOn
58// the light behind the switch under the pointer: drawn beneath the pill, never over it
59let SWITCH_LIT = DARK.switchLit
60// Compact's hover: clearly lighter than its resting chrome, its label still reads
61let COMPACT_LIT = DARK.compactLit
62function usePalette(p: Palette): void {
63 ;({ muted: MUTED, green: GREEN, greenText: GREEN_TEXT, amber: AMBER, red: RED, free: FREE, buffer: BUFFER, track: TRACK } = p)
64 ;({ palette: PALETTE, named: NAMED, switchOff: SWITCH_OFF, switchOn: SWITCH_ON, switchLit: SWITCH_LIT, compactLit: COMPACT_LIT } = p)
65}
66const CELLS = 16
67const HOUR = 3_600_000
68const WINDOWS: Record<string, { label: string; ms: number }> = {
69 five_hour: { label: '5 Hour', ms: 5 * HOUR },
70 seven_day: { label: 'Weekly', ms: 7 * 24 * HOUR },
71}
72
73// 5 hour on the left, Weekly on the right
74const order = (kind: string) => (kind === 'five_hour' ? 0 : 1)
75
76type Forecast = {
77 kind: string
78 label: string
79 percent: number
80 resetsAt: number
81 /** 'idle': no window running (unused, or reset): 0% until the next message starts one */
82 status: 'hit' | 'ok' | 'out' | 'idle'
83 projected: number
84 runOutAt: number
85 color: string
86}
87
88// The pace a window is forecast at: your usual one (the median of how far your past
89// windows got) blended with this window's own, which takes over as the window runs: it
90// counts for half a quarter of the way in. Early jumps barely move it. `skip`: the %
91// the first reply of a reopened chat took re-reading its old context, a one-off left
92// out of the pace (still in the % used).
93const TYPICAL_DEFAULT = 50
94const TRUST = 0.25
95const FINALS_KEPT = 8
96// one window's reset time as two readings give it: a few minutes apart at most
97const SAME_WINDOW_MS = 10 * 60_000
98
99function forecast(limit: Limit, now: number, pace?: PaceOf): Forecast | null {
100 const win = WINDOWS[limit.kind]
101 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
102 if (!win) return null
103 const p = limit.percentUsed
104 // no reset time, or one already passed: the window is not running, so it is at 0
105 if (!Number.isFinite(resetsAt) || resetsAt <= now) {
106 return { kind: limit.kind, label: win.label, percent: 0, resetsAt: NaN, status: 'idle', projected: 0, runOutAt: Infinity, color: GREEN }
107 }
108 const remaining = Math.max(0, resetsAt - now)
109 const elapsed = Math.max(1, win.ms - remaining)
110 const base = { kind: limit.kind, label: win.label, percent: p, resetsAt }
111 if (p >= 100) return { ...base, status: 'hit', projected: p, runOutAt: now, color: RED }
112 const typical = pace?.typical ?? TYPICAL_DEFAULT
113 const own = Math.max(0, p - (pace?.skip ?? 0)) / elapsed
114 const weight = elapsed / (elapsed + win.ms * TRUST)
115 const rate = weight * own + (1 - weight) * (typical / win.ms)
116 const projected = p + rate * remaining
117 if (projected < 100 || rate <= 0) {
118 return { ...base, status: 'ok', projected, runOutAt: Infinity, color: worse(GREEN, usedColor(p)) }
119 }
120 return {
121 ...base,
122 status: 'out',
123 projected,
124 runOutAt: now + (100 - p) / rate,
125 color: worse(projected > 130 ? RED : AMBER, usedColor(p)),
126 }
127}
128
129// A limit's colour by what is used, as the band shows it (rounded): amber from 75%,
130// red from 90%. The bar and its % take the worse of this and the forecast's.
131const LIMIT_AMBER_AT = 75
132const LIMIT_RED_AT = 90
133function usedColor(p: number): string {
134 const shown = Math.round(p)
135 return shown >= LIMIT_RED_AT ? RED : shown >= LIMIT_AMBER_AT ? AMBER : GREEN
136}
137function worse(a: string, b: string): string {
138 const rank = (c: string) => (c === RED ? 2 : c === AMBER ? 1 : 0)
139 return rank(b) > rank(a) ? b : a
140}
141
142function duration(ms: number): string {
143 const m = Math.max(0, Math.round(ms / 60_000))
144 const d = Math.floor(m / 1440)
145 const hours = Math.floor((m % 1440) / 60)
146 const min = m % 60
147 if (d > 0) return hours > 0 ? `${d}d ${hours}h` : `${d}d`
148 if (hours > 0) return min > 0 ? `${hours}h ${min}m` : `${hours}h`
149 return `${min}m`
150}
151
152const time = (t: number) =>
153 new Date(t).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
154const weekday = (t: number) => new Date(t).toLocaleDateString('en-US', { weekday: 'long' })
155const isSameDay = (a: number, b: number) =>
156 new Date(a).toLocaleDateString('en-US') === new Date(b).toLocaleDateString('en-US')
157
158// "the 8:40 PM reset" today, "Wednesday's reset" otherwise
159// the reset by its time when it is under a day away (an after-midnight one included),
160// else by its weekday: a time that far off would only lengthen the headline
161function resetName(t: number, now: number): string {
162 return t - now < 24 * 3_600_000 ? `the ${time(t)} reset` : `${weekday(t)}'s reset`
163}
164
165function headline(list: Forecast[], now: number): { text: string; color: string } | null {
166 const hit = list.find(f => f.status === 'hit')
167 if (hit) {
168 const at = isSameDay(hit.resetsAt, now) ? time(hit.resetsAt) : `${weekday(hit.resetsAt)} ${time(hit.resetsAt)}`
169 return { text: `Limit reached. Usage resumes at ${at}.`, color: RED }
170 }
171 const out = list.filter(f => f.status === 'out').sort((a, b) => a.runOutAt - b.runOutAt)[0]
172 if (out) {
173 return { text: `At this pace you'll run out before ${resetName(out.resetsAt, now)}.`, color: out.color }
174 }
175 const lead = list.find(f => f.kind === 'seven_day' && f.status !== 'idle') ?? list.find(f => f.status !== 'idle')
176 if (!lead) return null
177 return {
178 text: `On track. You should reach ${resetName(lead.resetsAt, now)} with room to spare.`,
179 color: GREEN,
180 }
181}
182
183function tokens(n: number): string {
184 // 1M, not 1.0M; 1.5M keeps its decimal
185 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
186 if (n >= 1000) return `${Math.round(n / 1000)}k`
187 return `${Math.round(n)}`
188}
189
190// Context by what it holds, as shown (rounded): amber from 50% (a long context costs
191// more on every reply: compacting pays), red from 80%
192const CONTEXT_AMBER_AT = 50
193const CONTEXT_RED_AT = 80
194function fillColor(percent: number): string {
195 const shown = Math.round(percent)
196 if (shown >= CONTEXT_RED_AT) return RED
197 if (shown >= CONTEXT_AMBER_AT) return AMBER
198 return GREEN
199}
200
201// text in green, which on the light band needs a deeper green than the bars to read
202function ink(color: string): string {
203 return color === GREEN ? GREEN_TEXT : color
204}
205
206function colorOf(category: Category, index: number): string {
207 if (category.kind === 'free') return FREE
208 if (category.kind === 'buffer') return BUFFER
209 return NAMED[category.name] ?? PALETTE[index % PALETTE.length]!
210}
211
212type Segment = { color: string; share: number }
213
214const BAR_PX = 7.5
215
216// an iOS-style switch: a pill, the knob right and green when on, left and grey when off
217// three cells wide, so the click area laid over it covers all of it
218const SWITCH_W = 30
219const SWITCH_H = 20
220const SWITCH_PAD = 3
221// the cells the switch's box takes: the press under it is clipped to this, so the
222// app's own button tint can't spill onto the text beside it
223const SWITCH_CELLS = 4
224// the box behind the switch measures a pixel off the drawing (19px tall, 31 wide):
225// the pill is drawn half a pixel down and right so it sits in the light's centre
226const SWITCH_NUDGE = 0.5
227// fully see-through: the unlit border, and the press's own hover tint, which drew a
228// square frame round the light (the word "transparent" draws white or nothing)
229const CLEAR = '#00000000'
230// a space a wrap never breaks at
231const NB = '\u00a0'
232
233function switchSvg(isOn: boolean): string {
234 // the pill sits inset by PAD: the light under the pointer shows around it
235 const w = SWITCH_W - 2 * SWITCH_PAD
236 const tall = SWITCH_H - 2 * SWITCH_PAD
237 const p = SWITCH_PAD
238 const knob = p + (isOn ? w - tall / 2 : tall / 2)
239 return (
240 `<svg xmlns="http://www.w3.org/2000/svg" width="${SWITCH_W}" height="${SWITCH_H}" viewBox="${-SWITCH_NUDGE} ${-SWITCH_NUDGE} ${SWITCH_W} ${SWITCH_H}">` +
241 `<rect x="${p}" y="${p}" width="${w}" height="${tall}" rx="${tall / 2}" fill="${isOn ? SWITCH_ON : SWITCH_OFF}"/>` +
242 `<circle cx="${knob}" cy="${p + tall / 2}" r="${tall / 2 - 2}" fill="#ffffff"/></svg>`
243 )
244}
245
246// a thin rounded bar for surfaces that draw Svg: segments laid out over 0..1000,
247// with an optional grey tick, the bar's own height, at `marker` percent (the forecast at reset)
248function barSvg(segments: Segment[], width: number, marker?: number): string {
249 const height = BAR_PX
250 const y = 0
251 const total = segments.reduce((sum, s) => sum + s.share, 0) || 1
252 let x = 0
253 const rects = segments
254 .map(s => {
255 const w = (s.share / total) * 1000
256 const rect = `<rect x="${x.toFixed(2)}" y="${y}" width="${w.toFixed(2)}" height="${BAR_PX}" fill="${s.color}"/>`
257 x += w
258 return rect
259 })
260 .join('')
261 // a plain upright block 3px wide whatever the drawn width: no rounded corners
262 // (stretched sideways, those drew it as a "D"), inside the bar's rounded ends so
263 // nothing pokes out past them. Placed on a whole pixel and left smooth-edged: snapped
264 // edges on a scaled screen drew it 3 device pixels wide on one bar and 4 on another
265 const TICK_PX = 3
266 const tickW = (TICK_PX / width) * 1000
267 const tickAt = marker === undefined ? 0 : Math.min(width - TICK_PX, Math.max(0, Math.round((marker / 100) * width - TICK_PX / 2)))
268 const tick =
269 marker === undefined
270 ? ''
271 : `<rect x="${((tickAt / width) * 1000).toFixed(3)}" y="${y}" width="${tickW.toFixed(3)}" height="${BAR_PX}" fill="${MUTED}"/>`
272 return (
273 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 1000 ${height}" preserveAspectRatio="none">` +
274 `<clipPath id="r"><rect y="${y}" width="1000" height="${BAR_PX}" rx="${BAR_PX / 2}" ry="${BAR_PX / 2}"/></clipPath>` +
275 `<g clip-path="url(#r)">${rects}${tick}</g></svg>`
276 )
277}
278
279// the forecast tick's percent, or none when there is nothing ahead to mark
280function markerOf(f: Forecast): number | undefined {
281 if (f.status === 'hit' || f.status === 'idle') return undefined
282 const at = Math.min(100, f.projected)
283 return at - f.percent >= 1 ? at : undefined
284}
285
286// the terminal's bar: one colour per cell, runs of a colour merged into one Box
287function cellRuns(f: Forecast, cells: number): { color: string; width: number }[] {
288 const filled = Math.max(0, Math.min(cells, Math.round((f.percent / 100) * cells)))
289 const marker = markerOf(f)
290 const tick = marker === undefined ? -1 : Math.max(filled, Math.min(cells - 1, Math.round((marker / 100) * cells) - 1))
291 const runs: { color: string; width: number }[] = []
292 for (let i = 0; i < cells; i++) {
293 const color = i < filled ? f.color : i === tick ? MUTED : TRACK
294 const last = runs[runs.length - 1]
295 if (last && last.color === color) last.width += 1
296 else runs.push({ color, width: 1 })
297 }
298 return runs
299}
300
301// The usage service rate-limits per login, and the app's own Usage panel asks it on
302// the same allowance, so ask it seldom: when a chat opens, then every 2 minutes with the
303// exact context count, one ask for the whole app (when it was last asked, the answer and
304// any back-off sit in the shared store). Between asks the figures every reply from
305// Claude carries keep the band current.
306const PLAN_EVERY_MS = 2 * 60_000
307// timers drift: a 2-minute tick a moment early still counts as due
308const PLAN_SLACK_MS = 10_000
309// after a failed ask wait 2, then 5, then 15 minutes, or what the service says
310const PLAN_BACKOFF_MS = [120_000, 300_000, 900_000]
311const LOCAL_EVERY_MS = 3_000
312
313type Backoff = { until: number; failures: number; error: string }
314/** 'no': the 3s tick, never asks; 'due': asks if 2 minutes have passed app-wide; 'now': asks unless backed off */
315type Ask = 'no' | 'due' | 'now'
316
317class AskError extends Error {
318 constructor(
319 message: string,
320 readonly retryAfterMs?: number,
321 ) {
322 super(message)
323 }
324}
325
326// The plan's limits, as the app's "Plan usage limits" panel reads them: the account's
327// usage endpoint, through the session's own credential (the plugin never sees it).
328async function fetchPlan($: EngineInterface): Promise<Limit[] | null> {
329 const auth = await $.session.authorize()
330 // no Claude login (an API key, a gateway): there is no plan to ask about
331 if (!auth || auth.kind !== 'bearer') return null
332 const res = await $.http.fetch('https://api.anthropic.com/api/oauth/usage', {
333 auth: auth.handle,
334 headers: { 'anthropic-beta': 'oauth-2025-04-20', 'content-type': 'application/json' },
335 })
336 if (!res.ok) {
337 const header = Object.entries(res.headers ?? {}).find(([k]) => k.toLowerCase() === 'retry-after')?.[1]
338 const seconds = Number(Array.isArray(header) ? header[0] : header)
339 const retry = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined
340 throw new AskError(res.status === 429 ? 'refused (429)' : `failed (${res.status})`, retry)
341 }
342 const body = JSON.parse(res.text) as Record<string, { utilization?: number | null; resets_at?: string | null } | null>
343 const limits: Limit[] = []
344 for (const kind of ['five_hour', 'seven_day']) {
345 const w = body[kind]
346 if (!w || typeof w.utilization !== 'number') continue
347 limits.push({ kind, percentUsed: Math.round(w.utilization * 10) / 10, resetsAt: w.resets_at ?? undefined })
348 }
349 if (limits.length === 0) throw new AskError('answer had no limits')
350 return limits
351}
352
353type Saved = { at: number; limits: Limit[] }
354
355// this chat's own reading from its replies, and when it last changed
356let live: Saved | undefined
357
358async function storeGet($: EngineInterface, key: string): Promise<unknown> {
359 try {
360 return await $.store.get(key)
361 } catch {
362 return undefined
363 }
364}
365
366// v0.1.5 renamed the plugin from claude-code-usage-quota, a name kept for Anthropic's
367// own plugins. Its store is a file under the old name, so what it held (each chat's
368// auto compact, the learned pace) is copied over once, never over a newer value
369const OLD_STORE = 'claude-code-usage-quota_claude-code-usage-quota-mod-'
370async function migrateStore($: EngineInterface): Promise<void> {
371 if (await storeGet($, 'migrated')) return
372 try {
373 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
374 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined)
375 if (config) {
376 const dir = `${config}/plugins/store`
377 const old = (await $.fs.list(dir))
378 .filter(f => f.kind === 'file' && f.name.startsWith(OLD_STORE) && f.name.endsWith('.json'))
379 .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
380 if (old) {
381 const saved = JSON.parse(String(await $.fs.read(`${dir}/${old.name}`))) as Record<string, unknown>
382 for (const [key, value] of Object.entries(saved)) {
383 if ((await storeGet($, key)) === undefined) await storeSet($, key, value)
384 }
385 }
386 }
387 } catch (error) {
388 $.ui.log(`usage-quota: the old settings could not be copied: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
389 }
390 await storeSet($, 'migrated', true)
391}
392
393async function storeSet($: EngineInterface, key: string, value: unknown): Promise<void> {
394 try {
395 await $.store.set(key, value)
396 } catch {
397 // this chat still has it
398 }
399}
400
401// Two sources, and the newest wins: the usage service (asked seldom, shared by every
402// chat through the store) and the figures each reply from Claude carries (this chat's,
403// stamped when they last changed). A window that has reset since is dropped.
404async function limitsOf(
405 $: EngineInterface,
406 rateLimits: SessionRateLimit[],
407 now: number,
408 ask: Ask,
409): Promise<{ limits: Limit[]; at?: number; error?: string }> {
410 if (rateLimits.length > 0) {
411 const limits = rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
412 if (!live || JSON.stringify(live.limits) !== JSON.stringify(limits)) live = { at: now, limits }
413 }
414 let saved = (await storeGet($, 'limits')) as Saved | undefined
415 let backoff = (await storeGet($, 'planBackoff')) as Backoff | undefined
416 const askedAt = ((await storeGet($, 'planAskedAt')) as number | undefined) ?? 0
417 const isFree = now >= (backoff?.until ?? 0)
418 const isDue = ask === 'now' || (ask === 'due' && now - askedAt >= PLAN_EVERY_MS - PLAN_SLACK_MS)
419 if (isDue && isFree) {
420 // claim the ask first, so the other chats see it taken and skip theirs
421 await storeSet($, 'planAskedAt', now)
422 try {
423 const plan = await fetchPlan($)
424 if (plan) {
425 saved = { at: now, limits: plan }
426 await storeSet($, 'limits', saved)
427 await storeSet($, 'planLimits', saved)
428 }
429 if (backoff) await storeSet($, 'planBackoff', { until: 0, failures: 0, error: '' })
430 backoff = undefined
431 } catch (error) {
432 const failures = (backoff?.failures ?? 0) + 1
433 const wait =
434 error instanceof AskError && error.retryAfterMs !== undefined
435 ? error.retryAfterMs
436 : PLAN_BACKOFF_MS[Math.min(failures - 1, PLAN_BACKOFF_MS.length - 1)]!
437 backoff = { until: now + wait, failures, error: error instanceof Error ? error.message : String(error) }
438 await storeSet($, 'planBackoff', backoff)
439 }
440 }
441 const pick = [saved, live]
442 .filter((r): r is Saved => r !== undefined)
443 .sort((x, y) => y.at - x.at)[0]
444 // newer reply figures go to the store too, so other chats get them
445 if (pick && pick === live && (!saved || live.at > saved.at)) await storeSet($, 'limits', live)
446 // The reply figures run behind the usage service, which the app's panel reads: whole
447 // percents, and seen at 18 for an hour while the service said 19.0. Use only climbs
448 // within a window, so a newer reply figure below the service's last answer is stale:
449 // the higher of the two is kept. Above it, the reply's is news.
450 const plan = (await storeGet($, 'planLimits')) as Saved | undefined
451 const finer = (l: Limit): Limit => {
452 const p = plan?.limits.find(x => x.kind === l.kind)
453 return p && sameWindow(p.resetsAt, l.resetsAt) && p.percentUsed > l.percentUsed ? { ...l, percentUsed: p.percentUsed } : l
454 }
455 // a window that has reset since stays, at 0: the forecast reads it as not running
456 const limits = (pick?.limits ?? []).map(l =>
457 l.resetsAt !== undefined && Date.parse(l.resetsAt) <= now ? { kind: l.kind, percentUsed: 0 } : finer(l),
458 )
459 return { limits, at: pick?.at, error: backoff?.error || undefined }
460}
461
462// What the forecast learns, shared by every chat through the store: how far each past
463// window got (its peak, the last few kept), and the running one's peak and skipped %.
464type PaceWindow = { resetsAt: number; peak: number; skip: number }
465type PaceStore = { finals: Record<string, number[]>; windows: Record<string, PaceWindow> }
466
467// A chat reopened with a long history re-reads it all on its first reply: one big jump
468// in usage. Its first turn notes the limits it started from; the first new reading
469// that moved is that jump, and is left out of the pace. Only for a chat that opened
470// with this much already in it (a new chat's first reply is real work).
471const RESUME_MIN_TOKENS = 20_000
472let isOpening = true
473let openMessages = 0
474let spikeFrom: { at: number; limits: Limit[] } | undefined
475
476function median(list: number[] | undefined): number | undefined {
477 if (!list || list.length === 0) return undefined
478 const s = [...list].sort((a, b) => a - b)
479 const mid = Math.floor(s.length / 2)
480 return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2
481}
482
483const sameWindow = (a: string | undefined, b: string | undefined) =>
484 a !== undefined && b !== undefined && Math.abs(Date.parse(a) - Date.parse(b)) <= SAME_WINDOW_MS
485
486// the jump the reopened chat's first reply made, once a reading after it has moved
487function takeSpike(): Record<string, number> | undefined {
488 if (!spikeFrom || !live || live.at <= spikeFrom.at) return undefined
489 const jump: Record<string, number> = {}
490 for (const l of live.limits) {
491 const before = spikeFrom.limits.find(b => b.kind === l.kind)
492 if (before && sameWindow(before.resetsAt, l.resetsAt) && l.percentUsed > before.percentUsed) {
493 jump[l.kind] = l.percentUsed - before.percentUsed
494 }
495 }
496 if (Object.keys(jump).length === 0) return undefined
497 spikeFrom = undefined
498 return jump
499}
500
501async function trackPace($: EngineInterface, limits: Limit[], now: number): Promise<Record<string, PaceOf>> {
502 const spike = takeSpike()
503 const saved = ((await storeGet($, 'pace')) as PaceStore | undefined) ?? { finals: {}, windows: {} }
504 let isChanged = false
505 const pace: Record<string, PaceOf> = {}
506 for (const l of limits) {
507 const resetsAt = l.resetsAt ? Date.parse(l.resetsAt) : NaN
508 if (!WINDOWS[l.kind] || !Number.isFinite(resetsAt) || resetsAt <= now) continue
509 let w = saved.windows[l.kind]
510 if (!w || Math.abs(w.resetsAt - resetsAt) > SAME_WINDOW_MS) {
511 // a new window: the last one's peak is how far it got
512 if (w && w.peak > 0) saved.finals[l.kind] = [...(saved.finals[l.kind] ?? []), w.peak].slice(-FINALS_KEPT)
513 w = { resetsAt, peak: l.percentUsed, skip: 0 }
514 saved.windows[l.kind] = w
515 isChanged = true
516 } else if (l.percentUsed > w.peak) {
517 w.peak = l.percentUsed
518 isChanged = true
519 }
520 const jump = spike?.[l.kind]
521 if (jump) {
522 w.skip = Math.min(w.peak, w.skip + jump)
523 isChanged = true
524 }
525 pace[l.kind] = { typical: median(saved.finals[l.kind]) ?? TYPICAL_DEFAULT, skip: w.skip }
526 }
527 if (isChanged) await storeSet($, 'pace', saved)
528 return pace
529}
530
531// The window is shown as three groups: Messages, Tools (System + MCP tools) and Other
532// (the system prompt, skills, memory files, agents, MCP server instructions).
533const TOOL_NAMES = new Set(['System tools', 'MCP tools'])
534type Group = 'Messages' | 'Tools' | 'Other'
535const groupOf = (name: string): Group => (name === 'Messages' ? 'Messages' : TOOL_NAMES.has(name) ? 'Tools' : 'Other')
536
537// The free local breakdown ('summary') estimates each category from its text, which
538// reads tools ~1.3-1.6x heavy. The exact count ('full', what /context and the app's
539// Context window panel show) costs no tokens but sends one token-count request per tool
540// and memory file (~320 here). So run it every 2 minutes, keep the exact/estimate ratio
541// of Tools and Other (in the store, for every session), and scale the live 3s estimate
542// by it; Messages is then the API's real input total less those two.
543const CALIBRATE_EVERY_MS = 2 * 60_000
544let calib: Partial<Record<Group, number>> = {}
545let isCalibrating = false
546// Right after /compact there is no API reply yet, so the window's real total is unknown
547// and the local estimate is all there is. The exact count fills that gap until the next reply.
548let exactCount: { at: number; sums: Record<Group, number> } | undefined
549let noReplySince: number | undefined
550
551function sumGroups(categories: readonly { name: string; tokens: number; kind: string }[]): Record<Group, number> {
552 const sums: Record<Group, number> = { Messages: 0, Tools: 0, Other: 0 }
553 for (const c of categories) if (c.kind === 'used') sums[groupOf(c.name)] += c.tokens
554 return sums
555}
556
557async function calibrate($: EngineInterface): Promise<void> {
558 if (isCalibrating) return
559 isCalibrating = true
560 try {
561 const exact = await $.session.usage({ breakdown: 'full' })
562 const rough = await $.session.usage({ breakdown: 'summary' })
563 const real = sumGroups(exact.context.breakdown?.categories ?? [])
564 const guess = sumGroups(rough.context.breakdown?.categories ?? [])
565 const next = { ...calib }
566 for (const g of ['Tools', 'Other'] as const) if (guess[g] > 0 && real[g] > 0) next[g] = real[g] / guess[g]
567 calib = next
568 exactCount = { at: await $.clock.now(), sums: real }
569 await $.store.set('calib', next)
570 lastSnapshot = ''
571 } catch (error) {
572 $.ui.log(`usage-quota: exact count failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
573 } finally {
574 isCalibrating = false
575 }
576 // the usage service is asked alongside, on the same 2 minutes
577 await refresh($, 'due')
578}
579
580// Auto compact fires whenever the chat is idle (no turn running) and the context
581// is at or past the %: at the end of a turn, on opening a chat, on any refresh between
582// turns. Held back only by: `waits` (the band is asking, or the person chose
583// "after my next compact", which is also what an unanswered ask means: cleared only by
584// a real compaction, never by the % flickering below), `stuck` (a compaction already ran and
585// the context is still past the %: it would only repeat; dropping below clears it).
586// The % is compared as the band shows it, rounded.
587// a reply has been seen since the chat opened or was last compacted
588let hadReply = false
589async function setHold($: EngineInterface, patch: Partial<Hold>): Promise<void> {
590 await update($, hold, held => ({ ...held, ...patch }))
591}
592let isBusy = false
593let isCompacting = false
594let lastPercent = 0
595
596function compactNow($: EngineInterface): void {
597 if (isCompacting) return
598 isCompacting = true
599 // compacting takes longer than a press or a hook may run, so a timer starts it
600 $.clock.after(0, async () => {
601 try {
602 await $.command.run({ command: 'compact' })
603 } finally {
604 isCompacting = false
605 }
606 })
607}
608
609// Auto compact compacts through the session itself, not by typing /compact: a
610// command run from the end of a turn is refused while the turn winds down, which
611// is why it toasted and then did nothing. The compaction also rejects while a turn
612// runs, so it is tried again a few times, a couple of seconds apart.
613const AUTO_TRIES = 6
614let canCompactDirectly = true
615const AUTO_RETRY_MS = 2_000
616
617function autoCompactNow($: EngineInterface, attempt = 1): void {
618 if (attempt === 1) {
619 if (isCompacting) return
620 isCompacting = true
621 }
622 $.clock.after(attempt === 1 ? 1_000 : AUTO_RETRY_MS, async () => {
623 try {
624 // the desktop app (an SDK session) has no direct compaction: there /compact runs
625 // as a turn of its own, so it is typed, as the Compact button does
626 const result = canCompactDirectly
627 ? await $.session.compact()
628 : (await $.command.run({ command: 'compact' }), undefined)
629 // held as stuck while the figures are read again, so no tick fires a second one
630 await setHold($, { stuck: true })
631 isCompacting = false
632 if (result && 'skip' in result && result.skip) $.ui.toast(`Auto compact was skipped: ${result.skip}`)
633 lastSnapshot = ''
634 const usage = await $.session.usage({ breakdown: 'summary' })
635 const percent = usage.context.percent ?? 0
636 // still past the % after compacting: say so once, and do not loop on it
637 const auto = await read($, autoCompact)
638 if (auto.at !== null && Math.round(percent) >= auto.at) {
639 $.ui.toast(`Context is still at ${Math.round(percent)}% after compacting, past your ${auto.at}%: auto compact waits until it drops below`)
640 } else await setHold($, { stuck: false })
641 await refresh($)
642 } catch (error) {
643 const reason = error instanceof Error ? error.message : String(error)
644 // any refusal of the direct way: type /compact from then on, as the button does
645 if (canCompactDirectly) {
646 canCompactDirectly = false
647 return autoCompactNow($, attempt + 1)
648 }
649 if (attempt < AUTO_TRIES) return autoCompactNow($, attempt + 1)
650 isCompacting = false
651 $.ui.toast(`Auto compact could not start: ${reason}`)
652 $.ui.log(`usage-quota: auto compact failed: ${reason}`, { to: 'debug' })
653 }
654 })
655}
656
657// called with the context % whenever it is read
658async function watchAuto($: EngineInterface, percent: number): Promise<void> {
659 lastPercent = percent
660 const auto = await read($, autoCompact)
661 if (!auto.isOn || auto.at === null) return
662 const held = await read($, hold)
663 if (Math.round(percent) < auto.at) {
664 if (held.stuck) await setHold($, { stuck: false })
665 return
666 }
667 if (held.waits || held.stuck || isBusy || isCompacting) return
668 $.ui.toast(`Context at ${Math.round(percent)}%: auto compacting (set at ${auto.at}%)`)
669 autoCompactNow($)
670}
671
672async function saveAuto($: EngineInterface, next: AutoCompact): Promise<void> {
673 await update($, autoCompact, () => next)
674 await storeSet($, `autoCompact:${autoChat ?? (await chatId($))}`, next)
675}
676
677// auto compact is set per chat: a chat that never had it opens with it off (at the
678// default %); a chat reopened, or the app restarted, gets back its own. Read again
679// whenever the chat's id changes (a /clear goes on under a new one, unannounced)
680let autoChat: string | undefined
681async function chatId($: EngineInterface): Promise<string> {
682 try {
683 return await $.session.id()
684 } catch {
685 return 'chat'
686 }
687}
688async function loadAuto($: EngineInterface): Promise<void> {
689 const id = await chatId($)
690 if (id === autoChat) return
691 autoChat = id
692 const saved = (await storeGet($, `autoCompact:${id}`)) as AutoCompact | undefined
693 await update($, autoCompact, () => (saved ? { ...saved, at: saved.at ?? AT_DEFAULT } : { isOn: false, at: AT_DEFAULT }))
694 // the same chat after a reload keeps its ask and what holds it back
695 if ((await read($, hold)).chat === id) return
696 await update($, hold, () => ({ chat: id, waits: false, stuck: false }))
697 await update($, autoAsk, () => null)
698}
699
700// the % field sets on Enter, or when the focus leaves it with an unset draft (a
701// click elsewhere in the band, or the next prompt sent); 15 to 99, outside is pulled
702// in. After a set the field is drawn afresh (fieldTick), which also drops its focus
703const AT_MIN = 15
704// the % whenever there is none: a blank field, a first switch-on
705const AT_DEFAULT = 30
706const AT_MAX = 99
707let draftAt: string | null = null
708
709// no event says a click landed outside the band, so a draft also sets itself once
710// typing has rested a while, as if the person had clicked away
711const AT_REST_MS = 2_000
712let restTimer: { cancel(): void } | null = null
713
714// the field takes digits only, 3 at most: anything else typed or pasted is dropped
715// as it arrives, by drawing the field's text again cleaned
716const AT_DIGITS = 3
717// a mark of no width, so a cleaned text equal to the one drawn still redraws (an
718// unchanged value would leave the typing as it is) without a new field losing focus
719const NO_WIDTH = '\u200b'
720
721function cleanAt(value: string): string {
722 return value.replace(/\D/g, '').slice(0, AT_DIGITS)
723}
724
725// what the field shows comes from state, not a module variable: a press or a typing
726// runs apart from the draw, so a value the draw kept would not be seen here
727async function redrawField($: EngineInterface, value: string): Promise<void> {
728 const at = (await read($, autoCompact)).at ?? AT_DEFAULT
729 await update($, fieldText, drawn => (value === (drawn ?? `${at}`) ? value + NO_WIDTH : value))
730}
731
732function typedAt($: EngineInterface, raw: string): void {
733 const value = cleanAt(raw)
734 if (value !== raw) void redrawField($, value)
735 draftAt = value
736 restTimer?.cancel()
737 restTimer = $.clock.after(AT_REST_MS, () => {
738 restTimer = null
739 void commitDraft($)
740 })
741}
742
743async function commitDraft($: EngineInterface): Promise<void> {
744 if (draftAt === null) return
745 await commitAt($, draftAt)
746}
747
748async function commitAt($: EngineInterface, value: string): Promise<void> {
749 draftAt = null
750 restTimer?.cancel()
751 restTimer = null
752 const text = cleanAt(value)
753 // only digits reach here: blank is the default, out of range is pulled in, every time
754 const typed = text === '' ? AT_DEFAULT : Number(text)
755 const at = Math.min(AT_MAX, Math.max(AT_MIN, typed))
756 if (typed !== at) $.ui.toast(`Auto compact: ${typed < at ? 'the least' : 'the most'} is ${at}% – set to ${at}%`)
757 await setThreshold($, at)
758 // a new field every time (its key changes), so it shows the set value whatever was typed
759 await update($, fieldText, () => null)
760 await update($, fieldTick, n => n + 1)
761}
762
763// a new threshold: armed when the context is below it, else the band asks, in its
764// own buttons (never the chat's question dialog, which runs through the chat)
765async function setThreshold($: EngineInterface, at: number): Promise<void> {
766 await saveAuto($, { isOn: true, at })
767 await askIfPast($, at)
768}
769
770// turning it on, or a new %, while the context is already past it: ask first
771async function askIfPast($: EngineInterface, at: number | null): Promise<void> {
772 const isPast = at !== null && Math.round(lastPercent) >= at
773 await setHold($, { waits: isPast, stuck: false })
774 await update($, autoAsk, () => (isPast ? { at: at!, percent: Math.round(lastPercent) } : null))
775 if (!isPast && at !== null) $.ui.toast(`Auto compact at ${at}% context`)
776}
777
778type AskChoice = 'now' | 'next'
779
780async function answerAsk($: EngineInterface, choice: AskChoice): Promise<void> {
781 await update($, autoAsk, () => null)
782 // 'next': goes on waiting until a compaction is seen (the reply total goes blank)
783 if (choice === 'now') {
784 await setHold($, { waits: false })
785 autoCompactNow($)
786 }
787}
788
789let isRefreshing = false
790// an ask that arrived while a 3s tick was running: done right after it, not dropped
791let pendingAsk: Ask = 'no'
792let lastSnapshot = ''
793let hadError = false
794
795// collapsed or not is one setting for every chat, kept in the shared store: each chat
796// reads it twice a second (one small read, nothing else) and follows
797const COLLAPSE_EVERY_MS = 500
798// this chat's own press, until the store has it
799async function syncCollapsed($: EngineInterface): Promise<void> {
800 const saved = ((await storeGet($, 'collapsed')) as boolean | undefined) === true
801 if (saved !== (await read($, isCollapsed))) await update($, isCollapsed, () => saved)
802}
803
804// The desktop app's theme is not told to plugins: it is read from the app's own
805// settings (userThemeMode), and for "system" from the OS. Looked at every 2s so a
806// switch shows at once, each look a single file-time check (the file is read only
807// once it changed); the OS, for "system" alone, is asked at most every 15s
808const THEME_EVERY_MS = 2_000
809const SYSTEM_EVERY_MS = 15_000
810let didLogTheme = false
811let systemTheme: { at: number; value: 'dark' | 'light' } | undefined
812async function desktopConfig($: EngineInterface): Promise<string | undefined> {
813 const appData = await $.env.get('APPDATA')
814 if (appData) return `${appData}/Claude/config.json`
815 const home = await $.env.get('HOME')
816 return home ? `${home}/Library/Application Support/Claude/config.json` : undefined
817}
818async function osTheme($: EngineInterface): Promise<'dark' | 'light'> {
819 const now = await $.clock.now()
820 if (systemTheme && now - systemTheme.at < SYSTEM_EVERY_MS) return systemTheme.value
821 let value: 'dark' | 'light' = 'dark'
822 try {
823 if (await $.env.get('APPDATA')) {
824 const { stdout } = await $.process.run(['reg', 'query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize', '/v', 'AppsUseLightTheme'], { timeoutMs: 5_000 })
825 if (/AppsUseLightTheme\s+REG_DWORD\s+0x1\b/.test(stdout)) value = 'light'
826 } else {
827 const { stdout } = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: 5_000 })
828 if (!/dark/i.test(stdout)) value = 'light'
829 }
830 } catch {
831 // unknown: dark, as the band was built
832 }
833 systemTheme = { at: now, value }
834 return value
835}
836// the settings file is read again only when it has changed since the last look: a
837// look is otherwise one file-time check, no read, no parse
838let themeFile: { mtimeMs: number; mode: string | undefined } | undefined
839async function syncTheme($: EngineInterface): Promise<void> {
840 let mode: string | undefined
841 try {
842 const path = await desktopConfig($)
843 if (!path) return
844 const { mtimeMs } = await $.fs.stat(path)
845 if (themeFile?.mtimeMs === mtimeMs) mode = themeFile.mode
846 else {
847 mode = (JSON.parse(await $.fs.read(path)) as { userThemeMode?: string }).userThemeMode
848 themeFile = { mtimeMs, mode }
849 }
850 } catch (error) {
851 if (!didLogTheme) $.ui.log(`usage-quota: the app's theme could not be read: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
852 didLogTheme = true
853 return
854 }
855 const next = mode === 'light' ? 'light' : mode === 'dark' ? 'dark' : await osTheme($)
856 if (next !== (await read($, theme))) await update($, theme, () => next)
857}
858
859async function refresh($: EngineInterface, ask: Ask = 'no'): Promise<void> {
860 await loadAuto($)
861 if (isRefreshing) {
862 if (ask === 'now' || (ask === 'due' && pendingAsk === 'no')) pendingAsk = ask
863 return
864 }
865 isRefreshing = true
866 try {
867 const now = await $.clock.now()
868 const usage = await $.session.usage({ breakdown: 'summary' })
869 const { context } = usage
870 const breakdown = context.breakdown
871 const all = (breakdown?.categories ?? []).filter(c => c.kind !== 'deferred' && c.tokens > 0)
872 const rough = sumGroups(all)
873 if (context.tokens === undefined) {
874 // no reply since the chat opened or was compacted: get the exact count once
875 if (noReplySince === undefined) {
876 noReplySince = now
877 $.clock.after(0, () => void calibrate($))
878 }
879 } else noReplySince = undefined
880 // what the chat opened with, before its first turn here (a reopened chat may
881 // already carry its last reply's total, so not only while that is blank)
882 if (isOpening) openMessages = Math.max(rough.Messages, context.tokens ?? 0)
883 // the reply total going blank after a reply: the chat was compacted (or cleared)
884 if (context.tokens !== undefined) hadReply = true
885 else if (hadReply) {
886 hadReply = false
887 // an ask left unanswered meant "after my next compact": done with now
888 if ((await read($, hold)).waits) {
889 await update($, autoAsk, () => null)
890 await setHold($, { waits: false })
891 }
892 }
893 const useExact = context.tokens === undefined && exactCount !== undefined && noReplySince !== undefined && exactCount.at >= noReplySince
894 let tools = Math.round(rough.Tools * (calib.Tools ?? 1))
895 let other = Math.round(rough.Other * (calib.Other ?? 1))
896 // Messages is what the window holds beyond everything else, as /context counts it:
897 // the API's real input total less the other groups, not the local estimate
898 let messages = context.tokens !== undefined && context.tokens - tools - other > 0
899 ? context.tokens - tools - other
900 : rough.Messages
901 if (useExact) ({ Messages: messages, Tools: tools, Other: other } = exactCount!.sums)
902 const used = context.tokens ?? (useExact ? messages + tools + other : breakdown?.totalTokens ?? 0)
903 const categories: Category[] = [
904 { name: 'Messages', tokens: messages, kind: 'used' },
905 { name: 'Tools', tokens: tools, kind: 'used' },
906 { name: 'Other', tokens: other, kind: 'used' },
907 ...all
908 .filter(c => c.kind === 'buffer' || c.kind === 'free')
909 .map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind as Category['kind'] })),
910 ].filter(c => c.tokens > 0)
911 const { limits, at: limitsAt, error: limitsError } = await limitsOf($, usage.rateLimits, now, ask)
912 const pace = await trackPace($, limits, now)
913 const next: Snapshot = {
914 window: context.window,
915 tokens: used,
916 percent: useExact ? Math.round((used / context.window) * 100) : context.percent ?? breakdown?.percentage ?? Math.round((used / context.window) * 100),
917 total: categories.reduce((sum, c) => sum + c.tokens, 0) || context.window,
918 categories,
919 limits,
920 ...(limitsAt !== undefined ? { limitsAt } : {}),
921 ...(limitsError ? { limitsError } : {}),
922 ...(Object.keys(pace).length > 0 ? { pace } : {}),
923 // the minute, so countdowns and ages redraw even when nothing else moves
924 minute: Math.floor(now / 60_000),
925 }
926 await watchAuto($, next.percent)
927 // redraw only when something moved, not on every tick
928 const json = JSON.stringify(next)
929 if (json !== lastSnapshot) {
930 lastSnapshot = json
931 await update($, snapshot, () => next)
932 }
933 if (hadError) $.ui.status(undefined)
934 hadError = false
935 } catch (error) {
936 const message = error instanceof Error ? error.message : String(error)
937 $.ui.status(`usage-quota: ${message.replace(/^usage-quota: /, '')}`)
938 hadError = true
939 } finally {
940 isRefreshing = false
941 }
942 if (pendingAsk !== 'no') {
943 const queued = pendingAsk
944 pendingAsk = 'no'
945 await refresh($, queued)
946 }
947}
948
949export const register: Register = on => {
950 on('session.start', async ($, e, next) => {
951 await $.command.register({ name: 'quota', description: 'Toggle the Claude Code Usage Quota band above the prompt' })
952 const result = await next(e)
953 await migrateStore($)
954 try {
955 calib = ((await $.store.get('calib')) as typeof calib | undefined) ?? {}
956 } catch {
957 calib = {}
958 }
959 await loadAuto($)
960 await syncCollapsed($)
961 // a chat just opened: its limits now, unless another chat asked a moment ago
962 await refresh($, 'due')
963 // the exact count on a timer, never from the hook itself: work a hook starts and
964 // leaves running ends with its dispatch, which is why it never ran before
965 $.clock.after(2_000, () => void calibrate($))
966 $.clock.every(CALIBRATE_EVERY_MS, () => void calibrate($))
967 // keep it live, mid-turn included: what Claude Code already holds (the context and
968 // the limits each reply carries), every 3s; timers stop when the module reloads
969 $.clock.every(LOCAL_EVERY_MS, () => void refresh($))
970 $.clock.every(COLLAPSE_EVERY_MS, () => void syncCollapsed($))
971 // whatever surface the session started on (a desktop chat may say none)
972 await syncTheme($)
973 $.clock.every(THEME_EVERY_MS, () => void syncTheme($))
974 return result
975 })
976
977 on('session.measure', async ($, e, next) => {
978 const result = await next(e)
979 await refresh($)
980 return result
981 })
982
983 // the focus leaving the % field sets what was typed in it
984 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
985 if (!e.element?.startsWith('autoAt')) await commitDraft($)
986 return next(e)
987 }).catch(($, e, next) => next(e)) // a draft that fails to set never holds the focus back
988
989 on('ui.press', { component: 'AbovePrompt' }, async ($, e, next) => {
990 await commitDraft($)
991 return next(e)
992 })
993
994 on('prompt.edit', async ($, e, next) => {
995 void commitDraft($)
996 return next(e)
997 })
998
999 on('turn.start', async ($, e, next) => {
1000 isBusy = true
1001 if (isOpening) {
1002 isOpening = false
1003 const snap = await read($, snapshot)
1004 if (snap && snap.limits.length > 0 && openMessages >= RESUME_MIN_TOKENS) {
1005 spikeFrom = { at: await $.clock.now(), limits: snap.limits }
1006 }
1007 }
1008 await commitDraft($)
1009 return next(e)
1010 })
1011
1012 on('turn.complete', async ($, e, next) => {
1013 const result = await next(e)
1014 await refresh($)
1015 // the first reply is done: whatever it used, the jump is behind it
1016 spikeFrom = undefined
1017 // the turn is over: auto compact may fire now, never mid-reply
1018 isBusy = false
1019 const snap = await read($, snapshot)
1020 if (snap) await watchAuto($, snap.percent)
1021 return result
1022 })
1023
1024 on('command.run', { command: 'quota' }, async $ => {
1025 const now = !(await read($, isOn))
1026 await update($, isOn, () => now)
1027 if (now) await refresh($, 'now')
1028 return { text: now ? 'Claude Code Usage Quota on.' : 'Claude Code Usage Quota off.' }
1029 })
1030
1031 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1032 if (e.props.hasSurvey || !(await read($, isOn))) return next(e)
1033 const snap = await read($, snapshot)
1034 if (!snap) return next(e)
1035 // the band is shared: what the plugins beneath draw goes under this one
1036 const below = await next(e)
1037
1038 const table = $.ui.resolve(e)
1039 const { Box, Text, Button, Input } = table
1040 const Svg = e.surface === 'desktop' && 'Svg' in table ? table.Svg : undefined
1041 usePalette(e.surface === 'desktop' && (await read($, theme)) === 'light' ? LIGHT : DARK)
1042 const width = Math.max(20, e.props.bodyColumns)
1043 const now = await $.clock.now()
1044 // both windows always, once there is any reading: one the reading lacks is not running
1045 const known = snap.limits.length === 0 ? [] : Object.keys(WINDOWS).map(kind => snap.limits.find(l => l.kind === kind) ?? { kind, percentUsed: 0 })
1046 const list = known.map(l => forecast(l, now, snap.pace?.[l.kind])).filter((f): f is Forecast => f !== null)
1047 const head = headline(list, now)
1048 // free as /context has it: the window less what is used and the autocompact buffer
1049 const buffer = snap.categories.find(c => c.kind === 'buffer')?.tokens ?? 0
1050 const free = Math.max(0, snap.window - snap.tokens - buffer)
1051 // highest first, as /context lists them: used categories, then the buffer, then free space
1052 const rank = (c: Category) => (c.kind === 'used' ? 0 : c.kind === 'buffer' ? 1 : 2)
1053 const shown = snap.categories
1054 .map((c, i) => ({ ...c, color: colorOf(c, i) }))
1055 .sort((a, b) => rank(a) - rank(b) || b.tokens - a.tokens)
1056
1057 const limits = [...list].sort((a, b) => order(a.kind) - order(b.kind))
1058 // say so when the figures are old: the service refused and no reply has come since
1059 const age = snap.limitsAt === undefined ? 0 : now - snap.limitsAt
1060 const stale =
1061 snap.limitsError && age >= 2 * 60_000 && limits.length > 0
1062 ? `usage check ${snap.limitsError} – figures from ${duration(age)} ago`
1063 : snap.limitsError && limits.length === 0
1064 ? `usage check ${snap.limitsError}`
1065 : undefined
1066 const collapsed = await read($, isCollapsed)
1067 const auto = await read($, autoCompact)
1068 const ask = await read($, autoAsk)
1069 const tick = await read($, fieldTick)
1070 const drawnAt = (await read($, fieldText)) ?? `${auto.at ?? AT_DEFAULT}`
1071 const submitAt = (value: string) => commitAt($, value)
1072 const toggleAuto = () => {
1073 const flipped = { isOn: !auto.isOn, at: auto.at ?? AT_DEFAULT }
1074 void saveAuto($, flipped).then(() =>
1075 flipped.isOn ? askIfPast($, flipped.at) : update($, autoAsk, () => null),
1076 )
1077 }
1078 // Laid out to the width the chat gives the band, down to the desktop's narrowest
1079 // chat: what fits side by side stays side by side; past that it stacks, and text
1080 // wraps rather than cuts off.
1081 // The header: the headline beside the controls when both fit, else the controls
1082 // on a row of their own beneath it
1083 // (the desktop's letters are narrower than its cells: about 0.8 of one)
1084 const CONTROLS = 40
1085 // the terminal draws its own hide control ([-]) over the band's top-right
1086 // corner: the first row keeps clear of it
1087 const HOST_HIDE = Svg ? 0 : 4
1088 const isHeadBeside = width >= (head?.text.length ?? 0) * (Svg ? 0.8 : 1) + CONTROLS + HOST_HIDE
1089 // the windows and the context side by side, or one above the other
1090 const isSplit = width >= 84
1091 const column = isSplit ? Math.floor((width - 4) / 2) : width
1092 // the limit bars fill their column: less the label (8) and the % (2 + 4)
1093 const cells = Math.max(6, column - 14)
1094 // collapsed: the three bars in one row, or each on a row of its own
1095 const isOneRow = width >= 72
1096 // the context's two figures on one line, or the free count on the next
1097 const isContextLine = column >= 38
1098 // the question and its two answers on one line when they fit, else the answers
1099 // beneath: measured from the words themselves (the desktop draws text narrower than
1100 // a cell each, as the headline's check allows), each button's chrome and the gaps
1101 const ASK_LABELS = ['Now', 'After my next compact']
1102 const askWords = (ask ? `Context is already at ${ask.percent}%, past ${ask.at}%. Auto compact:`.length : 0) + ASK_LABELS.join('').length
1103 const isAskLine = width >= askWords * (Svg ? 0.8 : 1) + ASK_LABELS.length * (Svg ? 3 : 4) + 3
1104
1105 const controls = (
1106 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0} flexWrap="wrap">
1107 {/* auto compact: the switch (the press), its name, the % field beside it,
1108 then a wide gap so the field reads as the switch's, not Compact's */}
1109 {Svg ? (
1110 <Box key="autoSwitch" width={SWITCH_CELLS} height={1} overflow="hidden">
1111 {/* three layers, each the box's full size, so all share one centre: the
1112 rounded light (unlit: an invisible border), the pill, the press */}
1113 <Box position="absolute" top={0} left={0} width={SWITCH_CELLS} height={1} borderStyle="round" borderColor={CLEAR} hover={{ backgroundColor: SWITCH_LIT, borderColor: SWITCH_LIT }} />
1114 <Box position="absolute" top={0} left={0} width={SWITCH_CELLS} height={1} alignItems="center" justifyContent="center">
1115 <Svg alt={`Auto compact ${auto.isOn ? 'on' : 'off'}`} source={switchSvg(auto.isOn)} width={SWITCH_W} height={SWITCH_H} />
1116 </Box>
1117 {/* a chromeless button over all of it takes the click */}
1118 <Box position="absolute" top={0} left={0}>
1119 <Button key="auto" label={' '} plain hover={{ backgroundColor: CLEAR }} onPress={toggleAuto} />
1120 </Box>
1121 </Box>
1122 ) : (
1123 <Button key="auto" label={auto.isOn ? '●' : '○'} plain onPress={toggleAuto} />
1124 )}
1125 <Text color={auto.isOn ? undefined : MUTED}>Auto compact</Text>
1126 {auto.isOn ? (
1127 // the field (3 digits) sets itself once typing pauses; a plain % right after it
1128 <Box flexDirection="row" alignItems="center">
1129 {/* the field's Enter chip can't be turned off: the field is drawn wider than
1130 its box and the box clips the chip away */}
1131 <Box width={4} overflow="hidden">
1132 <Box width={6} flexShrink={0}>
1133 <Input
1134 key={`autoAt${tick}`}
1135 value={drawnAt}
1136 submitLabel={'\u200b'}
1137 onInput={value => typedAt($, value)}
1138 onSubmit={value => submitAt(value)}
1139 />
1140 </Box>
1141 </Box>
1142 <Text color={MUTED}>%</Text>
1143 </Box>
1144 ) : Svg ? (
1145 // off: the field's room is kept, unseen, so the switch sits at the very same
1146 // spot and draws pixel for pixel as when on
1147 <Box flexDirection="row" alignItems="center">
1148 <Box width={4} />
1149 <Text color={CLEAR}>%</Text>
1150 </Box>
1151 ) : null}
1152 <Box key="compactBox" marginLeft={isHeadBeside ? 2 : 0}>
1153 <Button key="compact" label="Compact" hover={{ backgroundColor: COMPACT_LIT }} onPress={() => compactNow($)} />
1154 </Box>
1155 <Button
1156 key="collapse"
1157 label={collapsed ? '▲' : '▼'}
1158 plain
1159 onPress={async () => {
1160 // from the state, not this closure's value (an older draw's); saved for
1161 // the other chats first, so the half-second read never undoes the press
1162 const now = !(await read($, isCollapsed))
1163 await storeSet($, 'collapsed', now)
1164 await update($, isCollapsed, () => now)
1165 }}
1166 />
1167 </Box>
1168 )
1169 const gap = Svg ? 0.5 : 1
1170 const headText = <Text bold color={head ? ink(head.color) : MUTED} wrap="wrap">{head?.text ?? ' '}</Text>
1171
1172 // one bar: an Svg on the desktop, coloured cells on the terminal
1173 const bar = (alt: string, segments: { color: string; share: number }[], barCells: number, marker?: number, f?: Forecast) =>
1174 Svg ? (
1175 <Box width={barCells} height={1} alignItems="center">
1176 <Svg alt={alt} source={barSvg(segments, barCells * 8, marker)} height={BAR_PX} />
1177 </Box>
1178 ) : f ? (
1179 <Box flexDirection="row" width={barCells}>
1180 {cellRuns(f, barCells).map(run => (
1181 <Box width={run.width} backgroundColor={run.color}>
1182 <Text> </Text>
1183 </Box>
1184 ))}
1185 </Box>
1186 ) : (
1187 <Box flexDirection="row" width={barCells} overflow="hidden">
1188 {segments.map(seg => {
1189 const total = segments.reduce((sum, s) => sum + s.share, 0) || 1
1190 return (
1191 <Box backgroundColor={seg.color} flexGrow={Math.min(10000, Math.max(seg.share > 0 ? 1 : 0, Math.round((seg.share / total) * 1000)))} flexShrink={1}>
1192 <Text> </Text>
1193 </Box>
1194 )
1195 })}
1196 </Box>
1197 )
1198 const limitSegments = (f: Forecast) => [
1199 { color: f.color, share: Math.min(100, f.percent) },
1200 { color: TRACK, share: Math.max(0, 100 - f.percent) },types/index.d.ts 45 lines1export type Category = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' }
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3export type PaceOf = { typical: number; skip: number }
4export type Snapshot = {
5 window: number
6 tokens: number
7 percent: number
8 total: number
9 categories: Category[]
10 limits: Limit[]
11 /** when the limit figures were read */
12 limitsAt?: number
13 /** why the usage service last refused, while it is backed off */
14 limitsError?: string
15 /** per window kind: the usual final % and the one-off % left out of the pace */
16 pace?: Record<string, PaceOf>
17 /** the minute it was taken in: redraws countdowns at least once a minute */
18 minute?: number
19}
20
21/** auto compact: on or off, and the context % that sets it off (kept for every chat) */
22export type AutoCompact = { isOn: boolean; at: number | null }
23
24/** what holds auto compact back in a chat: an ask (or "after my next compact"), or a compaction that left it past the % */
25export type Hold = { chat: string; waits: boolean; stuck: boolean }
26
27declare module 'claude-code' {
28 interface PluginState {
29 'usage-quota': {
30 snapshot: Snapshot | null
31 isOn: boolean
32 isCollapsed: boolean
33 autoCompact: AutoCompact
34 /** the choice the band is asking for: the context was already past a new % */
35 autoAsk: { at: number; percent: number } | null
36 hold: Hold
37 /** bumped on every % set: draws the field afresh even when the value is unchanged */
38 fieldTick: number
39 fieldText: string | null
40 /** the desktop app's theme, as its settings (or the OS) have it */
41 theme: 'dark' | 'light'
42 }
43 }
44}
45