SLOPSHOPPER

Usage Meter

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…

newbandspinnercommandstatustimer
v0.8.1MITupdated 2026-10-08ErnestTramp/claude-usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /meter ⎿ usage-meter: Usage sheet shown. Session ━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────────────────────── 31% × Not using much right now ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits & 5h 31%

Draws

Band
Session ━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────────────────────── 31% × Not using much right now
README

Usage Meter

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:

  • At this rate you have 47m left on this session limit (amber chip) when you are on course to run out before the reset
  • Session limit resets before you run out when you are not
  • Not using much when you have been idle

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.

Install

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).

Commands

  • /meter shows or hides the sheet
  • /meter debug prints the raw numbers the meter is receiving

How it works

  • The session and weekly numbers come from the rate-limit figures Claude Code already has, pushed live after each response.
  • In the desktop app, once a minute it also asks the app's own read-only usage tool for per-model weekly limits such as Fable. That is how the extra rows appear.
  • No login tokens are read and nothing goes over the network. The only thing it writes is its own small store file (see below).
  • The pace is how fast tokens are being used right now across all your running Claude Code sessions (main chats and subagents), looked at over the last few minutes and refreshed every five seconds. It is turned into a percent-per-minute rate with a ratio the mod learns from how your real percentage has moved. Nothing running and nothing moving means no forecast.
  • A "you'll run out" warning only appears if the run-out is more than 10 minutes before the reset.
  • To do that it keeps token counts (never any text) per session in the plugin's own store under ~/.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.

Develop

claude plugin validate .
claude plugin test .

Licence

MIT

Source 6 files
hooks/register.tsx 370 lines
1import { 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}
370
hooks/line.ts 120 lines
1import 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}
120
hooks/rows.ts 83 lines
1import 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}
83
hooks/tokens.ts 108 lines
1import 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}
108
hooks/usage.ts 293 lines
1import 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}
293
types/index.d.ts 32 lines
1/** 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