SLOPSHOPPER

usage-meter-mod

A band above the message input that shows, at a glance, how close you are to your usage limits and how full the context is. It is a mod for Claude Code and the…

newbandnetworktimer
v0.1.5MITupdated 2026-10-14wendyyyanto/usage-meter-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter-mod
› 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 Model Opus 5.5 | Effort default [ Hide ▾ ] Context ▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ 49% ☀️ Sunny 97k / 200k 5-hour ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 31% ☀️ Sunny 7-day – Cost $0.42 ≈ 1.7 gumballs 🍬 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Model Opus 5.5 | Effort default [ Hide ▾ ] Context ▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ 49% ☀️ Sunny 97k / 200k 5-hour ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 31% ☀️ Sunny 7-day – Cost $0.42 ≈ 1.7 gumballs 🍬
README

usage-meter-mod

A band above the message input that shows, at a glance, how close you are to your usage limits and how full the context is. It is a mod for Claude Code and the Claude desktop app's Code tab.

<img width="764" height="115" alt="image" src="https://github.com/user-attachments/assets/84530bfc-21aa-445a-b4d0-7846641e999d" />

What it shows

  • Model and effort of the current session.
  • Context used against the model's window, with a progress bar and percent.
  • 5-hour and 7-day limits with a progress bar, percent and reset countdown.
  • Weather next to each bar, on the same four zones as the bar colour: ☀️ Sunny under 50%, ⛅ Cloudy 50-74%, 🌧️ Rainy 75-89%, ⛈️ Stormy 90% and over.
  • Session cost and a rough equivalent (a latte, a Big Mac, a burrito...).

Click [Hide ▾] to collapse it to one line: Sonnet 5.5 | high | 7d 78% 🌧️ | ctx 42% ☀️ [Details ▸]. It starts expanded in every session.

Install

In a terminal session of Claude Code, add the marketplace, then install the mod:

/plugin marketplace add wendyyyanto/usage-meter-mod
/plugin install usage-meter-mod@yanto-mods

Pick the user scope. From then on it loads in every session you start or resume, including the desktop app's Code tab. Sessions that are already open pick it up when restarted or resumed.

The /plugin command is a terminal command; the desktop app's Code tab says it isn't available there. Install from a terminal once and the desktop app loads it too.

To remove it:

claude plugin uninstall usage-meter-mod@yanto-mods

