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…

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" />
Click [Hide ▾] to collapse it to one line: Sonnet 5.5 | high | 7d 78% 🌧️ | ctx 42% ☀️ [Details ▸]. It starts expanded in every session.
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
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.Where it works
Data
–, with no bar.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.resets in 2h 43m are worked out whenever the pane redraws, so while a turn is running they stand still until the next refresh.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.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.window reset, updating… until fresh figures arrive.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.THINGS in hooks/format.ts for your own prices.Display
[-] mark can hide the whole band.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.
hooks/register.tsx 426 lines1import { 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};
426hooks/format.ts 155 lines1// 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}
155types/index.d.ts 20 lines1export 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