SLOPSHOPPER

usage-meter

Live per-session usage band: tokens, cache hit and session cost, with optional turn detail

newbandcommandstatus
★ 1v0.1.1MITupdated 2026-10-23patradinesh/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 › /usage-meter ⎿ usage-meter: **Display:** band · **Detail:** off ⎿ usage-meter: ⎿ usage-meter: | Command | Effect | ⎿ usage-meter: | --- | --- | ⎿ usage-meter: | `/usage-meter band` | Row above the prompt | ⎿ usage-meter: | `/usage-meter status` | Status line (shows the plugin name) | Tokens: In: 97.4k, Out: 1.5k, Cache: 93% Session Cost: $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Tokens: In: 97.4k, Out: 1.5k, Cache: 93% Session Cost: $0.42
README

usage-meter

Live per-session usage in Claude Code: tokens, cache hit and session cost. It reads figures the engine already reports, so it costs no tokens.

Costs no tokens. The mod only displays figures the engine already reports. It makes no model calls and adds nothing to your context.

usage-meter band above the prompt, with turn detail on

Or in the status line (/usage-meter status):

usage-meter in the status line

Install

Two steps: add the marketplace, then install the plugin.

At the prompt of a terminal session:

/plugin marketplace add patradinesh/claude-usage-meter
/plugin install usage-meter@claude-usage-meter

From a shell:

claude plugin marketplace add patradinesh/claude-usage-meter
claude plugin install usage-meter@claude-usage-meter

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with patradinesh/claude-usage-meter, then Add plugin.

Remove

Uninstall the plugin, then remove the marketplace.

At the prompt of a terminal session:

/plugin uninstall usage-meter@claude-usage-meter
/plugin marketplace remove claude-usage-meter

From a shell:

claude plugin uninstall usage-meter
claude plugin marketplace remove claude-usage-meter

In the desktop app: open Plugins, remove usage-meter, then remove the marketplace.

What it shows

Tokens: In: 1.0k, Out: 300, Cache: 80%                    Session Cost: $0.50
  • In / Out: tokens this session. In includes cached input.
  • Cache: share of input read from the prompt cache.
  • Session Cost: what this session has cost so far.

With detail on, a second row adds last turn time and cost, total time, turn count and average cost per turn.

Choose where and how

Run /usage-meter to see the options:

CommandEffect
/usage-meter bandRow above the prompt
/usage-meter statusStatus line (shows the plugin name)
/usage-meter detail on / offAdd or remove last turn time and cost, total time, turns and average cost

In/Out tokens · Cache input read from cache · Session cost so far

The same options are in the plugin's config menu as the defaults.

Limits

  • The status line shows the plugin name as a label, added by the engine, and long text can truncate.
  • Counts start when the mod loads, so earlier turns in a session are not counted.
  • Per-turn cost is the rise in session cost during the turn. Subagent cost is included.

Feedback

Found a bug or have an idea? Open an issue.

License

MIT

