A usage meter for Claude Code: a plain "5h 62%" button under the prompt that opens a compact sheet with your session, weekly and per-model weekly limits, and…

A small usage meter for Claude Code, built as a mod (a plugin of function hooks).
It adds a plain 5h 62% button in the footer under the prompt, styled like the model and effort selectors. Click it (or type /meter) and a compact sheet opens above the input:
| Session | ━━━━━━╌╌──── | 62% | resets in 2h 14m |
| Week | ━━━━━━━━━╌── | 83% | resets in 2d |
| Fable | ━─────────── | 3% | resets in 2d |
Under the rows is one line about your pace:
The dashed part of each bar is where your current pace takes it by the reset (red if that means running out). The percent changes colour as it fills. The bars stretch to fill the card at any width, and on a very thin card the reset text drops away so nothing overflows.
claude plugin marketplace add ErnestTramp/claude-usage-meter
claude plugin install usage-meter@claude-usage-meter
Or try it from a clone without installing: claude --plugin-dir ./claude-usage-meter.
Needs Claude Code 2.1.287 or newer (the mods feature).
/meter shows or hides the sheet/meter debug prints the raw numbers the meter is receiving~/.claude/plugins/store, and drops them after about 100 minutes.On the desktop the bars are drawn as vector, sized for the app's default code font (character-based bars wrap in its proportional font); with a very different font size they may fall slightly short of, or past, the column. In the terminal they are line characters.
Built and used in the desktop app's Code tab. In the terminal you should get the session and weekly bars, but the per-model rows need the desktop app, and I have tested that less.
The mods API is new and can change between Claude Code releases, so a future update may need a fix here.
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 370 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { UsageMeterPace, UsageMeterSample, UsageMeterWindow } from '../types'
5import {
6 AMBER,
7 CORAL,
8 DESKTOP_BAR_HEIGHT,
9 DESKTOP_CELL_PX,
10 MINT,
11 barSpans,
12 barSvg,
13 levelColor,
14 miniLabel,
15 sheetLayout,
16} from './line'
17import { buildRows } from './rows'
18import {
19 type Buckets,
20 type TokenUsage,
21 addTokens,
22 blendPctPerToken,
23 isFresh,
24 measurePctPerToken,
25 parseBuckets,
26 tokensPerMin,
27 weightOf,
28} from './tokens'
29import { fmtDuration, freshen, mergeWindows, parsePlanWindows, recordSamples } from './usage'
30
31const windows = atom({ plugin: 'usage-meter', key: 'windows' } as const, [])
32const history = atom({ plugin: 'usage-meter', key: 'history' } as const, {})
33const isOpen = atom({ plugin: 'usage-meter', key: 'isOpen' } as const, false)
34const pace = atom({ plugin: 'usage-meter', key: 'pace' } as const, { tokensPerMin: 0, pctPerToken: null })
35
36const STORE_KEY = 'usage-meter:history'
37const TOKENS_PREFIX = 'usage-meter:tok:'
38const SINCE_KEY = 'usage-meter:since'
39const RATIO_KEY = 'usage-meter:pct-per-token'
40/** The pace and the countdowns refresh this often, so the meter feels live. */
41const TICK_MS = 5_000
42/** The app's usage card is asked once a minute (every 12th beat). */
43const POLL_EVERY = 12
44/** The footer button is "live" if it drew within this long; else the status line steps in. */
45const FOOTER_LIVE_MS = 90_000
46/** Give the footer this long after start to draw before falling back. */
47const FOOTER_GRACE_MS = 45_000
48
49let lastPoll = 'not polled yet'
50let lastLine: string | undefined | null = null
51let startedAt = 0
52let footerAt = 0
53/** Whether the app's live usage card answers: until it has, Claude Code's own (stale-prone) readings are not trusted as samples. */
54let appState: 'unknown' | 'ok' | 'none' = 'unknown'
55let sessionKey = ''
56let mine: Buckets = []
57let isDirty = false
58let loggingSince: number | null = null
59
60/** Counts one model request's tokens (this session's main loop or any subagent's). */
61async function noteTokens($: EngineInterface, usage: TokenUsage | null): Promise<void> {
62 if (usage === null) return
63 try {
64 const now = await $.clock.now()
65 mine = addTokens(mine, now, weightOf(usage))
66 isDirty = true
67 if (loggingSince === null) {
68 loggingSince = now
69 await $.store.set(SINCE_KEY, now)
70 }
71 } catch {
72 // counting is best effort; the chat must never notice
73 }
74}
75
76/** Every running session's token slots: this one's from memory, the rest from the shared store. */
77async function allBuckets($: EngineInterface, now: number): Promise<Buckets[]> {
78 const all: Buckets[] = [mine]
79 for (const key of await $.store.keys()) {
80 if (!key.startsWith(TOKENS_PREFIX) || key === TOKENS_PREFIX + sessionKey) continue
81 const other = parseBuckets(await $.store.get(key))
82 if (other === null || !isFresh(other, now)) {
83 await $.store.delete(key)
84 continue
85 }
86 all.push(other)
87 }
88
89 return all
90}
91
92/**
93 * The beat: shares this session's tokens, works out the pace from every session's, keeps the
94 * fallback status line in step (shown only when the footer button is not drawing, so the number is
95 * never on screen twice), and asks the drawings to redraw.
96 */
97async function refresh($: EngineInterface): Promise<void> {
98 const now = await $.clock.now()
99 if (sessionKey !== '' && isDirty) {
100 isDirty = false
101 await $.store.set(TOKENS_PREFIX + sessionKey, mine)
102 }
103 const all = await allBuckets($, now)
104 const list = await read($, windows)
105 const samples = await read($, history)
106
107 if (loggingSince === null) {
108 const since = await $.store.get(SINCE_KEY)
109 if (typeof since === 'number') loggingSince = since
110 }
111 let ratio = (await read($, pace)).pctPerToken
112 if (ratio === null) {
113 const saved = await $.store.get(RATIO_KEY)
114 if (typeof saved === 'number') ratio = saved
115 }
116 const session = list.find(w => w.kind === 'five_hour')
117 if (session !== undefined) {
118 const measured = measurePctPerToken(freshen(session, now), samples.five_hour ?? [], all, loggingSince, now)
119 const blended = blendPctPerToken(ratio, measured)
120 if (blended !== null && (ratio === null || Math.abs(blended - ratio) / ratio > 0.05)) {
121 await $.store.set(RATIO_KEY, blended)
122 }
123 ratio = blended
124 }
125 const next: UsageMeterPace = { tokensPerMin: tokensPerMin(all, now), pctPerToken: ratio }
126 await update($, pace, () => next)
127
128 const rows = buildRows(list, samples, now, next)
129 const isFooterLive = footerAt !== 0 && now - footerAt < FOOTER_LIVE_MS
130 const isFooterDue = footerAt === 0 && now - startedAt < FOOTER_GRACE_MS
131 const line = isFooterLive || isFooterDue ? undefined : miniLabel(rows)
132 if (line !== lastLine) {
133 lastLine = line
134 $.ui.status(line)
135 }
136 $.ui.invalidate('ui.render')
137}
138
139async function ingest(
140 $: EngineInterface,
141 incoming: UsageMeterWindow[],
142 src: 'app' | 'engine',
143): Promise<void> {
144 if (incoming.length === 0) return
145 const now = await $.clock.now()
146 const tagged = incoming.map(w => ({ ...w, src }))
147 const merged = await update($, windows, prev => mergeWindows(prev, tagged))
148 // Claude Code's own reading can lag the account's real figure (a fresh session starts from an
149 // old response), so it is only kept as a sample when the app's usage card is not available.
150 if (src === 'app' || appState === 'none') {
151 let isChanged = false
152 const kept = await update($, history, prev => {
153 const r = recordSamples(prev, merged, now)
154 isChanged = r.changed
155
156 return r.history
157 })
158 if (isChanged) await $.store.set(STORE_KEY, kept)
159 }
160 await refresh($)
161}
162
163// The desktop app's own read-only usage card: the account's live figures, including the
164// per-model weekly limits the engine's headers leave out. No credentials, no network.
165async function poll($: EngineInterface): Promise<void> {
166 try {
167 const result = await $.mcp.call('ccd_session_mgmt', 'get_usage')
168 if (result.isError) {
169 lastPoll = 'get_usage answered an error'
170 if (appState === 'unknown') appState = 'none'
171 return
172 }
173 const found = parsePlanWindows(result)
174 lastPoll = `${found.length} windows from get_usage`
175 if (found.length > 0) appState = 'ok'
176 else if (appState === 'unknown') appState = 'none'
177 await ingest($, found, 'app')
178 } catch (error) {
179 if (appState === 'unknown') appState = 'none'
180 lastPoll = `get_usage unavailable (${error instanceof Error ? error.message : String(error)})`
181 }
182}
183
184function fromRateLimit(r: { kind: string; percentUsed: number; resetsAt?: string }): UsageMeterWindow {
185 const resetsAt = r.resetsAt === undefined ? Number.NaN : Date.parse(r.resetsAt)
186 return Number.isFinite(resetsAt)
187 ? { kind: r.kind, pct: r.percentUsed, resetsAt }
188 : { kind: r.kind, pct: r.percentUsed }
189}
190
191export const register: Register = on => {
192 let timer: Timer | null = null
193
194 on('session.start', async ($, e, next) => {
195 startedAt = await $.clock.now()
196 sessionKey = Math.floor(Math.random() * 1e9).toString(36)
197 mine = []
198 await $.command.register({
199 name: 'meter',
200 description: 'Usage meter: show or hide the stats sheet (or "debug")',
201 })
202
203 const saved = await $.store.get(STORE_KEY)
204 if (saved !== null && typeof saved === 'object' && !Array.isArray(saved)) {
205 await update($, history, prev =>
206 Object.keys(prev).length === 0 ? (saved as Record<string, UsageMeterSample[]>) : prev,
207 )
208 }
209
210 const usage = await $.session.usage()
211 await ingest($, usage.rateLimits.map(fromRateLimit), 'engine')
212
213 timer?.cancel()
214 let beat = 0
215 timer = $.clock.every(TICK_MS, async () => {
216 beat += 1
217 try {
218 if (beat % POLL_EVERY === 0) await poll($)
219 await refresh($)
220 } catch {
221 // a missed beat is harmless; the next one catches up
222 }
223 })
224 $.clock.after(1500, () => void poll($))
225
226 return next(e)
227 })
228
229 on('session.measure', async ($, e, next) => {
230 if (e.changed.includes('rateLimits')) await ingest($, e.rateLimits.map(fromRateLimit), 'engine')
231
232 return next(e)
233 })
234
235 // Counts every model request's tokens as it finishes, then hands the response on untouched.
236 on('turn.step', async function* ($, _e, next) {
237 const response = yield* next(_e)
238 await noteTokens($, response.usage)
239
240 return response
241 })
242
243 on('command.run', { command: 'meter' }, async ($, e) => {
244 if (e.args.trim() === 'debug') {
245 const now = await $.clock.now()
246 const held = await $.state.get({ plugin: 'usage-meter', key: 'windows' } as const)
247 const lines = (held.value ?? []).map(
248 w =>
249 `${w.kind}: ${w.pct}%${w.resetsAt === undefined ? '' : `, resets in ${fmtDuration(w.resetsAt - now)}`}`,
250 )
251
252 return { text: [`Usage meter windows (${lines.length}):`, ...lines, `Poll: ${lastPoll}`].join('\n') }
253 }
254
255 const open = await update($, isOpen, v => !v)
256
257 return { text: open ? 'Usage sheet shown.' : 'Usage sheet hidden.' }
258 })
259
260 // The button under the prompt: the footer's labels with "5h 62%" added, in the same
261 // plain white text as the model and effort selectors. Pressing it shows or hides the sheet.
262 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
263 const now = await $.clock.now()
264 const label = miniLabel(buildRows(await read($, windows), await read($, history), now))
265 if (label === undefined) return next(e)
266
267 footerAt = now
268 const { Box, Text, Button } = $.ui.resolve(e)
269 const modes = e.props.modes
270
271 return (
272 <Box>
273 {modes.length > 0 && <Text dimColor>{`${modes.join(' & ')} & `}</Text>}
274 <Button
275 key="meter"
276 plain
277 label={label}
278 onPress={() => void update($, isOpen, v => !v)}
279 />
280 </Box>
281 )
282 })
283
284 // The sheet: every limit on one compact block above the input, in aligned columns
285 // (name, bar, percent, reset) with the bars stretched to fill whatever width the card has.
286 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
287 if (e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
288 if (!(await read($, isOpen))) return next(e)
289
290 const rows = buildRows(
291 await read($, windows),
292 await read($, history),
293 await $.clock.now(),
294 await read($, pace),
295 )
296 if (rows.length === 0) return next(e)
297
298 const { Box, Text, Button } = $.ui.resolve(e)
299 const names = rows.map(row => row.label.replace(/ /g, '\u00a0'))
300 // one cell of cushion on the name: the desktop draws it in a proportional font
301 const widths = {
302 name: Math.max(...names.map(name => name.length)) + 1,
303 pct: Math.max(...rows.map(row => row.pctText.length)),
304 resets: Math.max(...rows.map(row => (row.resets ?? '').length)),
305 }
306 const { cells, showResets } = sheetLayout(e.props.bodyColumns, widths)
307 const chip = rows[0]?.verdict
308 const chipColor = chip?.tone === 'hit' ? CORAL : chip?.tone === 'warn' ? AMBER : MINT
309
310 // Each limit is its own row so its name, bar, percent and reset always share a baseline.
311 const bar = (row: (typeof rows)[number]) => {
312 if (e.surface === 'desktop') {
313 const { Svg } = $.ui.resolve(e)
314 const px = Math.round(cells * DESKTOP_CELL_PX)
315
316 return <Svg source={barSvg(px, row)} alt={`${row.label} ${row.pctText} used`} width={px} height={DESKTOP_BAR_HEIGHT} />
317 }
318
319 return (
320 <Text>
321 {barSpans(row, cells).map(span => (
322 <Text color={span.color}>{span.text}</Text>
323 ))}
324 </Text>
325 )
326 }
327
328 return (
329 <Box justifyContent="space-between">
330 <Box flexDirection="column">
331 {rows.map((row, i) => (
332 <Box gap={2} alignItems="center">
333 <Box width={widths.name} flexShrink={0}>
334 <Text wrap="truncate">{names[i]}</Text>
335 </Box>
336 <Box width={cells} flexShrink={0} overflow="hidden">
337 {bar(row)}
338 </Box>
339 <Box width={widths.pct} justifyContent="flex-end" flexShrink={0}>
340 <Text bold color={levelColor(row.pct)}>
341 {row.pctText}
342 </Text>
343 </Box>
344 {showResets && (
345 <Box width={widths.resets} flexShrink={0}>
346 <Text wrap="truncate" dimColor>
347 {row.resets ?? ''}
348 </Text>
349 </Box>
350 )}
351 </Box>
352 ))}
353 {chip !== undefined && (
354 <Text wrap="truncate" color={chipColor}>
355 {chip.text}
356 </Text>
357 )}
358 </Box>
359 <Button
360 key="close"
361 plain
362 role="dismiss"
363 label="×"
364 onPress={() => void update($, isOpen, () => false)}
365 />
366 </Box>
367 )
368 })
369}
370hooks/line.ts 120 lines1import type { MeterRow } from './rows'
2
3const FILL = '━'
4const GHOST = '╌'
5const REST = '─'
6
7export const MINT = '#5fd7a7'
8export const AMBER = '#f2b84b'
9export const CORAL = '#f0644e'
10const TRACK = '#3a3a44'
11
12export function levelColor(pct: number): string {
13 if (pct < 50) return MINT
14 if (pct < 80) return AMBER
15 return CORAL
16}
17
18function channels(hex: string): [number, number, number] {
19 const n = parseInt(hex.slice(1), 16)
20 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
21}
22
23/** `t` of the way from `a` to `b`. */
24export function mix(a: string, b: string, t: number): string {
25 const [ar, ag, ab] = channels(a)
26 const [br, bg, bb] = channels(b)
27 const part = (x: number, y: number) =>
28 Math.round(x + (y - x) * t)
29 .toString(16)
30 .padStart(2, '0')
31
32 return `#${part(ar, br)}${part(ag, bg)}${part(ab, bb)}`
33}
34
35/** How many of `cells` are filled, projected, and empty. */
36export function barCells(
37 cells: number,
38 bar: { pct: number; ghostPct: number | null },
39): { fill: number; ghost: number; rest: number } {
40 const share = (pct: number) => Math.round((Math.min(100, Math.max(0, pct)) / 100) * cells)
41 let fill = share(bar.pct)
42 if (bar.pct > 0 && fill === 0) fill = 1
43 const end = bar.ghostPct === null ? fill : Math.max(fill, share(bar.ghostPct))
44
45 return { fill, ghost: end - fill, rest: cells - end }
46}
47
48/** A thin line: heavy where used, dashed where the pace will take it, light where free. */
49export function textBar(cells: number, bar: { pct: number; ghostPct: number | null }): string {
50 const { fill, ghost, rest } = barCells(cells, bar)
51
52 return FILL.repeat(fill) + GHOST.repeat(ghost) + REST.repeat(rest)
53}
54
55export type Span = { text: string; color?: string; dim?: boolean; bold?: boolean }
56
57/** The bar as coloured runs: used, where the pace lands at reset, free. */
58export function barSpans(row: MeterRow, cells: number): Span[] {
59 const { fill, ghost, rest } = barCells(cells, row)
60 const ghostColor = row.alert !== null ? CORAL : levelColor(row.ghostPct ?? row.pct)
61 const spans: Span[] = [{ text: FILL.repeat(fill), color: levelColor(row.pct) }]
62 if (ghost > 0) spans.push({ text: GHOST.repeat(ghost), color: mix(ghostColor, TRACK, 0.5) })
63 if (rest > 0) spans.push({ text: REST.repeat(rest), color: TRACK })
64
65 return spans
66}
67
68/** The least text that is still a stat: "5h 62%". */
69export function miniLabel(rows: MeterRow[]): string | undefined {
70 const first = rows[0]
71
72 return first === undefined ? undefined : `${first.short} ${first.pctText}`
73}
74
75/**
76 * Lays out the sheet's columns for a card `bodyColumns` wide: the bars take whatever the name,
77 * percent and reset columns, their gaps and the close button leave, so the sheet fills the card
78 * edge to edge and only the bars grow. When the card is too thin to give the bars at least ten
79 * cells beside the reset column, that column is dropped and the bars take its space back.
80 */
81export function sheetLayout(
82 bodyColumns: number,
83 columns: { name: number; pct: number; resets: number },
84): { cells: number; showResets: boolean } {
85 const closeButton = 4
86 const minBar = 10
87 const fixed = columns.name + columns.pct + closeButton
88 const withResets = bodyColumns - fixed - columns.resets - 3 * 2
89 if (withResets >= minBar) return { cells: withResets, showResets: true }
90
91 return { cells: Math.max(4, bodyColumns - fixed - 2 * 2), showResets: false }
92}
93
94/**
95 * A desktop cell is the width of the app's code font, and the sheet's text is drawn in a
96 * proportional font, so bars made of line characters cannot fit a cell count there (they come out
97 * wider and wrap). The desktop draws the bar as vector instead, this many CSS pixels per cell
98 * (measured on the app's default code font).
99 */
100export const DESKTOP_CELL_PX = 7.9
101export const DESKTOP_BAR_HEIGHT = 3
102
103/** The same bar as exact-proportion vector: used, projected (dashed), free. */
104export function barSvg(widthPx: number, bar: { pct: number; ghostPct: number | null; alert: string | null }): string {
105 const h = DESKTOP_BAR_HEIGHT
106 const share = (pct: number) => (Math.min(100, Math.max(0, pct)) / 100) * widthPx
107 const fill = bar.pct > 0 ? Math.max(h, share(bar.pct)) : 0
108 const end = bar.ghostPct === null ? fill : Math.max(fill, share(bar.ghostPct))
109 const ghostColor = bar.alert !== null ? CORAL : levelColor(bar.ghostPct ?? bar.pct)
110 const round = (n: number) => Math.round(n * 100) / 100
111 const pill = (w: number, color: string) =>
112 w > 0 ? `<rect width="${round(w)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : ''
113 const dashes =
114 end - fill > 0.5
115 ? `<line x1="${round(fill)}" x2="${round(end)}" y1="${h / 2}" y2="${h / 2}" stroke="${mix(ghostColor, TRACK, 0.35)}" stroke-width="${h}" stroke-dasharray="4 3"/>`
116 : ''
117
118 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${round(widthPx)} ${h}" width="${round(widthPx)}" height="${h}">${pill(widthPx, TRACK)}${dashes}${pill(fill, levelColor(bar.pct))}</svg>`
119}
120hooks/rows.ts 83 lines1import type { UsageMeterPace, UsageMeterSample, UsageMeterWindow } from '../types'
2import { type Forecast, fmtDuration, forecast, freshen, labelFor, sortWindows } from './usage'
3
4/** One plain sentence about the pace, and how worried to look. */
5export type Verdict = { tone: 'ok' | 'warn' | 'hit'; text: string }
6
7export type MeterRow = {
8 kind: string
9 label: string
10 /** "5h", "Wk", "Fable": what sits beside a bar when several share a line. */
11 short: string
12 pct: number
13 pctText: string
14 /** Where the pace lands at reset, past the fill; null when there is nothing to show. */
15 ghostPct: number | null
16 /** "empty in 47m" or "LIMIT HIT"; null when the pace is safe. */
17 alert: string | null
18 /** "resets in 2h 14m". */
19 resets: string | null
20 verdict: Verdict
21}
22
23function pctText(pct: number): string {
24 return pct > 0 && pct < 1 ? '<1%' : `${Math.round(pct)}%`
25}
26
27export function shortLabel(kind: string): string {
28 if (kind === 'five_hour') return '5h'
29 if (kind === 'seven_day') return 'Wk'
30 const model = /^seven_day_(.+)$/.exec(kind)?.[1]
31 if (model !== undefined) return model.charAt(0).toUpperCase() + model.slice(1)
32
33 return labelFor(kind)
34}
35
36/** "At this rate you have 47m left", or that the reset comes first. */
37export function verdictFor(label: string, f: Forecast): Verdict {
38 const reset = f.resetsInMs === null ? null : fmtDuration(f.resetsInMs)
39 if (f.isFull) {
40 return { tone: 'hit', text: `${label} limit hit${reset === null ? '' : `, resets in ${reset}`}` }
41 }
42 if (f.willRunOut && f.msToFull !== null) {
43 return {
44 tone: 'warn',
45 text: `At this rate you have ${fmtDuration(f.msToFull)} left on this ${label.toLowerCase()} limit`,
46 }
47 }
48 if (f.rate === null) return { tone: 'ok', text: reset === null ? 'Plenty left' : `${label} limit resets in ${reset}` }
49 if (f.rate === 0) return { tone: 'ok', text: 'Not using much right now' }
50
51 return { tone: 'ok', text: `${label} limit resets before you run out` }
52}
53
54/** What each bar shows, from the raw windows, session first. */
55export function buildRows(
56 list: UsageMeterWindow[],
57 samples: Record<string, UsageMeterSample[]>,
58 now: number,
59 pace?: UsageMeterPace,
60): MeterRow[] {
61 return sortWindows(list).map(w => freshen(w, now)).map(w => {
62 const f = forecast(w, samples[w.kind] ?? [], now, pace)
63 let alert: string | null = null
64 if (f.isFull) alert = 'LIMIT HIT'
65 else if (f.willRunOut && f.msToFull !== null) alert = `empty in ${fmtDuration(f.msToFull)}`
66
67 const projected = f.projectedAtReset
68 const hasGhost = f.willRunOut || (projected !== null && projected > w.pct + 1)
69
70 return {
71 kind: w.kind,
72 label: labelFor(w.kind),
73 short: shortLabel(w.kind),
74 pct: w.pct,
75 pctText: pctText(w.pct),
76 ghostPct: hasGhost ? (f.willRunOut ? 100 : Math.min(100, projected ?? w.pct)) : null,
77 alert,
78 resets: f.resetsInMs === null ? null : `resets in ${fmtDuration(f.resetsInMs)}`,
79 verdict: verdictFor(labelFor(w.kind), f),
80 }
81 })
82}
83hooks/tokens.ts 108 lines1import type { UsageMeterSample, UsageMeterWindow } from '../types'
2import { MINUTE, sameWindow } from './usage'
3
4/** [slot index since the epoch, weighted tokens used in that slot]. Slots are 10 seconds long. */
5export type Buckets = [number, number][]
6
7export type TokenUsage = {
8 input_tokens: number
9 output_tokens: number
10 cache_read_input_tokens: number
11 cache_creation_input_tokens: number
12}
13
14export const SLOT_MS = 10_000
15const KEEP_SLOTS = 600
16/** The pace looks at this long a stretch: short enough to feel live, long enough not to flap. */
17const PACE_SPAN_MS = 3 * MINUTE
18
19const slotOf = (ms: number): number => Math.floor(ms / SLOT_MS)
20
21/** Tokens weighted the way the API prices them: output 5x, cache write 1.25x, cache read 0.1x. */
22export function weightOf(u: TokenUsage): number {
23 return (
24 u.input_tokens +
25 5 * u.output_tokens +
26 1.25 * u.cache_creation_input_tokens +
27 0.1 * u.cache_read_input_tokens
28 )
29}
30
31/** Adds `w` to the slot `now` falls in, dropping slots older than 100 minutes. */
32export function addTokens(buckets: Buckets, now: number, w: number): Buckets {
33 const slot = slotOf(now)
34 const kept = buckets.filter(([s]) => s > slot - KEEP_SLOTS)
35 const last = kept.at(-1)
36 if (last !== undefined && last[0] === slot) return [...kept.slice(0, -1), [slot, last[1] + w]]
37
38 return [...kept, [slot, w]]
39}
40
41/** Weighted tokens in the slots from the one holding `from` to the one holding `to`. */
42export function tokensBetween(buckets: Buckets, from: number, to: number): number {
43 const first = slotOf(from)
44 const last = slotOf(to)
45
46 return buckets.reduce((n, [s, w]) => (s >= first && s <= last ? n + w : n), 0)
47}
48
49/** Weighted tokens per minute over the last three minutes, summed over every session. */
50export function tokensPerMin(all: Buckets[], now: number): number {
51 const sum = all.reduce((n, b) => n + tokensBetween(b, now - PACE_SPAN_MS, now), 0)
52
53 return sum / (PACE_SPAN_MS / MINUTE)
54}
55
56/** True when the slot index is one a live session could still be writing (not stale). */
57export function isFresh(buckets: Buckets, now: number): boolean {
58 const last = buckets.at(-1)
59
60 return last !== undefined && last[0] > slotOf(now) - KEEP_SLOTS
61}
62
63/** Reads buckets back from the store, or null when the value is not that shape. */
64export function parseBuckets(value: unknown): Buckets | null {
65 if (!Array.isArray(value)) return null
66 const out: Buckets = []
67 for (const item of value as unknown[]) {
68 if (!Array.isArray(item) || typeof item[0] !== 'number' || typeof item[1] !== 'number') return null
69 out.push([item[0], item[1]])
70 }
71
72 return out
73}
74
75/**
76 * Percent of the limit one weighted token costs, measured over the last hour or so: how far
77 * the percent rose since an anchor reading 15 to 90 minutes old, over the tokens used since.
78 * Null when there is no usable anchor, the rise is under 3 points (too coarse), or token
79 * logging began after the anchor (the tokens would be undercounted).
80 */
81export function measurePctPerToken(
82 win: UsageMeterWindow,
83 samples: UsageMeterSample[],
84 all: Buckets[],
85 loggingSince: number | null,
86 now: number,
87): number | null {
88 const anchor = samples
89 .filter(
90 s =>
91 sameWindow(s.resetsAt, win.resetsAt) && now - s.t >= 15 * MINUTE && now - s.t <= 90 * MINUTE,
92 )
93 .sort((a, b) => a.t - b.t)[0]
94 if (anchor === undefined || loggingSince === null || loggingSince > anchor.t) return null
95
96 const rise = win.pct - anchor.pct
97 const tokens = all.reduce((n, b) => n + tokensBetween(b, anchor.t, now), 0)
98
99 return rise >= 3 && tokens > 0 ? rise / tokens : null
100}
101
102/** Smooths a new measurement into the old one so a single odd hour does not swing the forecast. */
103export function blendPctPerToken(prev: number | null, measured: number | null): number | null {
104 if (measured === null) return prev
105
106 return prev === null ? measured : prev * 0.7 + measured * 0.3
107}
108hooks/usage.ts 293 lines1import type { UsageMeterPace, UsageMeterSample, UsageMeterWindow } from '../types'
2
3export const MINUTE = 60_000
4export const HOUR = 60 * MINUTE
5export const DAY = 24 * HOUR
6
7type History = Record<string, UsageMeterSample[]>
8
9const SAME_WINDOW_MS = 5 * MINUTE
10/** A run-out this close to the reset is not worth a warning. */
11export const RUN_OUT_MARGIN_MS = 10 * MINUTE
12const KEEP_MS = 8 * DAY
13const KEEP_SAMPLES = 240
14
15export function windowLength(kind: string): number | null {
16 if (kind.startsWith('five_hour')) return 5 * HOUR
17 if (kind.startsWith('seven_day')) return 7 * DAY
18 return null
19}
20
21function titleCase(slug: string): string {
22 return slug
23 .split('_')
24 .filter(part => part !== '')
25 .map(part => part.charAt(0).toUpperCase() + part.slice(1))
26 .join(' ')
27}
28
29export function labelFor(kind: string): string {
30 if (kind === 'five_hour') return 'Session'
31 if (kind === 'seven_day') return 'Week'
32 const model = /^seven_day_(.+)$/.exec(kind)
33 if (model !== null) return titleCase(model[1] ?? '')
34 if (kind === 'spend_limit') return 'Spend'
35 return titleCase(kind)
36}
37
38function rank(kind: string): number {
39 if (kind === 'five_hour') return 0
40 if (kind === 'seven_day') return 1
41 if (kind.startsWith('seven_day_')) return 2
42 return 3
43}
44
45export function sortWindows(list: UsageMeterWindow[]): UsageMeterWindow[] {
46 return [...list].sort((a, b) => rank(a.kind) - rank(b.kind) || a.kind.localeCompare(b.kind))
47}
48
49/** "47m", "2h 14m", "2d 3h". */
50export function fmtDuration(ms: number): string {
51 const minutes = Math.floor(Math.max(0, ms) / MINUTE)
52 if (minutes < 1) return '<1m'
53 if (minutes < 60) return `${minutes}m`
54 const hours = Math.floor(minutes / 60)
55 if (hours < 24) {
56 const rest = minutes % 60
57 return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`
58 }
59 const days = Math.floor(hours / 24)
60 const restHours = hours % 24
61 return restHours === 0 ? `${days}d` : `${days}d ${restHours}h`
62}
63
64export function sameWindow(a: number | undefined, b: number | undefined): boolean {
65 if (a === undefined || b === undefined) return a === b
66 return Math.abs(a - b) < SAME_WINDOW_MS
67}
68
69/**
70 * Folds new readings into the old. Within one window the percent only goes up (a lower
71 * reading is stale or rounded), and a reading from a window that has already ended is
72 * ignored once a newer window is known.
73 */
74export function mergeWindows(
75 prev: UsageMeterWindow[],
76 incoming: UsageMeterWindow[],
77): UsageMeterWindow[] {
78 const byKind = new Map<string, UsageMeterWindow>(prev.map(w => [w.kind, w]))
79 for (const w of incoming) {
80 const old = byKind.get(w.kind)
81 if (old === undefined) {
82 byKind.set(w.kind, w)
83 continue
84 }
85 const isOlderWindow =
86 old.resetsAt !== undefined && w.resetsAt !== undefined && w.resetsAt < old.resetsAt - SAME_WINDOW_MS
87 if (isOlderWindow) continue
88 if (sameWindow(old.resetsAt, w.resetsAt)) {
89 byKind.set(w.kind, { ...w, pct: Math.max(old.pct, w.pct) })
90 } else {
91 byKind.set(w.kind, w)
92 }
93 }
94
95 return sortWindows([...byKind.values()])
96}
97
98/**
99 * Appends a sample for each window that rose (or is new), drops old ones.
100 * Same `history` object back when nothing changed.
101 */
102export function recordSamples(
103 history: History,
104 windows: UsageMeterWindow[],
105 now: number,
106): { history: History; changed: boolean } {
107 let changed = false
108 const next: History = {}
109 for (const [kind, list] of Object.entries(history)) {
110 const kept = list.filter(s => now - s.t < KEEP_MS)
111 if (kept.length !== list.length) changed = true
112 next[kind] = kept
113 }
114 for (const w of windows) {
115 const list = next[w.kind] ?? []
116 const last = list.filter(s => sameWindow(s.resetsAt, w.resetsAt)).at(-1)
117 if (last === undefined || w.pct > last.pct) {
118 const sample: UsageMeterSample =
119 w.resetsAt === undefined ? { t: now, pct: w.pct } : { t: now, pct: w.pct, resetsAt: w.resetsAt }
120 next[w.kind] = [...list, sample].slice(-KEEP_SAMPLES)
121 changed = true
122 }
123 }
124
125 return changed ? { history: next, changed } : { history, changed }
126}
127
128export type Forecast = {
129 /** Percent per hour; 0 when idle, null when unknown. */
130 rate: number | null
131 basis: 'tokens' | 'recent' | 'average' | 'none'
132 resetsInMs: number | null
133 /** Time until 100% at this pace; null when not burning. 0 when full. */
134 msToFull: number | null
135 /** Where it will stand at reset at this pace, uncapped. */
136 projectedAtReset: number | null
137 /** True when 100% comes well before the reset (by more than RUN_OUT_MARGIN_MS). */
138 willRunOut: boolean
139 isFull: boolean
140}
141
142/**
143 * Burn rate. For the session limit with a `pace`: tokens per minute right now times the learned
144 * percent-per-token, or the percent's own movement over the last few minutes, whichever is
145 * higher; nothing running and nothing moving is idle (rate 0). Other windows use the percent's
146 * slope over a longer look-back, then the window's average.
147 */
148export function forecast(
149 win: UsageMeterWindow,
150 samples: UsageMeterSample[],
151 now: number,
152 pace?: UsageMeterPace,
153): Forecast {
154 const resetsInMs = win.resetsAt === undefined ? null : Math.max(0, win.resetsAt - now)
155 if (win.pct >= 100) {
156 return {
157 rate: null,
158 basis: 'none',
159 resetsInMs,
160 msToFull: 0,
161 projectedAtReset: win.pct,
162 willRunOut: true,
163 isFull: true,
164 }
165 }
166
167 const length = windowLength(win.kind)
168 const isShort = length !== null && length <= 5 * HOUR
169 const mine = samples
170 .filter(s => sameWindow(s.resetsAt, win.resetsAt) && s.t <= now)
171 .sort((a, b) => a.t - b.t)
172
173 /** Percent per ms between an anchor sample and now, if the anchor is old enough. */
174 const slope = (lookback: number, minSpan: number): number | null => {
175 let anchor: UsageMeterSample | undefined
176 for (const s of mine) {
177 if (s.t <= now - lookback) anchor = s
178 }
179 anchor ??= mine.find(s => now - s.t <= lookback)
180 if (anchor === undefined || now - anchor.t < minSpan) return null
181
182 return Math.max(0, win.pct - anchor.pct) / (now - anchor.t)
183 }
184 const average = (): number | null => {
185 if (length === null || win.resetsAt === undefined) return null
186 const elapsed = now - (win.resetsAt - length)
187 const minElapsed = isShort ? 10 * MINUTE : 3 * HOUR
188
189 return elapsed >= minElapsed && elapsed <= length * 1.01 ? win.pct / elapsed : null
190 }
191
192 let perMs: number | null = null
193 let basis: Forecast['basis'] = 'none'
194 if (win.kind === 'five_hour' && pace !== undefined) {
195 const tokenRate = pace.pctPerToken === null ? null : (pace.pctPerToken * pace.tokensPerMin) / MINUTE
196 const moved = slope(10 * MINUTE, 4 * MINUTE)
197 if (tokenRate !== null || moved !== null) {
198 perMs = Math.max(tokenRate ?? 0, moved ?? 0)
199 basis = tokenRate !== null && tokenRate >= (moved ?? 0) ? 'tokens' : 'recent'
200 } else if (pace.tokensPerMin === 0) {
201 perMs = 0
202 basis = 'recent'
203 } else {
204 perMs = average()
205 basis = perMs === null ? 'none' : 'average'
206 }
207 } else {
208 const lookback = length === null ? HOUR : isShort ? 45 * MINUTE : DAY
209 const minSpan = length === null || isShort ? 5 * MINUTE : 2 * HOUR
210 perMs = slope(lookback, minSpan)
211 basis = perMs === null ? 'none' : 'recent'
212 if (perMs === null) {
213 perMs = average()
214 basis = perMs === null ? 'none' : 'average'
215 }
216 }
217
218 const msToFull = perMs !== null && perMs > 0 ? (100 - win.pct) / perMs : null
219 const projectedAtReset =
220 perMs !== null && resetsInMs !== null ? win.pct + perMs * resetsInMs : null
221
222 return {
223 rate: perMs === null ? null : perMs * HOUR,
224 basis,
225 resetsInMs,
226 msToFull,
227 projectedAtReset,
228 willRunOut:
229 msToFull !== null && (resetsInMs === null || msToFull + RUN_OUT_MARGIN_MS < resetsInMs),
230 isFull: false,
231 }
232}
233
234/** A window that reset while nothing was watching reads as a fresh, empty one. */
235export function freshen(win: UsageMeterWindow, now: number): UsageMeterWindow {
236 if (win.resetsAt !== undefined && win.resetsAt <= now) return { kind: win.kind, pct: 0 }
237 return win
238}
239
240function slug(text: string): string {
241 return text
242 .toLowerCase()
243 .replace(/[^a-z0-9]+/g, '_')
244 .replace(/^_+|_+$/g, '')
245}
246
247/** "5-hour limit" -> five_hour, "Weekly · all models" -> seven_day, "Weekly · Fable" -> seven_day_fable. */
248export function kindForLabel(label: string): string {
249 if (/5-?\s*hour/i.test(label)) return 'five_hour'
250 const weekly = /^weekly\s*[·:\-–—]\s*(.+)$/i.exec(label.trim())
251 if (weekly !== null) {
252 const scope = slug(weekly[1] ?? '')
253 return scope === 'all_models' || scope === 'all' || scope === '' ? 'seven_day' : `seven_day_${scope}`
254 }
255
256 return slug(label)
257}
258
259/** Reads the desktop app's `get_usage` answer: `plan.windows[]` with label, percentUsed, resetsAt. */
260export function parsePlanWindows(result: {
261 content?: readonly { type?: string; text?: string }[]
262 structuredContent?: unknown
263}): UsageMeterWindow[] {
264 let body: unknown = result.structuredContent
265 if (body === undefined) {
266 const text = result.content?.find(block => typeof block.text === 'string')?.text
267 if (text === undefined) return []
268 try {
269 body = JSON.parse(text)
270 } catch {
271 return []
272 }
273 }
274
275 const plan = (body as { plan?: { status?: string; windows?: unknown } } | null)?.plan
276 if (plan === undefined || plan === null || !Array.isArray(plan.windows)) return []
277
278 const out: UsageMeterWindow[] = []
279 for (const item of plan.windows as unknown[]) {
280 const w = item as { label?: unknown; percentUsed?: unknown; resetsAt?: unknown }
281 if (typeof w.label !== 'string' || typeof w.percentUsed !== 'number') continue
282 const resetsAt = typeof w.resetsAt === 'string' ? Date.parse(w.resetsAt) : Number.NaN
283 const kind = kindForLabel(w.label)
284 out.push(
285 Number.isFinite(resetsAt)
286 ? { kind, pct: w.percentUsed, resetsAt }
287 : { kind, pct: w.percentUsed },
288 )
289 }
290
291 return out
292}
293types/index.d.ts 32 lines1/** One rate-limit window: percent used (0 to 100+) and when it resets (epoch ms). */
2export type UsageMeterWindow = {
3 kind: string
4 pct: number
5 resetsAt?: number
6 /** Where the reading came from: the app's live usage card, or Claude Code's last response. */
7 src?: 'app' | 'engine'
8}
9
10/** A reading of a window, kept so the burn rate can be measured. */
11export type UsageMeterSample = { t: number; pct: number; resetsAt?: number }
12
13/** How fast limit is being used right now. */
14export type UsageMeterPace = {
15 /** Weighted tokens per minute across every running session, over the last few minutes. */
16 tokensPerMin: number
17 /** Percent of the session limit one weighted token costs, learned from how the percent moved. */
18 pctPerToken: number | null
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'usage-meter': {
24 windows: UsageMeterWindow[]
25 history: Record<string, UsageMeterSample[]>
26 /** The sheet above the input is showing. */
27 isOpen: boolean
28 pace: UsageMeterPace
29 }
30 }
31}
32