Usage limits (5h / 7d) and ccusage spend, drawn as a status band above the prompt.

A Claude Code mod that draws your usage limits and spend in the band above the prompt.
Claude Code desktop

Terminal

$.session.usage(), with a ring (desktop) or pie glyph (terminal) coloured green → amber (60%) → red (85%), and a countdown to the reset.ccusage daily --json --offline --since <first of month>).Rate limits only show on a Claude subscription (they're absent for API-key, Bedrock and Vertex sessions).
In Claude Code:
/plugin marketplace add estruyf/claude-stats-mod
/plugin install claude-stats@claude-stats-mod
/reload-plugins
Or from your shell:
claude plugin marketplace add estruyf/claude-stats-mod
claude plugin install claude-stats@claude-stats-mod --scope user
Refresh the marketplace, then update the plugin:
claude plugin marketplace update claude-stats-mod
claude plugin update claude-stats@claude-stats-mod
Run /reload-plugins in an open session (or restart the desktop app) to load the new version. Inside Claude Code you can also do this from /plugin, under the claude-stats-mod marketplace.
git clone https://github.com/estruyf/claude-stats-mod.git
claude --plugin-dir ./claude-stats-mod
Requires Claude Code 2.1.287+ (mods on by default). On 2.1.286, set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the env block of ~/.claude/settings.json.
Set them with /config or under pluginConfigs["claude-stats"].options in settings:
| Option | Default | |
|---|---|---|
showSpend | true | Show the spend pill. Turn it off to see only the usage limits; ccusage then never runs. |
ccusageCommand | npx -y ccusage@latest | Tried directly, then through your login shell so nvm/volta PATHs resolve in the desktop app. |
refreshSeconds | 60 | Minimum time between ccusage runs after a turn. While idle it refreshes every 10 minutes. |
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
Running with --plugin-dir hot-reloads the mod on every save, so there's no update step while developing.
To release, bump version in .claude-plugin/plugin.json and push to main. claude plugin update compares that version, so a push without a bump isn't picked up as an update.
hooks/claude-stats.mjs 279 lines1// Claude Stats: usage limits and ccusage spend, drawn in the band above the prompt.
2//
3// ◔ 14% 5h · resets 1h7m ◕ 83% 7d · resets 3h47m $ $0.10 · $0.10 today · $0.10 mo
4
5// Held by the host, so the readings survive a hot reload of this file.
6const USAGE = { plugin: "claude-stats", key: "usage" };
7const COSTS = { plugin: "claude-stats", key: "costs" };
8const TICK = { plugin: "claude-stats", key: "tick" };
9
10const MINUTE = 60_000;
11const IDLE_REFRESH_MS = 10 * MINUTE; // pick up spend from other sessions while this one is idle
12
13const LIMIT_LABELS = { five_hour: "5h", seven_day: "7d", spend_limit: "spend" };
14
15const GREEN = "#22c55e";
16const AMBER = "#f59e0b";
17const RED = "#ef4444";
18
19let refreshing = false;
20let ticker;
21
22export function register(on, options = {}) {
23 const refreshMs = Math.max(10, Number(options.refreshSeconds) || 60) * 1000;
24 const showSpend = options.showSpend !== false;
25
26 on("session.start", async ($, e, next) => {
27 const result = await next(e);
28 await takeUsage($);
29 if (showSpend) refreshCosts($, options, 0).catch(() => {});
30
31 // Redraw the countdowns every minute, and catch spend from other sessions.
32 ticker?.cancel();
33 ticker = $.clock.every(MINUTE, () => {
34 takeUsage($)
35 .then(() => $.clock.now())
36 .then((now) => $.state.set(TICK, now))
37 .catch(() => {});
38 if (showSpend) refreshCosts($, options, IDLE_REFRESH_MS).catch(() => {});
39 });
40 return result;
41 });
42
43 // Pushed by the engine whenever a rate-limit window or the session cost moves.
44 on("session.measure", async ($, e, next) => {
45 await saveUsage($, e);
46 return next(e);
47 });
48
49 on("turn.complete", async ($, e, next) => {
50 const result = await next(e);
51 if (!e.agentId) {
52 await takeUsage($); // main-loop turns only, not subagents
53 if (showSpend) refreshCosts($, options, refreshMs).catch(() => {});
54 }
55 return result;
56 });
57
58 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
59 if (e.props?.hasSurvey) return next(e);
60
61 const { value: usage } = await $.state.get(USAGE);
62 const { value: costs } = await $.state.get(COSTS);
63 await $.state.get(TICK); // subscribe, so the reset countdowns redraw each minute
64
65 const limits = usage?.rateLimits ?? [];
66 const spend = showSpend ? costParts(usage, costs) : null;
67 if (limits.length === 0 && !spend) return next(e);
68
69 const now = await $.clock.now();
70 const els = $.ui.resolve(e);
71 return e.surface === "terminal"
72 ? terminalBand(els, limits, spend, now, e.props?.bodyColumns ?? 120)
73 : desktopBand(els, limits, spend, now);
74 });
75}
76
77// ── Readings ────────────────────────────────────────────────────────────────
78
79async function takeUsage($) {
80 await saveUsage($, await $.session.usage());
81}
82
83async function saveUsage($, { rateLimits = [], cost } = {}) {
84 await $.state.set(USAGE, {
85 rateLimits: rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
86 sessionUsd: cost?.usd,
87 });
88}
89
90// Runs ccusage once for the month so far: today's row plus the month's total.
91async function refreshCosts($, options, maxAgeMs) {
92 if (refreshing) return;
93 const now = await $.clock.now();
94 const { value: prev } = await $.state.get(COSTS);
95 if (prev?.at && now - prev.at < maxAgeMs) return;
96
97 refreshing = true;
98 try {
99 const d = new Date(now);
100 const y = d.getFullYear();
101 const m = String(d.getMonth() + 1).padStart(2, "0");
102 const day = String(d.getDate()).padStart(2, "0");
103 const stdout = await runCcusage($, options, ["daily", "--json", "--offline", "--since", `${y}${m}01`]);
104 const data = JSON.parse(stdout.slice(stdout.indexOf("{")));
105 const todayRow = (data.daily ?? []).find((r) => (r.period ?? r.date) === `${y}-${m}-${day}`);
106 await $.state.set(COSTS, {
107 today: todayRow?.totalCost ?? 0,
108 month: data.totals?.totalCost ?? 0,
109 at: now,
110 });
111 } catch (err) {
112 const error = String(err?.message ?? err);
113 $.ui.log(`claude-stats: ccusage failed: ${error}`, { to: "debug" });
114 await $.state.set(COSTS, { ...prev, at: now, error });
115 } finally {
116 refreshing = false;
117 }
118}
119
120// Tries the command as-is, then through a login shell: the desktop app starts
121// without the PATH that nvm, volta or fnm set up in your shell profile.
122async function runCcusage($, options, args) {
123 const command = String(options.ccusageCommand || "npx -y ccusage@latest").trim();
124 const argv = [...command.split(/\s+/), ...args];
125 const init = { timeoutMs: 90_000 };
126
127 try {
128 const r = await $.process.run(argv, init);
129 if (r.exitCode === 0) return r.stdout;
130 if (r.exitCode !== 127) throw new Error(r.stderr.trim() || `exit ${r.exitCode}`);
131 } catch (err) {
132 if (!/ENOENT|not found|cannot start|spawn/i.test(String(err?.message ?? err))) throw err;
133 }
134
135 const shell = (await $.env.get("SHELL")) || "/bin/zsh";
136 const r = await $.process.run([shell, "-lic", argv.map(quote).join(" ")], init);
137 if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `exit ${r.exitCode}`);
138 return r.stdout;
139}
140
141function quote(arg) {
142 return /^[\w@./:=-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
143}
144
145// ── Drawing: desktop ────────────────────────────────────────────────────────
146
147function desktopBand({ Box, Text, Svg }, limits, spend, now) {
148 const pills = limits.map((l) => {
149 const pct = Math.round(l.percentUsed);
150 const reset = resetIn(l.resetsAt, now);
151 return pill(Box, [
152 Svg({ source: ring(l.percentUsed, colorFor(l.percentUsed)), alt: `${pct}% used`, width: 16, height: 16 }),
153 Text({ bold: true, children: `${pct}%` }),
154 Text({ dimColor: true, children: `${labelFor(l.kind)}${reset ? ` · resets ${reset}` : ""}` }),
155 ]);
156 });
157
158 if (spend) {
159 pills.push(
160 pill(Box, [
161 Svg({ source: dollarBadge(), alt: "cost", width: 16, height: 16 }),
162 Text({ bold: true, color: GREEN, children: spend.session }),
163 Text({ dimColor: true, children: spend.rest }),
164 ]),
165 );
166 }
167
168 return Box({ flexDirection: "row", flexWrap: "wrap", gap: 1, paddingX: 1, children: pills });
169}
170
171function pill(Box, children) {
172 return Box({
173 flexDirection: "row",
174 alignItems: "center",
175 gap: 1,
176 paddingX: 1,
177 borderStyle: "round",
178 borderDimColor: true,
179 children,
180 });
181}
182
183function ring(percent, color) {
184 const r = 6;
185 const c = 2 * Math.PI * r;
186 const offset = c * (1 - Math.min(Math.max(percent, 0), 100) / 100);
187 return (
188 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">` +
189 `<circle cx="8" cy="8" r="${r}" fill="none" stroke="${color}" stroke-opacity="0.25" stroke-width="2.5"/>` +
190 `<circle cx="8" cy="8" r="${r}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" ` +
191 `stroke-dasharray="${c.toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}" transform="rotate(-90 8 8)"/>` +
192 `</svg>`
193 );
194}
195
196function dollarBadge() {
197 return (
198 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">` +
199 `<circle cx="8" cy="8" r="7" fill="none" stroke="${GREEN}" stroke-width="1.5"/>` +
200 `<text x="8" y="11.5" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" font-weight="700" fill="${GREEN}">$</text>` +
201 `</svg>`
202 );
203}
204
205// ── Drawing: terminal ───────────────────────────────────────────────────────
206
207function terminalBand({ Box, Text }, limits, spend, now, columns) {
208 const wide = columns >= 80;
209 const parts = [];
210 const gap = () => parts.length && parts.push(Text({ children: " " }));
211
212 for (const l of limits) {
213 gap();
214 const reset = wide ? resetIn(l.resetsAt, now) : "";
215 parts.push(Text({ color: colorFor(l.percentUsed), children: `${pie(l.percentUsed)} ` }));
216 parts.push(Text({ bold: true, children: `${Math.round(l.percentUsed)}%` }));
217 parts.push(Text({ dimColor: true, children: ` ${labelFor(l.kind)}${reset ? ` · resets ${reset}` : ""}` }));
218 }
219
220 if (spend) {
221 gap();
222 parts.push(Text({ color: GREEN, bold: true, children: spend.session }));
223 if (wide) parts.push(Text({ dimColor: true, children: ` ${spend.rest}` }));
224 }
225
226 // A blank row above, so the band doesn't run into the transcript.
227 return Box({ flexDirection: "row", marginTop: 1, paddingX: 1, children: parts });
228}
229
230// Single-width glyphs, so the line stays aligned in every terminal font.
231function pie(percent) {
232 return "○◔◑◕●"[Math.min(4, Math.round(Math.max(percent, 0) / 25))];
233}
234
235// ── Formatting ──────────────────────────────────────────────────────────────
236
237function costParts(usage, costs) {
238 const hasSession = usage?.sessionUsd !== undefined;
239 const hasLedger = costs?.today !== undefined;
240 if (!hasSession && !hasLedger) return null;
241
242 const session = usd(usage?.sessionUsd ?? 0);
243 const rest = hasLedger
244 ? `${usd(costs.today)} today · ${usd(costs.month)} mo`
245 : costs?.error
246 ? "ccusage unavailable"
247 : "loading ccusage…";
248 return { session, rest };
249}
250
251function usd(n) {
252 if (n >= 1000) return `$${(n / 1000).toFixed(1)}k`;
253 if (n >= 100) return `$${Math.round(n)}`;
254 return `$${n.toFixed(2)}`;
255}
256
257function colorFor(percent) {
258 if (percent >= 85) return RED;
259 if (percent >= 60) return AMBER;
260 return GREEN;
261}
262
263function labelFor(kind) {
264 return LIMIT_LABELS[kind] ?? kind.replace(/_/g, " ");
265}
266
267function resetIn(resetsAt, now) {
268 if (!resetsAt) return "";
269 const ms = Date.parse(resetsAt) - now;
270 if (!(ms > 0)) return "now";
271 const mins = Math.ceil(ms / MINUTE);
272 const d = Math.floor(mins / 1440);
273 const h = Math.floor((mins % 1440) / 60);
274 const m = mins % 60;
275 if (d > 0) return `${d}d${h}h`;
276 if (h > 0) return `${h}h${m}m`;
277 return `${m}m`;
278}
279types/index.d.ts 16 lines1export type ClaudeStatsLimit = { kind: string; percentUsed: number; resetsAt?: string };
2
3export type ClaudeStatsUsage = { rateLimits: ClaudeStatsLimit[]; sessionUsd?: number };
4
5export type ClaudeStatsCosts = { today?: number; month?: number; at: number; error?: string };
6
7declare module "claude-code" {
8 interface PluginState {
9 "claude-stats": {
10 usage: ClaudeStatsUsage;
11 costs: ClaudeStatsCosts;
12 tick: number;
13 };
14 }
15}
16