SLOPSHOPPER

usage-meter

Your Claude plan usage right above the message box: the 5-hour and weekly limits with reset times, plus this session's context fill

newbandcommandtoast
v0.1.1no licenseupdated 2026-10-08jgilb17/claude-mods/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 › /usage-meter ⎿ usage-meter: 5-hour ██████░░░░░░░░░░░░░░ 31% ⎿ usage-meter: Context 49% of 200k ⎿ usage-meter: $0.42 this session at API rates ⟨Claude Code's own drawing⟩ 5-hour ██░░░░░░ 31% Context 49% of 200k $0.42 this session at API rates ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 5-hour ██░░░░░░ 31% Context 49% of 200k $0.42 this session at API rates
README

Joshua's Claude Code mods

Mods that load in every Claude Code session: the terminal, the desktop app's Code tab, and cloud sessions.

ModWhat it shows
repo-sentryBusiness, repo, branch, behind or ahead of origin, dirty count. Warns on work sitting on main, a stale repo, or a staged secret.
progress-pulseA live gradient progress band for the session's task list, a cheer on every finished task, /progress for the full view, /progress demo to see it run.
usage-meterThe 5-hour and weekly plan limits with reset times, context fill and session cost, right above the message box. /usage-meter hides or shows it.

How they load

  • Mac: setup-mac.js (run once) installs every mod as a Claude Code plugin read straight from this folder, and adds a session-start hook that runs sync-mods.js --quiet. A mod added to the marketplace installs itself at the next session start; an edit to a mod takes effect at the next session start or /reload-plugins.
  • Cloud: cloud-setup.sh goes into each cloud environment's setup script. Every new container clones this repo and runs sync-mods.js.

Adding a mod

Put it in its own folder with .claude-plugin/plugin.json, add it to .claude-plugin/marketplace.json, check it with claude plugin validate <folder> and claude plugin test <folder>, commit and push.

Other tools here

mcp-diagnose.js, mcp-where.js, move-mcp-to-repo.js, mcp-set-secret.js, mcp-env-from-dotenv.js: MCP server helpers that never print secret values. servicetrade-mcp/: the read-only ServiceTrade MCP server.