What it costs you

  • No model tokens. The mod never asks Claude anything and adds nothing to your prompt or your conversation. It only reads numbers the engine already has and draws them.
  • One small network call. When a session starts, when a conversation starts, each time a conversation finishes (the main one or a subagent's), and every 5 seconds while the session sits idle, it asks https://api.anthropic.com/api/oauth/usage for your account's plan usage, the same figures the usage pane in the app shows. While a turn is running the clock stays out of it: its start and its finishes do the asking. All asks go through one guard: one request at a time, never more than one every 5 seconds (a burst of finishes ends with one request when the gap is over), and if Anthropic answers HTTP 429 (too many requests) it stops asking until the time given in Retry-After (or 5 minutes) has passed. This is a plain metadata request, not a model request. The engine attaches your login to it itself, so the mod never sees your token, and the call can only go to Anthropic's own host.

Limitations

Where it works

  • Terminal and desktop app only. The band uses the slot above the prompt, which only those two surfaces draw. It does not appear in VS Code or on mobile.
  • Not tested everywhere. It was built mainly against the desktop app's Code tab. It has not been tested on API-key accounts, VS Code or mobile, and other surfaces may draw the emoji or box widths a little differently.
  • Installed per machine. User-scope install applies to your own sessions on that computer. Sessions that are already open pick it up when restarted or resumed.

Data

  • Limits need a subscription. The 5-hour and 7-day figures are plan limits. On an API key or a third-party provider those rows show –, with no bar.
  • The live source is not a documented API. Anthropic's plan-usage endpoint could change or disappear. If the call fails, the band falls back to the figures from the session's last reply.
  • The band does not say where its limits came from, or why a refresh failed. There is no status line: if the live call fails (no subscription login, a 429, a network error), the band quietly keeps showing the last good figures, or the session's own figures if it never had any. A 429 pauses that session's requests until the time Anthropic gives (or 5 minutes), then it tries again.
  • When it updates. On a new session the band appears at once with starting figures (see below). While a session is idle (open, with no conversation running) it refreshes every 5 seconds. While a turn is running it holds its figures, and refreshes when the conversation starts and when any conversation, main or subagent, finishes. A reply (not a finished conversation) updates only context and cost. Reset countdowns such as resets in 2h 43m are worked out whenever the pane redraws, so while a turn is running they stand still until the next refresh.
  • A new session starts with default figures. Context at 0 of the model's window, the model it will use, the effort from your effortLevel setting (or default if you have none, and corrected after the first request), and the limits as soon as the first live reading arrives, a moment after the session starts.
  • A fresh live reading outranks the session's own figures. For 5 minutes after a successful fetch, the limits a reply reports do not replace it.
  • Idle sessions ask often, so a 429 is likely. Each idle session asks every 5 seconds (12 requests a minute; a random head start keeps sessions that began together apart), on top of the asks at conversation starts and finishes. The guard keeps each session within its own limits, but sessions do not coordinate with each other, so several idle sessions together can easily exceed what Anthropic allows. When it answers 429, the affected sessions pause until the time it gives (or 5 minutes), and their limits stand still meanwhile. If you see that, lengthen IDLE_EVERY in hooks/register.tsx.
  • A window that has reset is never shown with its old number. Past its reset time a row reads window reset, updating… until fresh figures arrive.
  • If your organisation blocks non-essential traffic, the live call is refused and only the fallback figures are used.
  • Effort is a best guess until the first request. A mod can only read the effort a session really uses when a request is sent. Until then the band shows your effortLevel setting (or default), which can differ if you changed effort in the app for that session. A model with no effort setting shows n/a after the first request.
  • Cost equivalents are rough. They use a small hard-coded list of US prices in USD, and the weather icons are only a metaphor for how full each limit is. Edit THINGS in hooks/format.ts for your own prices.

Display

  • It steps aside for surveys, and the engine's own [-] mark can hide the whole band.
  • Very narrow panes clip the line instead of wrapping it.
  • Emoji and bar glyphs depend on your font. If one renders as a flat symbol or a box, that is the font.
  • Colours. Titles use Claude's clay orange, effort Claude's blue, and the bars green, yellow, orange and red by zone.

Develop

The mod is one hooks module (hooks/register.tsx) plus pure helpers (hooks/format.ts).

claude plugin validate .
claude plugin test .

If you installed it from a local folder, edit the files and run /reload-plugins.

License

MIT

Source 3 files
hooks/register.tsx 426 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { UsageMeterSnap } from "../types";
5import {
6	COLORS,
7	cells,
8	costEquiv,
9	countdown,
10	effortView,
11	longKind,
12	modelName,
13	PAUSE,
14	parseUsage,
15	retryDelay,
16	shortKind,
17	tokens,
18	weather,
19	zone
20} from "./format";
21
22const EMPTY: UsageMeterSnap = { rateLimits: [] };
23const snapRef = atom(
24	{ plugin: "usage-meter-mod", key: "snap" } as const,
25	EMPTY
26);
27// starts expanded; the person's toggle holds for the rest of the session
28const openRef = atom(
29	{ plugin: "usage-meter-mod", key: "isOpen" } as const,
30	true
31);
32
33const patch = ($: EngineInterface, p: Partial<UsageMeterSnap>) =>
34	update($, snapRef, (s) => ({ ...(s ?? EMPTY), ...p }));
35
36// a fresh session has a window but no tokens yet: that is 0, not "unknown"
37const percentOf = (c: UsageMeterSnap["context"]) =>
38	c?.percent ?? (c?.window ? ((c.tokens ?? 0) / c.window) * 100 : undefined);
39
40const TITLE = 9; // cells every row title takes, so bars and numbers start in one column
41const BAR = 20; // cells in a bar: 5% a cell
42
43// A session only learns its limits from its own replies, so they go stale while it idles and miss
44// what other sessions spend. The account's plan usage is what the real usage pane reads; the engine
45// holds the credential and attaches it (the mod never sees it), and only for Anthropic's own host.
46// It is asked for when a session starts, when a conversation starts, each time any turn finishes (main
47// or subagent), and once a minute while the session sits idle (no turn running). A running turn is
48// never on the clock: its start and its finishes ask instead.
49// Every ask goes through one guard (one request at a time, a minimum gap, a pause after a 429 or a failure).
50const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
51const LIVE_FRESH = 300_000; // a live reading this young outranks the session's own figures
52const IDLE_EVERY = 5_000; // an idle session redraws this often, so countdowns keep counting
53const IDLE_FETCH_EVERY = 60_000; // and re-reads the limits this often
54const IDLE_JITTER = 5_000; // sessions that start together do not all tick together
55
56// a reload starts the module afresh, so all of these start over
57let isFetching = false; // one request at a time
58let isActive = false; // a main-loop turn is running
59let isTrailing = false; // a skipped ask is already waiting for the gap to end
60let lastFetch = 0;
61let pausedUntil = 0; // after a 429 or a failure, no request before this
62let failures = 0; // failed reads in a row: each one doubles the pause
63let hasPlan: boolean | undefined; // false: no subscription login, so idle ticks do not ask
64
65// 10s, 20s, 40s … up to 5 minutes; a success starts over
66const backOff = (now: number) => {
67	failures++;
68	pausedUntil = now + Math.min(PAUSE.default, PAUSE.min * 2 ** failures);
69};
70
71// Any failure leaves the band as it is: the last good figures, or the session's own.
72const refreshLimits = async ($: EngineInterface) => {
73	if (isFetching) return;
74	isFetching = true;
75	let now = 0;
76	try {
77		now = await $.clock.now();
78		if (now < pausedUntil) return;
79		const wait = PAUSE.min - (now - lastFetch);
80		if (wait > 0) {
81			// too soon after the last request: ask once the gap is over, so a burst of finished turns
82			// still ends on fresh figures
83			if (!isTrailing) {
84				isTrailing = true;
85				$.clock.after(wait, () => {
86					isTrailing = false;
87					refreshLimits($).catch(() => {});
88				});
89			}
90			return;
91		}
92		lastFetch = now;
93		const auth = await $.session.authorize();
94		hasPlan = auth?.kind === "bearer";
95		if (!hasPlan) return; // no subscription login: there are no plan limits
96		const r = await $.http.fetch(USAGE_URL, {
97			auth: auth.handle,
98			headers: { "anthropic-beta": "oauth-2025-04-20" }
99		});
100		if (r.status === 429) {
101			pausedUntil = now + retryDelay(r.headers["retry-after"], now);
102			return;
103		}
104		if (!r.ok) return backOff(now);
105		const limits = parseUsage(JSON.parse(r.text));
106		failures = 0;
107		if (limits.length)
108			await patch($, { rateLimits: limits, rateAt: now, rateSource: "live" });
109	} catch {
110		// keep what the band already shows
111		if (now) backOff(now);
112	} finally {
113		isFetching = false;
114	}
115};
116
117// while idle (no turn running): redraw, so the countdowns keep counting, and re-read the limits once a
118// minute; a session with no subscription login leaves that to its turns, which check the login again
119const idleTick = async ($: EngineInterface) => {
120	if (isActive) return;
121	const now = await $.clock.now();
122	if (hasPlan !== false && now - lastFetch >= IDLE_FETCH_EVERY)
123		await refreshLimits($);
124	await patch($, { tickAt: now });
125};
126
127export const register: Register = (on) => {
128	// A new session shows the band at once, with its starting figures: the context window at 0, the
129	// model it will use, the effort it is set to (before a request says), and the limits as the
130	// session reports them until the live ones arrive a moment later. Nothing already known is wiped,
131	// so a hot reload keeps what the session has learned.
132	on("session.start", async ($, e, next) => {
133		const [u, model, settings] = await Promise.all([
134			$.session.usage(),
135			$.session.model(),
136			$.settings.read().catch(() => ({}) as Record<string, unknown>) // the effort default is a nicety: never lose the seed over it
137		]);
138		const level =
139			typeof settings.effortLevel === "string"
140				? settings.effortLevel
141				: undefined;
142		await update($, snapRef, (prev) => {
143			const s = prev ?? EMPTY;
144			return {
145				...s,
146				model,
147				effort: s.effort === undefined ? level : s.effort,
148				context: u.context,
149				rateLimits: s.rateLimits.length ? s.rateLimits : u.rateLimits,
150				costUsd: u.cost?.usd
151			};
152		});
153		refreshLimits($).catch(() => {});
154		// and the idle clock; a random head start keeps sessions that began together apart
155		$.clock.after(Math.floor(Math.random() * IDLE_JITTER), () => {
156			$.clock.every(IDLE_EVERY, () => {
157				idleTick($).catch(() => {});
158			});
159		});
160		return next(e);
161	});
162
163	// a conversation starts: the session is busy from here, and the limits are read once now
164	on("turn.start", async ($, e, next) => {
165		isActive = true;
166		refreshLimits($).catch(() => {});
167		return next(e);
168	});
169
170	// "a conversation finished": the main loop or any subagent ended a turn
171	on("turn.complete", async ($, e, next) => {
172		if (e.agentId === undefined) isActive = false;
173		refreshLimits($).catch(() => {});
174		return next(e);
175	});
176
177	on("session.measure", async ($, e, next) => {
178		const now = await $.clock.now();
179		await update($, snapRef, (prev) => {
180			const s = prev ?? EMPTY;
181			// the reply's own limits only stand in where there is no recent live reading, and a reply
182			// with none (no limits yet, or no subscription) never wipes what was fetched
183			const isLiveFresh =
184				s.rateSource === "live" &&
185				s.rateAt !== undefined &&
186				now - s.rateAt < LIVE_FRESH;
187			const limits =
188				e.rateLimits.length && !isLiveFresh
189					? {
190							rateLimits: e.rateLimits,
191							rateAt: now,
192							rateSource: "session" as const
193						}
194					: {};
195			return {
196				...s,
197				context: e.context,
198				costUsd: e.cost?.usd,
199				...limits
200			};
201		});
202		return next(e);
203	});
204
205	// the only place a mod sees effort; subagent loops carry an agentId and are skipped
206	on("turn.step", async function* ($, e, next) {
207		if (e.agentId === undefined)
208			await patch($, { model: e.model, effort: e.effort ?? null });
209		return yield* next(e);
210	});
211
212	on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
213		if (e.props.hasSurvey) return next(e);
214
215		const s = await read($, snapRef);
216		const isOpen = await read($, openRef);
217		const now = await $.clock.now();
218		const { Box, Button, Text } = $.ui.resolve(e);
219
220		const toggle = (
221			<Button
222				key="toggle"
223				label={isOpen ? "Hide ▾" : "Details ▸"}
224				onPress={() => update($, openRef, (v) => !v)}
225			/>
226		);
227		const ctxPct = percentOf(s.context);
228		// a window past its reset still holds the figure of the window that ended: never show it; a reset
229		// time that cannot be read says nothing about that, so the window counts as current
230		const isCurrent = (r: UsageMeterSnap["rateLimits"][number]) => {
231			const t = Date.parse(r.resetsAt ?? "");
232			return Number.isNaN(t) || t > now;
233		};
234		const worst = s.rateLimits
235			.filter(isCurrent)
236			.reduce<
237				UsageMeterSnap["rateLimits"][number] | undefined
238			>((a, b) => (a === undefined || b.percentUsed > a.percentUsed ? b : a), undefined);
239		const eff =
240			s.effort === undefined || s.effort === null
241				? undefined
242				: effortView(s.effort);
243
244		const val = (str: string, color?: string, isBold = false) => (
245			<Text color={color} bold={isBold}>
246				{str}
247			</Text>
248		);
249		const muted = (str: string) => val(str, COLORS.muted);
250
251		if (!isOpen) {
252			return (
253				<Box>
254					<Text wrap="truncate-end">
255						<Text bold>{s.model ? modelName(s.model) : "–"}</Text>
256						{s.effort === null ? null : (
257							<Text color={COLORS.muted}>{" | "}</Text>
258						)}
259						{s.effort === null ? null : (
260							<Text color={COLORS.effort}>
261								{eff ? eff.label : "default"}
262							</Text>
263						)}
264						<Text color={COLORS.muted}>{" | "}</Text>
265						<Text
266							color={
267								worst ? zone(worst.percentUsed) : COLORS.muted
268							}
269						>
270							{worst
271								? `${shortKind(worst.kind)} ${Math.round(worst.percentUsed)}% ${weather(worst.percentUsed).icon}`
272								: "5h –"}
273						</Text>
274						<Text color={COLORS.muted}>{" | "}</Text>
275						<Text
276							color={
277								ctxPct === undefined
278									? COLORS.muted
279									: zone(ctxPct)
280							}
281						>
282							{ctxPct === undefined
283								? "ctx –"
284								: `ctx ${Math.round(ctxPct)}% ${weather(ctxPct).icon}`}
285						</Text>
286					</Text>
287					<Box marginLeft={2}>{toggle}</Box>
288				</Box>
289			);
290		}
291
292		// Alignment never leans on runs of spaces or glyph widths (a web-style surface can collapse or
293		// widen them): titles, bars and numbers are boxes of exact cell widths, laid out by the surface.
294		const title = (str: string) => (
295			<Box width={TITLE} flexShrink={0}>
296				<Text color={COLORS.title} bold>
297					{str}
298				</Text>
299			</Box>
300		);
301		const sep = (
302			<Box marginX={1} flexShrink={0}>
303				{muted("|")}
304			</Box>
305		);
306		// one pill per cell, each in a box one cell wide: the bar is exactly BAR cells whatever the font
307		const barView = (pct: number) => {
308			const filled = cells(pct, BAR);
309			return (
310				<Box width={BAR} flexShrink={0}>
311					{Array.from({ length: BAR }, (_, i) => (
312						<Box
313							key={i}
314							width={1}
315							flexShrink={0}
316							justifyContent="center"
317						>
318							<Text color={i < filled ? zone(pct) : COLORS.empty}>
319								{i < filled ? "▰" : "▱"}
320							</Text>
321						</Box>
322					))}
323				</Box>
324			);
325		};
326		// a bar only when the figure exists; otherwise the title keeps a placeholder
327		const meter = (
328			name: string,
329			pct: number | undefined,
330			extra: unknown,
331			missing = "–"
332		) => (
333			<Box key={name}>
334				{title(name)}
335				{pct === undefined ? (
336					muted(missing)
337				) : (
338					<Box>
339						{barView(pct)}
340						<Box width={4} marginLeft={1} flexShrink={0}>
341							{val(`${Math.round(pct)}%`, zone(pct), true)}
342						</Box>
343						<Box width={10} marginLeft={2} flexShrink={0}>
344							<Box width={2} flexShrink={0}>
345								<Text>{weather(pct).icon}</Text>
346							</Box>
347							<Box marginLeft={1}>
348								{val(weather(pct).word, zone(pct))}
349							</Box>
350						</Box>
351						<Box marginLeft={2}>{extra}</Box>
352					</Box>
353				)}
354			</Box>
355		);
356
357		const win = s.context?.window;
358		const ctxExtra = win
359			? `${tokens(s.context?.tokens ?? 0)} / ${tokens(win)}`
360			: "";
361		const kinds = [
362			"five_hour",
363			"seven_day",
364			...s.rateLimits
365				.map((r) => r.kind)
366				.filter((k) => k !== "five_hour" && k !== "seven_day")
367		];
368		const equiv = s.costUsd === undefined ? "" : costEquiv(s.costUsd);
369		const paneWidth = Math.max(40, e.props.bodyColumns); // the rows that pin something to the right edge span the pane
370
371		return (
372			<Box flexDirection="column">
373				<Box width={paneWidth} justifyContent="space-between">
374					<Box>
375						{title("Model")}
376						{s.model
377							? val(modelName(s.model), undefined, true)
378							: muted("–")}
379						{sep}
380						<Box marginRight={1}>
381							<Text color={COLORS.title} bold>
382								Effort
383							</Text>
384						</Box>
385						{s.effort === null ? (
386							muted("n/a")
387						) : eff === undefined ? (
388							muted("default")
389						) : (
390							<Box>
391								{eff.dots ? val(eff.dots, COLORS.effort) : null}
392								<Box marginLeft={eff.dots ? 1 : 0}>
393									{val(eff.label, COLORS.effort)}
394								</Box>
395							</Box>
396						)}
397					</Box>
398					{toggle}
399				</Box>
400				{meter("Context", ctxPct, val(ctxExtra))}
401				{kinds.map((k) => {
402					const r = s.rateLimits.find((x) => x.kind === k);
403					const hasEnded = r !== undefined && !isCurrent(r);
404					const resets =
405						r && !hasEnded ? countdown(r.resetsAt, now) : "";
406					return meter(
407						longKind(k),
408						hasEnded ? undefined : r?.percentUsed,
409						muted(resets ? `resets in ${resets}` : ""),
410						hasEnded ? "window reset, updating…" : "–"
411					);
412				})}
413				<Box key="Cost">
414					{title("Cost")}
415					{s.costUsd === undefined
416						? muted("–")
417						: val(`$${s.costUsd.toFixed(2)}`, undefined, true)}
418					{equiv ? (
419						<Box marginLeft={2}>{val(equiv, COLORS.effort)}</Box>
420					) : null}
421				</Box>
422			</Box>
423		);
424	});
425};
426
hooks/format.ts 155 lines
1// Claude's palette: clay orange for titles, blue for effort and notes, its warm grey for separators
2export const COLORS = {
3	title: "#D97757",
4	effort: "#6A9BCC",
5	muted: "#B0AEA5",
6	empty: "#6B6A64",
7	green: "#8BA870",
8	yellow: "#E5C158",
9	orange: "#EE8C3A",
10	red: "#D4574E"
11};
12
13export const zone = (p: number) =>
14	p >= 90
15		? COLORS.red
16		: p >= 75
17			? COLORS.orange
18			: p >= 50
19				? COLORS.yellow
20				: COLORS.green;
21
22// the same four zones as weather: how close the limit is, at a glance
23export const weather = (p: number) =>
24	p >= 90
25		? { icon: "⛈️", word: "Stormy" }
26		: p >= 75
27			? { icon: "🌧️", word: "Rainy" }
28			: p >= 50
29				? { icon: "⛅", word: "Cloudy" }
30				: { icon: "☀️", word: "Sunny" };
31
32// filled cells of a bar `total` wide: any use at all shows one cell; NaN counts as 0, past 100 as full
33export function cells(percent: number, total: number) {
34	const p = Number.isFinite(percent)
35		? Math.min(100, Math.max(0, percent))
36		: 0;
37	return p > 0 ? Math.max(1, Math.round((p / 100) * total)) : 0;
38}
39
40// claude-sonnet-5-5 -> Sonnet 5.5; claude-opus-4-1-20250805[1m] -> Opus 4.1 1M; anything else unchanged
41export function modelName(id: string) {
42	const m =
43		/^claude-([a-z]+)-(\d+(?:-\d{1,2})*)(?:-\d{8})?(?:\[(\w+)\])?$/.exec(
44			id
45		);
46	if (!m) return id;
47	const family = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1);
48	return `${family} ${m[2]!.replace(/-/g, ".")}${m[3] ? " " + m[3].toUpperCase() : ""}`;
49}
50
51const LEVELS = ["low", "medium", "high", "xhigh", "max"];
52
53export function effortView(effort: string | number) {
54	const n = LEVELS.indexOf(String(effort)) + 1;
55	return {
56		label: String(effort),
57		dots: n > 0 ? "●".repeat(n) + "○".repeat(LEVELS.length - n) : ""
58	};
59}
60
61export function tokens(n: number) {
62	const k = n / 1000;
63	if (k < 1) return String(n);
64	if (k < 1000) return `${Math.round(k)}k`;
65	return `${+(k / 1000).toFixed(1)}M`;
66}
67
68export function countdown(iso: string | undefined, now: number) {
69	const ms = iso ? Date.parse(iso) - now : NaN;
70	if (Number.isNaN(ms)) return "";
71	if (ms <= 0) return "now";
72	const m = Math.floor(ms / 60000);
73	const h = Math.floor(m / 60);
74	const d = Math.floor(h / 24);
75	if (d > 0) return `${d}d ${h % 24}h`;
76	if (h > 0) return `${h}h ${m % 60}m`;
77	return `${Math.max(1, m)}m`;
78}
79
80// requests never closer than `min`; after a 429 with no usable Retry-After, wait `default`; never wait past `max`
81export const PAUSE = { min: 5_000, default: 300_000, max: 3_600_000 };
82
83// how long to hold off after a 429: Retry-After is seconds or an HTTP date
84export function retryDelay(header: string | undefined, now: number) {
85	const secs = Number(header);
86	const ms =
87		header?.trim() && Number.isFinite(secs)
88			? secs * 1000
89			: Date.parse(header ?? "") - now;
90	return Math.min(
91		PAUSE.max,
92		Math.max(PAUSE.min, Number.isFinite(ms) ? ms : PAUSE.default)
93	);
94}
95
96// Anthropic's plan-usage reply ({ five_hour: { utilization, resets_at }, seven_day: {...} }) in the
97// shape the session reports its limits in; anything it does not recognise is left out
98export function parseUsage(body: unknown) {
99	const out: { kind: string; percentUsed: number; resetsAt?: string }[] = [];
100	if (!body || typeof body !== "object") return out;
101	for (const kind of ["five_hour", "seven_day"]) {
102		const w = (
103			body as Record<
104				string,
105				{ utilization?: unknown; resets_at?: unknown } | null
106			>
107		)[kind];
108		if (
109			typeof w?.utilization === "number" &&
110			Number.isFinite(w.utilization)
111		) {
112			out.push({
113				kind,
114				percentUsed: Math.round(w.utilization * 10) / 10,
115				resetsAt:
116					typeof w.resets_at === "string" ? w.resets_at : undefined
117			});
118		}
119	}
120	return out;
121}
122
123const NAMES: Record<string, [string, string]> = {
124	five_hour: ["5h", "5-hour"],
125	seven_day: ["7d", "7-day"],
126	spend_limit: ["spend", "Spend"]
127};
128export const shortKind = (kind: string) => NAMES[kind]?.[0] ?? kind;
129export const longKind = (kind: string) => NAMES[kind]?.[1] ?? kind;
130
131// rough US prices in USD, ascending: what the session's cost is "worth"
132const THINGS: [price: number, one: string, many: string][] = [
133	[0.25, "gumball 🍬", "gumballs 🍬"],
134	[1.5, "candy bar 🍫", "candy bars 🍫"],
135	[3.5, "order of McDonald's fries 🍟", "orders of McDonald's fries 🍟"],
136	[4.5, "latte ☕", "lattes ☕"],
137	[5.99, "Big Mac 🍔", "Big Macs 🍔"],
138	[10, "burrito 🌯", "burritos 🌯"],
139	[15, "movie ticket 🎬", "movie tickets 🎬"],
140	[20, "large pizza 🍕", "large pizzas 🍕"],
141	[40, "sushi dinner 🍣", "sushi dinners 🍣"],
142	[75, "dinner for two 🍝", "dinners for two 🍝"],
143	[150, "concert ticket 🎤", "concert tickets 🎤"]
144];
145
146// "≈ 1.1 lattes ☕": the biggest thing the cost covers, counted to one decimal (whole past 10)
147export function costEquiv(usd: number) {
148	if (!Number.isFinite(usd) || usd <= 0) return "";
149	const thing = [...THINGS].reverse().find((t) => usd >= t[0]);
150	if (!thing) return `≈ less than a ${THINGS[0]![1]}`;
151	const n = usd / thing[0];
152	const count = n < 10 ? String(+n.toFixed(1)) : String(Math.round(n));
153	return `≈ ${count} ${count === "1" ? thing[1] : thing[2]}`;
154}
155
types/index.d.ts 20 lines
1export type UsageMeterSnap = {
2  model?: string
3  // undefined: no request seen yet; null: the model takes no effort setting
4  effort?: string | number | null
5  context?: { tokens?: number; window: number; percent?: number }
6  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
7  // when the limits were last read and from where: the account's live usage, or the session's last reply
8  rateAt?: number
9  rateSource?: 'live' | 'session'
10  // moves every minute while idle so the band redraws and its countdowns keep counting
11  tickAt?: number
12  costUsd?: number
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'usage-meter-mod': { snap: UsageMeterSnap; isOpen: boolean }
18  }
19}
20