SLOPSHOPPER

usage-meter

Always shows your 5-hour and weekly plan usage above the prompt, with when each window resets.

newbandtimer
v0.2.0no licenseupdated 2026-10-02jumoog/claude_mod_usage/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 5h ███░░░░░░░ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ███░░░░░░░ 31%
README

usage-meter

A Claude Code mod that always shows your plan usage above the prompt: the 5-hour window, the weekly window, and when each window resets.

5h ███░░░░░░░ 34% · resets 15:30 (in 1h 30m)   │  week ██░░░░░░░░ 18% · resets Sun 14:00 (in 3d 0h)
  • 5h and week each get a bar and a percentage, green below 50%, yellow from 50%, red from 80%. The empty part of the bar is dimmed.
  • resets is the local time each window resets, with a countdown that refreshes every 30 seconds. Resets after today get the weekday.
  • Under 105 columns the resets shorten to ↻ 15:30 and ↻ Sun 14:00; under 70 the bars go too: 5h 34% · ↻ 15:30 │ week 18% · ↻ Sun 14:00.
  • Until the first response of a session it shows usage: waiting for the first response…, since the numbers arrive with each response.

It works in the terminal and in the Claude desktop app's Code tab.

Requirements

  • A Claude Code build with mods (function hooks). Mods are early access: set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the environment Claude Code starts in, unless your account already has them on.
  • A Claude subscription plan. The 5-hour and weekly limits come from the response headers of plan accounts; with an API key there is nothing to show.

Install

Turn mods on. On Windows, as a user environment variable (then fully restart the desktop app or your terminal):

[Environment]::SetEnvironmentVariable("CLAUDE_CODE_ENABLE_FUNCTION_HOOKS", "1", "User")

On macOS or Linux, in your shell profile:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

Then, inside Claude Code:

/plugin marketplace add jumoog/claude_mod_usage
/plugin install usage-meter@usage
/reload-plugins

In the desktop app, if the band does not appear after a restart, run /reload-plugins once more: the Code tab only starts drawing mod UI once it has picked up the plugin.

Try it without installing

git clone git@github.com:jumoog/claude_mod_usage.git
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./claude_mod_usage/usage-meter

How it works

The whole mod is usage-meter/hooks/usage-meter.mjs.

  • $.session.usage() returns rateLimits, a list of { kind, percentUsed, resetsAt }. The mod keeps five_hour and seven_day.
  • It refreshes on session.start, after every main-agent turn.complete, on session.measure (raised when the rate-limit headers move the numbers), and every 30 seconds via $.clock.every so the countdown stays current.
  • Each refresh saves a snapshot to $.state. A ui.render hook on the AbovePrompt component reads it and draws the band, so every save redraws it.

Development

claude plugin validate ./usage-meter
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test ./usage-meter

After changing the mod, bump version in usage-meter/.claude-plugin/plugin.json, then:

claude plugin update usage-meter@usage
/reload-plugins

Layout

.claude-plugin/marketplace.json     marketplace "usage", listing the one plugin
usage-meter/
  .claude-plugin/plugin.json        plugin manifest
  hooks/hooks.json                  names the hooks module
  hooks/usage-meter.mjs             the mod
  types/index.d.ts                  $.state contract
  tests/usage-meter.test.ts         claude plugin test suite