Source 3 files
hooks/register.tsx 111 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput, SessionUsage } from 'claude-code'
3
4import type { Reading } from '../types'
5import { crossed, k, label, meter, money, ordered, resetText, textReading, tone } from './logic'
6
7const readingA = atom({ plugin: 'usage-meter', key: 'reading' } as const, null as Reading | null)
8const hiddenA = atom({ plugin: 'usage-meter', key: 'hidden' } as const, false)
9
10function toReading(u: SessionUsage | SessionMeasureInput, at: number): Reading {
11  return {
12    limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, ...(l.resetsAt ? { resetsAt: l.resetsAt } : {}) })),
13    contextPercent: u.context.percent ?? null,
14    contextWindow: u.context.window,
15    costUsd: u.cost ? u.cost.usd : null,
16    at,
17  }
18}
19
20async function store($: EngineInterface, next: Reading): Promise<void> {
21  const prev = await read($, readingA)
22  await update($, readingA, () => next)
23  for (const l of next.limits) {
24    const before = prev?.limits.find(p => p.kind === l.kind)?.percentUsed
25    const m = crossed(before, l.percentUsed)
26    if (m) $.ui.toast(`${label(l.kind)} limit at ${l.percentUsed}%. ${resetText(l.resetsAt, next.at)}`.trim(), { timeoutMs: 8000 })
27  }
28}
29
30export const register: Register = on => {
31  // A first reading at start (free: no API call), then every change the engine measures.
32  on('session.start', async ($, e, next) => {
33    const r = await next(e)
34    try {
35      const u = await $.session.usage()
36      await store($, toReading(u, await $.clock.now()))
37    } catch { /* the band waits for the first measurement instead */ }
38    await $.command.register({ name: 'usage-meter', description: 'Show plan usage now; add hide or show to toggle the meter above the message box' })
39    return r
40  })
41
42  on('session.measure', async ($, e, next) => {
43    const r = await next(e)
44    await store($, toReading(e, await $.clock.now()))
45    return r
46  })
47
48  // /usage-meter prints the reading as text (screens that draw no bands still see it);
49  // /usage-meter hide and /usage-meter show toggle the band.
50  on('command.run', { command: 'usage-meter' }, async ($, e) => {
51    const arg = e.args.trim().toLowerCase()
52    if (arg === 'hide' || arg === 'show') {
53      await update($, hiddenA, () => arg === 'hide')
54      return { text: arg === 'hide' ? 'Usage meter hidden. /usage-meter show brings it back.' : 'Usage meter shown.' }
55    }
56    let r = await read($, readingA)
57    try { r = toReading(await $.session.usage(), await $.clock.now()) } catch { /* keep the last reading */ }
58    return { text: textReading(r, await $.clock.now()) }
59  })
60
61  // Drawn below any other band, so it sits directly on top of the message box.
62  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
63    const below = await next(e)
64    const r = await read($, readingA)
65    if (e.props.hasSurvey || (await read($, hiddenA))) return below
66    const { Box, Text } = $.ui.resolve(e)
67    const now = await $.clock.now()
68    const cols = e.props.bodyColumns
69    const barW = cols >= 110 ? 12 : cols >= 80 ? 8 : 5
70
71    const limits = r ? ordered(r.limits) : []
72    const pieces = limits.map(l => {
73      const m = meter(l.percentUsed, barW)
74      const c = tone(l.percentUsed)
75      return (
76        <Text>
77          <Text bold>{label(l.kind) + ' '}</Text>
78          <Text color={c}>{m.filled}</Text><Text dimColor>{m.empty}</Text>
79          <Text bold color={c}>{` ${l.percentUsed}%`}</Text>
80          <Text dimColor>{cols >= 70 && l.resetsAt ? `  ${resetText(l.resetsAt, now)}` : ''}</Text>
81        </Text>
82      )
83    })
84    const ctx = r && r.contextPercent !== null
85      ? <Text>
86          <Text bold>{'Context '}</Text>
87          <Text bold color={tone(r.contextPercent)}>{`${r.contextPercent}%`}</Text>
88          <Text dimColor>{` of ${k(r.contextWindow)}`}</Text>
89        </Text>
90      : null
91    const cost = r && r.costUsd !== null && cols >= 100
92      ? <Text dimColor>{`${money(r.costUsd)} this session at API rates`}</Text>
93      : null
94    const empty = !limits.length
95      ? <Text dimColor>{r ? 'Plan limits appear after the first reply in this session.' : 'Usage appears after the first reply in this session.'}</Text>
96      : null
97
98    return (
99      <Box flexDirection="column">
100        {below}
101        <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
102          {empty}
103          {pieces}
104          {ctx}
105          {cost}
106        </Box>
107      </Box>
108    )
109  })
110}
111
hooks/logic.ts 62 lines
1import type { Limit, Reading } from '../types'
2
3export const LABEL: Record<string, string> = { five_hour: '5-hour', seven_day: 'Week', spend_limit: 'Spend' }
4export const label = (kind: string) => LABEL[kind] ?? kind.replace(/_/g, ' ')
5
6// Calm green while there is room, gold past half, orange from 80, red from 95.
7export function tone(pct: number): string {
8  if (pct >= 95) return '#ff4d4f'
9  if (pct >= 80) return '#ff6b35'
10  if (pct >= 50) return '#ffc14d'
11  return '#2ed573'
12}
13
14export function meter(pct: number, width: number): { filled: string; empty: string } {
15  const w = Math.max(3, width)
16  const n = Math.max(0, Math.min(w, Math.round((Math.min(100, Math.max(0, pct)) / 100) * w)))
17  return { filled: '█'.repeat(n), empty: '░'.repeat(w - n) }
18}
19
20// "resets 2:40 PM" when it is today, "resets Mon 9 AM" within a week, otherwise the date.
21export function resetText(iso: string | undefined, now: number, timeZone?: string): string {
22  if (!iso) return ''
23  const t = Date.parse(iso)
24  if (Number.isNaN(t)) return ''
25  if (t <= now) return 'resetting now'
26  const opts = timeZone ? { timeZone } : {}
27  const day = (ms: number) => new Intl.DateTimeFormat('en-US', { ...opts, year: 'numeric', month: '2-digit', day: '2-digit' }).format(ms)
28  const time = new Intl.DateTimeFormat('en-US', { ...opts, hour: 'numeric', minute: '2-digit' }).format(t).replace(':00', '')
29  if (day(t) === day(now)) return `resets ${time}`
30  if (t - now < 6.5 * 864e5) return `resets ${new Intl.DateTimeFormat('en-US', { ...opts, weekday: 'short' }).format(t)} ${time}`
31  return `resets ${new Intl.DateTimeFormat('en-US', { ...opts, month: 'short', day: 'numeric' }).format(t)}`
32}
33
34// The order people read them in: the short window first, the week second, anything else after.
35export function ordered(limits: Limit[]): Limit[] {
36  const rank = (k: string) => (k === 'five_hour' ? 0 : k === 'seven_day' ? 1 : 2)
37  return [...limits].sort((a, b) => rank(a.kind) - rank(b.kind))
38}
39
40// The thresholds worth a toast, each crossed once per climb.
41export function crossed(before: number | undefined, after: number): number | null {
42  for (const m of [95, 90, 80]) if ((before ?? 0) < m && after >= m) return m
43  return null
44}
45
46export const money = (usd: number) => (usd < 10 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`)
47export const k = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(n % 1_000_000 ? 1 : 0)}M` : `${Math.round(n / 1000)}k`)
48
49// The reading as plain text, for /usage-meter and for screens that draw no bands.
50export function textReading(r: Reading | null, now: number): string {
51  if (!r) return 'No usage reading yet. It arrives with the first reply in this session.'
52  const out: string[] = []
53  for (const l of ordered(r.limits)) {
54    const m = meter(l.percentUsed, 20)
55    out.push(`${label(l.kind).padEnd(7)} ${m.filled}${m.empty}  ${l.percentUsed}%  ${resetText(l.resetsAt, now)}`.trimEnd())
56  }
57  if (!r.limits.length) out.push('Plan limits: no reading from Claude Code yet in this session.')
58  if (r.contextPercent !== null) out.push(`Context ${r.contextPercent}% of ${k(r.contextWindow)}`)
59  if (r.costUsd !== null) out.push(`${money(r.costUsd)} this session at API rates`)
60  return out.join('\n')
61}
62
types/index.d.ts 16 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Reading = {
4  limits: Limit[]
5  contextPercent: number | null
6  contextWindow: number
7  costUsd: number | null
8  at: number
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'usage-meter': { reading: Reading | null; hidden: boolean }
14  }
15}
16