SLOPSHOPPER

usage-quota

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…

newbandcommandtoaststatusprompt
v0.1.6-luytbq.1MITupdated 2026-10-21luytbq/dotfiles/.claude/mods/usage-quota
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-quota
› fix the failing auth test and add an audit log call ● usage-quota: usage-quota: the app's theme could not be read: ENOENT: /Users/dev/Library/Application Support/Claude/config.json ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /quota ⎿ usage-quota: Claude Code Usage Quota off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code Usage Quota Mod with Auto Compact

Version: v0.1.6 Auto compact: 30% default Compact: one click 5 Hour + Weekly: whole account Forecast: before reset Works in: Desktop + Terminal License: MIT

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.

EXPANDED

The band, expanded, above the prompt in the Claude desktop app

COLLAPSED

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

The band, collapsed, above the prompt in the Claude desktop app

[!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 /compact in 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.

WHAT IT SHOWS

5 Hour and Weekly limits

  • The % used, matching the app's own Plan usage limits panel.
  • A forecast of where you'll be at reset: a grey tick on the bar and a note, e.g. On pace for about 87% by reset – resets in 23m.
  • A headline at a glance: On track. You should reach Wednesday's reset with room to spare.
  • The forecast starts from your usual pace (learned from your past windows) and shifts to your actual pace as the window goes on, so an early burst doesn't set it off.
  • Bars turn amber at 75% and red at 90%, or sooner if you're on course to run out.

On course to run out, it warns you and says when you'll hit the limit:

The band, expanded, on course to run out before the 5 hour reset

The band, collapsed, on course to run out

Context window

  • The % used and space free, as /context counts it, split into Messages, Tools and Other.
  • Amber at 50%, red at 80%: the point to compact.

Compacting

  • Compact runs /compact in one click.
  • Auto compact runs at your % (30% by default, 15 to 99), never mid-reply. Each session keeps its own setting; a new session starts with it off.

If the session is already past your % when you turn it on, it asks first:

Auto compact asking what to do, with the context already at 72%

The same question with the band collapsed

  • Now compacts straight away.
  • After my next compact waits until the session is compacted some other way (Compact, /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:

The band, expanded, early in a 5 hour window

The band, collapsed, early in a 5 hour window

Running out on the Weekly limit:

The band, expanded, on course to run out before the weekly reset

The band, collapsed, on course to run out before the weekly reset

Light themeNarrowest window
Light, expandedNarrow, expanded
Light, collapsedNarrow, collapsed

WHERE IT WORKS

  • Claude desktop app, Code tab
  • Claude Code in a terminal

[!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.

The band in the terminal, expanded

Collapsed:

The band in the terminal, collapsed

INSTALL

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-quota to usage-quota, because Claude Code reserves names starting with claude- 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-mod

Terminal: run these in your shell:

claude plugin uninstall claude-code-usage-quota@claude-code-usage-quota-mod
claude plugin marketplace update claude-code-usage-quota-mod
claude plugin install usage-quota@claude-code-usage-quota-mod

Then 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

PRIVACY

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.

  • Network: about every 2 minutes, one request to 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.
  • Your session: it reads the context size and the limits each reply carries, as Claude Code reports them. It doesn't read or send what you or Claude write.
  • Commands: it runs /compact when you press Compact, or when Auto compact reaches your %. It adds /quota, which turns the band off and on.
  • Programs: only to match your system's light or dark mode, when the desktop app follows the system: reg query for AppsUseLightTheme on Windows, defaults read -g AppleInterfaceStyle on macOS.
  • Files: it reads the desktop app's 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.
  • Hooks: the band's own clicks and focus (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.

FEEDBACK AND CONTRIBUTING

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.

CREDITS

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.

LICENSE

MIT © 2026 Anant Raghunath

Source 2 files
hooks/register.tsx 1428 lines
1import { 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 lines
1export 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