SLOPSHOPPER

usage-meter

Directory, branch, worktree, model and effort, with 5h and weekly limits (subscription) or cost (pay-per-token), above the prompt; context below it

newbandspinnerguardprocesstimer
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 app Opus 5.5 5h 69% Weekly – ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › Context 97k/200k

Draws

Band
app Opus 5.5 5h 69% Weekly –
Prompt hint
Context 97k/200k
README

usage-meter

A Claude Code mod that shows, around the prompt:

  • Above: the current directory and git branch (with a ● when dirty) on the left, cut short with … rather than wrapping. In a worktree, a ⎇ button stands in for its name and opens the worktree, directory and branch in full; the directory is named only when you are in a subfolder of it. A directory the branch already ends with (issue-538 on feat/issue-538) is left to the branch. Model and effort are on the right, followed by the usage meter:
  • Subscription: 5-hour and weekly rate limits as bars with the percent left and time until reset.
  • Pay-per-token: a Cost control. Press the period button to switch between Session, Today, 7 days and 30 days (the list opens on the right, below the meter); ⓘ explains how the estimate works.
  • Below: context as a bar twice as wide as the limit bars (fills as the window is used; green, then yellow, then red as little is left), with used/window beside it.

Subscription or metered?

The mod infers billing from what Claude Code reports: rate-limit windows (five_hour, seven_day) exist only on a subscription. With none, and a priced response already in, billing is treated as metered. Before the first response it can't tell, so it uses the mode it saw last (remembered across sessions); on first use it shows neither until it knows.

