SLOPSHOPPER

usage-meter

Shows plan rate-limit usage, session cost, context fill and a per-task token estimate above the prompt

newbandcommandtimer
★ 1v0.1.0MITupdated 2026-10-08ewxgwy1987/claude-code-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 › /usage-meter ⎿ usage-meter: Plan usage · as of 08:53:20 (CLI), just now ⎿ usage-meter: ██████░░░░░░░░░░░░░░ 5h 31% used (69% left, resets in NaNm) ⎿ usage-meter: ⎿ usage-meter: Session $0.420 · Context (this window) 49% (97K / 200K) ⎿ usage-meter: Last task: 99K tok (out 1.5K, 0 req) · $0.000 · 5h +0.0% ⎿ usage-meter: Average task (last 1): ~99K tok, $0.000, 0.0% of 5h window 5h █████████░░░░░░░░░░░░░░░░░░░░░ 31% 69% left · resets in NaNm · as of 08:53:20 (CLI) Context ███████████████░░░░░░░░░░░░░░░ 49% 97K / 200K · this window Session $0.420 · Last task: 99K tok (out 1.5K, 0 req) · $0.000 · 5h +0.0% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h █████████░░░░░░░░░░░░░░░░░░░░░ 31% 69% left · resets in NaNm · as of 08:53:20 (CLI) Context ███████████████░░░░░░░░░░░░░░░ 49% 97K / 200K · this window Session $0.420 · Last task: 99K tok (out 1.5K, 0 req) · $0.000 · 5h +0.0%
README

usage-meter

A Claude Code mod that shows how much of your plan you have used, right above the prompt. It draws coloured bars for your plan's rate limits (the 5-hour and 7-day windows), the context-window fill and the running task. A footer line shows the session cost and the last task's token count. The /usage-meter command prints the same figures as text, plus averages over your recent tasks. It never calls a model, so watching your usage costs no tokens.

For everyday use, see the user manual. This README is the full reference.

What the band above the prompt looks like (colours not shown):

5h       ████████░░░░░░░░░░░░  42% 58% left · resets in 2h 13m · as of 14:05:31 (CLI)
7d       █░░░░░░░░░░░░░░░░░░░   5% 95% left · resets in 4d 6h
Context  ████░░░░░░░░░░░░░░░░  21% 42K / 200K · this window
Session $0.150 · Last task: 1.5K tok (out 500, 3 req) · $0.050 · 5h +2.0% · ~29 more such tasks fit in the 5h window

While a task is running, and at least one earlier task in this session has finished, a Task bar appears and the footer shrinks to the session cost:

Task     ███████░░░░░░░░░░░░░  35% 520 / ~1.5K tok est. (avg of 1)
Session $0.120

Requirements

  • A Claude Code build that supports function-hook plugins (mods). Version 2.1.289 or later was used to build and test it.
  • A Claude subscription plan, if you want the rate-limit bars. With API-key billing, Claude Code receives no plan limits, so the mod shows only context, cost and task figures.

Install

The repository root is the plugin. Clone it first:

git clone https://github.com/ewxgwy1987/claude-code-usage-meter.git

Then pick one way of loading it.

A. One session.

claude --plugin-dir /path/to/claude-code-usage-meter

B. Every session, including the desktop app and Remote Control sessions started on that machine. Add this to your user settings, ~/.claude/settings.json. Project settings are ignored for this setting.

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-code-usage-meter"
  }
}
  • The path must be absolute; ~ is allowed.
  • On Windows forward slashes work, for example C:/src/claude-code-usage-meter.
  • Separate several paths with ; on Windows and : elsewhere.
  • You can instead name a folder that holds several mod clones side by side. That loads each one.
  • Start a new session afterwards.

C. Plugin marketplace. The repository is also a marketplace:

/plugin marketplace add ewxgwy1987/claude-code-usage-meter
/plugin install usage-meter@claude-code-usage-meter

To check that it loaded, start a session and send a message. The band appears above the prompt after the first reply, and /usage-meter shows up in the command menu.

Usage

The /usage-meter command

CommandArgumentsWhat it does
/usage-meternoneRe-reads the figures and prints a text report.

The command runs at once, even while a turn is in progress. You don't have to wait for the turn to finish. The report contains:

  • Plan usage, with where and when the figures came from, and how old they are (just now, or for example 3m ago).
  • One line per rate-limit window: a 20-cell bar, percent used, percent left and the time until it resets.
  • Session cost and context fill for this window.
  • The task line: the running task, or the last finished one (see below).
  • Average task (last N): the mean tokens, cost and share of the 5-hour window per task.
  • A note that other windows on this computer share plan limits (see "How it works").

