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

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
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"
}
}
~ is allowed.C:/src/claude-code-usage-meter.; on Windows and : elsewhere.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-meter command| Command | Arguments | What it does |
|---|---|---|
/usage-meter | none | Re-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:
just now, or for example 3m ago).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.
| Row | Meaning |
|---|---|
5h, 7d | Plan 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). |
Plan | Shown instead of the limit rows when no limits are reported. |
Context | How full this session's context window is, as tokens used / window size. It appears once a response has reported the fill. |
Task | Tokens 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. |
| Footer | Session 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 windowBefore the first task you see Task: no task yet; estimates appear after the first one.
| Bar | Green / cyan | Yellow | Red |
|---|---|---|---|
| Rate limits and context | below 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.
There are none. The mod has no userConfig in its plugin.json, and no options.
| Surface | Band above the prompt | /usage-meter |
|---|---|---|
| Terminal (CLI) | yes | yes |
| Claude Code desktop app | yes | yes |
| Mobile app, VS Code | no (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.
| Event | What the mod does |
|---|---|
session.start | Registers /usage-meter, takes a first reading and starts a 60-second timer. |
session.measure | Takes the figures Claude Code measures after responses. |
turn.start | Starts a new task record, noting the cost and 5-hour usage at the start. |
turn.step | After each model request, adds its tokens to the running task. |
turn.complete | Closes the task (main conversation only), works out its cost and 5-hour share, and adds it to the history. |
command.run | Prints the /usage-meter report. |
ui.render (AbovePrompt) | Draws the band. |
$.session.usage() and the session.measure event). That call is free: it reads figures Claude Code already has.| Data | Kept in | Lifetime |
|---|---|---|
| Latest snapshot, current task, task history, last readings | Session state held by Claude Code | This 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 source | The plugin store, a JSON file of the plugin's own in your Claude Code configuration directory | Between 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.
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.
as of time tells you how old.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.
| Symptom | Cause and fix |
|---|---|
No limit rows, Plan no rate limits reported | No model reply yet in any window on this computer, or API-key billing. Send a message and wait for a reply. |
No Context row | No response has reported the context fill yet in this window. It appears after the first reply. |
Task: no task yet | No task has finished in this session. The estimates start after the first one. |
| Figures look old | See "Limits and accuracy". Any model reply on this computer refreshes them. |
| No band in the mobile app or VS Code | Expected. Use /usage-meter where commands are available. |
| Nothing appears at all | Check 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 twice | Two 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 moment | A 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). |
Folder layout:
| Path | Contents |
|---|---|
.claude-plugin/plugin.json | Name, version, description and the path to the mod's types. |
.claude-plugin/marketplace.json | Makes the repository a marketplace with one plugin, usage-meter. |
hooks/hooks.json | Lists the hook module, register.tsx. |
hooks/register.tsx | All the logic and the band's drawing. |
types/index.d.ts | The mod's data types and its declared session state. |
tests/usage-meter.test.ts | Tests run by claude plugin test. |
tsconfig.json | Extends ./.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:
MIT © 2026 Rain Guo
hooks/register.tsx 392 lines1import { 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}
392types/index.d.ts 39 lines1export 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