Usage meter in the prompt footer: an Enterprise or Team seat's monthly budget, or a subscription's tightest rate-limit window.

A Claude Code mod that shows how much of your usage is left, at the bottom right of the prompt footer.
⏵⏵ auto mode on budget ███░░░░░░░ 31% · resets 19d
/usage reads it. It resets on the 1st of the month (UTC).The bar turns yellow at 75% and red at 90%. Below 100 columns the bar and reset time drop; below 70 the meter hides.
/plugin marketplace add Delavalom/delavalom-stack
/plugin install sub-meter@delavalom-stack
One hooks module, hooks/register.tsx:
$.session.usage()) and after each response (session.measure). The meter shows the highest one.GET https://api.anthropic.com/api/oauth/usage and reads extra_usage (is_enabled, utilization). It asks at session start, then at most every 5 minutes after a response. The request goes through $.http.fetch with the $.session.authorize() handle: the engine attaches your login for Anthropic's own host, and the token never reaches the mod. With CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC set, the request is refused and the meter keeps its last reading.$.store, so a new session shows it right away.claude plugin validate ./plugins/sub-meter
claude plugin test ./plugins/sub-meterhooks/register.tsx 110 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Reading } from '../types'
5
6const reading = atom({ plugin: 'sub-meter', key: 'reading' } as const, null as Reading | null)
7
8// Enterprise and Team seats get no rate-limit windows from the engine; their monthly budget is in the
9// account's usage, the endpoint /usage reads. The engine attaches the login; the token never reaches here.
10const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
11const POLL_MS = 5 * 60_000
12const LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'week', spend_limit: 'budget' }
13const BAR = 10
14// ponytail: fixed breakpoints on terminal width, not a measure of the footer's free room; measure if it clips.
15const FULL_COLUMNS = 100
16const SHORT_COLUMNS = 70
17
18let polledAt = -Infinity
19
20/** The window closest to its limit, or null when the engine measured none. */
21function tightest (limits: readonly SessionRateLimit[]): Reading | null {
22 const known = limits.filter(limit => limit.kind in LABELS)
23 if (known.length === 0) return null
24 const top = known.reduce((a, b) => (b.percentUsed > a.percentUsed ? b : a))
25 return { label: LABELS[top.kind], percent: top.percentUsed, resetsAt: top.resetsAt }
26}
27
28/** The seat's monthly budget; null off a claude.ai login, without a budget, or when the request fails. */
29async function seatBudget ($: EngineInterface): Promise<Reading | null> {
30 try {
31 const auth = await $.session.authorize()
32 if (auth?.kind !== 'bearer') return null
33 const res = await $.http.fetch(USAGE_URL, { auth: auth.handle })
34 if (!res.ok) return null
35 const budget = JSON.parse(res.text)?.extra_usage
36 if (!budget?.is_enabled || typeof budget.utilization !== 'number') return null
37 const now = new Date(await $.clock.now())
38 // ponytail: the API sends no reset for the budget; the 1st of next month, UTC, matches its overage reset.
39 const resetsAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString()
40 return { label: 'budget', percent: Math.round(budget.utilization * 10) / 10, resetsAt }
41 } catch {
42 return null
43 }
44}
45
46async function save ($: EngineInterface, next: Reading) {
47 await update($, reading, () => next)
48 await $.store.set('reading', next)
49}
50
51/** Takes the engine's windows when it has any, else asks for the seat's budget at most every POLL_MS. */
52async function refresh ($: EngineInterface, limits: readonly SessionRateLimit[]) {
53 const window = tightest(limits)
54 if (window) return save($, window)
55 const now = await $.clock.now()
56 if (now - polledAt < POLL_MS) return
57 polledAt = now
58 const budget = await seatBudget($)
59 if (budget) await save($, budget)
60}
61
62/** "19d", "4h", "12m": the largest whole unit left. */
63function countdown (ms: number) {
64 const minutes = Math.max(1, Math.floor(ms / 60_000))
65 if (minutes >= 1440) return `${Math.floor(minutes / 1440)}d`
66 if (minutes >= 60) return `${Math.floor(minutes / 60)}h`
67 return `${minutes}m`
68}
69
70const tone = (percent: number) => (percent >= 90 ? 'error' : percent >= 75 ? 'warning' : 'success')
71
72export const register: Register = on => {
73 on('session.start', async ($, e, next) => {
74 const stored = await $.store.get('reading')
75 if (stored && typeof stored === 'object') await update($, reading, () => stored as Reading)
76 await refresh($, (await $.session.usage()).rateLimits)
77 return next(e)
78 })
79
80 on('session.measure', async ($, e, next) => {
81 await refresh($, e.rateLimits)
82 return next(e)
83 })
84
85 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
86 const beneath = await next(e)
87 const shown = await read($, reading)
88 const columns = e.surface === 'terminal' ? e.viewport?.columns ?? Infinity : Infinity
89 if (!shown || columns < SHORT_COLUMNS) return beneath
90
91 const left = shown.resetsAt === undefined ? undefined : Date.parse(shown.resetsAt) - (await $.clock.now())
92 const percent = left !== undefined && left <= 0 ? 0 : shown.percent
93 const isFull = columns >= FULL_COLUMNS
94 const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * BAR)
95 const { Box, Text } = $.ui.resolve(e)
96
97 return (
98 <Box flexDirection="row">
99 <Text dimColor>{shown.label + ' '}</Text>
100 {isFull ? <Text color={tone(percent)}>{'█'.repeat(filled)}</Text> : null}
101 {isFull ? <Text dimColor>{'░'.repeat(BAR - filled) + ' '}</Text> : null}
102 <Text color={tone(percent)}>{`${Math.round(percent)}%`}</Text>
103 {isFull && left !== undefined && left > 0 ? <Text dimColor>{` · resets ${countdown(left)}`}</Text> : null}
104 {e.props.modes.length > 0 ? <Text dimColor>{' '}</Text> : null}
105 {beneath}
106 </Box>
107 )
108 })
109}
110types/index.d.ts 9 lines1/** What the meter shows: one usage figure against its limit. */
2export type Reading = { label: string; percent: number; resetsAt?: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'sub-meter': { reading: Reading | null }
7 }
8}
9