SLOPSHOPPER

usage-meter

Themed usage meters above the prompt: context, rate limits, cost, last turn

newband
v0.1.0MITupdated 2026-10-08dontcallmejames/claude-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 ctx ▰▰▱▱▱ 5h ▰▰▱▱▱ $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx ▰▰▱▱▱ 5h ▰▰▱▱▱ $0.42
README

usage-meter

A small Claude Code mod that puts your usage just above the prompt box.

ctx ▰▰▰▱▱  5h ▰▱▱▱▱  7d ▰▰▰▱▱  $1.84

Hover over it to see the details:

ctx    42%  84k / 200k        messages 52k · tools 18k · mcp 9k · system 3k
5h     23%  resets 2h14m
7d     61%  resets Thu 9:00
turn        12k in            1k out · 41k cached (89%)
  • ctx: how full the context window is, with the biggest categories from /context
  • 5h / 7d: your rate-limit windows and when they reset (only shown on a subscription)
  • turn: tokens for the last turn, and how much came from the prompt cache
  • $: what the session has cost so far

Colors come from your Claude Code theme. Bars are green below 60%, yellow up to 85%, red past that. The context breakdown is estimated locally, so the meter doesn't make any extra API calls.

Works in the terminal and in the Claude desktop app's Code tab. In a terminal, hover needs mouse reporting (fullscreen mode).

Install

In a Claude Code session, run:

/plugin install usage-meter --marketplace dontcallmejames/claude-usage-meter

Answer y to add the marketplace, then press Enter to install it for your user. The meter shows up right away and in every session after that.

Develop

claude plugin validate .
claude plugin test .

Or load a local checkout without installing it: claude --plugin-dir <path to this folder>.