Cost estimates

  • Session is the exact cost Claude Code reports.
  • Today / 7 days / 30 days are estimates (marked ~ and est.). hooks/scan.mjs scans local logs in ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects) and the Cowork sessions folder (macOS and Windows), counts each message once and prices it at public API list prices per model (including fast mode, US-only inference, server-side compaction, Haiku 5.5's long-prompt rates and web searches). Discounts, other machines and logs older than 30 days are not reflected; unrecognised models are priced as Sonnet. Refreshed at most once a minute, and only for metered users.
  • Requires node on the PATH. Per-model prices are hardcoded in hooks/scan.mjs and need updating when pricing changes.

Install

/plugin marketplace add akerskuuug/claude-mods
/plugin install usage-meter@claude-mods

Pick the user scope when prompted.

The mod runs as function hooks, which Claude Code only loads when they are switched on. Add this to ~/.claude/settings.json (if the file already exists, merge env into it) and restart Claude Code:

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

Without it the mod installs but shows nothing.

Source 2 files
hooks/register.tsx 466 lines
1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, Register } from 'claude-code'
3
4import type { CostView, GitState, KnownBilling, Usage } from '../types'
5
6const usage = atom({ plugin: 'usage-meter', key: 'usage' } as const, null)
7const now = atom({ plugin: 'usage-meter', key: 'now' } as const, null)
8const git = atom({ plugin: 'usage-meter', key: 'git' } as const, null)
9const model = atom({ plugin: 'usage-meter', key: 'model' } as const, null)
10const effort = atom({ plugin: 'usage-meter', key: 'effort' } as const, null)
11
12const costView = atom({ plugin: 'usage-meter', key: 'costView' } as const, 'session' as CostView)
13const costOpen = atom({ plugin: 'usage-meter', key: 'costOpen' } as const, false)
14const costInfo = atom({ plugin: 'usage-meter', key: 'costInfo' } as const, false)
15const ledger = atom({ plugin: 'usage-meter', key: 'ledger' } as const, {} as Record<string, number>)
16const sessionCost = atom({ plugin: 'usage-meter', key: 'sessionCost' } as const, 0)
17const knownBilling = atom({ plugin: 'usage-meter', key: 'knownBilling' } as const, null as KnownBilling | null)
18const dir = atom({ plugin: 'usage-meter', key: 'dir' } as const, null)
19const worktreeOpen = atom({ plugin: 'usage-meter', key: 'worktreeOpen' } as const, false)
20
21const BAR_CELLS = 10
22const CONTEXT_CELLS = BAR_CELLS * 2
23// Dark-terminal track colour; ThemeKey has no neutral background.
24const TRACK_COLOR = '#3a3a3a'
25const MINUTE = 60_000
26
27export type BillingMode = KnownBilling | 'unknown'
28
29// Rate-limit windows only exist on a subscription (Pro/Max/Team/Enterprise
30// seats). With none and a priced response already in, billing is per token.
31// Before the first response there is no reading, so it is unknown.
32export function billingMode(u: Usage | null, cost: number): BillingMode {
33  if (u?.rateLimits.some(r => r.kind === 'five_hour' || r.kind === 'seven_day')) return 'subscription'
34  return u && cost > 0 ? 'metered' : 'unknown'
35}
36
37// The live reading wins; until there is one, the mode last seen (in any
38// session) stands in, so a metered seat does not flash the limit bars.
39export function resolveBilling(live: BillingMode, cached: KnownBilling | null): BillingMode {
40  return live === 'unknown' ? (cached ?? 'unknown') : live
41}
42
43// Last path segment, for either separator; the root stays itself.
44export function baseName(path: string): string {
45  return path.split(/[\\/]/).filter(Boolean).pop() ?? path
46}
47
48export const COST_VIEWS: { id: CostView; label: string; days: number }[] = [
49  { id: 'session', label: 'Session', days: 0 },
50  { id: 'today', label: 'Today', days: 1 },
51  { id: '7d', label: '7 days', days: 7 },
52  { id: '30d', label: '30 days', days: 30 },
53]
54
55// Local-date key, so "today" rolls over at local midnight.
56export function dayKey(ms: number): string {
57  const d = new Date(ms)
58  const p = (n: number) => String(n).padStart(2, '0')
59  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
60}
61
62// Sum of the last `days` local days including today.
63export function sumDays(map: Record<string, number>, nowMs: number, days: number): number {
64  let total = 0
65  const d = new Date(nowMs)
66  // Step by local calendar date: a day is not always 24 h across DST changes.
67  for (let i = 0; i < days; i++, d.setDate(d.getDate() - 1)) total += map[dayKey(d.getTime())] ?? 0
68  return total
69}
70
71// Session is exact (reported by Claude Code); the rest are marked as estimates.
72export function formatCost(id: CostView, usd: number): string {
73  return `${id === 'session' ? '' : '~'}$${usd.toFixed(2)}`
74}
75
76const COST_EXPLANATION = [
77  'You appear to be billed per token, so cost is shown instead of 5-hour and weekly limits.',
78  'Session is the exact cost Claude Code reports for this session.',
79  "Today, 7 days and 30 days (~) are estimates: this mod scans the local logs in ~/.claude/projects and Cowork sessions, counts each message's token usage once, and prices it at public API list prices per model.",
80  'Discounts, other machines and logs older than 30 days are not reflected, usage billed to a subscription is priced the same way, and unrecognised models are priced as Sonnet. Refreshed at most once a minute.',
81]
82
83export function formatTokens(n: number): string {
84  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
85  if (n >= 1_000) return `${Math.round(n / 1_000)}k`
86  return `${n}`
87}
88
89export function percentLeft(kind: string, u: Usage | null): number | null {
90  const window = u?.rateLimits.find(r => r.kind === kind)
91  if (!window) return null
92  return Math.max(0, Math.min(100, Math.round(100 - window.percentUsed)))
93}
94
95export function formatResetIn(resetsAt: string | undefined, nowMs: number | null): string | null {
96  if (!resetsAt || nowMs === null) return null
97  const minutes = Math.max(0, Math.ceil((Date.parse(resetsAt) - nowMs) / MINUTE))
98  if (Number.isNaN(minutes)) return null
99  const days = Math.floor(minutes / 1440)
100  const hours = Math.floor((minutes % 1440) / 60)
101  if (days > 0) return `${days}d${hours}h`
102  if (hours > 0) return `${hours}h${minutes % 60}m`
103  return `${minutes}m`
104}
105
106export function parseGitStatus(porcelainV2: string): Omit<GitState, 'worktree' | 'root'> | null {
107  const lines = porcelainV2.split('\n').filter(line => line.length > 0)
108  const head = lines.find(line => line.startsWith('# branch.head '))
109  if (!head) return null
110  const branch = head.slice('# branch.head '.length)
111  return {
112    branch: branch === '(detached)' ? 'detached' : branch,
113    isDirty: lines.some(line => !line.startsWith('#')),
114  }
115}
116
117export function barCells(left: number, cells: number): { filled: string; empty: string } {
118  const label = `${left}%`
119  const start = Math.max(0, Math.floor((cells - label.length) / 2))
120  const text = (' '.repeat(start) + label).padEnd(cells).slice(0, cells)
121  const filledCount = Math.round((left / 100) * cells)
122  return { filled: text.slice(0, filledCount), empty: text.slice(filledCount) }
123}
124
125// Percent of the context window still free, from the tokens in use.
126export function contextLeft(u: Usage): number {
127  const used = u.context.percent ?? (u.context.window > 0 ? ((u.context.tokens ?? 0) / u.context.window) * 100 : 0)
128  return Math.max(0, Math.min(100, Math.round(100 - used)))
129}
130
131// A bar with no text inside: just the filled and empty runs.
132export function barFill(left: number, cells: number): { filled: string; empty: string } {
133  const filledCount = Math.round((left / 100) * cells)
134  return { filled: ' '.repeat(filledCount), empty: ' '.repeat(cells - filledCount) }
135}
136
137export function formatModel(id: string): string {
138  const bare = id.replace(/\[.*\]$/, '')
139  const match = /^claude-([a-z]+)-(\d+)(?:-(\d+))?/.exec(bare)
140  if (!match) return bare
141  const [, family = '', major, minor] = match
142  const name = family.charAt(0).toUpperCase() + family.slice(1)
143  return minor ? `${name} ${major}.${minor}` : `${name} ${major}`
144}
145
146async function refreshModel($: EngineInterface) {
147  const name = await $.session.model()
148  await update($, model, () => name)
149}
150
151function barColor(left: number): Color {
152  if (left > 50) return 'success'
153  if (left > 20) return 'warning'
154  return 'error'
155}
156
157export function parseWorktree(revParse: string): { worktree: string | null; root: string | null } {
158  const [gitDir, commonDir, topLevel] = revParse.trim().split('\n')
159  if (!topLevel) return { worktree: null, root: null }
160  const linked = gitDir && commonDir && gitDir !== commonDir
161  return { worktree: linked ? baseName(topLevel) : null, root: topLevel }
162}
163
164// What the git row names, without saying the same thing twice. In a worktree
165// the directory is its root, so only a subfolder (relative) is worth naming;
166// the worktree itself is behind the ⎇ toggle. A directory the branch already
167// ends with (feat/x in x) is left to the branch.
168export function locationLabel(cwd: string | null, repo: GitState | null): string | null {
169  if (!cwd) return null
170  let label: string | null = baseName(cwd)
171  if (repo?.worktree) {
172    const root = repo.root?.replace(/\/+$/, '')
173    if (root && cwd.startsWith(`${root}/`)) label = cwd.slice(root.length + 1).replace(/\/+$/, '') || null
174    else if (root === cwd.replace(/\/+$/, '') || label === repo.worktree) label = null
175  }
176  if (label && repo && (repo.branch === label || repo.branch.endsWith(`/${label}`))) return null
177  return label
178}
179
180async function refreshGit($: EngineInterface) {
181  try {
182    const [status, paths] = await Promise.all([
183      $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 5_000 }),
184      $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel'], {
185        timeoutMs: 5_000,
186      }),
187    ])
188    const parsed = status.exitCode === 0 ? parseGitStatus(status.stdout) : null
189    const where = paths.exitCode === 0 ? parseWorktree(paths.stdout) : { worktree: null, root: null }
190    const state = parsed && { ...parsed, ...where }
191    await update($, git, () => state)
192  } catch {
193    await update($, git, () => null)
194  }
195}
196
197async function refreshDir($: EngineInterface) {
198  const name = await $.session.cwd()
199  await update($, dir, () => name)
200}
201
202let lastScan = 0
203let lastDays: Record<string, number> | null = null
204
205// Re-estimates the per-day cost from local logs; throttled to 60 s and only
206// run for metered users. Where a host refuses $.process, the last value stays.
207async function refreshLedger($: EngineInterface) {
208  const time = await $.clock.now()
209  if (time - lastScan < MINUTE) {
210    // /clear, /resume and /branch reset the ledger atom; put the last scan back.
211    if (lastDays && Object.keys(await read($, ledger)).length === 0) await update($, ledger, () => lastDays!)
212    return
213  }
214  lastScan = time
215  try {
216    const run = await $.process.run(['node', `${$.plugin.root}/hooks/scan.mjs`, '30'], { timeoutMs: 60_000 })
217    if (run.exitCode !== 0) return
218    const { days } = JSON.parse(run.stdout) as { days: Record<string, number> }
219    lastDays = days
220    await update($, ledger, () => days)
221  } catch {}
222}
223
224async function noteCost($: EngineInterface, cost: { usd: number } | undefined, u: Usage) {
225  // The store is the source of truth: /clear and /resume reset state without a session.start.
226  const picked = await $.store.get('costView')
227  if (picked && picked !== (await read($, costView))) await update($, costView, () => picked as CostView)
228  if (cost) await update($, sessionCost, () => cost.usd)
229  const live = billingMode(u, await read($, sessionCost))
230  const stored = await $.store.get('billing')
231  const cached = live !== 'unknown' ? live : stored === 'subscription' || stored === 'metered' ? stored : null
232  if (cached !== (await read($, knownBilling))) await update($, knownBilling, () => cached)
233  if (live !== 'unknown' && live !== stored) await $.store.set('billing', live)
234  if (cached === 'metered') refreshLedger($).catch(() => {})
235}
236
237async function currentBilling($: EngineInterface): Promise<BillingMode> {
238  return resolveBilling(billingMode(await read($, usage), await read($, sessionCost)), await read($, knownBilling))
239}
240
241async function tick($: EngineInterface) {
242  const time = await $.clock.now()
243  await update($, now, () => time)
244  await Promise.all([refreshGit($), refreshDir($).catch(() => {})])
245  // Other sessions keep writing logs while this one is idle; pick their usage up too.
246  if ((await currentBilling($)) === 'metered') refreshLedger($).catch(() => {})
247}
248
249export const register: Register = on => {
250  on('session.start', async ($, e, next) => {
251    const result = await next(e)
252    const { context, rateLimits, cost } = await $.session.usage()
253    await update($, usage, () => ({ context, rateLimits }))
254    await noteCost($, cost, { context, rateLimits })
255    await tick($)
256    await refreshModel($)
257    $.clock.every(MINUTE, () => tick($))
258    return result
259  })
260
261  on('session.measure', async ($, e, next) => {
262    const u = { context: e.context, rateLimits: e.rateLimits }
263    await update($, usage, () => u)
264    await noteCost($, e.cost, u)
265    return next(e)
266  })
267
268  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
269    const result = await next(e)
270    refreshGit($).catch(() => {})
271    refreshDir($).catch(() => {})
272    return result
273  })
274
275  on('turn.step', async function* ($, e, next) {
276    if (e.agentId === undefined) {
277      const level = e.effort === undefined ? null : `${e.effort}`
278      update($, effort, () => level).catch(() => {})
279      update($, model, () => e.model).catch(() => {})
280    }
281    return yield* next(e)
282  })
283
284  on('turn.complete', async ($, e, next) => {
285    tick($).catch(() => {})
286    return next(e)
287  })
288
289  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
290    const u = await read($, usage)
291    if (e.props.hasSurvey || !u) return next(e)
292    const repo = await read($, git)
293    const cwd = await read($, dir)
294    const dirName = locationLabel(cwd, repo)
295    const isWorktreeOpen = !!repo?.worktree && (await read($, worktreeOpen))
296    const modelId = await read($, model)
297    const effortLevel = await read($, effort)
298    const spentSession = await read($, sessionCost)
299    const mode = await currentBilling($)
300    const isMetered = mode === 'metered'
301
302    const { Box, Button, Text } = $.ui.resolve(e)
303
304    const meter = (label: string, kind: string, nowMs: number | null) => {
305      const left = percentLeft(kind, u)
306      if (left === null) {
307        return (
308          <Box key={kind}>
309            <Text dimColor>{label} –</Text>
310          </Box>
311        )
312      }
313      const { filled, empty } = barCells(left, BAR_CELLS)
314      const resetIn = formatResetIn(u.rateLimits.find(r => r.kind === kind)?.resetsAt, nowMs)
315      return (
316        <Box key={kind}>
317          <Text dimColor>{label} </Text>
318          <Text color="inverseText" backgroundColor={barColor(left)} bold>
319            {filled}
320          </Text>
321          <Text backgroundColor={TRACK_COLOR}>{empty}</Text>
322          {resetIn && <Text dimColor> · {resetIn}</Text>}
323        </Box>
324      )
325    }
326
327    const current = await read($, costView)
328    const isOpen = await read($, costOpen)
329    const isInfo = await read($, costInfo)
330    const ledgerNow = await read($, ledger)
331    const nowMs = await $.clock.now()
332    const costs = COST_VIEWS.map(v => formatCost(v.id, v.id === 'session' ? spentSession : sumDays(ledgerNow, nowMs, v.days)))
333    const index = COST_VIEWS.findIndex(v => v.id === current)
334    const estimated = current !== 'session'
335
336    // Subscription: the two limit bars. Metered: the cost control. Unknown: nothing yet.
337    const limits = mode === 'unknown' ? null : isMetered ? (
338      <Box key="cost" columnGap={1}>
339        <Text dimColor>Cost</Text>
340        <Button
341          key="cost-toggle"
342          label={`${COST_VIEWS[index]!.label} ${isOpen ? '▴' : '▾'}`}
343          onPress={() => update($, costOpen, v => !v)}
344        />
345        <Text>
346          {costs[index]}
347          {estimated ? ' est.' : ''}
348        </Text>
349        <Button key="cost-info" label={isInfo ? 'ⓘ Hide' : 'ⓘ'} onPress={() => update($, costInfo, v => !v)} />
350      </Box>
351    ) : (
352      <Box key="limits" columnGap={3}>
353        {meter('5h', 'five_hour', await read($, now))}
354        {meter('Weekly', 'seven_day', await read($, now))}
355      </Box>
356    )
357
358    const costWidth = Math.max(...costs.map(c => c.length))
359    const labelWidth = Math.max(...COST_VIEWS.map(v => v.label.length))
360
361    return (
362      <Box flexDirection="column">
363        <Box flexDirection="row" width={e.props.bodyColumns} justifyContent="space-between" columnGap={3}>
364          {repo || dirName ? (
365            <Box key="git" flexShrink={1} minWidth={0} columnGap={1}>
366              {repo?.worktree && (
367                <Button
368                  key="worktree-toggle"
369                  label={isWorktreeOpen ? '⎇ Hide' : '⎇'}
370                  onPress={() => update($, worktreeOpen, v => !v)}
371                />
372              )}
373              <Text wrap="truncate-end">
374                {dirName && <Text>{dirName}</Text>}
375                {dirName && repo && <Text dimColor> on </Text>}
376                {repo && <Text color="suggestion">{repo.branch}</Text>}
377                {repo?.isDirty && <Text color="warning"> ●</Text>}
378              </Text>
379            </Box>
380          ) : (
381            <Text key="git"> </Text>
382          )}
383          <Box key="right" columnGap={3} flexShrink={0}>
384            {modelId && (
385              <Box key="model">
386                <Text color="claude">{formatModel(modelId)}</Text>
387                {effortLevel && <Text dimColor> · {effortLevel}</Text>}
388              </Box>
389            )}
390            {limits}
391          </Box>
392        </Box>
393        {isWorktreeOpen && repo?.worktree && (
394          <Box flexDirection="column" borderStyle="round" paddingX={1}>
395            {[
396              ['Worktree', repo.worktree],
397              ['Directory', cwd ?? ''],
398              ['Branch', repo.branch],
399            ].map(([label, value]) => (
400              <Box key={label}>
401                <Box width={11} flexShrink={0}>
402                  <Text dimColor>{label}</Text>
403                </Box>
404                <Text>{value}</Text>
405              </Box>
406            ))}
407          </Box>
408        )}
409        {isMetered && isInfo && (
410          <Box flexDirection="column" borderStyle="round" paddingX={1}>
411            {COST_EXPLANATION.map((paragraph, i) => (
412              <Box key={i} marginTop={i === 0 ? 0 : 1}>
413                <Text dimColor>{paragraph}</Text>
414              </Box>
415            ))}
416          </Box>
417        )}
418        {isMetered && isOpen && (
419          <Box flexDirection="column" alignSelf="flex-end">
420            {COST_VIEWS.map((v, i) => (
421              <Box key={v.id}>
422                <Box width={labelWidth + 8}>
423                  <Button
424                    key={v.id}
425                    label={`${v.id === current ? '●' : '○'} ${v.label}`}
426                    onPress={async () => {
427                      await update($, costView, () => v.id)
428                      await update($, costOpen, () => false)
429                      await $.store.set('costView', v.id)
430                    }}
431                  />
432                </Box>
433                <Box width={costWidth + 1} justifyContent="flex-end">
434                  <Text>{costs[i]}</Text>
435                </Box>
436              </Box>
437            ))}
438          </Box>
439        )}
440      </Box>
441    )
442  })
443
444  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
445    const u = await read($, usage)
446    if (!u) return next(e)
447    const { Box, Text } = $.ui.resolve(e)
448
449    const left = contextLeft(u)
450    // Fills as the window is used; the colour still turns red as little is left.
451    const { filled, empty } = barFill(100 - left, CONTEXT_CELLS)
452
453    return (
454      <Box flexDirection="row" flexGrow={1} flexShrink={1} justifyContent="flex-end">
455        <Text dimColor>Context </Text>
456        <Text backgroundColor={barColor(left)}>{filled}</Text>
457        <Text backgroundColor={TRACK_COLOR}>{empty}</Text>
458        <Text dimColor>
459          {' '}
460          {formatTokens(u.context.tokens ?? 0)}/{formatTokens(u.context.window)}
461        </Text>
462      </Box>
463    )
464  })
465}
466
types/index.d.ts 29 lines
1export type RateWindow = { kind: string; percentUsed: number; resetsAt?: string }
2export type Usage = {
3  context: { tokens?: number; window: number; percent?: number }
4  rateLimits: RateWindow[]
5}
6export type CostView = 'session' | 'today' | '7d' | '30d'
7export type KnownBilling = 'subscription' | 'metered'
8export type GitState = { branch: string; isDirty: boolean; worktree: string | null; root: string | null }
9
10declare module 'claude-code' {
11  interface PluginState {
12    'usage-meter': {
13      usage: Usage | null
14      now: number | null
15      git: GitState | null
16      model: string | null
17      effort: string | null
18      costView: CostView
19      costOpen: boolean
20      costInfo: boolean
21      ledger: Record<string, number>
22      sessionCost: number
23      knownBilling: KnownBilling | null
24      dir: string | null
25      worktreeOpen: boolean
26    }
27  }
28}
29