Source 3 files
hooks/register.tsx 133 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Detail, Prefs, Stats } from '../types'
5import { detailLine, left, right, statusText } from './format'
6
7const empty: Stats = { model: null, input: 0, output: 0, cached: 0, usd: null }
8const noDetail: Detail = { turns: 0, ms: 0, lastMs: null, prevUsd: null, lastUsd: null }
9const noPrefs: Prefs = { detail: null, display: null }
10const stats = atom({ plugin: 'usage-meter', key: 'stats' } as const, empty)
11const detail = atom({ plugin: 'usage-meter', key: 'detail' } as const, noDetail)
12const prefs = atom({ plugin: 'usage-meter', key: 'prefs' } as const, noPrefs)
13
14const HELP = `| Command | Effect |
15| --- | --- |
16| \`/usage-meter band\` | Row above the prompt |
17| \`/usage-meter status\` | Status line (shows the plugin name) |
18| \`/usage-meter detail on\` / \`off\` | Add or remove last turn time and cost, total time, turns and average cost |
19
20**In/Out** tokens · **Cache** input read from cache · **Session** cost so far`
21
22// Config options are the defaults; /usage-meter choices override them for the session.
23async function view($: any, dDisplay: string, dDetail: boolean) {
24  const p = await read($, prefs)
25  return { display: p.display ?? dDisplay, detail: p.detail ?? dDetail }
26}
27
28async function text($: any, dDisplay: string, dDetail: boolean): Promise<string> {
29  const [s, d, v] = await Promise.all([read($, stats), read($, detail), view($, dDisplay, dDetail)])
30  return statusText(s, d, v.detail)
31}
32
33// Pins the status line when display is status, clears it otherwise.
34async function sync($: any, dDisplay: string, dDetail: boolean) {
35  const v = await view($, dDisplay, dDetail)
36  $.ui.status(v.display === 'status' ? await text($, dDisplay, dDetail) : undefined)
37}
38
39export const register: Register = (on, options) => {
40  const dDisplay = options.display === 'status' ? 'status' : 'band'
41  const dDetail = options.detail === true
42
43  on('session.start', async ($, e, next) => {
44    await $.command.register({ name: 'usage-meter', description: 'Choose where and how usage is shown' })
45    // Choices made with /usage-meter in an earlier session.
46    const saved = (await $.store.get('prefs')) as Partial<Prefs> | undefined
47    if (saved) await update($, prefs, p => ({ ...p, ...saved }))
48    await sync($, dDisplay, dDetail)
49    return next(e)
50  })
51
52  on('command.run', { command: 'usage-meter' }, async ($, e) => {
53    // Bare "band" or "status" is shorthand for "display band|status".
54    const words = e.args
55      .trim()
56      .toLowerCase()
57      .replace(/^(band|status)\b/, 'display $1')
58      .split(/\s+/)
59      .filter(Boolean)
60    // Accept several pairs in one go: /usage-meter display status detail off
61    for (let i = 0; i < words.length; i += 2) {
62      const [key, value] = [words[i], words[i + 1]]
63      if (key === 'display' && (value === 'band' || value === 'status')) {
64        await update($, prefs, p => ({ ...p, display: value }))
65      } else if (key === 'detail' && (value === 'on' || value === 'off')) {
66        await update($, prefs, p => ({ ...p, detail: value === 'on' }))
67      } else {
68        return { text: HELP }
69      }
70    }
71    await $.store.set('prefs', await read($, prefs))
72    await sync($, dDisplay, dDetail)
73    const v = await view($, dDisplay, dDetail)
74    return { text: `**Display:** ${v.display} · **Detail:** ${v.detail ? 'on' : 'off'}\n\n${HELP}` }
75  })
76
77  // Engine pushes new figures after each turn: no polling, no model call, no tokens.
78  on('session.measure', async ($, e, next) => {
79    const s = await update($, stats, s => ({ ...s, usd: e.cost?.usd ?? s.usd }))
80    // Cost of a turn = every rise in the session total between its start and the next start.
81    await update($, detail, d => {
82      const rise = s.usd !== null && d.prevUsd !== null && s.usd > d.prevUsd ? s.usd - d.prevUsd : 0
83      return {
84        ...d,
85        prevUsd: s.usd ?? d.prevUsd,
86        lastUsd: rise > 0 ? (d.fresh !== false ? rise : (d.lastUsd ?? 0) + rise) : d.lastUsd,
87        fresh: rise > 0 ? false : d.fresh,
88      }
89    })
90    await sync($, dDisplay, dDetail)
91    return next(e)
92  })
93
94  on('turn.start', async ($, e, next) => {
95    await update($, detail, d => ({ ...d, fresh: true }))
96    return next(e)
97  })
98
99  // Running totals: add each finished turn's token counts (subagent turns included).
100  on('turn.complete', async ($, e, next) => {
101    const u = e.usage
102    if (u) {
103      await update($, stats, s => ({
104        ...s,
105        model: u.model,
106        input: s.input + u.input_tokens + u.cache_creation_input_tokens,
107        output: s.output + u.output_tokens,
108        cached: s.cached + u.cache_read_input_tokens,
109      }))
110      await update($, detail, d => ({ ...d, turns: d.turns + 1, ms: d.ms + e.durationMs, lastMs: e.durationMs }))
111      await sync($, dDisplay, dDetail)
112    }
113    return next(e)
114  })
115
116  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
117    const v = await view($, dDisplay, dDetail)
118    if (v.display === 'status' || e.props.hasSurvey) return next(e)
119    const s = await read($, stats)
120    const extra = v.detail ? detailLine(await read($, detail), s.usd) : ''
121    const { Box, Text } = $.ui.resolve(e)
122    return (
123      <Box flexDirection="column">
124        <Box justifyContent="space-between">
125          <Text>{left(s)}</Text>
126          <Text>{right(s)}</Text>
127        </Box>
128        {extra !== '' && <Text>{extra}</Text>}
129      </Box>
130    )
131  })
132}
133
hooks/format.ts 58 lines
1import type { Detail, Stats } from '../types'
2
3export const compact = (n: number): string =>
4  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n)
5
6// cache hit = share of all input tokens served from the prompt cache
7export const hit = (s: Stats): number | null =>
8  s.input + s.cached === 0 ? null : Math.round((s.cached / (s.input + s.cached)) * 100)
9
10export const left = (s: Stats): string =>
11  [
12    `Tokens: In: ${compact(s.input + s.cached)}`,
13    `Out: ${compact(s.output)}`,
14    hit(s) === null ? null : `Cache: ${hit(s)}%`,
15  ]
16    .filter(Boolean)
17    .join(', ')
18
19export const right = (s: Stats): string => (s.usd === null ? '' : `Session Cost: $${s.usd.toFixed(2)}`)
20
21// one string for the status line, which has no left/right ends
22export const line = (s: Stats): string => [left(s), right(s)].filter(Boolean).join(' | ')
23
24export const dur = (ms: number): string => {
25  const t = Math.round(ms / 1000)
26  return t >= 60 ? `${Math.floor(t / 60)}m${String(t % 60).padStart(2, '0')}s` : `${t}s`
27}
28
29export const detailLine = (d: Detail, usd: number | null): string =>
30  d.turns === 0
31    ? ''
32    : [
33        d.lastMs === null ? null : `Last turn: ${dur(d.lastMs)}${d.lastUsd === null ? '' : ` $${d.lastUsd.toFixed(2)}`}`,
34        `Total time: ${dur(d.ms)}`,
35        `Turns: ${d.turns}`,
36        usd === null ? null : `Avg: $${(usd / d.turns).toFixed(2)}/turn`,
37      ]
38        .filter(Boolean)
39        .join(', ')
40
41// Short form for the status line, which truncates long text: the most useful figures come first.
42export const statusText = (s: Stats, d: Detail, withDetail: boolean): string =>
43  [
44    s.usd === null ? null : `Session $${s.usd.toFixed(2)}`,
45    `In ${compact(s.input + s.cached)}`,
46    `Out ${compact(s.output)}`,
47    hit(s) === null ? null : `Cache ${hit(s)}%`,
48    ...(withDetail && d.turns > 0
49      ? [
50          d.lastMs === null ? null : `Last ${dur(d.lastMs)}${d.lastUsd === null ? '' : ` $${d.lastUsd.toFixed(2)}`}`,
51          s.usd === null ? null : `Avg $${(s.usd / d.turns).toFixed(2)}`,
52          `${d.turns} turns`,
53        ]
54      : []),
55  ]
56    .filter(Boolean)
57    .join(' · ')
58
types/index.d.ts 26 lines
1export type Stats = {
2  model: string | null
3  input: number
4  output: number
5  cached: number
6  usd: number | null
7}
8
9export type Detail = {
10  turns: number
11  ms: number
12  lastMs: number | null
13  prevUsd: number | null
14  // true from a turn's start until its first cost reading lands
15  fresh?: boolean
16  lastUsd: number | null
17}
18
19export type Prefs = { detail: boolean | null; display: 'band' | 'status' | null }
20
21declare module 'claude-code' {
22  interface PluginState {
23    'usage-meter': { stats: Stats; detail: Detail; prefs: Prefs }
24  }
25}
26