If no plan limits have been reported, the report says so. This happens with API-key billing, or before the first model reply. A prepaid credit balance is not available to Claude Code. Check your Console billing page for it.

Reading the band

RowMeaning
5h, 7dPlan rate-limit windows. Other windows the plan reports appear too: 7d Opus, 7d Sonnet, Spend (a gateway's spend limit), or the raw name. The first row also says as of HH:MM:SS (source).
PlanShown instead of the limit rows when no limits are reported.
ContextHow full this session's context window is, as tokens used / window size. It appears once a response has reported the fill.
TaskTokens used by the running task, compared with your average task. 100% means "as big as an average task". Shown only while a task runs and an average exists.
FooterSession cost (Session cost n/a if unknown) and the task line.

The source label in as of ... (source) is CLI for a terminal session and app for a desktop-app session. Other surfaces show their own name (for example mobile, vscode), and a session with no surface shows headless.

The task line has two forms:

  • Task running: <tokens> tok (out <output>, <n> req) · $<cost> · est. total ~<tokens> / $<cost> (avg of N)
  • Last task: <tokens> tok (out <output>, <n> req) · $<cost> · 5h +<x>% · ~<n> more such tasks fit in the 5h window

Before the first task you see Task: no task yet; estimates appear after the first one.

Colour thresholds

BarGreen / cyanYellowRed
Rate limits and contextbelow 70% (green)70% to under 90%90% and above
Task (vs. average)below 80% (cyan)80% to 100%above 100%

Bars are clamped to 0-100%, so a spend limit past 100% shows a full bar. The bar width follows the band's width, from 10 to 30 cells. If the band has fewer rows than the mod draws, the extra rows are cut.

Settings

There are none. The mod has no userConfig in its plugin.json, and no options.

Where it shows

SurfaceBand above the prompt/usage-meter
Terminal (CLI)yesyes
Claude Code desktop appyesyes
Mobile app, VS Codeno (Claude Code draws that band on terminal and desktop only)as text, where that surface runs commands (not tested there)

The band steps aside while a survey is using the same space.

How it works

Events it hooks

EventWhat the mod does
session.startRegisters /usage-meter, takes a first reading and starts a 60-second timer.
session.measureTakes the figures Claude Code measures after responses.
turn.startStarts a new task record, noting the cost and 5-hour usage at the start.
turn.stepAfter each model request, adds its tokens to the running task.
turn.completeCloses the task (main conversation only), works out its cost and 5-hour share, and adds it to the history.
command.runPrints the /usage-meter report.
ui.render (AbovePrompt)Draws the band.

Where each number comes from

  • Rate limits, context and session cost come from Claude Code's own session usage. These are the same figures the status line uses ($.session.usage() and the session.measure event). That call is free: it reads figures Claude Code already has.
  • Task tokens are the sum of input, output, cache-read and cache-creation tokens from every model request in the turn. Subagent requests made during the turn count too. If no request was seen, the turn's own total is used.
  • Task cost is the session cost at the end of the turn minus the cost at its start.
  • 5h +x% is the 5-hour figure at the end of the task minus the figure at its start. It is left out when the window reset during the task (the difference would be negative).
  • Averages use the last 20 finished tasks that used tokens. Interrupted tasks are not counted. "~N more such tasks fit" is the 5-hour percentage left divided by the average 5-hour share per task, rounded down.

What is stored, and where

DataKept inLifetime
Latest snapshot, current task, task history, last readingsSession state held by Claude CodeThis session. It survives a hot reload of the mod, but a new session starts empty.
Key limits: the newest rate-limit reading, with its time and sourceThe plugin store, a JSON file of the plugin's own in your Claude Code configuration directoryBetween sessions. Every Claude Code process on the machine that uses that directory shares it.

Each window publishes its own reading to limits only if it is newer than the stored one. Each window shows whichever reading is newest, its own or the stored one. So a reply in the desktop app updates the bars in an idle terminal session, and the other way round. That is why the first row says where and when the figures came from.

The timer, and why it costs no tokens

Every 60 seconds the mod re-reads the session usage and the stored limits key, then redraws. It never sends a request to a model. The test suite checks that the timer runs for more than 40 simulated minutes with no model call.

Limits and accuracy

  • Plan rate limits arrive only with model responses. Nothing asks the server for them. When every window is idle, the figures can be minutes or hours old. The as of time tells you how old.
  • Usage from other computers, or from claude.ai, shows up only after the next model reply on this computer.
  • A reading's time is when this window last saw the figures change. It is not refreshed when a reply reports the same figures.
  • Task cost and the 5-hour share are differences of session-wide totals. Usage in other windows during the task is included in the 5-hour share.
  • The averages start over in each new session.
  • Percentages come from Claude Code as reported, with at most one decimal.

Tokens and privacy

Tokens. usage-meter never calls a model. Not the timer, not the band, not the /usage-meter command. It only reads figures Claude Code already has, so it adds nothing to your usage.

What is stored. One key, limits, in Claude Code's plugin store on your machine. It holds the newest rate-limit reading: the percentages, reset times, when the reading was taken and the surface that took it (CLI, app and so on). Task history and the rest live only in session state and are gone when the session ends. The mod sends nothing anywhere.

Troubleshooting

SymptomCause and fix
No limit rows, Plan no rate limits reportedNo model reply yet in any window on this computer, or API-key billing. Send a message and wait for a reply.
No Context rowNo response has reported the context fill yet in this window. It appears after the first reply.
Task: no task yetNo task has finished in this session. The estimates start after the first one.
Figures look oldSee "Limits and accuracy". Any model reply on this computer refreshes them.
No band in the mobile app or VS CodeExpected. Use /usage-meter where commands are available.
Nothing appears at allCheck that the mod loads: run claude plugin validate /path/to/claude-code-usage-meter and fix what it reports. Check that the path in --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS points to the clone (or to a folder holding it) and is absolute. Remember the setting belongs in user settings, not project settings. Start a new session after changing it.
The mod appears twiceTwo paths load it, for example an old copy plus CLAUDE_CODE_PLUGIN_DIRS, or a clone plus a marketplace install. Keep one.
Band hidden for a momentA survey is using the band. It comes back when the survey closes. You can also collapse or expand the band yourself (ctrl+x ctrl+a, or the [-] mark).

Development

Folder layout:

PathContents
.claude-plugin/plugin.jsonName, version, description and the path to the mod's types.
.claude-plugin/marketplace.jsonMakes the repository a marketplace with one plugin, usage-meter.
hooks/hooks.jsonLists the hook module, register.tsx.
hooks/register.tsxAll the logic and the band's drawing.
types/index.d.tsThe mod's data types and its declared session state.
tests/usage-meter.test.tsTests run by claude plugin test.
tsconfig.jsonExtends ./.claude-plugin/types/tsconfig.json.

Commands, run from the repository root:

claude plugin validate .               # check the manifest, hooks and state names
claude plugin test .                   # run the tests
npx -p typescript tsc -p . --noEmit    # type-check

tsconfig.json extends ./.claude-plugin/types/tsconfig.json. Claude Code writes that file, along with the engine's type declarations, into .claude-plugin/types/ when it loads the plugin. So load the plugin once (for example with --plugin-dir) before you type-check. That folder is in .gitignore.

While a session loads the clone through --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS, saving a file reloads the mod straight away.

The tests cover these cases:

  • A finished task is recorded, and the next task shows an estimate.
  • An interrupted task is not counted.
  • Bars fill in proportion and clamp at both ends.
  • A newer reading from another window wins.
  • An older local reading never overwrites a newer stored one.
  • The timer shares new readings without calling a model.

See also

License

MIT © 2026 Rain Guo

Source 2 files
hooks/register.tsx 392 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register, RenderSurface, SessionMeasureInput } from 'claude-code'
3
4import type { Limit, Reading, Snapshot, Task, TaskRecord } from '../types'
5
6const snapshotAtom = atom({ plugin: 'usage-meter', key: 'snapshot' } as const, null)
7const taskAtom = atom({ plugin: 'usage-meter', key: 'task' } as const, null)
8const historyAtom = atom({ plugin: 'usage-meter', key: 'history' } as const, [])
9const localAtom = atom({ plugin: 'usage-meter', key: 'local' } as const, null)
10const shownAtom = atom({ plugin: 'usage-meter', key: 'shown' } as const, null)
11
12const HISTORY_SIZE = 20
13const STORE_KEY = 'limits'
14const TICK_MS = 60_000
15
16export const SHARING_NOTE =
17  'Plan limits update when any Claude Code window on this computer gets a model reply; usage from other computers shows up after your next message here.'
18
19let stopTick: (() => void) | null = null
20
21type Measured = Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'>
22
23const toSnapshot = (u: Measured): Snapshot => ({
24  contextTokens: u.context.tokens ?? null,
25  contextWindow: u.context.window,
26  contextPercent: u.context.percent ?? null,
27  limits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
28  usd: u.cost?.usd ?? null,
29})
30
31const limitsKey = (ls: Limit[]) =>
32  JSON.stringify([...ls].sort((a, b) => a.kind.localeCompare(b.kind)).map(l => [l.kind, l.percentUsed, l.resetsAt ?? null]))
33
34export const sameLimits = (a: Limit[], b: Limit[]) => limitsKey(a) === limitsKey(b)
35
36const isLimit = (v: unknown): v is Limit => {
37  if (typeof v !== 'object' || v === null) return false
38  const l = v as Record<string, unknown>
39  return typeof l.kind === 'string' && typeof l.percentUsed === 'number' && (l.resetsAt === undefined || typeof l.resetsAt === 'string')
40}
41
42export const parseReading = (v: unknown): Reading | null => {
43  if (typeof v !== 'object' || v === null) return null
44  const r = v as Record<string, unknown>
45  if (typeof r.at !== 'number' || typeof r.source !== 'string' || !Array.isArray(r.limits)) return null
46  const limits = r.limits.filter(isLimit)
47  if (limits.length === 0) return null
48  return { at: r.at, source: r.source, limits: limits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })) }
49}
50
51export const newest = (current: Reading | null, ...candidates: (Reading | null)[]) =>
52  candidates.reduce<Reading | null>((best, r) => (r && (!best || r.at > best.at) ? r : best), current)
53
54export const sourceOf = (surfaces: readonly RenderSurface[]) =>
55  surfaces[0] === 'terminal' ? 'CLI' : surfaces.includes('desktop') ? 'app' : (surfaces[0] ?? 'headless')
56
57const pad2 = (n: number) => `${n}`.padStart(2, '0')
58
59export const fmtClock = (ms: number) => {
60  const d = new Date(ms)
61  return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
62}
63
64const asOf = (r: Reading) => `as of ${fmtClock(r.at)} (${r.source})`
65
66const viewOf = (snap: Snapshot, shown: Reading | null): Snapshot => (shown ? { ...snap, limits: shown.limits } : snap)
67
68const totalTokens = (u: ModelUsage) =>
69  u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
70
71const fiveHour = (s: Snapshot | null) => s?.limits.find(l => l.kind === 'five_hour')?.percentUsed ?? null
72
73export const fmtTokens = (n: number) => {
74  if (n < 1000) return `${Math.round(n)}`
75  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}K`
76  return `${(n / 1_000_000).toFixed(2)}M`
77}
78
79const fmtUsd = (n: number) => `$${n < 1 ? n.toFixed(3) : n.toFixed(2)}`
80
81const fmtDuration = (ms: number) => {
82  const minutes = Math.max(0, Math.round(ms / 60_000))
83  const d = Math.floor(minutes / 1440)
84  const h = Math.floor((minutes % 1440) / 60)
85  const m = minutes % 60
86  if (d > 0) return `${d}d ${h}h`
87  if (h > 0) return `${h}h ${m}m`
88  return `${m}m`
89}
90
91const limitLabel = (kind: string) =>
92  ({ five_hour: '5h', seven_day: '7d', seven_day_opus: '7d Opus', seven_day_sonnet: '7d Sonnet', spend_limit: 'Spend' })[kind] ??
93  kind.replace(/_/g, ' ')
94
95const levelColor = (pct: number) => (pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green')
96
97export const bar = (pct: number, width: number) => {
98  const filled = Math.round((Math.min(100, Math.max(0, pct)) / 100) * width)
99  return { filled: '█'.repeat(filled), empty: '░'.repeat(width - filled) }
100}
101
102const barText = (pct: number, width: number) => {
103  const b = bar(pct, width)
104  return b.filled + b.empty
105}
106
107export type Estimate = { tasks: number; avgTokens: number; avgUsd: number | null; avgFiveHour: number | null }
108
109export const estimate = (history: TaskRecord[]): Estimate | null => {
110  const done = history.filter(h => h.tokens > 0)
111  if (done.length === 0) return null
112  const avg = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null)
113  return {
114    tasks: done.length,
115    avgTokens: avg(done.map(h => h.tokens)) ?? 0,
116    avgUsd: avg(done.flatMap(h => (h.usd === null ? [] : [h.usd]))),
117    avgFiveHour: avg(done.flatMap(h => (h.fiveHourDelta === null ? [] : [h.fiveHourDelta]))),
118  }
119}
120
121const limitLine = (l: Limit, now: number) => {
122  const left = Math.max(0, 100 - l.percentUsed)
123  const reset = l.resetsAt ? `, resets in ${fmtDuration(Date.parse(l.resetsAt) - now)}` : ''
124  return `${limitLabel(l.kind)} ${l.percentUsed}% used (${left.toFixed(0)}% left${reset})`
125}
126
127const tasksLeft = (snap: Snapshot | null, est: Estimate | null) => {
128  const used = fiveHour(snap)
129  if (used === null || !est?.avgFiveHour || est.avgFiveHour <= 0) return null
130  return Math.floor(Math.max(0, 100 - used) / est.avgFiveHour)
131}
132
133const taskLine = (task: Task | null, snap: Snapshot | null, est: Estimate | null) => {
134  if (!task) return 'Task: no task yet; estimates appear after the first one'
135  const liveUsd = task.isRunning
136    ? snap?.usd != null && task.startUsd != null
137      ? snap.usd - task.startUsd
138      : null
139    : task.usd
140  const parts = [
141    `${fmtTokens(task.tokens)} tok (out ${fmtTokens(task.output)}, ${task.requests} req)`,
142    ...(liveUsd != null ? [fmtUsd(liveUsd)] : []),
143  ]
144  if (task.isRunning) {
145    if (est) {
146      const projected = Math.max(task.tokens, est.avgTokens)
147      const usd = est.avgUsd != null ? ` / ${fmtUsd(Math.max(liveUsd ?? 0, est.avgUsd))}` : ''
148      parts.push(`est. total ~${fmtTokens(projected)}${usd} (avg of ${est.tasks})`)
149    }
150    return `Task running: ${parts.join(' · ')}`
151  }
152  if (task.fiveHourDelta != null) parts.push(`5h +${task.fiveHourDelta.toFixed(1)}%`)
153  const left = tasksLeft(snap, est)
154  if (left !== null) parts.push(`~${left} more such tasks fit in the 5h window`)
155  return `Last task: ${parts.join(' · ')}`
156}
157
158const sessionLine = (snap: Snapshot) => {
159  const ctx =
160    snap.contextTokens != null
161      ? `Context (this window) ${snap.contextPercent ?? 0}% (${fmtTokens(snap.contextTokens)} / ${fmtTokens(snap.contextWindow)})`
162      : `Context (this window): ${fmtTokens(snap.contextWindow)} window`
163  const cost = snap.usd != null ? `Session ${fmtUsd(snap.usd)}` : 'Session cost n/a'
164  return `${cost} · ${ctx}`
165}
166
167const sourceLabel = async ($: EngineInterface) => {
168  try {
169    return sourceOf(await $.session.surfaces())
170  } catch {
171    return sourceOf([])
172  }
173}
174
175const readStored = async ($: EngineInterface) => {
176  try {
177    return parseReading(await $.store.get(STORE_KEY))
178  } catch {
179    return null
180  }
181}
182
183const publish = async ($: EngineInterface, reading: Reading) => {
184  try {
185    const stored = await readStored($)
186    if (stored && stored.at >= reading.at) return
187    await $.store.set(STORE_KEY, reading)
188  } catch {}
189}
190
191const show = ($: EngineInterface, ...candidates: (Reading | null)[]) =>
192  update($, shownAtom, current => newest(current ?? null, ...candidates))
193
194const observe = async ($: EngineInterface, limits: Limit[], isFresh: boolean) => {
195  if (limits.length === 0) return
196  const prev = await read($, localAtom)
197  if (!isFresh && prev && sameLimits(prev.limits, limits)) return
198  const reading: Reading = { at: await $.clock.now(), limits, source: await sourceLabel($) }
199  await update($, localAtom, () => reading)
200  await publish($, reading)
201  await show($, reading)
202}
203
204const refresh = async ($: EngineInterface) => {
205  const snap = toSnapshot(await $.session.usage())
206  await update($, snapshotAtom, () => snap)
207  await observe($, snap.limits, false)
208  await show($, await readStored($))
209  return snap
210}
211
212export const register: Register = on => {
213  on('session.start', async ($, e, next) => {
214    await $.command.register({
215      name: 'usage-meter',
216      description: 'Plan usage, remaining limits, session cost and task token estimates',
217      immediate: true,
218    })
219    await refresh($)
220    stopTick?.()
221    const timer = $.clock.every(TICK_MS, () => {
222      refresh($).catch(() => {})
223    })
224    stopTick = () => timer.cancel()
225    return next(e)
226  })
227
228  on('session.measure', async ($, e, next) => {
229    const snap = toSnapshot(e)
230    await update($, snapshotAtom, () => snap)
231    await observe($, snap.limits, e.changed.includes('rateLimits') || e.changed.includes('context'))
232    return next(e)
233  })
234
235  on('turn.start', async ($, e, next) => {
236    const usage = await $.session.usage()
237    const snap = toSnapshot(usage)
238    await update($, snapshotAtom, () => snap)
239    const task: Task = {
240      startedAt: await $.clock.now(),
241      startUsd: snap.usd,
242      startFiveHour: fiveHour(snap),
243      tokens: 0,
244      output: 0,
245      requests: 0,
246      isRunning: true,
247      usd: null,
248      fiveHourDelta: null,
249      durationMs: 0,
250    }
251    await update($, taskAtom, () => task)
252    return next(e)
253  })
254
255  on('turn.step', async function* ($, e, next) {
256    const result = yield* next(e)
257    const usage = result.usage
258    if (usage) {
259      await update($, taskAtom, t =>
260        t?.isRunning
261          ? { ...t, tokens: t.tokens + totalTokens(usage), output: t.output + usage.output_tokens, requests: t.requests + 1 }
262          : t,
263      )
264    }
265    return result
266  })
267
268  on('turn.complete', async ($, e, next) => {
269    if (e.agentId) return next(e)
270    const snap = toSnapshot(await $.session.usage())
271    await update($, snapshotAtom, () => snap)
272    await observe($, snap.limits, false)
273    const now = await $.clock.now()
274    const finished = await update($, taskAtom, t => {
275      if (!t?.isRunning) return t
276      const tokens = t.tokens === 0 && e.usage ? totalTokens(e.usage) : t.tokens
277      const output = t.output === 0 && e.usage ? e.usage.output_tokens : t.output
278      const endFiveHour = fiveHour(snap)
279      const delta = endFiveHour !== null && t.startFiveHour !== null ? endFiveHour - t.startFiveHour : null
280      return {
281        ...t,
282        tokens,
283        output,
284        isRunning: false,
285        usd: snap.usd != null && t.startUsd != null ? snap.usd - t.startUsd : null,
286        fiveHourDelta: delta !== null && delta >= 0 ? delta : null,
287        durationMs: now - t.startedAt,
288      }
289    })
290    if (finished && !finished.isRunning && finished.tokens > 0 && !e.isAborted) {
291      const record: TaskRecord = { tokens: finished.tokens, usd: finished.usd, fiveHourDelta: finished.fiveHourDelta }
292      await update($, historyAtom, h => [...h, record].slice(-HISTORY_SIZE))
293    }
294    return next(e)
295  })
296
297  on('command.run', { command: 'usage-meter' }, async $ => {
298    const snap = await refresh($)
299    const shown = await read($, shownAtom)
300    const view = viewOf(snap, shown)
301    const task = await read($, taskAtom)
302    const history = await read($, historyAtom)
303    const est = estimate(history)
304    const now = await $.clock.now()
305    const age = shown ? (now - shown.at < 60_000 ? 'just now' : `${fmtDuration(now - shown.at)} ago`) : ''
306    const lines = [shown ? `Plan usage · ${asOf(shown)}, ${age}` : 'Plan usage']
307    if (view.limits.length === 0) {
308      lines.push('  No plan rate limits reported (API-key billing, or no response yet).')
309      lines.push('  A prepaid credit balance is not exposed to Claude Code; check the Console billing page.')
310    } else {
311      for (const l of view.limits) lines.push(`  ${barText(l.percentUsed, 20)} ${limitLine(l, now)}`)
312    }
313    lines.push('', sessionLine(snap), taskLine(task, view, est))
314    if (est) {
315      const usd = est.avgUsd != null ? `, ${fmtUsd(est.avgUsd)}` : ''
316      const pct = est.avgFiveHour != null ? `, ${est.avgFiveHour.toFixed(1)}% of 5h window` : ''
317      lines.push(`Average task (last ${est.tasks}): ~${fmtTokens(est.avgTokens)} tok${usd}${pct}`)
318    }
319    lines.push('', SHARING_NOTE)
320    return { text: lines.join('\n') }
321  })
322
323  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
324    const snap = await read($, snapshotAtom)
325    if (e.props.hasSurvey || !snap) return next(e)
326
327    const shown = await read($, shownAtom)
328    const view = viewOf(snap, shown)
329    const task = await read($, taskAtom)
330    const est = estimate(await read($, historyAtom))
331    const { Box, Text } = $.ui.resolve(e)
332    const now = await $.clock.now()
333
334    const width = Math.min(30, Math.max(10, e.props.bodyColumns - 50))
335
336    const meter = (key: string, label: string, pct: number, color: string, detail: string) => {
337      const b = bar(pct, width)
338      return (
339        <Box key={key}>
340          <Text dimColor>{label.padEnd(9)}</Text>
341          <Text color={color}>{b.filled}</Text>
342          <Text dimColor>{b.empty}</Text>
343          <Text color={color} bold>
344            {` ${Math.round(pct)}%`.padStart(5)}
345          </Text>
346          <Text dimColor> {detail}</Text>
347        </Box>
348      )
349    }
350
351    const rows = view.limits.length
352      ? view.limits.map((l, i) => {
353          const left = Math.max(0, 100 - l.percentUsed).toFixed(0)
354          const reset = l.resetsAt ? ` · resets in ${fmtDuration(Date.parse(l.resetsAt) - now)}` : ''
355          const note = i === 0 && shown ? ` · ${asOf(shown)}` : ''
356          return meter(`limit-${l.kind}`, limitLabel(l.kind), l.percentUsed, levelColor(l.percentUsed), `${left}% left${reset}${note}`)
357        })
358      : [
359          <Box key="plan">
360            <Text dimColor>{'Plan'.padEnd(9)}no rate limits reported (API billing; credit balance not exposed)</Text>
361          </Box>,
362        ]
363
364    if (snap.contextTokens != null) {
365      const pct = snap.contextPercent ?? 0
366      rows.push(meter('context', 'Context', pct, levelColor(pct), `${fmtTokens(snap.contextTokens)} / ${fmtTokens(snap.contextWindow)} · this window`))
367    }
368
369    const cost = snap.usd != null ? `Session ${fmtUsd(snap.usd)}` : 'Session cost n/a'
370    if (task?.isRunning && est) {
371      const ratio = (task.tokens / est.avgTokens) * 100
372      const color = ratio > 100 ? 'red' : ratio >= 80 ? 'yellow' : 'cyan'
373      rows.push(meter('task', 'Task', ratio, color, `${fmtTokens(task.tokens)} / ~${fmtTokens(est.avgTokens)} tok est. (avg of ${est.tasks})`))
374      rows.push(
375        <Box key="footer">
376          <Text dimColor>{cost}</Text>
377        </Box>,
378      )
379    } else {
380      rows.push(
381        <Box key="footer">
382          <Text dimColor={!task?.isRunning} color={task?.isRunning ? 'cyan' : undefined}>
383            {`${cost} · ${taskLine(task, view, est)}`}
384          </Text>
385        </Box>,
386      )
387    }
388
389    return <Box flexDirection="column">{rows.slice(0, Math.max(1, e.props.maxRows))}</Box>
390  })
391}
392
types/index.d.ts 39 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Snapshot = {
4  contextTokens: number | null
5  contextWindow: number
6  contextPercent: number | null
7  limits: Limit[]
8  usd: number | null
9}
10
11export type Task = {
12  startedAt: number
13  startUsd: number | null
14  startFiveHour: number | null
15  tokens: number
16  output: number
17  requests: number
18  isRunning: boolean
19  usd: number | null
20  fiveHourDelta: number | null
21  durationMs: number
22}
23
24export type Reading = { at: number; limits: Limit[]; source: string }
25
26export type TaskRecord = { tokens: number; usd: number | null; fiveHourDelta: number | null }
27
28declare module 'claude-code' {
29  interface PluginState {
30    'usage-meter': {
31      snapshot: Snapshot | null
32      task: Task | null
33      history: TaskRecord[]
34      local: Reading | null
35      shown: Reading | null
36    }
37  }
38}
39