Your Claude Code usage as colored chips above the prompt: 5h and weekly limits (% left), session cost and spend history. /usage-mod opens the full view.

Your Claude Code usage as colored chips above the prompt: the 5-hour and weekly limits (% left), the Fable and extra-usage limits, the context window and today's spend. Open the Details pane for this session's tokens and cost, yesterday, the last 30 days and a 14-day chart.
claude plugin marketplace add kreddevils18/claude-usage-mod
claude plugin install usage-mod@claude-usage-mod
The same two steps work inside a session as /plugin marketplace add kreddevils18/claude-usage-mod and /plugin install usage-mod@claude-usage-mod. Then run /reload-plugins, or restart Claude Code.
| Command | What it does |
|---|---|
/usage-mod | Opens the Details pane (the Details button on the band does the same) |
/usage-mod text | Prints the same numbers as plain text, for surfaces that draw nothing |
/usage-mod refresh | Re-reads your transcripts and the plan limits now, then prints the text |
/usage-mod debug | Says whether the plan limits fetch worked and which fields it returned |
| Chip | It means |
|---|---|
| 5h / 7d | The 5-hour and weekly plan limits: how much is left, and when each resets |
| Fable and other models | A model's own weekly limit, when your plan has one |
| Extra | Your extra-usage spend against its monthly cap, when extra usage is switched on and capped |
| ctx | The context window: how much is left before Claude Code compacts |
| $81.58 today | Spend today across all your sessions |
Hover a chip on the desktop app for a sentence saying what the number is.
The bars are Pac-Man eating dots. Pac-Man sits at how much you have used; the dots ahead of him are what is left. The dots are green with plenty left, yellow under 35% and red under 15%.
A chip that looks pale is a reading from an earlier session: a fresh session has no engine reading until its first response, so the mod shows the last one it saw rather than a blank band.
When the width is short the band fills up to two lines before it drops anything. If that is still too wide it gives up, in order: today's spend, the reset times, the context chip, and the bars (a window shrinks to its icon, name and percent). Every limit stays on the band until the very last step, where only the tightest one is left.
/usage-mod or the Details button opens it:
In Claude Code's /config menu, under the plugin's name:
| Setting | Default |
|---|---|
Band: band or off | band |
| Show today spend on the band | on |
| Plan limits from Anthropic (Fable, extra usage) | on |
| Tooltips and animation on the desktop app | on (turn it off if the band flickers) |
| Spend refresh minutes | 5 |
| Plan limits refresh minutes (5 at the least) | 15 |
| Where you run Claude Code | Band and pane |
|---|---|
claude in a terminal | Yes, as colored text chips |
| The Code tab of the desktop app | Yes, as SVG pills with tooltips |
The VS Code extension's chat panel, claude -p | No: use /usage-mod text |
claude --version.PATH for the spend figures (today, yesterday, 30 days, the chart). The script uses only Node's built-ins and was developed on Node 25. Without Node everything else still works, and the pane says the spend is missing.| Figure | Source |
|---|---|
| 5h and 7d limits, context, session cost | Claude Code itself, as its status line gets them; the limits arrive with the first response of a session |
| Fable and other model limits, extra usage, rate limit resets | Anthropic's plan usage API, the one Claude Code's own /usage reads, asked through your signed-in session every 15 minutes (the Plan limits refresh setting). The sessions take turns: one asks and saves the reply for the others, so the terminal and the desktop app show the same figures however many sessions are open, and a session with no turn since its last request waits until the reply is 30 minutes old. Model limits are the weekly_scoped entries of its limits list, extra usage is dollars spent over the monthly cap, resets are your granted one-off resets. It also fills 5h and 7d before the first response. Switch it off with the Plan limits setting |
| Session tokens | Added up from each completed turn; kept per session, so a resume continues the count |
| Today, yesterday, 30 days | scripts/aggregate-usage.mjs reads the usage numbers in your transcripts under ~/.claude/projects and prices them with config/pricing.json |
The dollar figures from transcripts are estimates at list price. If you are on a subscription plan, they show what the same tokens would cost through the API, not what you are billed. Prices change; if a model is missing or wrong, the pane says so and CONTRIBUTING shows the one-line fix.
It reads token counts and timestamps from your Claude Code transcripts and keeps a small summary, and the plan usage API's last reply, in ~/.claude/claude-usage-mod. It never keeps prompt text, tool output or file paths. Its one network request is the plan usage call above, made through Claude Code's credential handle, so the mod never sees your token; the setting turns it off. Details in PRIVACY.md.
A mod is code that runs inside Claude Code with your permissions, written by its publisher and not by Anthropic. Read the source before you install it; it is a few small files, and claude plugin validate lists every call it makes.
/plugin and look for usage-mod; run /reload-plugins or restart. Mods need Claude Code 2.1.287 or later, and the view setting must not be off.node runs in a terminal, then run /usage-mod refresh. You can also run node scripts/aggregate-usage.mjs by hand or from cron: the mod reads the file it writes./usage-mod debug to see whether the plan call worked and which fields it returned. If it says http 429, the API is refusing requests for a while; the mod then shows the last reply any session saved, and no session asks again before the time Anthropic gives (a minute after any other failure). Other apps that read the same API, such as OpenUsage, count against the same account limit. The endpoint is not a documented API, so it can change; when it fails with nothing saved, the band falls back to the two windows Claude Code reports.Pull requests are welcome, above all pricing updates. See CONTRIBUTING.md.
hooks/register.tsx 505 lines1// usage-mod (repo claude-usage-mod): usage as chips in Claude Code.
2//
3// session.start: registers /usage-mod, seeds the state from $.session.usage() and the spend
4// cache, and starts two timers: a minute tick (reset countdowns) and the spend refresh.
5// session.measure: keeps limits, context and session cost live; the engine pushes it when a
6// rate-limit window moves a whole point and after each main-thread turn.
7// turn.complete: adds the turn's tokens to this session's totals, and marks the session in use
8// so its next plan usage request is not held back as idle.
9// ui.render: AbovePrompt draws the one-line band (SVG pills on desktop, Text chips on the
10// terminal); Pane draws the full view.
11// command.run: /usage-mod opens the pane; /usage-mod text prints it; /usage-mod refresh re-reads transcripts.
12//
13// Helpers that take `$` stay in this file: the engine follows `$` only inside the registering file.
14import { atom, read, update } from 'claude-code'
15import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
16
17import type { ContextUsage, Limit, PlanInfo, RawLimit, ResetGrants, SessionTokens, SpendStatus, SpendSummary, StoredLimits, StoredTokens } from '../types'
18import { bandTiers, pickLayout } from './band-model'
19import type { BandSnapshot } from './band-model'
20import { formatDuration } from './format'
21import { mergeLimits, resetSignature } from './limits'
22import { FAILURE_BACKOFF_MS, claimed, failed, holdReason, parsePlanCache, planCacheFile, retryAtFrom } from './plan-cache'
23import type { PlanCache } from './plan-cache'
24import { PLAN_USAGE_URL, parsePlanUsage, planUserAgent } from './plan-usage'
25import { SCRIPT_TIMEOUT_MS, parseSummary, scriptPath, summaryFile } from './spend-cache'
26import { summaryText } from './summary-text'
27import { bandSvg, bandWidth } from './svg-band'
28import { paneModel } from './pane-model'
29import { paneSvg } from './svg-pane'
30import { cellWidth, chipRow } from './terminal-band'
31import { terminalPane } from './terminal-pane'
32
33const COMMAND = 'usage-mod'
34const PANE = 'usage-mod'
35const DEFAULT_REFRESH_MINUTES = 5
36// The plan usage API is asked less often than the transcripts are read: 5h and 7d come from the
37// engine for free, and what only the API has (Fable, Extra, resets) moves slowly. Never under 5.
38const DEFAULT_PLAN_REFRESH_MINUTES = 15
39const MIN_PLAN_REFRESH_MINUTES = 5
40// Pixels per terminal column on a surface that draws the band as SVG; an estimate, kept on the
41// narrow side so the band never overflows. The Details button keeps its own room.
42const CELL_PX = 7.4
43const DETAILS_PX = 90
44const DETAILS_CELLS = 12
45
46const limits = atom({ plugin: 'usage-mod', key: 'limits' } as const, [] as Limit[])
47const liveLimits = atom({ plugin: 'usage-mod', key: 'liveLimits' } as const, [] as RawLimit[])
48const planLimits = atom({ plugin: 'usage-mod', key: 'planLimits' } as const, [] as RawLimit[])
49const storedLimits = atom({ plugin: 'usage-mod', key: 'storedLimits' } as const, [] as RawLimit[])
50const planInfo = atom({ plugin: 'usage-mod', key: 'planInfo' } as const, null as PlanInfo | null)
51const resetGrants = atom({ plugin: 'usage-mod', key: 'resetGrants' } as const, null as ResetGrants | null)
52const planRaw = atom({ plugin: 'usage-mod', key: 'planRaw' } as const, null as string | null)
53const context = atom({ plugin: 'usage-mod', key: 'context' } as const, null as ContextUsage | null)
54const sessionUsd = atom({ plugin: 'usage-mod', key: 'sessionUsd' } as const, null as number | null)
55const spend = atom({ plugin: 'usage-mod', key: 'spend' } as const, null as SpendSummary | null)
56const spendStatus = atom({ plugin: 'usage-mod', key: 'spendStatus' } as const, 'idle' as SpendStatus)
57const tokens = atom({ plugin: 'usage-mod', key: 'tokens' } as const, { up: 0, down: 0, cache: 0 } as SessionTokens)
58const tick = atom({ plugin: 'usage-mod', key: 'tick' } as const, 0)
59// Names the module that started the timers, so timers of an older module that was reloaded away
60// find out and stop instead of running beside the new ones.
61const generation = atom({ plugin: 'usage-mod', key: 'generation' } as const, '')
62
63// A write redraws everything that read the value, and the desktop redraws an SVG by reloading its
64// frame, which shows as a flicker. So a value is written only when it differs from what is held:
65// every write below is `if (changed(await read($, x), next)) await update($, x, () => next)`.
66// (The engine reads an atom only where it is named, so this cannot be one generic helper.)
67const changed = (held: unknown, next: unknown) => JSON.stringify(held) !== JSON.stringify(next)
68
69/** Runs `fn` every `ms` while this module is the current generation; cancels itself once a newer one exists. */
70function every($: EngineInterface, ms: number, gen: string, fn: () => unknown): Timer {
71 const timer: Timer = $.clock.every(ms, async () => {
72 if ((await read($, generation)) !== gen) {
73 timer.cancel()
74 return
75 }
76 await fn()
77 })
78 return timer
79}
80
81type Measure = { rateLimits: readonly SessionRateLimit[]; context: ContextUsage; cost?: { usd: number } }
82
83// The windows last seen, kept across sessions: a fresh or resumed session has no engine reading until
84// its first API response, and a blank band for that stretch looks broken. mergeLimits drops the
85// windows that have since reset.
86async function loadStoredLimits($: EngineInterface): Promise<RawLimit[]> {
87 const saved = (await $.store.get('limits')) as Partial<StoredLimits> | undefined
88 if (!saved || !Array.isArray(saved.limits)) return []
89 return saved.limits.filter(l => typeof l?.kind === 'string' && typeof l.percentUsed === 'number')
90}
91
92// One list from three sources: the engine now, the plan usage API, and the previous session.
93async function rebuildLimits($: EngineInterface) {
94 const from = { live: await read($, liveLimits), plan: await read($, planLimits), stored: await read($, storedLimits) }
95 const now = await $.clock.now()
96 {
97 const next = mergeLimits(from, now)
98 if (changed(await read($, limits), next)) await update($, limits, () => next)
99 }
100}
101
102let lastSavedLimits = ''
103
104async function apply($: EngineInterface, m: Measure) {
105 if (m.rateLimits.length > 0) {
106 const raw: RawLimit[] = m.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
107 {
108 const next = raw
109 if (changed(await read($, liveLimits), next)) await update($, liveLimits, () => next)
110 }
111 const json = JSON.stringify(raw)
112 if (json !== lastSavedLimits) {
113 lastSavedLimits = json
114 const saved: StoredLimits = { limits: raw }
115 await $.store.set('limits', saved)
116 }
117 }
118 await rebuildLimits($)
119 {
120 const next = m.context
121 if (changed(await read($, context), next)) await update($, context, () => next)
122 }
123 {
124 const next = m.cost?.usd ?? null
125 if (changed(await read($, sessionUsd), next)) await update($, sessionUsd, () => next)
126 }
127}
128
129/** The shared plan reply on disk; null when there is none, or HOME is unknown. */
130async function readPlanCache($: EngineInterface, home: string | undefined): Promise<PlanCache | null> {
131 if (!home) return null
132 try {
133 return parsePlanCache(await $.fs.read(planCacheFile(home)))
134 } catch {
135 return null
136 }
137}
138
139async function writePlanCache($: EngineInterface, home: string | undefined, cache: PlanCache) {
140 if (!home) return
141 try {
142 await $.fs.write(planCacheFile(home), JSON.stringify(cache))
143 } catch {
144 // Another session asks for itself next time; nothing on screen depends on this write.
145 }
146}
147
148/**
149 * Draws a plan reply. An old one (another session's, not refreshed for two intervals) only fills
150 * windows nothing else has, drawn pale like the previous session's. Null when it is not JSON.
151 */
152async function applyPlan($: EngineInterface, text: string, isOld: boolean): Promise<string[] | null> {
153 {
154 const next = text
155 if (changed(await read($, planRaw), next)) await update($, planRaw, () => next)
156 }
157 const parsed = parsePlanUsage(text, await $.clock.now())
158 if (!parsed) return null
159 if (isOld) {
160 const stored = await read($, storedLimits)
161 const next = [...stored, ...parsed.limits.filter(p => !stored.some(s => s.kind === p.kind))]
162 if (changed(stored, next)) await update($, storedLimits, () => next)
163 } else {
164 const next = parsed.limits
165 if (changed(await read($, planLimits), next)) await update($, planLimits, () => next)
166 }
167 {
168 const next = parsed.resetGrants ?? null
169 if (changed(await read($, resetGrants), next)) await update($, resetGrants, () => next)
170 }
171 await rebuildLimits($)
172 return parsed.keys
173}
174
175// What `/usage-mod debug` says when this session used the shared reply instead of asking.
176const HOLD_NOTES: Record<string, string> = {
177 'another session is asking': 'another session is asking now',
178 recent: 'the shared reply is recent',
179 idle: 'no turn since the last request',
180}
181
182// True until this session asks, and again after each of its turns: a session nobody is using asks
183// only once the shared reply is older than the idle interval (plan-cache.ts).
184let hasTurnSinceAsk = true
185// The request in flight in this session; a second caller waits for it instead of asking again.
186let planFetch: Promise<void> | null = null
187
188/**
189 * Asks the plan usage API for every window of the plan, through the engine's credential handle: the
190 * token itself never reaches this mod. Only a signed-in (bearer) session can ask; an API key, a
191 * gateway or no login gets nothing and the engine's own two windows stand.
192 *
193 * The sessions take turns through plan-cache.ts, so the account is asked about once per interval
194 * however many sessions are open; a session that does not ask draws the shared reply. `force` (the
195 * refresh and debug commands) asks even when that reply is recent, but still waits out a refusal.
196 */
197function fetchPlanLimits($: EngineInterface, planMs: number, force = false): Promise<void> {
198 const pending = planFetch ?? askPlan($, planMs, force).then(() => undefined).finally(() => (planFetch = null))
199 planFetch = pending
200 return pending
201}
202
203async function askPlan($: EngineInterface, planMs: number, force: boolean) {
204 const at = await $.clock.now()
205 const note = (outcome: string, keys: string[] = []) => update($, planInfo, () => ({ at, outcome, keys }))
206 const home = await $.env.get('HOME')
207 let cache: PlanCache | null = null
208 let isAsking = false
209 const fromCache = async (outcome: string) => {
210 if (cache?.text === undefined || cache.at === undefined) return note(outcome)
211 const age = at - cache.at
212 const keys = await applyPlan($, cache.text, age >= planMs * 2)
213 return note(`${outcome}; showing the shared reply from ${formatDuration(age)} ago`, keys ?? [])
214 }
215 try {
216 const auth = await $.session.authorize()
217 if (!auth || auth.kind !== 'bearer') return note('no signed-in session')
218 cache = await readPlanCache($, home)
219 const hold = holdReason(cache, at, { intervalMs: planMs, isActive: hasTurnSinceAsk, force })
220 if (hold === 'waiting') return fromCache(`the last request was refused, asking again in ${formatDuration((cache?.retryAt ?? at) - at)}`)
221 if (hold) return fromCache(HOLD_NOTES[hold] ?? hold)
222
223 await writePlanCache($, home, claimed(cache, at))
224 isAsking = true
225 hasTurnSinceAsk = false
226 const userAgent = planUserAgent((await $.session.version()).version)
227 const headers = { 'anthropic-beta': 'oauth-2025-04-20', accept: 'application/json', ...(userAgent ? { 'user-agent': userAgent } : {}) }
228 const res = await $.http.fetch(PLAN_USAGE_URL, { auth: auth.handle, headers })
229 if (!res.ok) {
230 const retryAt = res.status === 429 ? retryAtFrom(res.headers, at, planMs) : at + FAILURE_BACKOFF_MS
231 await writePlanCache($, home, failed(cache, retryAt))
232 return fromCache(`http ${res.status}`)
233 }
234 const keys = await applyPlan($, res.text, false)
235 if (!keys) {
236 await writePlanCache($, home, failed(cache, at + FAILURE_BACKOFF_MS))
237 return fromCache('not json')
238 }
239 await writePlanCache($, home, { at, text: res.text })
240 await note('ok', keys)
241 } catch {
242 if (isAsking) await writePlanCache($, home, failed(cache, at + FAILURE_BACKOFF_MS))
243 await fromCache('request failed')
244 }
245}
246
247// Reading `tick` subscribes a drawing to the countdown. It moves only when a displayed countdown changes.
248async function snapshot($: EngineInterface): Promise<BandSnapshot & { spendStatus: SpendStatus; context: ContextUsage | null; resetGrants: ResetGrants | null }> {
249 await read($, tick)
250 return {
251 resetGrants: await read($, resetGrants),
252 limits: await read($, limits),
253 context: await read($, context),
254 sessionUsd: await read($, sessionUsd),
255 tokens: await read($, tokens),
256 spend: await read($, spend),
257 spendStatus: await read($, spendStatus),
258 now: await $.clock.now(),
259 }
260}
261
262// The band shows limits, context and today's spend, nothing else: it reads only those, so a new
263// token total or a spend status change does not redraw it.
264async function bandSnapshot($: EngineInterface): Promise<BandSnapshot> {
265 await read($, tick)
266 return {
267 limits: await read($, limits),
268 context: await read($, context),
269 spend: await read($, spend),
270 now: await $.clock.now(),
271 }
272}
273
274/** The last summary on disk, or null when the script has never run or the file is unreadable. */
275async function readSpend($: EngineInterface): Promise<SpendSummary | null> {
276 const home = await $.env.get('HOME')
277 if (!home) return null
278 try {
279 return parseSummary(await $.fs.read(summaryFile(home)))
280 } catch {
281 return null
282 }
283}
284
285/** Runs the aggregation script; null when it cannot run here (no $.process, no node) or fails. */
286async function runSpendScript($: EngineInterface): Promise<SpendSummary | null> {
287 try {
288 const run = await $.process.run(['node', scriptPath($.plugin.root)], { timeoutMs: SCRIPT_TIMEOUT_MS })
289 return run.exitCode === 0 ? parseSummary(run.stdout) : null
290 } catch {
291 return null
292 }
293}
294
295let isRefreshing = false
296
297// One script run at a time; a failed run falls back to the file on disk, which a CLI session
298// or a scheduled run may have refreshed, and counts as fine while that file is recent. The figures
299// on screen stay until the new ones are in: there is no "refreshing" state to flash through.
300async function refresh($: EngineInterface, refreshMs: number) {
301 if (isRefreshing) return
302 isRefreshing = true
303 try {
304 const fresh = await runSpendScript($)
305 if (fresh) {
306 {
307 const next = fresh
308 if (changed(await read($, spend), next)) await update($, spend, () => next)
309 }
310 {
311 const next = 'ok'
312 if (changed(await read($, spendStatus), next)) await update($, spendStatus, () => next)
313 }
314 return
315 }
316 const cached = await readSpend($)
317 if (cached && changed(await read($, spend), cached)) await update($, spend, () => cached)
318 const isRecent = cached !== null && (await $.clock.now()) - cached.updatedAt < refreshMs * 2
319 {
320 const next = isRecent ? 'ok' : 'unavailable'
321 if (changed(await read($, spendStatus), next)) await update($, spendStatus, () => next)
322 }
323 } finally {
324 isRefreshing = false
325 }
326}
327
328let isBooted = false
329
330// Everything a session needs started: the command, the first readings, and the timers. It runs on
331// session.start, but also from the first measure or command, because a reload through
332// /reload-plugins drops the old timers without firing session.start again.
333async function boot($: EngineInterface, refreshMs: number, planMs: number, wantsPlanLimits: boolean) {
334 if (isBooted) return
335 isBooted = true
336 await $.command.register({
337 name: COMMAND,
338 description: 'Open the usage pane. /usage-mod text prints it, /usage-mod refresh re-reads transcripts.',
339 argumentHint: '[text|refresh|debug]',
340 })
341
342 const gen = `${await $.clock.now()}-${Math.floor(Math.random() * 1e9)}`
343 await update($, generation, () => gen)
344 const stored = await loadStoredLimits($)
345 {
346 const next = stored
347 if (changed(await read($, storedLimits), next)) await update($, storedLimits, () => next)
348 }
349 await apply($, await $.session.usage())
350 await update($, tick, () => 0)
351 const savedTokens = (await $.store.get('tokens')) as Partial<StoredTokens> | undefined
352 if (savedTokens?.sessionId === (await $.session.id())) {
353 {
354 const next = { up: Number(savedTokens.up) || 0, down: Number(savedTokens.down) || 0, cache: Number(savedTokens.cache) || 0 }
355 if (changed(await read($, tokens), next)) await update($, tokens, () => next)
356 }
357 }
358 const cached = await readSpend($)
359 if (cached && changed(await read($, spend), cached)) await update($, spend, () => cached)
360
361 // The countdowns show minutes, so the band is redrawn only on a minute where one of them changes.
362 let lastSignature = ''
363 every($, 60_000, gen, async () => {
364 const now = await $.clock.now()
365 const signature = resetSignature(await read($, limits), now)
366 if (signature === lastSignature) return
367 lastSignature = signature
368 await update($, tick, () => now)
369 })
370 every($, refreshMs, gen, () => refresh($, refreshMs))
371 void refresh($, refreshMs)
372 if (wantsPlanLimits) {
373 every($, planMs, gen, () => fetchPlanLimits($, planMs))
374 void fetchPlanLimits($, planMs)
375 }
376}
377
378export const register: Register = (on, options) => {
379 const refreshMinutes = Number(options.refreshMinutes)
380 const refreshMs = (refreshMinutes >= 1 ? refreshMinutes : DEFAULT_REFRESH_MINUTES) * 60_000
381 const planMinutes = Number(options.planRefreshMinutes)
382 const planMs = (Number.isFinite(planMinutes) && planMinutes > 0 ? Math.max(MIN_PLAN_REFRESH_MINUTES, planMinutes) : DEFAULT_PLAN_REFRESH_MINUTES) * 60_000
383 const isBandOn = options.view !== 'off'
384 const showSpend = options.showSpend !== false
385 const wantsPlanLimits = options.planLimits !== false
386 const isInteractive = options.interactive !== false
387
388 on('session.start', async ($, e, next) => {
389 await boot($, refreshMs, planMs, wantsPlanLimits)
390 return next(e)
391 })
392
393 on('session.measure', async ($, e, next) => {
394 await boot($, refreshMs, planMs, wantsPlanLimits)
395 await apply($, e)
396 return next(e)
397 })
398
399 on('turn.complete', async ($, e, next) => {
400 hasTurnSinceAsk = true
401 const u = e.usage
402 if (u) {
403 await update($, tokens, t => ({
404 up: t.up + u.input_tokens,
405 down: t.down + u.output_tokens,
406 cache: t.cache + u.cache_read_input_tokens + u.cache_creation_input_tokens,
407 }))
408 const totals: StoredTokens = { ...(await read($, tokens)), sessionId: await $.session.id() }
409 await $.store.set('tokens', totals)
410 }
411 return next(e)
412 })
413
414 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
415 if (!isBandOn || e.props.hasSurvey) return next(e)
416 const tiers = bandTiers(await bandSnapshot($), showSpend)
417 if (tiers.length === 0) return next(e)
418
419 const els = $.ui.resolve(e)
420 const { Box, Button, Svg } = els
421 const details = <Button key="details" label="Details" plain onPress={() => $.ui.open({ id: PANE, title: 'Usage' })} />
422 if (e.surface === 'terminal') {
423 const rows = pickLayout(tiers, e.props.bodyColumns - DETAILS_CELLS, cellWidth)
424 return (
425 <Box flexDirection="column">
426 {rows.map((row, i) => (
427 <Box key={`row-${i}`} columnGap={1}>
428 {chipRow(els, row)}
429 {i === 0 ? details : null}
430 </Box>
431 ))}
432 </Box>
433 )
434 }
435 const rows = pickLayout(tiers, e.props.bodyColumns * CELL_PX - DETAILS_PX, bandWidth)
436 return (
437 <Box flexDirection="column" rowGap={1}>
438 {rows.map((row, i) => {
439 const band = bandSvg(row)
440 return (
441 <Box key={`row-${i}`} columnGap={2} alignItems="center">
442 <Svg source={band.source} alt={band.alt} width={band.width} height={band.height} isInteractive={isInteractive} />
443 {i === 0 ? details : null}
444 </Box>
445 )
446 })}
447 </Box>
448 )
449 })
450
451 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
452 const els = $.ui.resolve(e)
453 const { Box, Button, Svg } = els
454 const model = paneModel(await snapshot($))
455 const refreshButton = <Button key="refresh" label="Refresh spend" onPress={() => refresh($, refreshMs)} />
456 if (e.surface === 'terminal') {
457 return (
458 <Box flexDirection="column" gap={1}>
459 {terminalPane(els, model)}
460 {refreshButton}
461 </Box>
462 )
463 }
464 // The card is laid out at the panel's own width, so its blocks run edge to edge.
465 const card = paneSvg(model, Math.max(280, Math.floor(e.props.bodyColumns * CELL_PX)))
466 return (
467 <Box flexDirection="column" gap={1}>
468 <Svg source={card.source} alt={card.alt} width={card.width} height={card.height} isInteractive={isInteractive} />
469 {refreshButton}
470 </Box>
471 )
472 })
473
474 on('command.run', { command: COMMAND }, async ($, e) => {
475 const arg = e.args.trim()
476 await boot($, refreshMs, planMs, wantsPlanLimits)
477 if (arg === 'refresh') {
478 await refresh($, refreshMs)
479 if (wantsPlanLimits) await fetchPlanLimits($, planMs, true)
480 }
481 if (arg === 'debug') {
482 // Always asks again: the saved reply must be the one this command's figures come from.
483 if (wantsPlanLimits) await fetchPlanLimits($, planMs, true)
484 const info = await read($, planInfo)
485 const at = info ? formatDuration((await $.clock.now()) - info.at) : undefined
486 // The raw reply is saved so a bug report can show exactly what the API sent; it holds usage
487 // figures and nothing of the transcripts. Delete the file when you are done with it.
488 const raw = await read($, planRaw)
489 const home = await $.env.get('HOME')
490 const file = raw && home ? `${home}/.claude/claude-usage-mod/plan-usage.json` : undefined
491 if (raw && file) await $.fs.write(file, raw)
492 return {
493 text: !wantsPlanLimits
494 ? 'Plan limits are switched off in the settings.'
495 : info
496 ? `Plan usage fetch ${at} ago: ${info.outcome}${info.keys.length ? `. Fields: ${info.keys.join(', ')}` : ''}${file ? `. Raw reply saved to ${file}` : info.outcome === 'ok' ? '. The raw reply could not be saved' : ''}`
497 : 'Plan usage has not been fetched yet.',
498 }
499 }
500 if (arg === 'text' || arg === 'refresh') return { text: summaryText(await snapshot($)) }
501 const opened = await $.ui.open({ id: PANE, title: 'Usage' })
502 return { text: opened.isPlaced ? 'Usage pane opened.' : summaryText(await snapshot($)) }
503 })
504}
505hooks/band-model.ts 166 lines1// Decides what the band shows, independent of how a surface draws it: the segments, in order,
2// each with a tooltip saying what the number is, and which of them survive at a given width.
3// The SVG band (desktop) and the Text band (terminal) both draw this same list, and the pane
4// reads the same segment builders.
5import { formatDuration, formatTokens, formatUsd, untilReset } from './format'
6import { severity } from './limits'
7import type { Severity } from './limits'
8import type { ContextUsage, Limit, SpendSummary } from '../types'
9
10/** Which color family a limit pill takes. */
11export type Tone = 'five' | 'seven' | 'model' | 'extra' | 'context'
12
13export type LimitSegment = {
14 type: 'limit'
15 tone: Tone
16 /** Short name on the pill: "5h", "7d", "Fable", "ctx". */
17 label: string
18 /** Longer name for the pane: "5-hour limit". */
19 name: string
20 /** The line under the name in the pane: "resets in 3h52m" or "126.0k of 1M tokens". */
21 detail?: string
22 percentLeft: number
23 severity: Severity
24 /** "3h52m"; absent when compact or when the window has no reset time. */
25 reset?: string
26 /** Drawn without the bar, so every window still fits in a short band. */
27 slim?: boolean
28 /** True when the reading is from a previous session and no response has refreshed it yet. */
29 isStale: boolean
30 tip: string
31}
32export type TokenKind = 'up' | 'down' | 'cache'
33export type TokenSegment = { type: 'token'; kind: TokenKind; text: string; tip: string }
34export type MoneySegment = { type: 'money'; kind: 'session' | 'today'; text: string; tip: string }
35export type Segment = LimitSegment | TokenSegment | MoneySegment
36
37/** What the band reads: the windows, the context, today's spend and the clock. */
38export type BandSnapshot = {
39 limits: readonly Limit[]
40 context: ContextUsage | null
41 spend: SpendSummary | null
42 now: number
43}
44
45const toneOf = (l: Limit): Tone =>
46 l.kind === 'five_hour' ? 'five' : l.kind === 'seven_day' ? 'seven' : l.group === 'model' ? 'model' : 'extra'
47
48const nameOf = (l: Limit): string =>
49 l.kind === 'extra_usage' ? 'Extra usage' : l.kind === 'five_hour' ? '5-hour' : l.kind === 'seven_day' ? 'Weekly' : l.group === 'model' ? `${l.label} weekly` : l.label
50
51function limitSegment(l: Limit, now: number, withReset: boolean, slim: boolean): LimitSegment {
52 const left = untilReset(l.resetsAt, now)
53 const resetText = left !== undefined ? formatDuration(left) : undefined
54 const tip =
55 (l.kind === 'extra_usage'
56 ? `Extra usage: ${l.usedUsd !== undefined && l.limitUsd !== undefined ? `${formatUsd(l.usedUsd)} of ${formatUsd(l.limitUsd)} spent this month, ` : ''}${l.percentLeft}% of the monthly limit left.`
57 : `${nameOf(l)} limit: ${l.percentLeft}% left.`) +
58 (resetText ? ` Resets in ${resetText}.` : '') +
59 (l.isStale ? ' Last seen in an earlier session; it updates with the next response.' : '')
60 return {
61 type: 'limit',
62 tone: toneOf(l),
63 label: l.label,
64 name: nameOf(l),
65 detail: l.kind === 'extra_usage' && l.usedUsd !== undefined && l.limitUsd !== undefined ? `${formatUsd(l.usedUsd)} of ${formatUsd(l.limitUsd)} this month` : resetText ? `resets in ${resetText}` : undefined,
66 percentLeft: l.percentLeft,
67 severity: severity(l.percentLeft),
68 reset: withReset ? resetText : undefined,
69 ...(slim ? { slim } : {}),
70 isStale: l.isStale === true,
71 tip,
72 }
73}
74
75export const limitSegments = (s: BandSnapshot, withReset: boolean, slim = false): LimitSegment[] => s.limits.map(l => limitSegment(l, s.now, withReset, slim))
76
77/** The context window as a pill that looks like a limit, with no reset. */
78function contextSegment(s: BandSnapshot, slim = false): LimitSegment | undefined {
79 const c = s.context
80 if (!c || c.percent == null) return undefined
81 const percentLeft = Math.max(0, Math.min(100, Math.round(100 - c.percent)))
82 const used = c.tokens != null ? `${formatTokens(c.tokens)} of ${formatTokens(c.window)}` : undefined
83 return {
84 type: 'limit',
85 tone: 'context',
86 label: 'ctx',
87 ...(slim ? { slim } : {}),
88 name: 'Context window',
89 detail: used ? `${used} tokens used` : undefined,
90 percentLeft,
91 severity: severity(percentLeft),
92 isStale: false,
93 tip: `Context window: ${percentLeft}% left${used ? ` (${used} tokens used)` : ''}. When it fills, Claude Code compacts the conversation.`,
94 }
95}
96
97function todaySegment(s: BandSnapshot): MoneySegment | undefined {
98 if (!s.spend) return undefined
99 const d = s.spend.today
100 return { type: 'money', kind: 'today', text: `${formatUsd(d.usd)} today`, tip: `Spend today across all sessions: ${formatUsd(d.usd)}, ${formatTokens(d.tokens)} tokens.` }
101}
102
103const defined = <T,>(x: T | undefined): x is T => x !== undefined
104
105/**
106 * The band from richest to leanest. This session's tokens and cost are not on it (they live in the
107 * pane). Every limit window stays on the band as long as it can: when space runs out the order of
108 * loss is today's spend, the reset times, the bars (a window shrinks to icon, name and percent),
109 * and only last every window but the tightest.
110 */
111export function bandTiers(s: BandSnapshot, showSpend: boolean): Segment[][] {
112 const today = showSpend ? [todaySegment(s)].filter(defined) : []
113 const ctx = [contextSegment(s)].filter(defined)
114 const slimCtx = [contextSegment(s, true)].filter(defined)
115 const withReset = limitSegments(s, true)
116 const compact = limitSegments(s, false)
117 const slim = limitSegments(s, false, true)
118 const tightest = compact.reduce<LimitSegment | undefined>((min, l) => (min === undefined || l.percentLeft < min.percentLeft ? l : min), undefined)
119 const tight: Segment[] = tightest ? [tightest] : ctx
120
121 const tiers: Segment[][] = [
122 [...withReset, ...ctx, ...today],
123 [...withReset, ...ctx],
124 [...compact, ...ctx],
125 [...compact],
126 [...slim, ...slimCtx],
127 [...slim],
128 tight,
129 ]
130 return tiers.filter(t => t.length > 0)
131}
132
133/** Rows of segments, each drawn on its own line. */
134type Layout = Segment[][]
135
136/**
137 * Packs segments, in order, into at most `maxRows` lines no wider than `room` (as `measure` counts
138 * it). Null when they do not fit, or when one segment alone is wider than a line.
139 */
140export function packRows(tier: readonly Segment[], room: number, measure: (t: readonly Segment[]) => number, maxRows = 2): Layout | null {
141 const rows: Segment[][] = [[]]
142 for (const seg of tier) {
143 const row = rows[rows.length - 1]
144 if (measure([...row, seg]) <= room) {
145 row.push(seg)
146 continue
147 }
148 if (row.length === 0 || rows.length === maxRows) return null
149 rows.push([seg])
150 if (measure([seg]) > room) return null
151 }
152 return rows
153}
154
155/**
156 * The richest tier that packs into the room, on one line if it can and on two if it must; the
157 * leanest tier is the floor, and nothing known gives no rows.
158 */
159export function pickLayout(tiers: readonly Segment[][], room: number, measure: (t: readonly Segment[]) => number, maxRows = 2): Layout {
160 for (const tier of tiers) {
161 const rows = packRows(tier, room, measure, maxRows)
162 if (rows) return rows
163 }
164 return tiers.length > 0 ? [[...tiers[tiers.length - 1]]] : []
165}
166hooks/format.ts 78 lines1// Pure formatting helpers shared by the band, the pane and the /usage-mod text.
2
3/** "4h14m", "2d8h", "12m"; under a minute "<1m". Negative or missing input gives "now". */
4export function formatDuration(ms: number): string {
5 if (!(ms > 0)) return 'now'
6 const minutes = Math.floor(ms / 60_000)
7 if (minutes < 1) return '<1m'
8 const days = Math.floor(minutes / 1440)
9 const hours = Math.floor((minutes % 1440) / 60)
10 if (days > 0) return `${days}d${hours}h`
11 if (hours > 0) return `${hours}h${String(minutes % 60).padStart(2, '0')}m`
12 return `${minutes}m`
13}
14
15/** Milliseconds until an ISO reset time, or undefined when there is none. */
16export function untilReset(resetsAt: string | undefined, now: number): number | undefined {
17 if (!resetsAt) return undefined
18 const at = Date.parse(resetsAt)
19 return Number.isNaN(at) ? undefined : at - now
20}
21
22/** "$29.90", "$694.08", "$1.2K", "$3.4M". */
23export function formatUsd(usd: number): string {
24 if (usd >= 1_000_000) return `$${trim(usd / 1_000_000)}M`
25 if (usd >= 1000) return `$${trim(usd / 1000)}K`
26 return `$${usd.toFixed(2)}`
27}
28
29/** "900", "3.0k", "954.2k", "45.1M", "1.5B": thousands keep one decimal like the reference chips. */
30export function formatTokens(tokens: number): string {
31 if (tokens >= 1e9) return `${trim(tokens / 1e9)}B`
32 if (tokens >= 1e6) return `${trim(tokens / 1e6)}M`
33 if (tokens >= 1e3) return `${(tokens / 1e3).toFixed(1)}k`
34 return String(Math.round(tokens))
35}
36
37// One decimal under 100, none above, so "45.1M" and "450M" stay short.
38function trim(n: number): string {
39 return n >= 100 ? String(Math.round(n)) : n.toFixed(1).replace(/\.0$/, '')
40}
41
42/** A small bar, `width` cells, filled by `percent` (0-100): "▰▰▰▱▱▱▱▱▱▱". */
43export function miniBar(percent: number, width = 10): string {
44 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
45 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
46}
47
48/**
49 * Pac-Man bar for a terminal, `width` cells: Pac-Man sits at how much is used, dots ahead are what is
50 * left and eaten cells are blank. Returned in three parts so the caller can color the dots and
51 * Pac-Man apart.
52 */
53export function pacText(percentLeft: number, width = 10): { before: string; pac: string; after: string } {
54 const k = Math.max(0, Math.min(width - 1, Math.round((1 - percentLeft / 100) * (width - 1))))
55 return { before: ' '.repeat(k), pac: 'ᗧ', after: '·'.repeat(width - 1 - k) }
56}
57
58const SPARK = '▁▂▃▄▅▆▇█'
59
60/** One block per value, scaled to the largest: "▁▂▇▃". All zeros draw the lowest block. */
61export function sparkline(values: readonly number[]): string {
62 const max = Math.max(0, ...values)
63 return values.map(v => SPARK[max === 0 ? 0 : Math.min(SPARK.length - 1, Math.round((v / max) * (SPARK.length - 1)))]).join('')
64}
65
66/** 15600 -> "15,600". Written by hand: the module environment has no Intl to rely on. */
67export function groupDigits(n: number): string {
68 return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
69}
70
71const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
72
73/** "2026-10-03" -> "Oct 3"; anything else comes back unchanged. */
74export function shortDay(day: string): string {
75 const m = /^\d{4}-(\d{2})-(\d{2})$/.exec(day)
76 return m ? `${MONTHS[Number(m[1]) - 1] ?? m[1]} ${Number(m[2])}` : day
77}
78hooks/limits.ts 75 lines1// Turns the engine's rate-limit windows into the Limit rows the mod draws.
2import type { SessionRateLimit } from 'claude-code'
3
4import { formatDuration } from './format'
5import type { Limit, LimitGroup, RawLimit } from '../types'
6
7export type Severity = 'ok' | 'warn' | 'crit'
8
9// Percent left under which a limit turns yellow and red.
10const WARN_BELOW = 35
11const CRIT_BELOW = 15
12
13export function severity(percentLeft: number): Severity {
14 if (percentLeft < CRIT_BELOW) return 'crit'
15 if (percentLeft < WARN_BELOW) return 'warn'
16 return 'ok'
17}
18
19// The plan API names some limits by internal codename; show what they are.
20const MODEL_NAMES: Record<string, string> = { omelette: 'Design', oauth_apps: 'Apps', cowork: 'Cowork' }
21
22const title = (words: string) => words.replace(/[_-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
23
24function describe(kind: string): { label: string; group: LimitGroup; order: number } {
25 if (kind === 'five_hour') return { label: '5h', group: 'limits', order: 0 }
26 if (kind === 'seven_day') return { label: '7d', group: 'limits', order: 1 }
27 // A per-model weekly window, e.g. seven_day_opus: drawn under its own group and name.
28 const model = /^seven_day_(.+)$/.exec(kind)
29 if (model) return { label: MODEL_NAMES[model[1]] ?? title(model[1]), group: 'model', order: 2 }
30 if (kind === 'extra_usage') return { label: 'Extra', group: 'extra', order: 4 }
31 return { label: title(kind), group: 'extra', order: 3 }
32}
33
34/** Windows ordered 5h, 7d, per-model, then anything else; empty while no API response reported one. */
35type Reading = SessionRateLimit & Pick<RawLimit, 'label' | 'usedUsd' | 'limitUsd'>
36
37export function toLimits(raw: readonly Reading[], stale: boolean | ReadonlySet<string> = false): Limit[] {
38 const isStale = (kind: string) => (typeof stale === 'boolean' ? stale : stale.has(kind))
39 return raw
40 .map(r => {
41 const { label, group, order } = describe(r.kind)
42 const percentLeft = Math.max(0, Math.min(100, Math.round(100 - r.percentUsed)))
43 const money = r.limitUsd !== undefined ? { usedUsd: r.usedUsd ?? 0, limitUsd: r.limitUsd } : {}
44 return { order, limit: { kind: r.kind, label: r.label ?? label, group, percentLeft, resetsAt: r.resetsAt, ...money, ...(isStale(r.kind) ? { isStale: true } : {}) } satisfies Limit }
45 })
46 .sort((a, b) => a.order - b.order)
47 .map(x => x.limit)
48}
49
50/**
51 * One list of windows from three sources, freshest first: what the engine reports now, what the plan
52 * usage API returned, and what the previous session saw. A window a fresher source has is not taken
53 * from an older one; a window whose reset time has passed is dropped, because its percent means
54 * nothing now (the engine's own reading is always current). Only the stored windows are stale.
55 */
56export function mergeLimits(from: { live: readonly RawLimit[]; plan: readonly RawLimit[]; stored: readonly RawLimit[] }, now: number): Limit[] {
57 const isCurrent = (r: RawLimit) => !r.resetsAt || Date.parse(r.resetsAt) > now
58 const byKind = new Map<string, RawLimit>()
59 const stale = new Set<string>()
60 for (const r of from.stored.filter(isCurrent)) {
61 byKind.set(r.kind, r)
62 stale.add(r.kind)
63 }
64 for (const r of [...from.plan.filter(isCurrent), ...from.live]) {
65 byKind.set(r.kind, r)
66 stale.delete(r.kind)
67 }
68 return toLimits([...byKind.values()], stale)
69}
70
71/** What the countdowns would read right now; it changes only when the band's text would. */
72export function resetSignature(limits: readonly Limit[], now: number): string {
73 return limits.map(l => (l.resetsAt ? formatDuration(Date.parse(l.resetsAt) - now) : '')).join('|')
74}
75hooks/plan-cache.ts 112 lines1// The last good plan usage reply, kept in one file on disk that every session reads: the terminal,
2// the desktop app and any other copy of the mod (an installed one and a --plugin-dir one have
3// separate $.store files, but the same HOME). The endpoint answers 429 when it is asked too often,
4// and each session asking on its own start and timer is what makes it too often; a session that
5// was refused then had no Fable, Extra or resets at all while another one still drew them.
6//
7// So the sessions take turns, the way a single app would ask on its own:
8// - a reply younger than the interval is used as it is, by every session;
9// - a session about to ask says so (`askingAt`), and the others wait for its reply for a while;
10// - a 429 makes every session wait until its Retry-After time, any other failure for a minute;
11// - a session nobody is using asks only once the reply is older than the idle interval.
12// Pure; register.tsx does the IO.
13
14export type PlanCache = {
15 /** Epoch ms of the last good reply, and its body. */
16 at?: number
17 text?: string
18 /** Epoch ms before which no session should ask again (a 429, or another failure). */
19 retryAt?: number
20 /** Epoch ms when a session started asking; the others leave it to that session for CLAIM_MS. */
21 askingAt?: number
22}
23
24export const planCacheFile = (home: string) => `${home}/.claude/claude-usage-mod/plan-cache.json`
25
26/** How long the other sessions leave a request to the session that claimed it. */
27export const CLAIM_MS = 30_000
28/** How long every session waits after a failure that is not a 429 (no Retry-After to follow). */
29export const FAILURE_BACKOFF_MS = 60_000
30/** How old the reply may get before a session with no turn since its last request asks again. */
31export const IDLE_MS = 30 * 60_000
32
33const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
34
35/** Null when the file is not a cache this mod wrote. */
36export function parsePlanCache(text: string): PlanCache | null {
37 let raw: unknown
38 try {
39 raw = JSON.parse(text)
40 } catch {
41 return null
42 }
43 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
44 const c = raw as Record<string, unknown>
45 const out: PlanCache = {}
46 if (num(c.at) && typeof c.text === 'string') {
47 out.at = c.at
48 out.text = c.text
49 }
50 if (num(c.retryAt)) out.retryAt = c.retryAt
51 if (num(c.askingAt)) out.askingAt = c.askingAt
52 return out
53}
54
55/**
56 * When to ask again after a 429: the Retry-After header (seconds, or an HTTP date) when it is
57 * there and sensible, otherwise `fallbackMs` from now.
58 */
59export function retryAtFrom(headers: Readonly<Record<string, string>>, now: number, fallbackMs: number): number {
60 const value = headers['retry-after']?.trim()
61 if (value) {
62 const seconds = Number(value)
63 if (Number.isFinite(seconds) && seconds >= 0) return now + seconds * 1000
64 const date = Date.parse(value)
65 if (Number.isFinite(date) && date > now) return date
66 }
67 return now + fallbackMs
68}
69
70export type AskPolicy = {
71 /** How long a reply stays good for a session in use. */
72 intervalMs: number
73 /** True when this session has had a turn since it last asked (or has never asked). */
74 isActive: boolean
75 /** The refresh and debug commands: a recent reply or another session's claim does not stop them. */
76 force?: boolean
77}
78
79/**
80 * Why this session should not ask now, or undefined when it should. A wait for a failure holds even
81 * for the commands: asking a limiting endpoint again only makes the wait longer.
82 */
83export function holdReason(cache: PlanCache | null, now: number, policy: AskPolicy): string | undefined {
84 if (cache?.retryAt !== undefined && cache.retryAt > now) return 'waiting'
85 if (policy.force || !cache) return undefined
86 if (cache.askingAt !== undefined && now - cache.askingAt >= 0 && now - cache.askingAt < CLAIM_MS) return 'another session is asking'
87 if (cache.at === undefined) return undefined
88 const age = now - cache.at
89 if (age < policy.intervalMs) return 'recent'
90 if (!policy.isActive && age < IDLE_MS) return 'idle'
91 return undefined
92}
93
94/** The cache as it stands while this session asks: the old reply, and its claim. */
95export const claimed = (cache: PlanCache | null, now: number): PlanCache => ({ ...withoutClaim(cache), askingAt: now })
96
97/** After a failure: the old reply stays, the claim goes, and every session waits until `retryAt`. */
98export function failed(cache: PlanCache | null, retryAt: number): PlanCache {
99 const held = cache?.retryAt !== undefined && cache.retryAt > retryAt ? cache.retryAt : retryAt
100 return { ...withoutClaim(cache), retryAt: held }
101}
102
103function withoutClaim(cache: PlanCache | null): PlanCache {
104 const out: PlanCache = {}
105 if (cache?.at !== undefined && cache.text !== undefined) {
106 out.at = cache.at
107 out.text = cache.text
108 }
109 if (cache?.retryAt !== undefined) out.retryAt = cache.retryAt
110 return out
111}
112hooks/plan-usage.ts 121 lines1// The plan usage API (what Claude Code's own /usage reads) returns every window of the plan, which
2// the engine's `$.session.usage()` does not: the per-model weekly windows such as Fable, the
3// extra-usage spend, and the one-off rate-limit resets. This turns its JSON into the same RawLimit
4// rows the engine reports, so one merge handles both. Pure; register.tsx makes the request.
5//
6// The shape was learned from the open-source OpenUsage app (MIT, robinebers/openusage), whose
7// Claude provider reads the same reply:
8// five_hour, seven_day { utilization (0-100 used), resets_at }
9// limits[] { kind: "weekly_scoped", scope: { model: { display_name } },
10// percent (0-100 used), resets_at } <- Fable lives here
11// extra_usage { is_enabled, used_credits (cents), monthly_limit (cents) }
12// cedar_ember { eligible, grants: [{ resets_left, ends_at }] }
13// The old top-level seven_day_<model> windows now come back null, but are read when they are not.
14import type { RawLimit, ResetGrants } from '../types'
15
16// The endpoint returns `cedar_ember` (the one-off rate limit resets) as null unless asked for it.
17export const PLAN_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage?cedar_ember=1'
18
19/**
20 * Anthropic grants resets by client surface: a request it does not recognise as Claude Code comes back
21 * `eligible: false, ineligible_reason: "surface"` with no grants. This mod runs inside Claude Code and
22 * asks on its behalf, so it names the same client in the format Claude Code itself uses, with the
23 * version of the engine it is running on. Undefined when that version is not a release number.
24 */
25export function planUserAgent(engineVersion: string): string | undefined {
26 const release = /^\d+\.\d+\.\d+/.exec(engineVersion)?.[0]
27 return release ? `claude-cli/${release} (external, cli)` : undefined
28}
29
30const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
31const obj = (v: unknown): Record<string, unknown> | null => (typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null)
32const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() !== '' ? v : undefined)
33
34const WINDOW_KEY = /^(five_hour|seven_day(_.+)?)$/
35const slug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
36
37/** A window object: `utilization` is percent used. */
38function windowOf(value: unknown): { percentUsed: number; resetsAt?: string } | null {
39 const w = obj(value)
40 if (!w || !num(w.utilization)) return null
41 return { percentUsed: w.utilization, resetsAt: str(w.resets_at) }
42}
43
44/** Model-scoped weekly windows from the `limits` array, named by their model's display name. */
45function scopedWindows(limits: unknown): RawLimit[] {
46 if (!Array.isArray(limits)) return []
47 const out: RawLimit[] = []
48 for (const entry of limits) {
49 const e = obj(entry)
50 const name = str(obj(obj(e?.scope)?.model)?.display_name)
51 if (!e || e.kind !== 'weekly_scoped' || !name || !num(e.percent)) continue
52 out.push({ kind: `seven_day_${slug(name)}`, label: name, percentUsed: e.percent, resetsAt: str(e.resets_at) })
53 }
54 return out
55}
56
57/**
58 * Extra usage is money, in cents: spent so far against a monthly cap. With a cap it is a bar (spent
59 * over cap); switched off, or on with no cap, there is nothing to draw. (An uncapped spend has no
60 * "left" to show.)
61 */
62function extraUsage(value: unknown): RawLimit | null {
63 const x = obj(value)
64 if (!x || x.is_enabled !== true) return null
65 const cap = x.monthly_limit
66 if (!num(cap) || cap <= 0) return null
67 const used = num(x.used_credits) ? x.used_credits : 0
68 return { kind: 'extra_usage', percentUsed: (used / cap) * 100, usedUsd: used / 100, limitUsd: cap / 100 }
69}
70
71/**
72 * The reset grants still usable: each reset left counts once, and carries the deadline it must be used
73 * by. Undefined unless the account is eligible: an ineligible reply (no block, or the API not
74 * recognising the client) says nothing about how many resets there are, so none is claimed.
75 */
76function resetGrants(value: unknown, now: number): ResetGrants | undefined {
77 const g = obj(value)
78 if (!g || g.eligible !== true) return undefined
79 const expiries: string[] = []
80 let count = 0
81 for (const grant of Array.isArray(g.grants) ? g.grants : []) {
82 const item = obj(grant)
83 if (!item || !num(item.resets_left) || item.resets_left < 1) continue
84 const endsAt = str(item.ends_at)
85 if (endsAt && Date.parse(endsAt) <= now) continue
86 const n = Math.floor(item.resets_left)
87 count += n
88 if (endsAt) expiries.push(...Array<string>(n).fill(endsAt))
89 }
90 return { count, expiries: expiries.sort() }
91}
92
93/**
94 * Null when the text is not a JSON object. Otherwise the plan's windows (five_hour, seven_day, the
95 * `limits` array's model windows, any non-null seven_day_<model>, extra usage), the reset grants when
96 * the account has that block, and the reply's field names for `/usage-mod debug`. Internal
97 * feature-flag fields with codenames are ignored.
98 */
99export function parsePlanUsage(text: string, now = Date.now()): { limits: RawLimit[]; keys: string[]; resetGrants?: ResetGrants } | null {
100 let raw: unknown
101 try {
102 raw = JSON.parse(text)
103 } catch {
104 return null
105 }
106 const reply = obj(raw)
107 if (!reply) return null
108 const limits: RawLimit[] = []
109 for (const [kind, value] of Object.entries(reply)) {
110 if (!WINDOW_KEY.test(kind)) continue
111 const w = windowOf(value)
112 if (w) limits.push({ kind, ...w })
113 }
114 for (const scoped of scopedWindows(reply.limits)) {
115 if (!limits.some(l => l.kind === scoped.kind)) limits.push(scoped)
116 }
117 const extra = extraUsage(reply.extra_usage)
118 if (extra) limits.push(extra)
119 return { limits, keys: Object.keys(reply), resetGrants: resetGrants(reply.cedar_ember, now) }
120}
121hooks/spend-cache.ts 36 lines1// Pure side of the spend cache. Spend history comes from scripts/aggregate-usage.mjs, which
2// writes a small usage.json; register.tsx reads that file with $.fs and runs the script with
3// $.process. Those calls live there because the engine follows `$` only inside the file that
4// registers the hooks, never across an import.
5//
6// Where $.process is missing (it is CLI only) or node is not on PATH, a refresh fails and the
7// last file stays on screen: any CLI session, or a cron/launchd run of the script, keeps the
8// same file fresh for every surface.
9import type { SpendDay, SpendSummary } from '../types'
10
11export const SCRIPT_TIMEOUT_MS = 120_000
12
13export const summaryFile = (home: string) => `${home}/.claude/claude-usage-mod/usage.json`
14
15export const scriptPath = (pluginRoot: string) => `${pluginRoot}/scripts/aggregate-usage.mjs`
16
17const isDay = (v: unknown): v is SpendDay =>
18 typeof v === 'object' && v !== null && typeof (v as SpendDay).usd === 'number' && typeof (v as SpendDay).tokens === 'number'
19
20/** Validates what the script wrote; anything malformed is null so the UI shows "no data" rather than NaN. */
21export function parseSummary(text: string): SpendSummary | null {
22 let raw: Partial<SpendSummary>
23 try {
24 raw = JSON.parse(text)
25 } catch {
26 return null
27 }
28 if (typeof raw !== 'object' || raw === null) return null
29 if (typeof raw.updatedAt !== 'number' || !isDay(raw.today) || !isDay(raw.yesterday) || !isDay(raw.last30)) return null
30 const trend = Array.isArray(raw.trend)
31 ? raw.trend.filter(t => typeof t?.day === 'string' && typeof t?.usd === 'number')
32 : []
33 const unknownModels = Array.isArray(raw.unknownModels) ? raw.unknownModels.filter(m => typeof m === 'string') : []
34 return { updatedAt: raw.updatedAt, today: raw.today, yesterday: raw.yesterday, last30: raw.last30, trend, unknownModels }
35}
36hooks/summary-text.ts 48 lines1// Plain-text usage lines for /usage-mod: the answer on surfaces that draw nothing
2// (VS Code chat panel, `claude -p`) and the fallback while no band is shown.
3import { formatDuration, formatTokens, formatUsd, miniBar, untilReset } from './format'
4import { severity } from './limits'
5import type { ContextUsage, Limit, SpendStatus, SpendSummary } from '../types'
6
7type UsageSnapshot = {
8 limits: readonly Limit[]
9 context: ContextUsage | null
10 sessionUsd: number | null
11 spend: SpendSummary | null
12 spendStatus: SpendStatus
13 now: number
14}
15
16const DOT = { ok: '●', warn: '◐', crit: '○' } as const
17
18function limitLine(l: Limit, now: number): string {
19 const reset = untilReset(l.resetsAt, now)
20 const resets = reset === undefined ? '' : ` resets in ${formatDuration(reset)}`
21 return `${l.label.padEnd(7)} ${DOT[severity(l.percentLeft)]} ${String(l.percentLeft).padStart(3)}% left ${miniBar(l.percentLeft)}${resets}`
22}
23
24const spendLine = (label: string, d: { usd: number; tokens: number }) =>
25 `${label.padEnd(10)} ${formatUsd(d.usd)} · ${formatTokens(d.tokens)} tokens`
26
27export function summaryText(s: UsageSnapshot): string {
28 const lines: string[] = []
29 if (s.limits.length === 0) lines.push('Limits: no reading yet (they arrive with the first API response).')
30 else lines.push(...s.limits.map(l => limitLine(l, s.now)))
31
32 if (s.context?.percent != null) lines.push(`Context ${100 - s.context.percent}% left`)
33 if (s.sessionUsd != null) lines.push(`Session cost ${formatUsd(s.sessionUsd)}`)
34
35 if (s.spend) {
36 lines.push('', spendLine('Today', s.spend.today), spendLine('Yesterday', s.spend.yesterday), spendLine('30 days', s.spend.last30))
37 const age = formatDuration(s.now - s.spend.updatedAt)
38 const stale = s.spendStatus === 'unavailable' ? ` (cache is ${age} old; the script cannot run in this session)` : ''
39 if (stale) lines.push(`Spend history${stale}`)
40 if (s.spend.unknownModels.length) lines.push(`Unpriced models: ${s.spend.unknownModels.join(', ')}; add them to config/pricing.json`)
41 } else if (s.spendStatus === 'unavailable') {
42 lines.push('', 'Spend history: no data. Run `node scripts/aggregate-usage.mjs` once from a terminal.')
43 } else {
44 lines.push('', 'Spend history: reading transcripts...')
45 }
46 return lines.join('\n')
47}
48hooks/svg-band.ts 103 lines1// Draws the band as one SVG for surfaces that have `Svg` (desktop, vscode, mobile): rounded
2// pills with line icons, a Pac-Man progress bar, and a tint per metric.
3// Every pill carries a <title>, which the interactive SVG shows as a hover tooltip.
4// Pure: Segment[] in, markup and size out.
5import type { LimitSegment, Segment, TokenKind } from './band-model'
6import { ICON, icon, pacBar, pacBarHeight, text, textWidth, tipped } from './svg-kit'
7import type { Piece } from './svg-kit'
8import type { IconName } from './svg-icons'
9import { BAR_COLOR, TONES } from './theme'
10
11const HEIGHT = 28
12const PAD = 10
13const GAP = 8
14const BAR_W = 64
15const PAC_R = 5.5
16const PAC_DOTS = 9
17const TEXT_Y = HEIGHT / 2 + 4
18const ICON_Y = (HEIGHT - ICON) / 2
19// A reading carried over from an earlier session is drawn paler until a response refreshes it.
20const STALE_OPACITY = 0.6
21
22const pill = (x: number, width: number, bg: string, inner: string): string =>
23 `<g transform="translate(${x} 0)"><rect width="${width}" height="${HEIGHT}" rx="${HEIGHT / 2}" fill="${bg}"/>${inner}</g>`
24
25const LEAD: Record<LimitSegment['tone'], IconName> = { five: 'gauge', extra: 'gauge', seven: 'calendar', model: 'layers', context: 'pie' }
26
27function limitPill(s: LimitSegment, x: number): Piece {
28 const p = TONES[s.tone]
29 const pct = `${s.percentLeft}%`
30 let cur = PAD
31 let inner = icon(LEAD[s.tone], cur, ICON_Y, p.accent)
32 cur += ICON + 5
33 inner += text(s.label, cur, TEXT_Y, p.ink)
34 cur += textWidth(s.label) + 6
35 if (!s.slim) {
36 inner += pacBar(cur, (HEIGHT - pacBarHeight(PAC_R)) / 2, BAR_W, s.percentLeft, BAR_COLOR[s.severity], p.ink, { dots: PAC_DOTS, radius: PAC_R })
37 cur += BAR_W + 6
38 }
39 inner += text(pct, cur, TEXT_Y, p.ink, { bold: true })
40 cur += textWidth(pct)
41 if (s.reset) {
42 cur += 7
43 inner += `<rect x="${cur}" y="7" width="1" height="${HEIGHT - 14}" fill="${p.ink}" fill-opacity="0.2"/>`
44 cur += 8
45 inner += icon(s.tone === 'five' ? 'clock' : 'history', cur, ICON_Y, p.accent)
46 cur += ICON + 4
47 inner += text(s.reset, cur, TEXT_Y, p.ink)
48 cur += textWidth(s.reset)
49 }
50 const width = cur + PAD
51 return { svg: tipped(s.tip, pill(x, width, p.bg, inner), s.isStale ? STALE_OPACITY : undefined), width }
52}
53
54type ChipTone = keyof typeof TONES
55
56function chipPill(x: number, tone: ChipTone, name: IconName, label: string, tip: string): Piece {
57 const p = TONES[tone]
58 const width = PAD + ICON + 5 + textWidth(label) + PAD
59 const inner = icon(name, PAD, ICON_Y, p.accent) + text(label, PAD + ICON + 5, TEXT_Y, p.ink)
60 return { svg: tipped(tip, pill(x, width, p.bg, inner)), width }
61}
62
63const TOKEN_STYLE: Record<TokenKind, { tone: ChipTone; icon: IconName }> = {
64 up: { tone: 'up', icon: 'upload' },
65 down: { tone: 'down', icon: 'download' },
66 cache: { tone: 'cache', icon: 'layers' },
67}
68
69/** One segment as a pill at `x`, so the pane can place the same pills on its own rows. */
70export function segmentPill(s: Segment, x: number): Piece {
71 if (s.type === 'limit') return limitPill(s, x)
72 if (s.type === 'token') return chipPill(x, TOKEN_STYLE[s.kind].tone, TOKEN_STYLE[s.kind].icon, s.text, s.tip)
73 return s.kind === 'session' ? chipPill(x, 'session', 'coin', s.text, s.tip) : chipPill(x, 'today', 'trend', s.text, s.tip)
74}
75
76const describeSegments = (segments: readonly Segment[]): string =>
77 segments
78 .map(s =>
79 s.type === 'limit'
80 ? `${s.name} ${s.percentLeft}% left${s.reset ? `, resets in ${s.reset}` : ''}`
81 : s.type === 'token'
82 ? `${{ up: 'input', down: 'output', cache: 'cache' }[s.kind]} tokens ${s.text}`
83 : s.text,
84 )
85 .join('; ')
86
87/** The whole band as one SVG document, with its pixel size. */
88export function bandSvg(segments: readonly Segment[]): { source: string; width: number; height: number; alt: string } {
89 let x = 0
90 let body = ''
91 for (const seg of segments) {
92 const piece = segmentPill(seg, x)
93 body += piece.svg
94 x += piece.width + GAP
95 }
96 const width = Math.max(1, x - GAP)
97 const source = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${HEIGHT}" width="${width}" height="${HEIGHT}">${body}</svg>`
98 return { source, width, height: HEIGHT, alt: describeSegments(segments) }
99}
100
101/** Pixel width the band would take, to pick the richest tier that fits. */
102export const bandWidth = (segments: readonly Segment[]): number => bandSvg(segments).width
103hooks/pane-model.ts 109 lines1// What the Details pane shows, independent of surface: the desktop draws it as one SVG card
2// (svg-pane.ts) and the terminal as Text rows (terminal-pane.tsx), both from this model.
3// The context window is not a pane section: it lives in the band's ctx chip.
4import { limitSegments } from './band-model'
5import type { BandSnapshot, LimitSegment, MoneySegment, TokenSegment } from './band-model'
6import { formatDuration, formatTokens, formatUsd, groupDigits, shortDay } from './format'
7import type { ResetGrants, SessionTokens, SpendStatus } from '../types'
8
9type SpendRow = { label: string; usd: string; tokens: string; tip: string }
10type TrendBar = { day: string; usd: number; tip: string }
11
12/** A window the pane always has a row for: drawn as "No data" until the plan reports it. */
13type EmptyLimit = { type: 'empty'; tone: LimitSegment['tone']; name: string; note: string; detail: string; tip: string }
14
15/** A count rather than a bar: the rate-limit resets Anthropic has granted. */
16type CountRow = { type: 'count'; tone: LimitSegment['tone']; name: string; value: string; detail: string; tip: string }
17
18export type PaneModel = {
19 /** Rate limits in order; Fable and Extra are always there, as an EmptyLimit when there is no reading. */
20 limits: (LimitSegment | EmptyLimit | CountRow)[]
21 /** This session's token chips and cost. */
22 session: (TokenSegment | MoneySegment)[]
23 spend: SpendRow[]
24 /** The last 14 days, oldest first. */
25 trend: TrendBar[]
26 peak?: string
27 /** One line under the card: stale cache, no data, or still reading. */
28 note?: string
29}
30
31/** What the pane reads: everything the band does, plus this session and the spend status. */
32export type PaneSnapshot = BandSnapshot & { sessionUsd: number | null; tokens: SessionTokens; spendStatus: SpendStatus; resetGrants?: ResetGrants | null }
33
34const defined = <T,>(x: T | undefined): x is T => x !== undefined
35
36const hasTokens = (t: SessionTokens) => t.up + t.down + t.cache > 0
37
38export function tokenSegments(s: PaneSnapshot): TokenSegment[] {
39 if (!hasTokens(s.tokens)) return []
40 const t = s.tokens
41 return [
42 { type: 'token', kind: 'up', text: formatTokens(t.up), tip: `Input tokens this session, not counting cache: ${groupDigits(t.up)}.` },
43 { type: 'token', kind: 'down', text: formatTokens(t.down), tip: `Output tokens this session: ${groupDigits(t.down)}.` },
44 { type: 'token', kind: 'cache', text: formatTokens(t.cache), tip: `Cache reads and writes this session: ${groupDigits(t.cache)} tokens.` },
45 ]
46}
47
48export function sessionSegment(s: PaneSnapshot): MoneySegment | undefined {
49 return s.sessionUsd == null ? undefined : { type: 'money', kind: 'session', text: formatUsd(s.sessionUsd), tip: `Cost of this session so far: ${formatUsd(s.sessionUsd)}.` }
50}
51
52function noteFor(s: PaneSnapshot): string | undefined {
53 if (s.spend && s.spendStatus === 'unavailable') return `Spend is from ${formatDuration(s.now - s.spend.updatedAt)} ago; the script cannot run in this session.`
54 if (s.spend) return s.spend.unknownModels.length ? `No price for ${s.spend.unknownModels.join(', ')}; add it to config/pricing.json.` : undefined
55 if (s.spendStatus === 'unavailable') return 'No spend data. Run `node scripts/aggregate-usage.mjs` once from a terminal.'
56 return 'Reading transcripts...'
57}
58
59// The windows the pane always has a row for, in order. A missing reading reads as "No data" rather
60// than as a bug; any other window the plan reports (another model) goes between Fable and Extra.
61const SLOTS: { has: (l: LimitSegment) => boolean; name: string; tone: LimitSegment['tone']; detail: string; tip: string }[] = [
62 { has: l => l.label === '5h', name: '5-hour', tone: 'five', detail: 'arrives with the first response', tip: '5-hour limit: no data yet; it arrives with the first response of a session.' },
63 { has: l => l.label === '7d', name: 'Weekly', tone: 'seven', detail: 'arrives with the first response', tip: 'Weekly limit: no data yet; it arrives with the first response of a session.' },
64 { has: l => l.label === 'Fable', name: 'Fable weekly', tone: 'model', detail: 'not reported by your plan', tip: 'Fable weekly: no data. Your plan has not reported one.' },
65]
66const EXTRA_SLOT = { has: (l: LimitSegment) => l.name === 'Extra usage', name: 'Extra usage', tone: 'extra' as const, detail: 'off, or not reported', tip: 'Extra usage: no data. It is off, or your plan has not reported it.' }
67
68function resetsRow(g: ResetGrants | null | undefined): CountRow | undefined {
69 if (!g) return undefined
70 const first = g.expiries[0]
71 return {
72 type: 'count',
73 tone: 'extra',
74 name: 'Usage resets',
75 value: `${g.count} available`,
76 detail: first ? `use by ${shortDay(first.slice(0, 10))}` : 'none granted',
77 tip: g.count > 0 ? `${g.count} one-off usage-limit reset${g.count === 1 ? '' : 's'} granted by Anthropic.${g.expiries.length ? ` Use ${g.count === 1 ? 'it' : 'them'} by ${g.expiries.map(d => shortDay(d.slice(0, 10))).join(', ')}.` : ''}` : 'No usage-limit resets are available on this account.',
78 }
79}
80
81function withEmptySlots(limits: LimitSegment[]): (LimitSegment | EmptyLimit)[] {
82 const slot = (s: (typeof SLOTS)[number]): LimitSegment | EmptyLimit =>
83 limits.find(s.has) ?? { type: 'empty', tone: s.tone, name: s.name, note: 'No data', detail: s.detail, tip: s.tip }
84 const claimed = new Set<LimitSegment>([...SLOTS, EXTRA_SLOT].flatMap(s => limits.filter(s.has)))
85 const others = limits.filter(l => !claimed.has(l))
86 return [...SLOTS.map(slot), ...others, slot(EXTRA_SLOT)]
87}
88
89export function paneModel(s: PaneSnapshot): PaneModel {
90 const spend: SpendRow[] = s.spend
91 ? ([['Today', s.spend.today], ['Yesterday', s.spend.yesterday], ['Last 30 days', s.spend.last30]] as const).map(([label, d]) => ({
92 label,
93 usd: formatUsd(d.usd),
94 tokens: `${formatTokens(d.tokens)} tokens`,
95 tip: `${label}: ${formatUsd(d.usd)} and ${formatTokens(d.tokens)} tokens across all sessions.`,
96 }))
97 : []
98 const trend: TrendBar[] = (s.spend?.trend ?? []).map(t => ({ day: t.day, usd: t.usd, tip: `${shortDay(t.day)}: ${formatUsd(t.usd)}` }))
99 const peak = trend.length > 0 ? Math.max(...trend.map(t => t.usd)) : 0
100 return {
101 limits: [...withEmptySlots(limitSegments(s, true)), ...[resetsRow(s.resetGrants)].filter(defined)],
102 session: [...tokenSegments(s), sessionSegment(s)].filter(defined),
103 spend,
104 trend,
105 peak: peak > 0 ? formatUsd(peak) : undefined,
106 note: noteFor(s),
107 }
108}
109hooks/svg-pane.ts 205 lines1// Draws the Details pane as one SVG card for surfaces that have `Svg`. One visual language
2// with the band: tinted rounded rows with the same icons, Pac-Man bars and tooltips; sections
3// are spaced evenly and the spend rows are justified, label left and figures right.
4// Pure: PaneModel in, markup and size out.
5import type { PaneModel } from './pane-model'
6import { shortDay } from './format'
7import { segmentPill } from './svg-band'
8import { ICON, SANS_FAMILY, icon, pacBar, pacBarHeight, text, tipped } from './svg-kit'
9import type { IconName } from './svg-icons'
10import { BAR_COLOR, TONES } from './theme'
11import type { Tone } from './band-model'
12
13const DEFAULT_WIDTH = 440
14// Cards run edge to edge of the panel, so there is no horizontal padding; text inside a card
15// keeps its own inset. Only the top and bottom of the stack have a little air.
16const PAD = 0
17const EDGE = 4
18const HEAD_X = 2
19// Laid out at the panel's real width: set at the start of each paneSvg call.
20let W = DEFAULT_WIDTH
21let INNER = W
22const RADIUS = 14
23// Space between rows inside a section, and between sections.
24const ROW_GAP = 8
25const SECTION_GAP = 22
26const LIMIT_ROW = 46
27const SPEND_ROW = 34
28const PILL_H = 28
29
30const CARD = { bg: '#f6f4ef', edge: '#e3dfd6', ink: '#2b2a26', dim: '#8a8578', well: '#ebe8e1', rule: '#d9d5cb' }
31const MONO = 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace'
32const LEAD: Record<Tone, IconName> = { five: 'gauge', extra: 'gauge', seven: 'calendar', model: 'layers', context: 'pie' }
33const PAC_R = 6
34const STALE_OPACITY = 0.6
35
36const heading = (label: string, y: number) => text(label, HEAD_X, y + 9, CARD.dim, { bold: true, size: 10.5, spacing: 1.4, family: SANS_FAMILY })
37
38function countRow(s: Extract<PaneModel['limits'][number], { type: 'count' }>, y: number): string {
39 const p = TONES[s.tone]
40 const right = W - PAD - 14
41 const mid = y + LIMIT_ROW / 2
42 const inner =
43 `<rect x="${PAD}" y="${y}" width="${INNER}" height="${LIMIT_ROW}" rx="${RADIUS}" fill="${p.bg}"/>` +
44 `<circle cx="${PAD + 24}" cy="${mid}" r="14" fill="#ffffff" fill-opacity="0.65"/>` +
45 icon('history', PAD + 24 - ICON / 2, mid - ICON / 2, p.accent) +
46 text(s.name, PAD + 48, y + 20, p.ink, { bold: true, size: 13, family: SANS_FAMILY }) +
47 text(s.detail, PAD + 48, y + 35, p.ink, { size: 11, opacity: 0.65, family: SANS_FAMILY }) +
48 text(s.value, right, mid + 5, p.ink, { bold: true, size: 14, anchor: 'end', family: MONO })
49 return tipped(s.tip, inner)
50}
51
52function emptyRow(s: Extract<PaneModel['limits'][number], { type: 'empty' }>, y: number): string {
53 const p = TONES[s.tone]
54 const right = W - PAD - 14
55 const barW = Math.max(96, W - 292)
56 const barX = right - 52 - barW
57 const mid = y + LIMIT_ROW / 2
58 const inner =
59 `<g opacity="0.8"><rect x="${PAD}" y="${y}" width="${INNER}" height="${LIMIT_ROW}" rx="${RADIUS}" fill="${p.bg}"/>` +
60 `<circle cx="${PAD + 24}" cy="${mid}" r="14" fill="#ffffff" fill-opacity="0.65"/>` +
61 icon(LEAD[s.tone], PAD + 24 - ICON / 2, mid - ICON / 2, p.accent) +
62 text(s.name, PAD + 48, y + 20, p.ink, { bold: true, size: 13, family: SANS_FAMILY }) +
63 text(s.detail, PAD + 48, y + 35, p.ink, { size: 11, opacity: 0.65, family: SANS_FAMILY }) +
64 `<rect x="${barX}" y="${mid - pacBarHeight(PAC_R) / 2}" width="${barW}" height="${pacBarHeight(PAC_R)}" rx="${pacBarHeight(PAC_R) / 2}" fill="${p.ink}" fill-opacity="0.1"/>` +
65 text(s.note, right, mid + 5, p.ink, { size: 12, anchor: 'end', opacity: 0.55, family: MONO }) +
66 '</g>'
67 return tipped(s.tip, inner)
68}
69
70function limitRow(s: Extract<PaneModel['limits'][number], { type: 'limit' }>, y: number): string {
71 const p = TONES[s.tone]
72 const right = W - PAD - 14
73 const barW = Math.max(96, W - 292)
74 const barX = right - 52 - barW
75 const mid = y + LIMIT_ROW / 2
76 const inner =
77 `<rect x="${PAD}" y="${y}" width="${INNER}" height="${LIMIT_ROW}" rx="${RADIUS}" fill="${p.bg}"/>` +
78 `<circle cx="${PAD + 24}" cy="${mid}" r="14" fill="#ffffff" fill-opacity="0.65"/>` +
79 icon(LEAD[s.tone], PAD + 24 - ICON / 2, mid - ICON / 2, p.accent) +
80 text(s.name, PAD + 48, y + (s.detail ? 20 : 27), p.ink, { bold: true, size: 13, family: SANS_FAMILY }) +
81 (s.detail ? text(s.detail, PAD + 48, y + 35, p.ink, { size: 11, opacity: 0.65, family: SANS_FAMILY }) : '') +
82 pacBar(barX, mid - pacBarHeight(PAC_R) / 2, barW, s.percentLeft, BAR_COLOR[s.severity], p.ink, { dots: 22, radius: PAC_R }) +
83 text(`${s.percentLeft}%`, right, mid + 5, p.ink, { bold: true, size: 15, anchor: 'end', family: MONO })
84 return tipped(s.tip, inner, s.isStale ? STALE_OPACITY : undefined)
85}
86
87/** Pills wrapped onto rows that fit the card; returns the markup and its height. */
88function pillRows(segments: PaneModel['session'], y: number): { svg: string; height: number } {
89 let x = PAD
90 let row = 0
91 let svg = ''
92 for (const seg of segments) {
93 const piece = segmentPill(seg, 0)
94 if (x > PAD && x + piece.width > W - PAD) {
95 x = PAD
96 row++
97 }
98 svg += `<g transform="translate(${x} ${y + row * (PILL_H + ROW_GAP)})">${piece.svg}</g>`
99 x += piece.width + ROW_GAP
100 }
101 return { svg, height: (row + 1) * PILL_H + row * ROW_GAP }
102}
103
104function spendBlock(rows: PaneModel['spend'], y: number): { svg: string; height: number } {
105 const height = rows.length * SPEND_ROW + 8
106 const right = W - PAD - 14
107 let svg = `<rect x="${PAD}" y="${y}" width="${INNER}" height="${height}" rx="${RADIUS}" fill="${CARD.well}"/>`
108 rows.forEach((r, i) => {
109 const top = y + 4 + i * SPEND_ROW
110 const base = top + SPEND_ROW / 2 + 4.5
111 const line = i > 0 ? `<rect x="${PAD + 14}" y="${top}" width="${INNER - 28}" height="1" fill="${CARD.rule}"/>` : ''
112 svg += tipped(
113 r.tip,
114 line +
115 text(r.label, PAD + 14, base, CARD.ink, { size: 13, family: SANS_FAMILY }) +
116 text(r.usd, right - 112, base, CARD.ink, { bold: true, size: 13, anchor: 'end', family: MONO }) +
117 text(r.tokens, right, base, CARD.dim, { size: 12, anchor: 'end', family: MONO }),
118 )
119 })
120 return { svg, height }
121}
122
123function trendBlock(trend: PaneModel['trend'], peak: string | undefined, y: number): { svg: string; height: number } {
124 const chartH = 46
125 const top = 38
126 const height = top + chartH + 24
127 const right = W - PAD - 14
128 const left = PAD + 14
129 const n = trend.length
130 const gap = 6
131 const barW = (right - left - gap * (n - 1)) / n
132 const max = Math.max(1, ...trend.map(t => t.usd))
133 let svg =
134 `<rect x="${PAD}" y="${y}" width="${INNER}" height="${height}" rx="${RADIUS}" fill="${CARD.well}"/>` +
135 text(`Last ${n} days`, left, y + 24, CARD.ink, { size: 13, family: SANS_FAMILY }) +
136 (peak ? text(`peak ${peak}`, right, y + 24, CARD.dim, { size: 12, anchor: 'end', family: MONO }) : '')
137 trend.forEach((t, i) => {
138 const h = Math.max(3, Math.round((t.usd / max) * chartH))
139 const x = left + i * (barW + gap)
140 const isToday = i === n - 1
141 svg += tipped(t.tip, `<rect x="${x.toFixed(1)}" y="${y + top + chartH - h}" width="${barW.toFixed(1)}" height="${h}" rx="3" fill="${isToday ? '#3f8f5b' : '#b9cdbf'}"/>`)
142 })
143 svg += text(shortDay(trend[0].day), left, y + height - 8, CARD.dim, { size: 10.5, family: SANS_FAMILY })
144 svg += text(shortDay(trend[n - 1].day), right, y + height - 8, CARD.dim, { size: 10.5, anchor: 'end', family: SANS_FAMILY })
145 return { svg, height }
146}
147
148export function paneSvg(m: PaneModel, width = DEFAULT_WIDTH): { source: string; width: number; height: number; alt: string } {
149 W = Math.max(300, Math.round(width))
150 INNER = W - 2 * PAD
151 let y = EDGE
152 let body = ''
153 const section = (label: string) => {
154 body += heading(label, y)
155 y += 14 + ROW_GAP
156 }
157
158 section('LIMITS')
159 if (m.limits.length === 0) {
160 body += text('No reading yet; it arrives with the first response.', HEAD_X, y + 12, CARD.dim, { size: 12, family: SANS_FAMILY })
161 y += 24
162 }
163 m.limits.forEach((l, i) => {
164 body += l.type === 'empty' ? emptyRow(l, y) : l.type === 'count' ? countRow(l, y) : limitRow(l, y)
165 y += LIMIT_ROW + (i < m.limits.length - 1 ? ROW_GAP : 0)
166 })
167
168 if (m.session.length > 0) {
169 y += SECTION_GAP
170 section('THIS SESSION')
171 const rows = pillRows(m.session, y)
172 body += rows.svg
173 y += rows.height
174 }
175
176 if (m.spend.length > 0) {
177 y += SECTION_GAP
178 section('SPEND')
179 const block = spendBlock(m.spend, y)
180 body += block.svg
181 y += block.height
182 if (m.trend.length > 1) {
183 y += ROW_GAP
184 const t = trendBlock(m.trend, m.peak, y)
185 body += t.svg
186 y += t.height
187 }
188 }
189
190 if (m.note) {
191 y += 14
192 body += text(m.note, HEAD_X, y + 8, CARD.dim, { size: 11, family: SANS_FAMILY })
193 y += 12
194 }
195
196 const height = y + EDGE
197 const source = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${height}" width="${W}" height="${height}">${body}</svg>`
198 const alt = [
199 ...m.limits.map(l => (l.type === 'empty' ? `${l.name} no data` : l.type === 'count' ? `${l.name} ${l.value}` : `${l.name} ${l.percentLeft}% left`)),
200 ...m.session.map(s => (s.type === 'token' ? `${s.kind} tokens ${s.text}` : `session ${s.text}`)),
201 ...m.spend.map(r => `${r.label} ${r.usd}`),
202 ].join('; ')
203 return { source, width: W, height, alt }
204}
205hooks/terminal-band.tsx 75 lines1// Draws a Segment[] with Box and Text, which every surface has: colored chips with a mini bar.
2// The terminal uses it for the band; the pane uses it on every surface.
3import type { BoxProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
4
5import type { LimitSegment, Segment } from './band-model'
6import { pacText } from './format'
7import { BAR_COLOR, TONES } from './theme'
8
9type Els = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
10
11const LEAD = { five: '◔', seven: '▦', model: '≡', extra: '◔', context: '◑' } as const
12
13// Pac-Man's yellow reads poorly on a pale chip, so the terminal draws it in its darker edge color.
14const PAC_INK = '#b8860b'
15
16function limitChip({ Text }: Els, s: LimitSegment): RenderElement {
17 const p = TONES[s.tone]
18 const bar = pacText(s.percentLeft)
19 return (
20 <Text key={`limit-${s.label}`} backgroundColor={p.bg} color={p.ink}>
21 {' '}
22 <Text color={p.accent}>{LEAD[s.tone]}</Text> {s.label}{' '}
23 {s.slim ? null : (
24 <Text>
25 <Text color={BAR_COLOR[s.severity]}>{bar.before}</Text>
26 <Text color={PAC_INK} bold>{bar.pac}</Text>
27 <Text color={BAR_COLOR[s.severity]}>{bar.after}</Text>{' '}
28 </Text>
29 )}
30 <Text bold>{s.percentLeft}%</Text>
31 {s.reset ? <Text color={p.accent}> ↻ {s.reset}</Text> : null}{' '}
32 </Text>
33 )
34}
35
36function plainChip({ Text }: Els, key: string, tone: keyof typeof TONES, glyph: string, text: string): RenderElement {
37 const p = TONES[tone]
38 return (
39 <Text key={key} backgroundColor={p.bg} color={p.ink}>
40 {' '}
41 <Text color={p.accent}>{glyph}</Text>
42 {text}{' '}
43 </Text>
44 )
45}
46
47const TOKEN_GLYPH = { up: '↑', down: '↓', cache: '◈' } as const
48
49function chipOf(els: Els, s: Segment): RenderElement {
50 if (s.type === 'limit') return limitChip(els, s)
51 if (s.type === 'token') return plainChip(els, s.kind, s.kind, TOKEN_GLYPH[s.kind], s.text)
52 return s.kind === 'session' ? plainChip(els, 'session', 'session', '$', s.text.replace('$', '')) : plainChip(els, 'today', 'today', '↗', s.text)
53}
54
55/** One row of chips separated by a space; wraps when the row is wider than the band. */
56export function chipRow(els: Els, segments: readonly Segment[]): RenderElement {
57 const { Box } = els
58 return (
59 <Box flexWrap="wrap" columnGap={1}>
60 {segments.map(s => chipOf(els, s))}
61 </Box>
62 )
63}
64
65/** Terminal cells one chip takes: its text plus the padding and glyph around it. */
66function chipCells(s: Segment): number {
67 if (s.type === 'limit') return 4 + s.label.length + 1 + (s.slim ? 0 : 11) + String(s.percentLeft).length + 1 + (s.reset ? 3 + s.reset.length : 0) + 1
68 return s.text.length + 3
69}
70
71/** Terminal cells the chips take, with the one-cell gaps, to pick the richest tier that fits. */
72export function cellWidth(segments: readonly Segment[]): number {
73 return segments.reduce((sum, s) => sum + chipCells(s), 0) + Math.max(0, segments.length - 1)
74}
75