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

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.

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

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.
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.
Tokens: In: 1.0k, Out: 300, Cache: 80% Session Cost: $0.50
With detail on, a second row adds last turn time and cost, total time, turn count and average cost per turn.
Run /usage-meter to see the options:
| Command | Effect |
|---|---|
/usage-meter band | Row above the prompt |
/usage-meter status | Status line (shows the plugin name) |
/usage-meter detail on / off | Add 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.
Found a bug or have an idea? Open an issue.
hooks/register.tsx 133 lines1import { 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}
133hooks/format.ts 58 lines1import 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(' · ')
58types/index.d.ts 26 lines1export 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