SLOPSHOPPER

Usage Meter

Plan limits, time-to-limit pace, session cost, tokens, cache hit rate, context and model in the band above the prompt

newbandtoast
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 Session 31% Opus 5.5 · Context 49% · $0.42 · in 2.1k · out 1.5k · cache 93% hit ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Session 31% Opus 5.5 · Context 49% · $0.42 · in 2.1k · out 1.5k · cache 93% hit
README

usage-meter

A Claude Code mod that shows your plan usage and session spend in the band above the prompt.

usage-meter in a Claude Code session: session and weekly limits on the first line; model, effort, context, cost, tokens and an amber cache hit rate on the second

Later in a busy window, with the pace projection showing:

Session 82% · resets in 2h 14m · ~48m to limit at this pace   Week 71% · resets Thu 9am
Opus 5.5 (1M) (high effort) · Context 34% · $1.37 · in 12.4k · out 8.1k · cache 94% hit

What it shows

Limits line (Claude subscriptions only)

  • Session: percent of the 5-hour window used and time until it resets.
  • Pace: when your recent rate of use would hit the session limit before it resets, how long you have left. Projected from the last 45 minutes of readings; hidden when there's too little data or you're on track.
  • Week: percent of the weekly limit used and the reset day and time, in local time.

Session line

  • Model and the effort level sent with the last main-loop request
  • Context window fill
  • Session cost (the same total as /cost)
  • Tokens this session: uncached input, output, and the share of input served from the prompt cache

Each line is hidden when it has no data; with no data at all the band is empty.

Colours

FigureAmberRed
Session, week, context %from 70%from 90%
Cache hit ratebelow 80%below 50%
Paceunder the resetunder 30 minutes

Warnings

A toast appears when the session or weekly limit reaches 80% and again at 95%, once per window. It reacts as soon as a limit moves a whole point, so it can appear mid-turn.

Install

/plugin marketplace add shubhamshinde245/claude-mods
/plugin install usage-meter@shubham-mods

Requires a Claude Code version with function-hook plugins (built and tested on 2.1.286).

Notes

  • Token totals count from when the mod loads; turns before that aren't included. They survive a mod reload but start over when Claude Code restarts.
  • The effort level appears after the first request of the session.

Privacy

usage-meter only reads figures Claude Code already has for the current session: rate-limit percentages and reset times, cost, token counts, context fill, model and effort level. It makes no network requests, runs no commands, reads no files and sends nothing anywhere. Everything it keeps is held in the session's own plugin state and is gone when Claude Code restarts.

License

MIT