Source 2 files
hooks/usage-meter.mjs 131 lines
1// Usage Meter: your 5-hour and weekly plan usage, always above the prompt,
2// with the time each window resets.
3
4const WIDTH = 10;
5const TICK_MS = 30_000;
6const LABELS = { five_hour: "5h", seven_day: "week" };
7const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
8
9const snapshot = { plugin: "usage-meter", key: "snapshot" };
10
11export function register(on) {
12  on("session.start", async ($, e, next) => {
13    const result = await next(e);
14    await refresh($);
15    // Keep the countdown honest between turns.
16    $.clock.every(TICK_MS, () => refresh($));
17    return result;
18  });
19
20  // Fires whenever Claude Code re-measures the session, including when the
21  // rate-limit headers on a response move the numbers.
22  on("session.measure", async ($, e, next) => {
23    const result = await next(e);
24    if (Array.isArray(e.rateLimits)) {
25      await save($, e.rateLimits);
26    }
27    return result;
28  });
29
30  on("turn.complete", async ($, e, next) => {
31    const result = await next(e);
32    if (!e.agentId) await refresh($);
33    return result;
34  });
35
36  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
37    if (e.props.hasSurvey) return next(e);
38    const { value } = await $.state.get(snapshot);
39    const { Box, Text } = $.ui.resolve(e);
40    return band(Box, Text, value, e.props.bodyColumns);
41  });
42}
43
44async function refresh($) {
45  const { rateLimits } = await $.session.usage();
46  await save($, rateLimits ?? []);
47}
48
49async function save($, rateLimits) {
50  const limits = {};
51  for (const r of rateLimits) {
52    if (r.kind in LABELS) {
53      limits[r.kind] = { percent: clamp(r.percentUsed), resetsAt: r.resetsAt };
54    }
55  }
56  const { value: previous } = await $.state.get(snapshot);
57  // Keep the last known numbers when a measurement comes back without them.
58  const merged = { ...(previous?.limits ?? {}), ...limits };
59  await $.state.set(snapshot, { limits: merged, now: await $.clock.now() });
60}
61
62function band(Box, Text, value, columns) {
63  const limits = value?.limits ?? {};
64  // The desktop app lays the band out itself and may not pass a column count.
65  const roomy = columns === undefined || columns >= 70;
66  // Spelled-out resets with countdowns for both windows need about 105 columns.
67  const verbose = columns === undefined || columns >= 105;
68
69  if (!limits.five_hour && !limits.seven_day) {
70    return Box({
71      paddingX: 1,
72      children: [Text({ dimColor: true, children: "usage: waiting for the first response…" })],
73    });
74  }
75
76  const parts = [];
77  for (const kind of Object.keys(LABELS)) {
78    const limit = limits[kind];
79    if (!limit) continue;
80    if (parts.length) parts.push(Text({ dimColor: true, children: "   │  " }));
81    parts.push(...meter(Text, LABELS[kind], limit.percent, roomy));
82    const reset = resetText(limit.resetsAt, value.now, verbose);
83    if (reset) parts.push(Text({ dimColor: true, children: reset }));
84  }
85  return Box({ flexDirection: "row", paddingX: 1, children: parts });
86}
87
88function meter(Text, label, percent, roomy) {
89  const color = percent >= 80 ? "red" : percent >= 50 ? "yellow" : "green";
90  const parts = [Text({ bold: true, children: `${label} ` })];
91  if (roomy) {
92    const filled = Math.round((percent / 100) * WIDTH);
93    if (filled > 0) parts.push(Text({ color, children: "█".repeat(filled) }));
94    parts.push(Text({ dimColor: true, children: "░".repeat(WIDTH - filled) + " " }));
95  }
96  parts.push(Text({ color, bold: true, children: `${Math.round(percent)}%` }));
97  return parts;
98}
99
100function resetText(resetsAt, now, verbose) {
101  const at = Date.parse(resetsAt);
102  if (!Number.isFinite(at)) return "";
103  const left = at - now;
104  if (left <= 0) return " · resetting…";
105  const clock = when(new Date(at), new Date(now));
106  return verbose ? ` · resets ${clock} (in ${duration(left)})` : ` · ↻ ${clock}`;
107}
108
109// A bare time for today; anything later also gets the weekday.
110function when(d, today) {
111  const sameDay = d.toDateString() === today.toDateString();
112  return sameDay ? hhmm(d) : `${DAYS[d.getDay()]} ${hhmm(d)}`;
113}
114
115function hhmm(d) {
116  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
117}
118
119function duration(ms) {
120  const mins = Math.max(1, Math.ceil(ms / 60_000));
121  const days = Math.floor(mins / 1440);
122  const h = Math.floor(mins / 60) % 24;
123  const m = mins % 60;
124  if (days) return `${days}d ${h}h`;
125  return h ? `${h}h ${m}m` : `${m}m`;
126}
127
128function clamp(n) {
129  return Math.min(100, Math.max(0, Number(n) || 0));
130}
131
types/index.d.ts 13 lines
1export type UsageMeterLimit = { percent: number; resetsAt: string };
2
3export type UsageMeterSnapshot = {
4  limits: { five_hour?: UsageMeterLimit; seven_day?: UsageMeterLimit };
5  now: number;
6};
7
8declare module "claude-code" {
9  interface PluginState {
10    "usage-meter": { snapshot: UsageMeterSnapshot };
11  }
12}
13