Source 2 files
hooks/register.tsx 187 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { Category, Limit, Snapshot, TurnTokens } from '../types'
5
6const snap = atom({ plugin: 'usage-meter', key: 'snap' } as const, null)
7const turn = atom({ plugin: 'usage-meter', key: 'turn' } as const, null)
8
9const CELLS = 5
10const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: '$cap' }
11const LIMIT_ORDER = ['five_hour', 'seven_day', 'spend_limit']
12const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
13
14// Theme keys, so the colors follow the person's theme.
15export const levelColor = (percent: number) =>
16  percent >= 85 ? 'error' : percent >= 60 ? 'warning' : 'success'
17
18export const compact = (n: number) =>
19  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
20
21export const filledCells = (percent: number) =>
22  Math.min(CELLS, Math.max(0, Math.round((percent / 100) * CELLS)))
23
24export const untilReset = (resetsAt: string | undefined, now: number) => {
25  if (!resetsAt) return ''
26  const at = Date.parse(resetsAt)
27  const mins = Math.max(0, Math.round((at - now) / 60000))
28  if (mins < 60) return `${mins}m`
29  if (mins < 24 * 60) return `${Math.floor(mins / 60)}h${String(mins % 60).padStart(2, '0')}m`
30  const d = new Date(at)
31  return `${DAYS[d.getDay()]} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
32}
33
34const toSnapshot = (
35  context: SessionContextUsage,
36  rateLimits: SessionRateLimit[],
37  cost: SessionCost | undefined,
38  categories: Category[],
39): Snapshot => ({
40  ctxPercent: context.percent,
41  ctxTokens: context.tokens,
42  window: context.window,
43  limits: rateLimits.map((l): Limit => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt })),
44  usd: cost?.usd,
45  categories,
46})
47
48// The local estimate is free; `full` would spend token-count requests every turn.
49async function measure($: EngineInterface): Promise<Snapshot> {
50  const u = await $.session.usage({ breakdown: 'summary' })
51  const categories = (u.context.breakdown?.categories ?? [])
52    .filter(c => c.kind === 'used' && c.tokens > 0)
53    .map(c => ({ name: c.name, tokens: c.tokens, color: c.color }))
54  return toSnapshot(u.context, u.rateLimits, u.cost, categories)
55}
56
57// A reading can come back without rate limits or cost (a fresh or restarted session,
58// a response that carried no headers). Keep the last known values instead of blanking them.
59export const keepKnown = (prev: Snapshot | null, fresh: Snapshot): Snapshot => {
60  if (!prev) return fresh
61  const limits = [...prev.limits.filter(p => !fresh.limits.some(f => f.kind === p.kind)), ...fresh.limits]
62  limits.sort((a, b) => LIMIT_ORDER.indexOf(a.kind) - LIMIT_ORDER.indexOf(b.kind))
63  return { ...fresh, limits, usd: fresh.usd ?? prev.usd }
64}
65
66export const register: Register = on => {
67
68  on('session.start', async ($, e, next) => {
69    const ran = await next(e)
70    const fresh = await measure($)
71    await update($, snap, s => keepKnown(s, fresh))
72    return ran
73  })
74
75  on('session.measure', async ($, e, next) => {
76    if (e.changed.includes('context')) {
77      const fresh = await measure($)
78      await update($, snap, s => keepKnown(s, fresh))
79    } else {
80      await update($, snap, s => keepKnown(s, toSnapshot(e.context, e.rateLimits, e.cost, s?.categories ?? [])))
81    }
82    return next(e)
83  })
84
85  on('turn.complete', async ($, e, next) => {
86    const done = await next(e)
87    const u = done.usage
88    if (u) {
89      const t: TurnTokens = {
90        input: u.input_tokens,
91        output: u.output_tokens,
92        cacheRead: u.cache_read_input_tokens,
93        cacheWrite: u.cache_creation_input_tokens,
94      }
95      await update($, turn, () => t)
96    }
97    return done
98  })
99
100  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
101    const s = await read($, snap)
102    if (e.props.hasSurvey || s === null) return next(e)
103
104    const { Box, Text } = $.ui.resolve(e)
105    const last = await read($, turn)
106    const now = await $.clock.now()
107
108    const Bar = ({ percent }: { percent?: number }) => {
109      if (percent === undefined) return <Text color="subtle">{'▱'.repeat(CELLS)}</Text>
110      const n = filledCells(percent)
111      return (
112        <Box>
113          <Text color={levelColor(percent)}>{'▰'.repeat(n)}</Text>
114          <Text color="subtle">{'▱'.repeat(CELLS - n)}</Text>
115        </Box>
116      )
117    }
118    // One grid for every detail row: label | percent (right-aligned) | figure | extra.
119    const Row = (props: { label: string; percent?: number; figure: string; children?: unknown }) => (
120      <Box>
121        <Box width={5}>
122          <Text color="inactive">{props.label}</Text>
123        </Box>
124        <Box width={4} justifyContent="flex-end">
125          {props.percent !== undefined && (
126            <Text color={levelColor(props.percent)}>{`${Math.round(props.percent)}%`}</Text>
127          )}
128        </Box>
129        <Box width={16} marginLeft={2}>
130          <Text color="subtle">{props.figure}</Text>
131        </Box>
132        <Box flexShrink={1}>{props.children}</Box>
133      </Box>
134    )
135    const Sep = () => <Text color="subtle">{' · '}</Text>
136
137    const ctx = s.ctxPercent ?? 0
138    // Until the first reply reports rate limits, show empty 5h/7d slots so they read as
139    // "waiting" rather than missing. After a turn with none, the account has none (API key).
140    const limits: (Omit<Limit, 'percent'> & { percent?: number })[] =
141      s.limits.length > 0 || last ? s.limits : [{ kind: 'five_hour' }, { kind: 'seven_day' }]
142    const rows = [{ label: 'ctx', percent: ctx as number | undefined }, ...limits.map(l => ({ label: LIMIT_LABEL[l.kind] ?? l.kind, percent: l.percent }))]
143    const cost = s.usd === undefined ? '' : `$${s.usd.toFixed(2)}`
144    const cached = last ? last.input + last.cacheRead + last.cacheWrite : 0
145    const hitRate = last && cached > 0 ? Math.round((last.cacheRead / cached) * 100) : 0
146
147    // Details sit above the summary and appear while the pointer is over the band,
148    // so the row under the pointer never moves.
149    return (
150      <Box key="usage-meter" flexDirection="column">
151        <Box flexDirection="column" display="none" hover={{ display: 'flex' }}>
152          <Row label="ctx" percent={ctx} figure={s.ctxTokens === undefined ? '' : `${compact(s.ctxTokens)} / ${compact(s.window)}`}>
153            {[...s.categories]
154              .sort((x, y) => y.tokens - x.tokens)
155              .slice(0, 4)
156              .flatMap((c, i) => [
157                ...(i > 0 ? [<Sep />] : []),
158                <Text color={c.color} wrap="truncate">{`${c.name.toLowerCase()} ${compact(c.tokens)}`}</Text>,
159              ])}
160          </Row>
161          {limits.map(l => (
162            <Row
163              label={LIMIT_LABEL[l.kind] ?? l.kind}
164              percent={l.percent}
165              figure={l.percent === undefined ? 'waiting for a reply' : l.resetsAt ? `resets ${untilReset(l.resetsAt, now)}` : ''}
166            />
167          ))}
168          <Row label="turn" figure={last ? `${compact(last.input + last.cacheWrite)} in` : 'no turns yet'}>
169            {last && <Text color="subtle">{`${compact(last.output)} out`}</Text>}
170            {last && <Sep />}
171            {last && <Text color="subtle">{`${compact(last.cacheRead)} cached (${hitRate}%)`}</Text>}
172          </Row>
173        </Box>
174        <Box gap={2}>
175          {rows.map(r => (
176            <Box gap={1}>
177              <Text color="inactive">{r.label}</Text>
178              <Bar percent={r.percent} />
179            </Box>
180          ))}
181          {cost && <Text color="claude">{cost}</Text>}
182        </Box>
183      </Box>
184    )
185  })
186}
187
types/index.d.ts 18 lines
1export type Limit = { kind: string; percent: number; resetsAt?: string }
2export type Category = { name: string; tokens: number; color: string }
3export type TurnTokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
4export type Snapshot = {
5  ctxPercent?: number
6  ctxTokens?: number
7  window: number
8  limits: Limit[]
9  usd?: number
10  categories: Category[]
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'usage-meter': { snap: Snapshot | null; turn: TurnTokens | null }
16  }
17}
18