Plan limits, time-to-limit pace, session cost, tokens, cache hit rate, context and model in the band above the prompt

A Claude Code mod that shows your plan usage and session spend in the band above the prompt.

Later in a busy window, with the pace projection showing:
Session 82% · resets in 2h 14m · ~48m to limit at this pace Week 71% · resets Thu 9am
Opus 5.5 (1M) (high effort) · Context 34% · $1.37 · in 12.4k · out 8.1k · cache 94% hit
Limits line (Claude subscriptions only)
Session line
/cost)Each line is hidden when it has no data; with no data at all the band is empty.
| Figure | Amber | Red |
|---|---|---|
| Session, week, context % | from 70% | from 90% |
| Cache hit rate | below 80% | below 50% |
| Pace | under the reset | under 30 minutes |
A toast appears when the session or weekly limit reaches 80% and again at 95%, once per window. It reacts as soon as a limit moves a whole point, so it can appear mid-turn.
/plugin marketplace add shubhamshinde245/claude-mods
/plugin install usage-meter@shubham-mods
Requires a Claude Code version with function-hook plugins (built and tested on 2.1.286).
usage-meter only reads figures Claude Code already has for the current session: rate-limit percentages and reset times, cost, token counts, context fill, model and effort level. It makes no network requests, runs no commands, reads no files and sends nothing anywhere. Everything it keeps is held in the session's own plugin state and is gone when Claude Code restarts.
MIT
hooks/register.tsx 279 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 Register,
5 SessionContextUsage,
6 SessionCost,
7 SessionRateLimit,
8} from 'claude-code'
9
10import type { Limit, Limits, Pace, Tokens } from '../types'
11
12const limits = atom({ plugin: 'usage-meter', key: 'limits' } as const, {
13 session: null,
14 weekly: null,
15})
16const costUsd = atom({ plugin: 'usage-meter', key: 'costUsd' } as const, null)
17const contextPercent = atom({ plugin: 'usage-meter', key: 'contextPercent' } as const, null)
18const model = atom({ plugin: 'usage-meter', key: 'model' } as const, null)
19const effort = atom({ plugin: 'usage-meter', key: 'effort' } as const, null)
20const tokens = atom({ plugin: 'usage-meter', key: 'tokens' } as const, {
21 input: 0,
22 output: 0,
23 cacheRead: 0,
24 cacheWrite: 0,
25})
26// Recent session-percent readings, for the time-to-limit projection
27const pace = atom({ plugin: 'usage-meter', key: 'pace' } as const, { window: null, samples: [] })
28// Thresholds already toasted, as `<kind>@<resetsAt>:<threshold>`
29const warned = atom({ plugin: 'usage-meter', key: 'warned' } as const, [])
30
31const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
32const WARN_AT = [80, 95]
33const PACE_WINDOW_MS = 45 * 60 * 1000
34const PACE_MIN_SPAN_MS = 3 * 60 * 1000
35
36type Seg = { text: string; color?: string }
37
38function formatIn(ms: number): string {
39 const minutes = Math.max(0, Math.round(ms / 60000))
40 const days = Math.floor(minutes / 1440)
41 const hours = Math.floor((minutes % 1440) / 60)
42 const mins = minutes % 60
43 if (days > 0) return `${days}d ${hours}h`
44 if (hours > 0) return `${hours}h ${mins}m`
45 return `${mins}m`
46}
47
48function formatAt(date: Date): string {
49 const hours = date.getHours()
50 const mins = date.getMinutes()
51 const suffix = hours < 12 ? 'am' : 'pm'
52 const h12 = hours % 12 === 0 ? 12 : hours % 12
53 const time = mins === 0 ? `${h12}${suffix}` : `${h12}:${String(mins).padStart(2, '0')}${suffix}`
54 return `${DAYS[date.getDay()]} ${time}`
55}
56
57function count(n: number): string {
58 if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
59 if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
60 return String(n)
61}
62
63// `claude-opus-5-5[1m]` → `Opus 5.5 (1M)`; anything else is shown as given
64function modelName(id: string): string {
65 const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i.exec(id)
66 const [, word = '', major = '', minor] = m ?? []
67 if (!m || !word) return id
68 const family = word.charAt(0).toUpperCase() + word.slice(1)
69 const version = minor === undefined ? major : `${major}.${minor}`
70 return `${family} ${version}${m[4] ? ' (1M)' : ''}`
71}
72
73// Amber from 70%, red from 90%; undefined keeps the band's dim default
74function levelColor(pct: number): string | undefined {
75 if (pct >= 90) return 'red'
76 if (pct >= 70) return 'yellow'
77 return undefined
78}
79
80function resetText(kind: string, resetsAt: string | undefined, now: number): string {
81 const resets = resetsAt ? Date.parse(resetsAt) : NaN
82 if (Number.isNaN(resets)) return ''
83 return kind === 'five_hour'
84 ? ` · resets in ${formatIn(resets - now)}`
85 : ` · resets ${formatAt(new Date(resets))}`
86}
87
88// Milliseconds until the session window is used up at the recent rate, or null
89// when there are too few readings, usage isn't climbing, or the reset comes first
90function timeToLimit(session: Limit, p: Pace, now: number): number | null {
91 const first = p.samples[0]
92 const last = p.samples[p.samples.length - 1]
93 if (!first || !last || first === last) return null
94 const span = last.at - first.at
95 const climbed = last.percent - first.percent
96 if (span < PACE_MIN_SPAN_MS || climbed <= 0) return null
97 const remaining = ((100 - session.percentUsed) / climbed) * span
98 const resets = session.resetsAt ? Date.parse(session.resetsAt) : NaN
99 if (!Number.isNaN(resets) && now + remaining >= resets) return null
100 return Math.max(0, remaining)
101}
102
103async function apply(
104 $: EngineInterface,
105 rateLimits: SessionRateLimit[],
106 cost: SessionCost | undefined,
107 context: SessionContextUsage,
108) {
109 const pick = (kind: string): Limit | null => {
110 const found = rateLimits.find(r => r.kind === kind)
111 return found ? { percentUsed: found.percentUsed, resetsAt: found.resetsAt } : null
112 }
113 const fresh: Limits = { session: pick('five_hour'), weekly: pick('seven_day') }
114 await update($, limits, () => fresh)
115 await update($, costUsd, () => cost?.usd ?? null)
116 await update($, contextPercent, () => context.percent ?? null)
117
118 const now = await $.clock.now()
119 const session = fresh.session
120 if (session) {
121 const window = session.resetsAt ?? null
122 await update($, pace, (p): Pace => {
123 const kept = p.window === window ? p.samples.filter(s => now - s.at <= PACE_WINDOW_MS) : []
124 const last = kept[kept.length - 1]
125 const samples =
126 last && last.percent === session.percentUsed
127 ? kept
128 : [...kept, { at: now, percent: session.percentUsed }]
129 return { window, samples }
130 })
131 }
132
133 const done = await read($, warned)
134 const fired: string[] = []
135 for (const [kind, limit, label] of [
136 ['five_hour', fresh.session, 'Session'],
137 ['seven_day', fresh.weekly, 'Weekly'],
138 ] as const) {
139 if (!limit) continue
140 const top = [...WARN_AT].reverse().find(t => limit.percentUsed >= t)
141 if (top === undefined) continue
142 const key = `${kind}@${limit.resetsAt ?? ''}:${top}`
143 if (done.includes(key)) continue
144 fired.push(key)
145 $.ui.toast(
146 `${label} limit at ${Math.round(limit.percentUsed)}%${resetText(kind, limit.resetsAt, now)}`,
147 { timeoutMs: 10000 },
148 )
149 }
150 if (fired.length > 0) {
151 // Firing 95 also covers 80, so a later dip and climb doesn't toast 80 again
152 const extra = fired.flatMap(k => {
153 const top = Number(k.slice(k.lastIndexOf(':') + 1))
154 return WARN_AT.filter(t => t < top).map(t => k.replace(/:\d+$/, `:${t}`))
155 })
156 await update($, warned, w => [...new Set([...w, ...fired, ...extra])].slice(-40))
157 }
158}
159
160async function refresh($: EngineInterface) {
161 const { rateLimits, cost, context } = await $.session.usage()
162 await apply($, rateLimits, cost, context)
163 const name = await $.session.model()
164 await update($, model, () => (name ? modelName(name) : null))
165}
166
167export const register: Register = on => {
168 on('session.start', async ($, e, next) => {
169 await refresh($)
170 return next(e)
171 })
172
173 // Pushed whenever a rate-limit window moves a whole point, so warnings land mid-turn
174 on('session.measure', async ($, e, next) => {
175 await apply($, e.rateLimits, e.cost, e.context)
176 return next(e)
177 })
178
179 on('turn.step', async function* ($, e, next) {
180 if (e.agentId === undefined) {
181 const level = e.effort === undefined ? null : String(e.effort)
182 if (level !== (await read($, effort))) {
183 await update($, effort, () => level)
184 }
185 }
186 return yield* next(e)
187 })
188
189 on('turn.complete', async ($, e, next) => {
190 const result = await next(e)
191 const usage = e.usage
192 if (usage) {
193 await update($, tokens, (t): Tokens => ({
194 input: t.input + usage.input_tokens,
195 output: t.output + usage.output_tokens,
196 cacheRead: t.cacheRead + usage.cache_read_input_tokens,
197 cacheWrite: t.cacheWrite + usage.cache_creation_input_tokens,
198 }))
199 }
200 await refresh($)
201 return result
202 })
203
204 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
205 const { session, weekly } = await read($, limits)
206 const cost = await read($, costUsd)
207 const ctx = await read($, contextPercent)
208 const name = await read($, model)
209 const level = await read($, effort)
210 const t = await read($, tokens)
211 const p = await read($, pace)
212 const prompt = t.input + t.cacheRead + t.cacheWrite
213 const hasLimits = session !== null || weekly !== null
214 if (e.props.hasSurvey || (!hasLimits && prompt + t.output === 0 && !cost && ctx === null && name === null)) {
215 return next(e)
216 }
217
218 const now = await $.clock.now()
219 const limitItems: Seg[][] = []
220 if (session) {
221 const item: Seg[] = [
222 { text: 'Session ' },
223 { text: `${Math.round(session.percentUsed)}%`, color: levelColor(session.percentUsed) },
224 { text: resetText('five_hour', session.resetsAt, now) },
225 ]
226 const left = timeToLimit(session, p, now)
227 if (left !== null) {
228 item.push({
229 text: ` · ~${formatIn(left)} to limit at this pace`,
230 color: left < 30 * 60 * 1000 ? 'red' : 'yellow',
231 })
232 }
233 limitItems.push(item)
234 }
235 if (weekly) {
236 limitItems.push([
237 { text: 'Week ' },
238 { text: `${Math.round(weekly.percentUsed)}%`, color: levelColor(weekly.percentUsed) },
239 { text: resetText('seven_day', weekly.resetsAt, now) },
240 ])
241 }
242
243 const spendItems: Seg[][] = []
244 if (name !== null) spendItems.push([{ text: level === null ? name : `${name} (${level} effort)` }])
245 if (ctx !== null) spendItems.push([{ text: 'Context ' }, { text: `${ctx}%`, color: levelColor(ctx) }])
246 if (cost) spendItems.push([{ text: `$${cost.toFixed(2)}` }])
247 if (prompt + t.output > 0) {
248 spendItems.push([{ text: `in ${count(t.input)}` }], [{ text: `out ${count(t.output)}` }])
249 if (prompt > 0) {
250 const hit = Math.round((t.cacheRead / prompt) * 100)
251 const hitColor = hit < 50 ? 'red' : hit < 80 ? 'yellow' : undefined
252 spendItems.push([{ text: 'cache ' }, { text: `${hit}% hit`, color: hitColor }])
253 }
254 }
255
256 const { Box, Text } = $.ui.resolve(e)
257 const line = (items: Seg[][], sep: string, key: string) => (
258 <Box key={key} flexWrap="wrap">
259 {items.map((segs, i) => (
260 <Box key={String(i)}>
261 {segs.map((s, j) => (
262 <Text key={String(j)} dimColor={s.color === undefined} color={s.color}>
263 {s.text}
264 </Text>
265 ))}
266 {i < items.length - 1 && <Text dimColor>{sep}</Text>}
267 </Box>
268 ))}
269 </Box>
270 )
271 return (
272 <Box flexDirection="column">
273 {limitItems.length > 0 && line(limitItems, ' ', 'limits')}
274 {spendItems.length > 0 && line(spendItems, ' · ', 'spend')}
275 </Box>
276 )
277 })
278}
279types/index.d.ts 21 lines1export type Limit = { percentUsed: number; resetsAt?: string }
2export type Limits = { session: Limit | null; weekly: Limit | null }
3export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
4export type Sample = { at: number; percent: number }
5export type Pace = { window: string | null; samples: Sample[] }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'usage-meter': {
10 limits: Limits
11 costUsd: number | null
12 contextPercent: number | null
13 model: string | null
14 effort: string | null
15 tokens: Tokens
16 pace: Pace
17 warned: string[]
18 }
19 }
20}
21