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

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%)
/contextColors 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).
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.
claude plugin validate .
claude plugin test .
Or load a local checkout without installing it: claude --plugin-dir <path to this folder>.
hooks/register.tsx 187 lines1import { 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}
187types/index.d.ts 18 lines1export 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