SLOPSHOPPER

usage-mod

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.

newpanebandcommandprocessnetwork
v0.1.3MITupdated 2026-10-03kreddevils18/claude-usage-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-mod
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ LIMITS │ ┃ ◔ 5h ᗧ······ 69% ⏺ Read(src/auth.ts) │ ┃ Weekly No data ⎿ Read 6 lines │ ┃ Fable weekly No data ⏺ Update(src/auth.ts) │ ┃ Extra usage No data ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ THIS SESSION ⎿ 3 pass, 1 fail │ ┃ ↑2.1k ↓1.5k ◈95.3k $0.42 │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ No spend data. Run `node │ ┃ scripts/aggregate-usage.mjs` once from a ✻ Worked for 42s · done 4:20 PM │ ┃ terminal. │ ┃ › /usage-mod │ ┃ [ Refresh spend ] ⎿ usage-mod: Usage pane opened. │ │ ◔ 5h ᗧ······ 69% ◑ ctx ᗧ····· 51% Details ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◔ 5h ᗧ······ 69% ◑ ctx ᗧ····· 51% Details
Pane · Usage
LIMITS ◔ 5h ᗧ······ 69% Weekly No data Fable weekly No data Extra usage No data THIS SESSION ↑2.1k ↓1.5k ◈95.3k $0.42 No spend data. Run `node scripts/aggregate-usage.mjs` once from a terminal. [ Refresh spend ]
README

claude-usage-mod

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.

Install

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.

Use

CommandWhat it does
/usage-modOpens the Details pane (the Details button on the band does the same)
/usage-mod textPrints the same numbers as plain text, for surfaces that draw nothing
/usage-mod refreshRe-reads your transcripts and the plan limits now, then prints the text
/usage-mod debugSays whether the plan limits fetch worked and which fields it returned

What the band shows

ChipIt means
5h / 7dThe 5-hour and weekly plan limits: how much is left, and when each resets
Fable and other modelsA model's own weekly limit, when your plan has one
ExtraYour extra-usage spend against its monthly cap, when extra usage is switched on and capped
ctxThe context window: how much is left before Claude Code compacts
$81.58 todaySpend 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.

The Details pane

/usage-mod or the Details button opens it:

  • Limits: one row per window with the Pac-Man bar and the reset countdown. Fable and Extra always have a row, marked No data when your plan does not report them. When Anthropic has granted you one-off usage-limit resets, a Usage resets row shows how many are left and the deadline.
  • This session: input tokens (↑), output tokens (↓), cache reads and writes (◈) and the session's cost so far.
  • Spend: today, yesterday and the last 30 days, with a 14-day chart.

Settings

In Claude Code's /config menu, under the plugin's name:

SettingDefault
Band: band or offband
Show today spend on the bandon
Plan limits from Anthropic (Fable, extra usage)on
Tooltips and animation on the desktop appon (turn it off if the band flickers)
Spend refresh minutes5
Plan limits refresh minutes (5 at the least)15

Where it draws

Where you run Claude CodeBand and pane
claude in a terminalYes, as colored text chips
The Code tab of the desktop appYes, as SVG pills with tooltips
The VS Code extension's chat panel, claude -pNo: use /usage-mod text

Requirements

  • Claude Code 2.1.287 or later, which is the first version with mods. Check with claude --version.
  • Node.js on your 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.

Where the numbers come from

FigureSource
5h and 7d limits, context, session costClaude 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 resetsAnthropic'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 tokensAdded up from each completed turn; kept per session, so a resume continues the count
Today, yesterday, 30 daysscripts/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.

What it does on your machine

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.

Troubleshooting

  • Nothing shows above the prompt. Run /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.
  • The limits say "no reading yet". They arrive with the first response of a session; send any message.
  • Spend is missing or old. Check that 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.
  • No Fable or Extra chip. The pane's rows say No data when your plan does not report them: Extra needs extra usage switched on with a monthly cap. Run /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.
  • A figure disagrees with the plan page. Limits and session cost come straight from Claude Code. The spend figures are the list-price estimate described above.

Contributing

Pull requests are welcome, above all pricing updates. See CONTRIBUTING.md.

License

MIT

Source 17 files
hooks/register.tsx 505 lines
1// 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}
505
hooks/band-model.ts 166 lines
1// 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}
166
hooks/format.ts 78 lines
1// 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}
78
hooks/limits.ts 75 lines
1// 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}
75
hooks/plan-cache.ts 112 lines
1// 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}
112
hooks/plan-usage.ts 121 lines
1// 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}
121
hooks/spend-cache.ts 36 lines
1// 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}
36
hooks/summary-text.ts 48 lines
1// 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}
48
hooks/svg-band.ts 103 lines
1// 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
103
hooks/pane-model.ts 109 lines
1// 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}
109
hooks/svg-pane.ts 205 lines
1// 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}
205
hooks/terminal-band.tsx 75 lines
1// 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