A row of pills above the prompt: rate limits with pace, context, tokens and cost.

A row of pills above the Claude Code prompt that shows how much of your plan you have used, and warns you before you run out.
<img alt="The usage band in a terminal, above the prompt" src="../../docs/images/hero-light.png">
/plugin install usage-band --marketplace tahabozdemir/claude-mods
Type y, then press Enter twice. See the main README for the full steps.
| Pill | Meaning |
|---|---|
| 5h | How much of the 5-hour rate limit you have used, and when it resets. |
| 7d | The same for the 7-day limit. |
| ctx | How full the conversation's context window is. |
| ↑ ↓ | Tokens sent and received this session, subagents included. |
| ≈$ | Session cost at API list prices. Hidden on a subscription unless you turn it on. |
The thin line inside a 5h or 7d bar marks how much of the window has passed. If the fill is ahead of that line, you are using the limit faster than it refills.
<img alt="Three bands in the desktop app: normal in gray, warning with the 5h pill in yellow, critical with the 5h pill in red" src="../../docs/images/states-light.png">
The 5h and 7d pills appear only on a Claude subscription plan, because only subscriptions have these limits. When the window is narrow, the band drops tokens first, then cost, then 7d, and then draws 5h and ctx more compactly. It never wraps. The full state sheet shows every case.
| Command | What it does |
|---|---|
/usage-pill | Refresh now and print a one-line summary |
/usage-pill hide | Hide the band |
/usage-pill show | Show it again |
/usage-pill cost on | Always show the cost pill |
/usage-pill cost off | Never show the cost pill |
/usage-pill cost auto | Hide the cost pill on a subscription, show it otherwise (the default) |
Your choices are remembered across sessions.
Set them when you install, or later from a terminal:
echo '{"desktopCellPx": "8.4"}' | claude plugin configure usage-band@claude-mods --values-stdin
| Option | Default | What it does |
|---|---|---|
| Desktop cell width (px) | 7.2 | CSS pixels per column of the desktop app's code font, used to fit the band. Raise it if the desktop band drops pills it has room for. |
~/.claude/projects/, summed by scripts/tokens.mjs with Node.js. The script runs only when the transcript has changed.node on PATH, /usr/local/bin/node or /opt/homebrew/bin/node), the band adds up each turn's usage instead and marks the numbers with ~.The band doesn't appear. It shows after the first response of a session. Run /usage-pill: if it prints a summary, the band may be hidden, and /usage-pill show brings it back. If the command is unknown, check that the mod is installed and enabled with claude plugin list.
Token numbers start with ~. Node.js was not found, so the counts are estimates. Install Node.js to get exact counts.
Something else is wrong. Start Claude Code with claude --debug. Lines beginning with usage-band: explain what failed. Include them when you open an issue.
hooks/register.tsx 379 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionUsage } from 'claude-code'
3
4import type {
5 UsageBandCostMode,
6 UsageBandPlan,
7 UsageBandSnapshot,
8 UsageBandTokens,
9 UsageBandWindow,
10} from '../types'
11import { buildPills, fitPills, gapBefore, summaryLine } from './band'
12import { bandSvgs, DEFAULT_DESKTOP_CELL_PX } from './svg'
13import { GAP_BETWEEN_COLUMNS, GAP_WITHIN_COLUMNS, terminalPill, terminalWidth } from './terminal'
14import type { Tone } from './terminal'
15
16const ZERO_TOKENS: UsageBandTokens = { input: 0, cacheCreation: 0, output: 0, cacheRead: 0, requests: 0 }
17
18const snapshot = atom({ plugin: 'usage-band', key: 'snapshot' } as const, null)
19const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
20const plan = atom({ plugin: 'usage-band', key: 'plan' } as const, 'unknown')
21const isHidden = atom({ plugin: 'usage-band', key: 'isHidden' } as const, false)
22const costMode = atom({ plugin: 'usage-band', key: 'costMode' } as const, 'auto')
23const fallbackTokens = atom({ plugin: 'usage-band', key: 'fallbackTokens' } as const, ZERO_TOKENS)
24const transcript = atom({ plugin: 'usage-band', key: 'transcript' } as const, null)
25const scriptTokens = atom({ plugin: 'usage-band', key: 'scriptTokens' } as const, null)
26
27const COMMAND = 'usage-pill'
28const TICK_MS = 30_000
29const NODE_CANDIDATES = ['node', '/usr/local/bin/node', '/opt/homebrew/bin/node'] as const
30
31const HELP = [
32 'Usage: /usage-pill [hide | show | cost on | cost off | cost auto]',
33 ' /usage-pill refresh now and print a summary',
34 ' /usage-pill hide|show hide or show the band',
35 ' /usage-pill cost on show the cost pill',
36 ' /usage-pill cost off hide the cost pill',
37 ' /usage-pill cost auto hide it on a subscription, show it otherwise (default)',
38].join('\n')
39
40type Engine = EngineInterface
41type Measured = Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>
42
43const STATUS_COLOR = { warning: 'warning', critical: 'error' } as const
44
45// --- Data ------------------------------------------------------------------
46
47function toWindow(limits: readonly SessionRateLimit[], kind: string): UsageBandWindow | null {
48 const found = limits.find(limit => limit.kind === kind)
49 if (found === undefined) return null
50 const resetsAt = found.resetsAt === undefined ? Number.NaN : Date.parse(found.resetsAt)
51
52 return { percentUsed: found.percentUsed, resetsAt: Number.isFinite(resetsAt) ? resetsAt : null }
53}
54
55async function projectsDir($: Engine): Promise<string | null> {
56 const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
57 if (configDir) return `${configDir}/projects`
58 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
59
60 return home ? `${home}/.claude/projects` : null
61}
62
63/** The main transcript: the project folder named after the cwd first, then a scan. */
64async function findTranscript($: Engine, id: string): Promise<string | null> {
65 const projects = await projectsDir($)
66 if (projects === null) return null
67
68 for (const dir of [await $.session.cwd(), await $.session.root()]) {
69 const guess = `${projects}/${dir.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
70 if (await $.fs.exists(guess)) return guess
71 }
72 const entries = await $.fs.list(projects).catch(() => [])
73 for (const entry of entries) {
74 const path = `${projects}/${entry.name}/${id}.jsonl`
75 if (entry.kind === 'dir' && (await $.fs.exists(path))) return path
76 }
77
78 return null
79}
80
81function parseTokens(stdout: string): UsageBandTokens | null {
82 try {
83 const value: unknown = JSON.parse(stdout.trim().split('\n').at(-1) ?? '')
84 if (typeof value !== 'object' || value === null) return null
85 const row = value as Record<string, unknown>
86 const tokens = { ...ZERO_TOKENS }
87 for (const key of Object.keys(ZERO_TOKENS) as (keyof UsageBandTokens)[]) {
88 const field = row[key]
89 if (typeof field !== 'number' || !Number.isFinite(field)) return null
90 tokens[key] = field
91 }
92
93 return tokens
94 } catch {
95 return null
96 }
97}
98
99/** Runs scripts/tokens.mjs with the first node that starts; null when it fails. */
100async function runTokensScript($: Engine, id: string): Promise<UsageBandTokens | null> {
101 const script = `${$.plugin.root}/scripts/tokens.mjs`
102 for (const node of NODE_CANDIDATES) {
103 let ran
104 try {
105 ran = await $.process.run([node, script, id], { timeoutMs: 60_000 })
106 } catch {
107 continue // this node could not start (or timed out): try the next
108 }
109
110 return ran.exitCode === 0 ? parseTokens(ran.stdout) : null
111 }
112
113 return null
114}
115
116/** Token totals from the transcripts, rerun only when the main one changed. */
117async function readTokens($: Engine): Promise<{ tokens: UsageBandTokens; isEstimated: boolean }> {
118 const id = await $.session.id()
119 const cached = await read($, transcript)
120 const path = cached?.path.endsWith(`/${id}.jsonl`) ? cached.path : await findTranscript($, id)
121 const stat = path === null ? null : await $.fs.stat(path).catch(() => null)
122 const lastTokens = await read($, scriptTokens)
123
124 const isUnchanged =
125 stat !== null && cached !== null && lastTokens !== null &&
126 cached.path === path && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs
127 if (isUnchanged) return { tokens: lastTokens, isEstimated: false }
128
129 const tokens = await runTokensScript($, id)
130 if (tokens !== null) {
131 await update($, scriptTokens, () => tokens)
132 if (path !== null && stat !== null) {
133 await update($, transcript, () => ({ path, size: stat.size, mtimeMs: stat.mtimeMs }))
134 }
135
136 return { tokens, isEstimated: false }
137 }
138
139 return { tokens: await read($, fallbackTokens), isEstimated: true }
140}
141
142/** Subscriptions alone report rate-limit windows; a response without any means API billing. */
143function inferPlan(current: UsageBandPlan, usage: Measured): UsageBandPlan {
144 const hasWindows = usage.rateLimits.some(l => l.kind === 'five_hour' || l.kind === 'seven_day')
145 if (hasWindows) return 'subscription'
146 if (current === 'unknown' && usage.context.tokens !== undefined) return 'api'
147
148 return current
149}
150
151async function refreshOnce($: Engine, measured?: Measured): Promise<void> {
152 const usage = measured ?? (await $.session.usage())
153 const at = await $.clock.now()
154 const { tokens, isEstimated } = await readTokens($)
155 const context = usage.context
156 const percent =
157 context.percent ??
158 (context.tokens === undefined || context.window <= 0 ? null : Math.round((context.tokens / context.window) * 100))
159
160 const next: UsageBandSnapshot = {
161 fiveHour: toWindow(usage.rateLimits, 'five_hour'),
162 sevenDay: toWindow(usage.rateLimits, 'seven_day'),
163 context: { tokens: context.tokens ?? null, window: context.window, percent },
164 costUsd: usage.cost?.usd ?? null,
165 tokens,
166 isTokensEstimated: isEstimated,
167 utcOffsetMinutes: -new Date(at).getTimezoneOffset(),
168 }
169 await update($, snapshot, () => next)
170 await update($, now, () => at)
171
172 const currentPlan = await read($, plan)
173 const nextPlan = inferPlan(currentPlan, usage)
174 if (nextPlan !== currentPlan) {
175 await update($, plan, () => nextPlan)
176 await $.store.set('plan', nextPlan)
177 }
178}
179
180// One refresh at a time; calls during one fold into a single rerun after it.
181let running: Promise<void> | null = null
182let isQueued = false
183let queuedUsage: Measured | undefined
184
185function refresh($: Engine, measured?: Measured): Promise<void> {
186 if (running !== null) {
187 isQueued = true
188 queuedUsage = measured ?? queuedUsage
189
190 return running
191 }
192 running = (async () => {
193 try {
194 await refreshOnce($, measured)
195 while (isQueued) {
196 isQueued = false
197 const usage = queuedUsage
198 queuedUsage = undefined
199 await refreshOnce($, usage)
200 }
201 } finally {
202 running = null
203 }
204 })()
205
206 return running
207}
208
209async function loadPreferences($: Engine): Promise<void> {
210 const [storedHidden, storedCost, storedPlan] = await Promise.all([
211 $.store.get('isHidden'),
212 $.store.get('costMode'),
213 $.store.get('plan'),
214 ])
215 if (typeof storedHidden === 'boolean') await update($, isHidden, () => storedHidden)
216 if (storedCost === 'auto' || storedCost === 'on' || storedCost === 'off') await update($, costMode, () => storedCost)
217 if (storedPlan === 'subscription' || storedPlan === 'api') await update($, plan, () => storedPlan)
218}
219
220async function setHidden($: Engine, value: boolean): Promise<void> {
221 await update($, isHidden, () => value)
222 await $.store.set('isHidden', value)
223}
224
225async function setCostMode($: Engine, value: UsageBandCostMode): Promise<void> {
226 await update($, costMode, () => value)
227 await $.store.set('costMode', value)
228}
229
230// --- Hooks -----------------------------------------------------------------
231
232export const register: Register = (on, options) => {
233 const configured = options.desktopCellPx
234 const cellPx = typeof configured === 'number' && configured > 0 ? configured : DEFAULT_DESKTOP_CELL_PX
235
236 on('session.start', async ($, e, next) => {
237 await $.command.register({
238 name: COMMAND,
239 description: 'Usage band: refresh and summarize rate limits, context, tokens and cost',
240 argumentHint: '[hide | show | cost on | cost off | cost auto]',
241 })
242 await loadPreferences($)
243 $.clock.every(TICK_MS, () => void refresh($))
244 void refresh($)
245
246 return next(e)
247 })
248
249 on('session.measure', ($, e, next) => {
250 void refresh($, e)
251
252 return next(e)
253 })
254
255 on('turn.complete', async ($, e, next) => {
256 const usage = e.usage
257 if (usage !== undefined) {
258 await update($, fallbackTokens, total => ({
259 input: total.input + usage.input_tokens,
260 cacheCreation: total.cacheCreation + usage.cache_creation_input_tokens,
261 output: total.output + usage.output_tokens,
262 cacheRead: total.cacheRead + usage.cache_read_input_tokens,
263 requests: total.requests + 1,
264 }))
265 }
266
267 return next(e)
268 })
269
270 // /clear starts a new session id: forget the old one's totals.
271 on('session.end', async ($, e, next) => {
272 if (e.reason === 'clear') {
273 await update($, fallbackTokens, () => ZERO_TOKENS)
274 await update($, transcript, () => null)
275 await update($, scriptTokens, () => null)
276 await update($, snapshot, () => null)
277 }
278
279 return next(e)
280 })
281
282 on('command.run', { command: COMMAND }, async ($, e) => {
283 const args = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean).join(' ')
284 switch (args) {
285 case '': {
286 await refresh($)
287 const [held, at, heldPlan, mode] = await Promise.all([
288 read($, snapshot),
289 read($, now),
290 read($, plan),
291 read($, costMode),
292 ])
293
294 return { text: summaryLine({ snapshot: held, now: at, plan: heldPlan, costMode: mode }) }
295 }
296 case 'hide':
297 await setHidden($, true)
298
299 return { text: 'Usage band hidden. /usage-pill show brings it back.' }
300 case 'show':
301 await setHidden($, false)
302
303 return { text: 'Usage band shown.' }
304 case 'cost on':
305 case 'cost off':
306 case 'cost auto': {
307 const mode = args.slice('cost '.length) as UsageBandCostMode
308 await setCostMode($, mode)
309 const said = { on: 'shown', off: 'hidden', auto: 'hidden on a subscription, shown otherwise' }[mode]
310
311 return { text: `Cost pill ${said}.` }
312 }
313 default:
314 return { text: HELP }
315 }
316 })
317
318 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
319 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
320
321 const [held, at, heldPlan, mode] = await Promise.all([
322 read($, snapshot),
323 read($, now),
324 read($, plan),
325 read($, costMode),
326 ])
327 const pills = buildPills({ snapshot: held, now: at, plan: heldPlan, costMode: mode })
328 if (pills === null) return next(e)
329
330 if (e.surface === 'terminal') {
331 const { Box, Text } = $.ui.resolve(e)
332 const { pills: shown, density } = fitPills(
333 pills,
334 e.props.bodyColumns,
335 terminalWidth,
336 GAP_WITHIN_COLUMNS,
337 GAP_BETWEEN_COLUMNS,
338 )
339
340 return (
341 <Box flexDirection="row" flexWrap="nowrap" overflow="hidden">
342 {shown.map((pill, i) => {
343 const { status, segments } = terminalPill(pill, density)
344 const colorOf = (tone: Tone) =>
345 tone === 'status' && status !== 'normal' ? STATUS_COLOR[status] : undefined
346
347 return (
348 <Box flexShrink={0} marginLeft={gapBefore(shown, i, GAP_WITHIN_COLUMNS, GAP_BETWEEN_COLUMNS)}>
349 <Text wrap="truncate">
350 {segments.map(segment => (
351 <Text
352 color={colorOf(segment.tone)}
353 dimColor={segment.tone === 'dim' || (segment.tone === 'status' && status === 'normal')}
354 bold={segment.isBold === true}
355 >
356 {segment.text}
357 </Text>
358 ))}
359 </Text>
360 </Box>
361 )
362 })}
363 </Box>
364 )
365 }
366
367 const { Box, Svg } = $.ui.resolve(e)
368 const svgs = bandSvgs(pills, e.props.bodyColumns * cellPx)
369
370 return (
371 <Box flexDirection="row" flexWrap="nowrap" overflow="hidden">
372 {svgs.map(svg => (
373 <Svg source={svg.source} alt={svg.alt} width={svg.width} height={svg.height} isInteractive />
374 ))}
375 </Box>
376 )
377 })
378}
379hooks/band.ts 385 lines1// The band's view model: which pills show, what each says, and how they fit.
2// Pure: the renderers and the command read it, nothing here touches `$`.
3
4import type {
5 UsageBandCostMode,
6 UsageBandPlan,
7 UsageBandSnapshot,
8 UsageBandTokens,
9 UsageBandWindow,
10} from '../types'
11import {
12 formatClock,
13 formatCount,
14 formatDuration,
15 formatExact,
16 formatPercent,
17 formatUsd,
18} from './format'
19import { assessWindow, contextStatus } from './status'
20import type { Pace, Status, WindowKind } from './status'
21
22const DAY = 24 * 60 * 60_000
23
24export type PillId = 'fiveHour' | 'sevenDay' | 'context' | 'tokens' | 'cost'
25
26/** Groups, left to right: [5h 7d context] [tokens] [cost]. */
27export type PillGroup = 0 | 1 | 2
28
29type PillBase = { group: PillGroup; tooltip: string; alt: string }
30
31export type WindowPill = PillBase & {
32 kind: 'window'
33 id: 'fiveHour' | 'sevenDay'
34 label: '5h' | '7d'
35 status: Status
36 /** Bar fill, 0 to 1. */
37 fraction: number
38 percentText: string
39 /** Elapsed fraction of the window for the tick; null without a reset time. */
40 elapsed: number | null
41 resetText: string
42 pace: Pace
43}
44
45export type ContextPill = PillBase & {
46 kind: 'context'
47 id: 'context'
48 label: 'ctx'
49 status: Status
50 fraction: number
51 percentText: string
52}
53
54/** A bar pill with no reading yet, drawn dimmed at its final width. */
55export type PlaceholderPill = PillBase & {
56 kind: 'placeholder'
57 id: 'fiveHour' | 'sevenDay' | 'context'
58 label: '5h' | '7d' | 'ctx'
59}
60
61export type TokensPill = PillBase & {
62 kind: 'tokens'
63 id: 'tokens'
64 inText: string
65 outText: string
66 isEstimated: boolean
67}
68
69export type CostPill = PillBase & {
70 kind: 'cost'
71 id: 'cost'
72 text: string
73}
74
75export type Pill = WindowPill | ContextPill | PlaceholderPill | TokensPill | CostPill
76
77export type BandInput = {
78 snapshot: UsageBandSnapshot | null
79 now: number
80 plan: UsageBandPlan
81 costMode: UsageBandCostMode
82}
83
84const WINDOW_NAME: Record<WindowKind, string> = {
85 five_hour: '5-hour limit',
86 seven_day: '7-day limit',
87}
88
89export function paceLine(pace: Pace): string {
90 switch (pace.kind) {
91 case 'over':
92 return `At this pace you'll hit the limit in ~${formatDuration(pace.timeToLimitMs)}`
93 case 'under':
94 return 'On pace to stay under the limit'
95 case 'insufficient':
96 return 'Not enough data yet'
97 }
98}
99
100function windowPill(
101 kind: WindowKind,
102 window: UsageBandWindow,
103 now: number,
104 utcOffsetMinutes: number,
105): WindowPill {
106 const id = kind === 'five_hour' ? 'fiveHour' : 'sevenDay'
107 const label = kind === 'five_hour' ? '5h' : '7d'
108 const name = WINDOW_NAME[kind]
109 const base = { kind: 'window', id, label, group: 0 } as const
110
111 // A window whose reset time has passed has started over; the next response
112 // reports its new figure.
113 if (window.resetsAt !== null && window.resetsAt <= now) {
114 return {
115 ...base,
116 status: 'normal',
117 fraction: 0,
118 percentText: '0%',
119 elapsed: 0,
120 resetText: 'reset',
121 pace: { kind: 'insufficient' },
122 tooltip: `${name}: the window has reset\nNew figures arrive with the next response`,
123 alt: `${name}: reset`,
124 }
125 }
126
127 const remainingMs = window.resetsAt === null ? null : window.resetsAt - now
128 const { status, pace, elapsed } = assessWindow(kind, window.percentUsed, remainingMs)
129 const percentText = formatPercent(window.percentUsed)
130
131 let resetText = '—'
132 let resetLine = 'Reset time not reported'
133 if (window.resetsAt !== null && remainingMs !== null) {
134 const countdown = formatDuration(remainingMs)
135 const clock = formatClock(window.resetsAt, utcOffsetMinutes)
136 resetText = kind === 'five_hour' || remainingMs < DAY ? countdown : clock
137 resetLine = `Resets in ${countdown} · ${clock}`
138 }
139
140 const lines = [`${name}: ${percentText} used`]
141 if (elapsed !== null) lines.push(`${formatPercent(elapsed * 100)} of the window elapsed`)
142 lines.push(resetLine, paceLine(pace))
143
144 return {
145 ...base,
146 status,
147 fraction: clamp01(window.percentUsed / 100),
148 percentText,
149 elapsed,
150 resetText,
151 pace,
152 tooltip: lines.join('\n'),
153 alt: `${name}: ${percentText} used, resets ${resetText}${statusSuffix(status)}`,
154 }
155}
156
157function placeholderPill(id: PlaceholderPill['id']): PlaceholderPill {
158 const label = id === 'fiveHour' ? '5h' : id === 'sevenDay' ? '7d' : 'ctx'
159 const name = id === 'fiveHour' ? '5-hour limit' : id === 'sevenDay' ? '7-day limit' : 'Context'
160 const text = `${name}: not reported yet\nIt appears after the first response`
161
162 return { kind: 'placeholder', id, label, group: 0, tooltip: text, alt: `${name}: not reported yet` }
163}
164
165function contextPill(snapshot: UsageBandSnapshot): ContextPill | PlaceholderPill {
166 const context = snapshot.context
167 if (context === null || context.percent === null) return placeholderPill('context')
168
169 const status = contextStatus(context.percent)
170 const percentText = formatPercent(context.percent)
171 const used = context.tokens === null ? '' : `${formatExact(context.tokens)} / `
172
173 return {
174 kind: 'context',
175 id: 'context',
176 label: 'ctx',
177 group: 0,
178 status,
179 fraction: clamp01(context.percent / 100),
180 percentText,
181 tooltip: [
182 `Context: ${used}${formatExact(context.window)} tokens (${percentText})`,
183 'The session is compacted automatically as it fills up',
184 ].join('\n'),
185 alt: `Context: ${percentText} used${statusSuffix(status)}`,
186 }
187}
188
189function tokensPill(tokens: UsageBandTokens, isEstimated: boolean): TokensPill {
190 const mark = isEstimated ? '~' : ''
191 const inText = `↑${mark}${formatCount(tokens.input + tokens.cacheCreation)}`
192 const outText = `↓${mark}${formatCount(tokens.output)}`
193 const lines = [
194 `Input (uncached): ${formatExact(tokens.input)}`,
195 `Cache writes: ${formatExact(tokens.cacheCreation)}`,
196 `Output: ${formatExact(tokens.output)}`,
197 `Cache reads: ${formatExact(tokens.cacheRead)}`,
198 `Cache hit rate: ${cacheHitRate(tokens)}`,
199 isEstimated ? `Turns: ${formatExact(tokens.requests)}` : `Requests: ${formatExact(tokens.requests)}`,
200 ]
201 if (isEstimated) lines.unshift('Estimated — transcript file could not be read')
202
203 return {
204 kind: 'tokens',
205 id: 'tokens',
206 group: 1,
207 inText,
208 outText,
209 isEstimated,
210 tooltip: lines.join('\n'),
211 alt: `Tokens: ${inText} in, ${outText} out`,
212 }
213}
214
215function costPill(usd: number): CostPill {
216 const text = `≈${formatUsd(usd)}`
217
218 return {
219 kind: 'cost',
220 id: 'cost',
221 group: 2,
222 text,
223 tooltip: `${text} this session\nEstimated at API list prices. Subscription plans are not billed this amount.`,
224 alt: `Cost: about ${formatUsd(usd)} at API list prices`,
225 }
226}
227
228export function cacheHitRate(tokens: UsageBandTokens): string {
229 const input = tokens.input + tokens.cacheCreation + tokens.cacheRead
230 if (input === 0) return '—'
231
232 return `${((tokens.cacheRead / input) * 100).toFixed(1)}%`
233}
234
235function statusSuffix(status: Status): string {
236 return status === 'normal' ? '' : ` (${status})`
237}
238
239const clamp01 = (x: number): number => Math.min(1, Math.max(0, x))
240
241/** True once anything the band shows has a reading. */
242export function hasAnyData(snapshot: UsageBandSnapshot | null): snapshot is UsageBandSnapshot {
243 if (snapshot === null) return false
244 const tokens = snapshot.tokens
245 const tokenSum =
246 tokens === null ? 0 : tokens.input + tokens.cacheCreation + tokens.output + tokens.cacheRead
247
248 return (
249 snapshot.fiveHour !== null ||
250 snapshot.sevenDay !== null ||
251 snapshot.context?.percent != null ||
252 tokenSum > 0 ||
253 (snapshot.costUsd ?? 0) > 0
254 )
255}
256
257export function isCostShown(plan: UsageBandPlan, costMode: UsageBandCostMode): boolean {
258 if (costMode !== 'auto') return costMode === 'on'
259
260 return plan !== 'subscription'
261}
262
263/** The pills in band order, or null when there is nothing to show yet. */
264export function buildPills(input: BandInput): Pill[] | null {
265 const { snapshot, now, plan, costMode } = input
266 if (!hasAnyData(snapshot)) return null
267
268 const offset = snapshot.utcOffsetMinutes
269 const pills: Pill[] = []
270
271 if (plan !== 'api') {
272 pills.push(
273 snapshot.fiveHour === null
274 ? placeholderPill('fiveHour')
275 : windowPill('five_hour', snapshot.fiveHour, now, offset),
276 snapshot.sevenDay === null
277 ? placeholderPill('sevenDay')
278 : windowPill('seven_day', snapshot.sevenDay, now, offset),
279 )
280 }
281 pills.push(contextPill(snapshot))
282 if (snapshot.tokens !== null) pills.push(tokensPill(snapshot.tokens, snapshot.isTokensEstimated))
283 if (snapshot.costUsd !== null && isCostShown(plan, costMode)) pills.push(costPill(snapshot.costUsd))
284
285 return pills
286}
287
288/** Pills dropped first when the band is too narrow; 5h and context stay. */
289export const DROP_ORDER: readonly PillId[] = ['tokens', 'cost', 'sevenDay']
290
291/**
292 * How much a bar pill shows: `full`, then `compact` (no reset time), then
293 * `minimal` (label and percentage). Chosen by width alone, never by data.
294 */
295export type Density = 'full' | 'compact' | 'minimal'
296
297export const DENSITIES: readonly Density[] = ['full', 'compact', 'minimal']
298
299export type Fit = { pills: Pill[]; density: Density }
300
301/** Space before `pills[index]`: none for the first, then within or between groups. */
302export function gapBefore(pills: readonly Pill[], index: number, within: number, between: number): number {
303 const previous = pills[index - 1]
304 const pill = pills[index]
305 if (previous === undefined || pill === undefined) return 0
306
307 return previous.group === pill.group ? within : between
308}
309
310export function bandWidth(
311 pills: readonly Pill[],
312 widthOf: (pill: Pill) => number,
313 within: number,
314 between: number,
315): number {
316 return pills.reduce((sum, pill, i) => sum + gapBefore(pills, i, within, between) + widthOf(pill), 0)
317}
318
319/**
320 * Fits the band to `available`: drops pills in DROP_ORDER, then draws the
321 * pills that must stay more compactly. Never wraps; below the minimal width
322 * the row is clipped.
323 */
324export function fitPills(
325 pills: readonly Pill[],
326 available: number,
327 widthOf: (pill: Pill, density: Density) => number,
328 within: number,
329 between: number,
330): Fit {
331 const fits = (list: readonly Pill[], density: Density) =>
332 bandWidth(list, pill => widthOf(pill, density), within, between) <= available
333
334 let shown = [...pills]
335 for (const id of DROP_ORDER) {
336 if (fits(shown, 'full')) return { pills: shown, density: 'full' }
337 shown = shown.filter(pill => pill.id !== id)
338 }
339 const density = DENSITIES.find(each => fits(shown, each)) ?? 'minimal'
340
341 return { pills: shown, density }
342}
343
344/** The `/usage-pill` line: every reading, with status markers and pace. */
345export function summaryLine(input: BandInput): string {
346 const pills = buildPills({ ...input, costMode: 'on' })
347 if (pills === null) return 'No usage data yet. It appears after the first response.'
348
349 const tokens = input.snapshot?.tokens ?? null
350 const parts = pills.map(pill => {
351 switch (pill.kind) {
352 case 'placeholder':
353 return `${pill.label} —`
354 case 'window': {
355 const head = `${marker(pill.status)}${pill.label} ${pill.percentText}${criticalWord(pill.status)}`
356 if (pill.status !== 'normal' && pill.pace.kind === 'over') {
357 return `${head} — limit in ~${formatDuration(pill.pace.timeToLimitMs)} at this pace`
358 }
359
360 return `${head} (${pill.resetText})`
361 }
362 case 'context':
363 return `${marker(pill.status)}ctx ${pill.percentText}${criticalWord(pill.status)}`
364 case 'tokens': {
365 const mark = pill.isEstimated ? '~' : ''
366 const cached = tokens === null ? '' : ` ⧉${mark}${formatCount(tokens.cacheRead)}`
367
368 return `${pill.inText} ${pill.outText}${cached}`
369 }
370 case 'cost':
371 return pill.text
372 }
373 })
374
375 return parts.join(' · ')
376}
377
378function marker(status: Status): string {
379 return status === 'normal' ? '' : '⚠ '
380}
381
382function criticalWord(status: Status): string {
383 return status === 'critical' ? ' critical' : ''
384}
385hooks/svg.ts 226 lines1// Desktop pills: one SVG document per pill, generated as markup. Pure.
2//
3// Every width comes from the monospace advance (0.6em), so a pill is as wide
4// as its widest realistic content whatever the numbers say now. The gap after
5// a pill is drawn inside its own SVG as transparent space, so the spacing is
6// exact in CSS pixels whatever the host does between elements.
7
8import { fitPills, gapBefore } from './band'
9import type { Density, Pill } from './band'
10
11const FONT_PX = 11
12const CHAR_PX = FONT_PX * 0.6
13const HEIGHT = 22
14const PAD = 8
15const ICON = 12
16const ICON_GAP = 5
17const GAP = 6
18const BAR = 36
19const BAR_H = 4
20const BAR_Y = 9
21const CLOCK = 10
22const CLOCK_GAP = 4
23const BASELINE = 15
24
25export const GAP_WITHIN_PX = 4
26export const GAP_BETWEEN_PX = 10
27
28/**
29 * CSS pixels per cell of `bodyColumns` on the desktop. The engine reports the
30 * desktop's width only in cells of its code font, never in pixels, so this is
31 * a lower bound: a 12px monospace font (0.6em advance). A larger code font
32 * only leaves room unused; the band never overflows. `desktopCellPx` in
33 * /config overrides it.
34 */
35export const DEFAULT_DESKTOP_CELL_PX = 7.2
36
37/** Widest realistic content per slot, in characters. */
38const SLOT = {
39 label: { '5h': 2, '7d': 2, ctx: 3 },
40 percent: 4, // "100%"
41 reset: { fiveHour: 6, sevenDay: 9 }, // "4h 59m", "Sun 14:00"
42 tokens: 15, // "↑999.9k ↓999.9k"
43 cost: 8, // "≈$999.99"
44} as const
45
46const chars = (n: number): number => n * CHAR_PX
47
48const STYLE = `
49svg{--bg:#F1F1EF;--text:#2B2B2B;--muted:#6B6B6B;--track:#DDDDD9;--fill:#6B6B6B;--warn:#B26B00;--warn-text:#A06000;--warn-bg:#FFF4DE;--crit:#C2261C;--crit-bg:#FDE7E5}
50@media (prefers-color-scheme:dark){svg{--bg:#2A2A2C;--text:#E4E4E6;--muted:#9A9AA0;--track:#3D3D42;--fill:#A8A8AE;--warn:#F0B341;--warn-text:#F0B341;--warn-bg:#3A2E14;--crit:#FF6B5E;--crit-bg:#3D1A18}}
51text{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:${FONT_PX}px;fill:var(--text)}
52.bg{fill:var(--bg)}
53.warning .bg{fill:var(--warn-bg);stroke:var(--warn)}
54.critical .bg{fill:var(--crit-bg);stroke:var(--crit)}
55.muted{fill:var(--muted)}
56.warning .pct{fill:var(--warn-text);font-weight:700}
57.critical .pct{fill:var(--crit);font-weight:700}
58.icon{fill:none;stroke:var(--muted);stroke-width:1.25;stroke-linecap:round;stroke-linejoin:round}
59.dot{fill:var(--muted);stroke:none}
60.warning .icon{stroke:var(--warn)}.warning .dot{fill:var(--warn)}
61.critical .icon{stroke:var(--crit)}.critical .dot{fill:var(--crit)}
62.track{fill:var(--track)}
63.fill{fill:var(--fill)}
64.warning .fill{fill:var(--warn)}
65.critical .fill{fill:var(--crit)}
66.tick{fill:var(--text)}
67.divider{stroke:var(--muted)}
68`
69
70/** 12×12 icons, stroked in the secondary color (or the status color). */
71const ICONS = {
72 gauge:
73 '<path d="M1.5 9.5a4.5 4.5 0 0 1 9 0"/><path d="M6 9.5 8.6 6.4"/><circle class="dot" cx="6" cy="9.5" r="1"/>',
74 calendar: '<rect x="1.5" y="2.5" width="9" height="8.5" rx="1.5"/><path d="M1.5 5.5h9M4 1v3M8 1v3"/>',
75 stack: '<path d="M1.5 2.5h9M1.5 6h9M1.5 9.5h9"/>',
76 arrows: '<path d="M3.5 10.5v-9M1.3 3.7 3.5 1.5l2.2 2.2M8.5 1.5v9M6.3 8.3l2.2 2.2 2.2-2.2"/>',
77 dollar:
78 '<path d="M8.6 3.3C8.1 2.5 7.2 2 6 2 4.6 2 3.6 2.8 3.6 3.8c0 2.4 4.8 1.5 4.8 4.2 0 1.1-1 1.9-2.4 1.9-1.2 0-2.1-.5-2.6-1.3M6 .6V2M6 9.9v1.5"/>',
79 warning: '<path d="M6 1.3 11 10.5H1z"/><path d="M6 4.8v2.6"/><circle class="dot" cx="6" cy="9" r=".75"/>',
80} as const
81
82/** 10×10 countdown icon: an hourglass. */
83const HOURGLASS = '<path d="M2.5 1h5L5 5l2.5 4h-5L5 5z"/>'
84
85type IconName = keyof typeof ICONS
86
87function escapeXml(text: string): string {
88 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
89}
90
91const n = (x: number): string => String(Math.round(x * 100) / 100)
92
93function icon(name: IconName, x: number): string {
94 return `<g class="icon" transform="translate(${n(x)} ${(HEIGHT - ICON) / 2})">${ICONS[name]}</g>`
95}
96
97function text(x: number, content: string, attrs = ''): string {
98 return `<text x="${n(x)}" y="${BASELINE}"${attrs}>${escapeXml(content)}</text>`
99}
100
101function bar(x: number, fraction: number, elapsed: number | null): string {
102 const fill = fraction > 0 ? Math.max(2, fraction * BAR) : 0
103 const parts = [`<rect class="track" x="${n(x)}" y="${BAR_Y}" width="${BAR}" height="${BAR_H}" rx="2"/>`]
104 if (fill > 0) parts.push(`<rect class="fill" x="${n(x)}" y="${BAR_Y}" width="${n(fill)}" height="${BAR_H}" rx="2"/>`)
105 if (elapsed !== null) {
106 const tickX = x + Math.min(BAR - 1.5, Math.max(0, elapsed * BAR - 0.75))
107 parts.push(`<rect class="tick" x="${n(tickX)}" y="${BAR_Y - 4}" width="1.5" height="${BAR_H + 4}" rx=".5"/>`)
108 }
109
110 return parts.join('')
111}
112
113const PILL_ICON: Record<Pill['id'], IconName> = {
114 fiveHour: 'gauge',
115 sevenDay: 'calendar',
116 context: 'stack',
117 tokens: 'arrows',
118 cost: 'dollar',
119}
120
121/** A bar pill: icon, label, the bar unless minimal, percent, the reset when full. */
122function barPillWidth(labelChars: number, hasBar: boolean, resetChars: number | null): number {
123 let width = PAD + ICON + ICON_GAP + chars(labelChars) + GAP
124 if (hasBar) width += BAR + GAP
125 width += chars(SLOT.percent)
126 if (resetChars !== null) width += GAP + 1 + GAP + CLOCK + CLOCK_GAP + chars(resetChars)
127
128 return Math.ceil(width + PAD)
129}
130
131/** The pill's width in CSS pixels, from its widest realistic content. */
132export function pillWidthPx(pill: Pill, density: Density): number {
133 const lead = PAD + ICON + ICON_GAP
134 switch (pill.kind) {
135 case 'window':
136 case 'context':
137 case 'placeholder': {
138 const reset = pill.id !== 'context' && density === 'full' ? SLOT.reset[pill.id] : null
139
140 return barPillWidth(SLOT.label[pill.label], density !== 'minimal', reset)
141 }
142 case 'tokens': {
143 const length = `${pill.inText} ${pill.outText}`.length
144
145 return Math.ceil(lead + chars(Math.max(SLOT.tokens, length)) + PAD)
146 }
147 case 'cost':
148 return Math.ceil(lead + chars(Math.max(SLOT.cost, pill.text.length)) + PAD)
149 }
150}
151
152function body(pill: Pill, density: Density): string {
153 const isCritical = 'status' in pill && pill.status === 'critical'
154 const parts = [icon(isCritical ? 'warning' : PILL_ICON[pill.id], PAD)]
155 let x = PAD + ICON + ICON_GAP
156
157 switch (pill.kind) {
158 case 'placeholder':
159 parts.push(text(x, pill.label, ' class="muted"'))
160 x += chars(SLOT.label[pill.label]) + GAP
161 parts.push(text(x, '—', ' class="muted"'))
162 break
163 case 'window':
164 case 'context': {
165 parts.push(text(x, pill.label, ' class="muted"'))
166 x += chars(SLOT.label[pill.label]) + GAP
167 if (density !== 'minimal') {
168 parts.push(bar(x, pill.fraction, pill.kind === 'window' ? pill.elapsed : null))
169 x += BAR + GAP
170 }
171 x += chars(SLOT.percent)
172 parts.push(text(x, pill.percentText, ' class="pct" text-anchor="end"'))
173 if (pill.kind === 'window' && density === 'full') {
174 x += GAP
175 parts.push(`<path class="divider" d="M${n(x + 0.5)} 6v10"/>`)
176 x += 1 + GAP
177 parts.push(`<g class="icon" transform="translate(${n(x)} ${(HEIGHT - CLOCK) / 2})">${HOURGLASS}</g>`)
178 x += CLOCK + CLOCK_GAP
179 parts.push(text(x, pill.resetText, ' class="muted"'))
180 }
181 break
182 }
183 case 'tokens':
184 parts.push(text(x, `${pill.inText} ${pill.outText}`))
185 break
186 case 'cost':
187 parts.push(text(x, pill.text))
188 break
189 }
190
191 return parts.join('')
192}
193
194export type PillSvg = {
195 id: Pill['id']
196 source: string
197 alt: string
198 width: number
199 height: number
200}
201
202/** One pill as a standalone SVG, `trailing` px of transparent gap after it. */
203export function pillSvg(pill: Pill, trailing: number, density: Density): PillSvg {
204 const pillWidth = pillWidthPx(pill, density)
205 const width = pillWidth + trailing
206 const status = pill.kind === 'window' || pill.kind === 'context' ? pill.status : 'normal'
207 const source =
208 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${HEIGHT}" viewBox="0 0 ${width} ${HEIGHT}">` +
209 `<style>${STYLE}</style>` +
210 `<g class="pill ${status}"><title>${escapeXml(pill.tooltip)}</title>` +
211 `<rect class="bg" x=".5" y=".5" width="${pillWidth - 1}" height="${HEIGHT - 1}" rx="6"/>` +
212 body(pill, density) +
213 '</g></svg>'
214
215 return { id: pill.id, source, alt: pill.alt, width, height: HEIGHT }
216}
217
218/** The band for `availablePx`: pills dropped, then compacted, until it fits; gaps inside. */
219export function bandSvgs(pills: readonly Pill[], availablePx: number): PillSvg[] {
220 const { pills: shown, density } = fitPills(pills, availablePx, pillWidthPx, GAP_WITHIN_PX, GAP_BETWEEN_PX)
221
222 return shown.map((pill, i) =>
223 pillSvg(pill, gapBefore(shown, i + 1, GAP_WITHIN_PX, GAP_BETWEEN_PX), density),
224 )
225}
226hooks/terminal.ts 114 lines1// Terminal pills: runs of text with a tone each, padded to stable widths. Pure.
2
3import type { Density, Pill } from './band'
4import type { Status } from './status'
5
6export const GAP_WITHIN_COLUMNS = 1
7export const GAP_BETWEEN_COLUMNS = 3
8
9const BAR_CELLS = 10
10const PERCENT_COLUMNS = 4
11const RESET_COLUMNS = { fiveHour: 6, sevenDay: 9 } as const
12const TOKENS_COLUMNS = 15
13const COST_COLUMNS = 8
14
15/** `dim` for neutral text; `status` takes the pill's status color. */
16export type Tone = 'dim' | 'plain' | 'status'
17
18export type Segment = { text: string; tone: Tone; isBold?: boolean }
19
20export type TerminalPill = { pill: Pill; status: Status; segments: Segment[] }
21
22/**
23 * Ten cells of `█` and `░`. With `elapsed`, a `│` is inserted between the
24 * cells where the window's elapsed time falls, so every cell still shows fill.
25 */
26export function barSegments(fraction: number, elapsed: number | null, status: Status): Segment[] {
27 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * BAR_CELLS)
28 const tickAt = elapsed === null ? -1 : Math.round(Math.min(1, Math.max(0, elapsed)) * BAR_CELLS)
29 const fillTone: Tone = status === 'normal' ? 'dim' : 'status'
30 const cells: Segment[] = []
31 for (let cell = 0; cell <= BAR_CELLS; cell += 1) {
32 if (cell === tickAt) cells.push({ text: '│', tone: status === 'normal' ? 'dim' : 'plain' })
33 if (cell < BAR_CELLS) cells.push(cell < filled ? { text: '█', tone: fillTone } : { text: '░', tone: 'dim' })
34 }
35
36 const segments: Segment[] = []
37 for (const segment of cells) {
38 const last = segments.at(-1)
39 if (last !== undefined && last.tone === segment.tone) {
40 last.text += segment.text
41 } else {
42 segments.push(segment)
43 }
44 }
45
46 return segments
47}
48
49/** A critical pill leads with `⚠`; the others keep the same two columns blank. */
50function mark(status: Status): Segment {
51 return { text: status === 'critical' ? '⚠ ' : ' ', tone: 'status' }
52}
53
54export function terminalPill(pill: Pill, density: Density): TerminalPill {
55 switch (pill.kind) {
56 case 'placeholder': {
57 const width = terminalWidth(pill, density)
58
59 return { pill, status: 'normal', segments: [{ text: ` ${pill.label} —`.padEnd(width), tone: 'dim' }] }
60 }
61 case 'window':
62 case 'context': {
63 const status = pill.status
64 const isNormal = status === 'normal'
65 const segments: Segment[] = [mark(status), { text: `${pill.label} `, tone: 'dim' }]
66 if (density !== 'minimal') {
67 segments.push(...barSegments(pill.fraction, pill.kind === 'window' ? pill.elapsed : null, status))
68 // The tick's column stays reserved when the window reported no reset time.
69 const tickRoom = pill.kind === 'window' && pill.elapsed === null ? ' ' : ''
70 segments.push({ text: `${tickRoom} `, tone: 'dim' })
71 }
72 segments.push({
73 text: pill.percentText.padStart(PERCENT_COLUMNS),
74 tone: isNormal ? 'dim' : 'status',
75 isBold: !isNormal,
76 })
77 if (pill.kind === 'window' && density === 'full') {
78 segments.push({ text: ` ${pill.resetText.padEnd(RESET_COLUMNS[pill.id])}`, tone: 'dim' })
79 }
80
81 return { pill, status, segments }
82 }
83 case 'tokens':
84 return {
85 pill,
86 status: 'normal',
87 segments: [{ text: `${pill.inText} ${pill.outText}`.padEnd(TOKENS_COLUMNS), tone: 'dim' }],
88 }
89 case 'cost':
90 return { pill, status: 'normal', segments: [{ text: pill.text.padEnd(COST_COLUMNS), tone: 'dim' }] }
91 }
92}
93
94/** Columns the pill takes, from its widest realistic content. */
95export function terminalWidth(pill: Pill, density: Density): number {
96 switch (pill.kind) {
97 case 'placeholder':
98 case 'window':
99 case 'context': {
100 // Mark, "5h " or "ctx ", the bar and its space (a window's tick too),
101 // the percent, and a window's " <reset>" when full.
102 const isWindow = pill.id !== 'context'
103 const bar = density === 'minimal' ? 0 : BAR_CELLS + (isWindow ? 1 : 0) + 1
104 const reset = pill.id !== 'context' && density === 'full' ? 1 + RESET_COLUMNS[pill.id] : 0
105
106 return 2 + pill.label.length + 1 + bar + PERCENT_COLUMNS + reset
107 }
108 case 'tokens':
109 return Math.max(TOKENS_COLUMNS, `${pill.inText} ${pill.outText}`.length)
110 case 'cost':
111 return Math.max(COST_COLUMNS, pill.text.length)
112 }
113}
114hooks/format.ts 50 lines1// Number and time formatting: pure functions.
2
3const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
4
5const MINUTE = 60_000
6
7/** Up to 999 as is, then `15.6k`, then `1.25M`. */
8export function formatCount(n: number): string {
9 const value = Math.max(0, Math.round(n))
10 if (value < 1000) return String(value)
11 if (value < 999_950) return `${(value / 1000).toFixed(1)}k`
12
13 return `${(value / 1_000_000).toFixed(2)}M`
14}
15
16/** Exact count with thousands separators: `1,234,567`. */
17export function formatExact(n: number): string {
18 return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
19}
20
21/** `<1m`, `45m`, `2h 40m`, `1d 7h`. */
22export function formatDuration(ms: number): string {
23 const minutes = Math.round(Math.max(0, ms) / MINUTE)
24 if (minutes < 1) return '<1m'
25 if (minutes < 60) return `${minutes}m`
26
27 const hours = Math.floor(minutes / 60)
28 if (hours < 24) return `${hours}h ${minutes % 60}m`
29
30 return `${Math.floor(hours / 24)}d ${hours % 24}h`
31}
32
33/** `Sun 14:00` in the zone `utcOffsetMinutes` east of UTC. */
34export function formatClock(epochMs: number, utcOffsetMinutes: number): string {
35 const local = new Date(epochMs + utcOffsetMinutes * MINUTE)
36 const hh = String(local.getUTCHours()).padStart(2, '0')
37 const mm = String(local.getUTCMinutes()).padStart(2, '0')
38
39 return `${WEEKDAYS[local.getUTCDay()]} ${hh}:${mm}`
40}
41
42/** `$4.32`; whole dollars from $1,000. */
43export function formatUsd(usd: number): string {
44 return usd >= 1000 ? `$${formatExact(usd)}` : `$${usd.toFixed(2)}`
45}
46
47export function formatPercent(percent: number): string {
48 return `${Math.round(percent)}%`
49}
50hooks/status.ts 102 lines1// Status logic: pure functions, no UI and no engine API, so they test alone.
2
3export type Status = 'normal' | 'warning' | 'critical'
4
5export type WindowKind = 'five_hour' | 'seven_day'
6
7const MINUTE = 60_000
8const HOUR = 60 * MINUTE
9
10export const WINDOW_MS: Record<WindowKind, number> = {
11 five_hour: 5 * HOUR,
12 seven_day: 7 * 24 * HOUR,
13}
14
15/** A projected run-out this close (and before reset) is critical. */
16export const CRITICAL_HORIZON_MS: Record<WindowKind, number> = {
17 five_hour: 30 * MINUTE,
18 seven_day: 24 * HOUR,
19}
20
21/** Below this elapsed fraction there is too little data to project. */
22export const MIN_ELAPSED_FOR_PACE = 0.05
23
24export const USAGE_WARNING = 0.75
25export const USAGE_CRITICAL = 0.9
26export const CONTEXT_WARNING = 70
27export const CONTEXT_CRITICAL = 90
28
29export type Pace =
30 | { kind: 'insufficient' }
31 | { kind: 'under' }
32 | { kind: 'over'; timeToLimitMs: number }
33
34export type WindowAssessment = {
35 status: Status
36 pace: Pace
37 /** Fraction of the window that has passed, 0 to 1; null without a reset time. */
38 elapsed: number | null
39}
40
41const clamp01 = (x: number): number => Math.min(1, Math.max(0, x))
42
43/** `1 − remaining / window`, clamped to 0..1. */
44export function elapsedFraction(remainingMs: number, windowMs: number): number {
45 return clamp01(1 - remainingMs / windowMs)
46}
47
48/**
49 * Projects when the window runs out at the pace so far. `u` and `e` are the
50 * used and elapsed fractions; `remainingMs` is the time until reset.
51 */
52export function projectPace(u: number, e: number, windowMs: number, remainingMs: number): Pace {
53 if (e < MIN_ELAPSED_FOR_PACE) return { kind: 'insufficient' }
54 if (u <= 0) return { kind: 'under' }
55
56 const elapsedMs = e * windowMs
57 const rate = u / elapsedMs
58 const timeToLimitMs = Math.max(0, (1 - u) / rate)
59
60 return timeToLimitMs < remainingMs ? { kind: 'over', timeToLimitMs } : { kind: 'under' }
61}
62
63/**
64 * Classifies a rate-limit window by usage and pace. `remainingMs` is null when
65 * the window reported no reset time: then only raw usage counts.
66 */
67export function assessWindow(
68 kind: WindowKind,
69 percentUsed: number,
70 remainingMs: number | null,
71): WindowAssessment {
72 const u = percentUsed / 100
73 const windowMs = WINDOW_MS[kind]
74
75 if (remainingMs === null) {
76 return { status: statusByUsage(u, false, false), pace: { kind: 'insufficient' }, elapsed: null }
77 }
78
79 const remaining = Math.max(0, remainingMs)
80 const elapsed = elapsedFraction(remaining, windowMs)
81 const pace = projectPace(u, elapsed, windowMs, remaining)
82 const isOver = pace.kind === 'over'
83 const isOverSoon = isOver && pace.timeToLimitMs <= CRITICAL_HORIZON_MS[kind]
84
85 return { status: statusByUsage(u, isOver, isOverSoon), pace, elapsed }
86}
87
88function statusByUsage(u: number, isOver: boolean, isOverSoon: boolean): Status {
89 if (u >= USAGE_CRITICAL || isOverSoon) return 'critical'
90 if (u >= USAGE_WARNING || isOver) return 'warning'
91
92 return 'normal'
93}
94
95/** Context fill, 0 to 100: warning from 70, critical from 90. */
96export function contextStatus(percent: number): Status {
97 if (percent >= CONTEXT_CRITICAL) return 'critical'
98 if (percent >= CONTEXT_WARNING) return 'warning'
99
100 return 'normal'
101}
102types/index.d.ts 76 lines1/** One rate-limit window as the band keeps it. */
2export type UsageBandWindow = {
3 /** 0 to 100, as the API reports it. */
4 percentUsed: number
5 /** When the window resets, in epoch milliseconds; null when unreported. */
6 resetsAt: number | null
7}
8
9/** The live context window. */
10export type UsageBandContext = {
11 /** Tokens the last response was answered over; null before the first one. */
12 tokens: number | null
13 /** The model's context window, in tokens. */
14 window: number
15 /** `tokens` over `window`, 0 to 100; null before the first response. */
16 percent: number | null
17}
18
19/** Token totals of the session, main thread and subagents together. */
20export type UsageBandTokens = {
21 /** Uncached input tokens. */
22 input: number
23 /** Input tokens written to the prompt cache. */
24 cacheCreation: number
25 output: number
26 /** Input tokens the prompt cache served. */
27 cacheRead: number
28 /** API requests counted (turns, on the fallback path). */
29 requests: number
30}
31
32/** Everything the band draws from, as of one refresh. */
33export type UsageBandSnapshot = {
34 fiveHour: UsageBandWindow | null
35 sevenDay: UsageBandWindow | null
36 context: UsageBandContext | null
37 /** Session cost in US dollars at API list prices; null when not reported. */
38 costUsd: number | null
39 tokens: UsageBandTokens | null
40 /** True when `tokens` came from turn.complete sums, not the transcripts. */
41 isTokensEstimated: boolean
42 /** The host's local time zone, minutes east of UTC, for absolute reset times. */
43 utcOffsetMinutes: number
44}
45
46/** Size and modification time of the main transcript at the last script run. */
47export type UsageBandTranscript = {
48 path: string
49 size: number
50 mtimeMs: number
51}
52
53/** `auto` hides the cost pill on a subscription and shows it otherwise. */
54export type UsageBandCostMode = 'auto' | 'on' | 'off'
55
56/** Inferred from the rate-limit windows: only subscriptions report them. */
57export type UsageBandPlan = 'subscription' | 'api' | 'unknown'
58
59declare module 'claude-code' {
60 interface PluginState {
61 'usage-band': {
62 snapshot: UsageBandSnapshot | null
63 /** The clock at the last refresh; countdowns and pace are drawn against it. */
64 now: number
65 plan: UsageBandPlan
66 isHidden: boolean
67 costMode: UsageBandCostMode
68 /** Running sum of turn.complete usage, used when the script fails. */
69 fallbackTokens: UsageBandTokens
70 /** Cache key of `scriptTokens`. */
71 transcript: UsageBandTranscript | null
72 scriptTokens: UsageBandTokens | null
73 }
74 }
75}
76