Source 2 files
hooks/register.tsx 279 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  EngineInterface,
4  Register,
5  SessionContextUsage,
6  SessionCost,
7  SessionRateLimit,
8} from 'claude-code'
9
10import type { Limit, Limits, Pace, Tokens } from '../types'
11
12const limits = atom({ plugin: 'usage-meter', key: 'limits' } as const, {
13  session: null,
14  weekly: null,
15})
16const costUsd = atom({ plugin: 'usage-meter', key: 'costUsd' } as const, null)
17const contextPercent = atom({ plugin: 'usage-meter', key: 'contextPercent' } as const, null)
18const model = atom({ plugin: 'usage-meter', key: 'model' } as const, null)
19const effort = atom({ plugin: 'usage-meter', key: 'effort' } as const, null)
20const tokens = atom({ plugin: 'usage-meter', key: 'tokens' } as const, {
21  input: 0,
22  output: 0,
23  cacheRead: 0,
24  cacheWrite: 0,
25})
26// Recent session-percent readings, for the time-to-limit projection
27const pace = atom({ plugin: 'usage-meter', key: 'pace' } as const, { window: null, samples: [] })
28// Thresholds already toasted, as `<kind>@<resetsAt>:<threshold>`
29const warned = atom({ plugin: 'usage-meter', key: 'warned' } as const, [])
30
31const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
32const WARN_AT = [80, 95]
33const PACE_WINDOW_MS = 45 * 60 * 1000
34const PACE_MIN_SPAN_MS = 3 * 60 * 1000
35
36type Seg = { text: string; color?: string }
37
38function formatIn(ms: number): string {
39  const minutes = Math.max(0, Math.round(ms / 60000))
40  const days = Math.floor(minutes / 1440)
41  const hours = Math.floor((minutes % 1440) / 60)
42  const mins = minutes % 60
43  if (days > 0) return `${days}d ${hours}h`
44  if (hours > 0) return `${hours}h ${mins}m`
45  return `${mins}m`
46}
47
48function formatAt(date: Date): string {
49  const hours = date.getHours()
50  const mins = date.getMinutes()
51  const suffix = hours < 12 ? 'am' : 'pm'
52  const h12 = hours % 12 === 0 ? 12 : hours % 12
53  const time = mins === 0 ? `${h12}${suffix}` : `${h12}:${String(mins).padStart(2, '0')}${suffix}`
54  return `${DAYS[date.getDay()]} ${time}`
55}
56
57function count(n: number): string {
58  if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
59  if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
60  return String(n)
61}
62
63// `claude-opus-5-5[1m]` → `Opus 5.5 (1M)`; anything else is shown as given
64function modelName(id: string): string {
65  const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i.exec(id)
66  const [, word = '', major = '', minor] = m ?? []
67  if (!m || !word) return id
68  const family = word.charAt(0).toUpperCase() + word.slice(1)
69  const version = minor === undefined ? major : `${major}.${minor}`
70  return `${family} ${version}${m[4] ? ' (1M)' : ''}`
71}
72
73// Amber from 70%, red from 90%; undefined keeps the band's dim default
74function levelColor(pct: number): string | undefined {
75  if (pct >= 90) return 'red'
76  if (pct >= 70) return 'yellow'
77  return undefined
78}
79
80function resetText(kind: string, resetsAt: string | undefined, now: number): string {
81  const resets = resetsAt ? Date.parse(resetsAt) : NaN
82  if (Number.isNaN(resets)) return ''
83  return kind === 'five_hour'
84    ? ` · resets in ${formatIn(resets - now)}`
85    : ` · resets ${formatAt(new Date(resets))}`
86}
87
88// Milliseconds until the session window is used up at the recent rate, or null
89// when there are too few readings, usage isn't climbing, or the reset comes first
90function timeToLimit(session: Limit, p: Pace, now: number): number | null {
91  const first = p.samples[0]
92  const last = p.samples[p.samples.length - 1]
93  if (!first || !last || first === last) return null
94  const span = last.at - first.at
95  const climbed = last.percent - first.percent
96  if (span < PACE_MIN_SPAN_MS || climbed <= 0) return null
97  const remaining = ((100 - session.percentUsed) / climbed) * span
98  const resets = session.resetsAt ? Date.parse(session.resetsAt) : NaN
99  if (!Number.isNaN(resets) && now + remaining >= resets) return null
100  return Math.max(0, remaining)
101}
102
103async function apply(
104  $: EngineInterface,
105  rateLimits: SessionRateLimit[],
106  cost: SessionCost | undefined,
107  context: SessionContextUsage,
108) {
109  const pick = (kind: string): Limit | null => {
110    const found = rateLimits.find(r => r.kind === kind)
111    return found ? { percentUsed: found.percentUsed, resetsAt: found.resetsAt } : null
112  }
113  const fresh: Limits = { session: pick('five_hour'), weekly: pick('seven_day') }
114  await update($, limits, () => fresh)
115  await update($, costUsd, () => cost?.usd ?? null)
116  await update($, contextPercent, () => context.percent ?? null)
117
118  const now = await $.clock.now()
119  const session = fresh.session
120  if (session) {
121    const window = session.resetsAt ?? null
122    await update($, pace, (p): Pace => {
123      const kept = p.window === window ? p.samples.filter(s => now - s.at <= PACE_WINDOW_MS) : []
124      const last = kept[kept.length - 1]
125      const samples =
126        last && last.percent === session.percentUsed
127          ? kept
128          : [...kept, { at: now, percent: session.percentUsed }]
129      return { window, samples }
130    })
131  }
132
133  const done = await read($, warned)
134  const fired: string[] = []
135  for (const [kind, limit, label] of [
136    ['five_hour', fresh.session, 'Session'],
137    ['seven_day', fresh.weekly, 'Weekly'],
138  ] as const) {
139    if (!limit) continue
140    const top = [...WARN_AT].reverse().find(t => limit.percentUsed >= t)
141    if (top === undefined) continue
142    const key = `${kind}@${limit.resetsAt ?? ''}:${top}`
143    if (done.includes(key)) continue
144    fired.push(key)
145    $.ui.toast(
146      `${label} limit at ${Math.round(limit.percentUsed)}%${resetText(kind, limit.resetsAt, now)}`,
147      { timeoutMs: 10000 },
148    )
149  }
150  if (fired.length > 0) {
151    // Firing 95 also covers 80, so a later dip and climb doesn't toast 80 again
152    const extra = fired.flatMap(k => {
153      const top = Number(k.slice(k.lastIndexOf(':') + 1))
154      return WARN_AT.filter(t => t < top).map(t => k.replace(/:\d+$/, `:${t}`))
155    })
156    await update($, warned, w => [...new Set([...w, ...fired, ...extra])].slice(-40))
157  }
158}
159
160async function refresh($: EngineInterface) {
161  const { rateLimits, cost, context } = await $.session.usage()
162  await apply($, rateLimits, cost, context)
163  const name = await $.session.model()
164  await update($, model, () => (name ? modelName(name) : null))
165}
166
167export const register: Register = on => {
168  on('session.start', async ($, e, next) => {
169    await refresh($)
170    return next(e)
171  })
172
173  // Pushed whenever a rate-limit window moves a whole point, so warnings land mid-turn
174  on('session.measure', async ($, e, next) => {
175    await apply($, e.rateLimits, e.cost, e.context)
176    return next(e)
177  })
178
179  on('turn.step', async function* ($, e, next) {
180    if (e.agentId === undefined) {
181      const level = e.effort === undefined ? null : String(e.effort)
182      if (level !== (await read($, effort))) {
183        await update($, effort, () => level)
184      }
185    }
186    return yield* next(e)
187  })
188
189  on('turn.complete', async ($, e, next) => {
190    const result = await next(e)
191    const usage = e.usage
192    if (usage) {
193      await update($, tokens, (t): Tokens => ({
194        input: t.input + usage.input_tokens,
195        output: t.output + usage.output_tokens,
196        cacheRead: t.cacheRead + usage.cache_read_input_tokens,
197        cacheWrite: t.cacheWrite + usage.cache_creation_input_tokens,
198      }))
199    }
200    await refresh($)
201    return result
202  })
203
204  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
205    const { session, weekly } = await read($, limits)
206    const cost = await read($, costUsd)
207    const ctx = await read($, contextPercent)
208    const name = await read($, model)
209    const level = await read($, effort)
210    const t = await read($, tokens)
211    const p = await read($, pace)
212    const prompt = t.input + t.cacheRead + t.cacheWrite
213    const hasLimits = session !== null || weekly !== null
214    if (e.props.hasSurvey || (!hasLimits && prompt + t.output === 0 && !cost && ctx === null && name === null)) {
215      return next(e)
216    }
217
218    const now = await $.clock.now()
219    const limitItems: Seg[][] = []
220    if (session) {
221      const item: Seg[] = [
222        { text: 'Session ' },
223        { text: `${Math.round(session.percentUsed)}%`, color: levelColor(session.percentUsed) },
224        { text: resetText('five_hour', session.resetsAt, now) },
225      ]
226      const left = timeToLimit(session, p, now)
227      if (left !== null) {
228        item.push({
229          text: ` · ~${formatIn(left)} to limit at this pace`,
230          color: left < 30 * 60 * 1000 ? 'red' : 'yellow',
231        })
232      }
233      limitItems.push(item)
234    }
235    if (weekly) {
236      limitItems.push([
237        { text: 'Week ' },
238        { text: `${Math.round(weekly.percentUsed)}%`, color: levelColor(weekly.percentUsed) },
239        { text: resetText('seven_day', weekly.resetsAt, now) },
240      ])
241    }
242
243    const spendItems: Seg[][] = []
244    if (name !== null) spendItems.push([{ text: level === null ? name : `${name} (${level} effort)` }])
245    if (ctx !== null) spendItems.push([{ text: 'Context ' }, { text: `${ctx}%`, color: levelColor(ctx) }])
246    if (cost) spendItems.push([{ text: `$${cost.toFixed(2)}` }])
247    if (prompt + t.output > 0) {
248      spendItems.push([{ text: `in ${count(t.input)}` }], [{ text: `out ${count(t.output)}` }])
249      if (prompt > 0) {
250        const hit = Math.round((t.cacheRead / prompt) * 100)
251        const hitColor = hit < 50 ? 'red' : hit < 80 ? 'yellow' : undefined
252        spendItems.push([{ text: 'cache ' }, { text: `${hit}% hit`, color: hitColor }])
253      }
254    }
255
256    const { Box, Text } = $.ui.resolve(e)
257    const line = (items: Seg[][], sep: string, key: string) => (
258      <Box key={key} flexWrap="wrap">
259        {items.map((segs, i) => (
260          <Box key={String(i)}>
261            {segs.map((s, j) => (
262              <Text key={String(j)} dimColor={s.color === undefined} color={s.color}>
263                {s.text}
264              </Text>
265            ))}
266            {i < items.length - 1 && <Text dimColor>{sep}</Text>}
267          </Box>
268        ))}
269      </Box>
270    )
271    return (
272      <Box flexDirection="column">
273        {limitItems.length > 0 && line(limitItems, '   ', 'limits')}
274        {spendItems.length > 0 && line(spendItems, ' · ', 'spend')}
275      </Box>
276    )
277  })
278}
279
types/index.d.ts 21 lines
1export type Limit = { percentUsed: number; resetsAt?: string }
2export type Limits = { session: Limit | null; weekly: Limit | null }
3export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
4export type Sample = { at: number; percent: number }
5export type Pace = { window: string | null; samples: Sample[] }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'usage-meter': {
10      limits: Limits
11      costUsd: number | null
12      contextPercent: number | null
13      model: string | null
14      effort: string | null
15      tokens: Tokens
16      pace: Pace
17      warned: string[]
18    }
19  }
20}
21