A band above the prompt showing session usage: cost, prompts, rate limits and context fill.

A Claude Code mod: a band above the prompt showing session usage: the cost, prompt count and rate limits on the left, the context gauge on the right. Based on the token-weather sample in anthropics/claude-code-playground. Needs Claude Code 2.1.287 or later.

/cost totals it (an estimate at API prices), where Claude Code reports one.On desktop the rate-limit pills have a slightly darker, recessed background; the rest is plain text.
Under 80 columns the band drops the prompt count.
The band reads usage as soon as the session starts, showing an animated placeholder until the first figures arrive (it gives up after 30 seconds without any). It refreshes when a turn finishes and steps aside while a survey is shown.
/plugin marketplace add QuantiCZ/claude-code-mods
/plugin install usage-band@quanti-mods
claude plugin validate plugins/usage-band
claude plugin test plugins/usage-bandhooks/register.tsx 301 lines1import type {
2 EngineInterface,
3 Register,
4 SessionContextUsage,
5 SessionCost,
6 SessionRateLimit,
7 Timer,
8} from 'claude-code'
9
10import {
11 bar,
12 colorFor,
13 dots,
14 limitLabel,
15 meterSvg,
16 resetIn,
17 shimmerSvg,
18 short,
19 skeletonSvg,
20 sortLimits,
21 spinner,
22 sweep,
23} from './format'
24
25type Limit = { kind: string; percent: number; resetsAt?: number }
26
27type Reading = {
28 percent: number
29 tokens: number
30 window: number
31 usd?: number
32 limits: Limit[]
33 prompts?: number
34}
35
36// One loading frame every FRAME_MS; a fresh reading is tried every
37// RETRY_FRAMES frames, and the band gives up after GIVE_UP_FRAMES.
38const FRAME_MS = 100
39const RETRY_FRAMES = 3
40const GIVE_UP_FRAMES = 300
41// The reset countdowns move on once a minute.
42const CLOCK_MS = 60_000
43// Below this width (in cells) the band drops the prompt count.
44const PROMPTS_MIN_COLUMNS = 80
45// The rate-limit pills' recessed background on desktop.
46const PILL_BG = '#0000002e'
47
48let reading: Reading | null = null
49let now = 0
50let frame = 0
51let ticker: Timer | null = null
52let clockTicker: Timer | null = null
53let isRetrying = false
54// Only the terminal's placeholder needs a redraw per frame; the desktop's
55// animates inside its SVG and would restart if redrawn.
56let animatesTerminal = false
57
58function toLimit(limit: SessionRateLimit): Limit {
59 const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
60 return {
61 kind: limit.kind,
62 percent: Math.round(limit.percentUsed),
63 resetsAt: Number.isNaN(resetsAt) ? undefined : resetsAt,
64 }
65}
66
67// Folds the usage figures into the reading; false when they have no window yet.
68function applyUsage(
69 context: SessionContextUsage | undefined,
70 cost: SessionCost | undefined,
71 rateLimits: readonly SessionRateLimit[] | undefined,
72): boolean {
73 if (!context || !context.window) {
74 return false
75 }
76 const tokens = context.tokens ?? 0
77 const percent = Math.round(context.percent ?? (tokens / context.window) * 100)
78 reading = {
79 percent,
80 tokens,
81 window: context.window,
82 usd: cost?.usd,
83 limits: sortLimits(rateLimits ?? []).map(toLimit),
84 prompts: reading?.prompts,
85 }
86 return true
87}
88
89async function takeReading($: EngineInterface): Promise<void> {
90 try {
91 const [usage, prompts, at] = await Promise.all([
92 $.session.usage(),
93 $.session.turns().catch(() => undefined),
94 $.clock.now().catch(() => now),
95 ])
96 now = at
97 if (!applyUsage(usage.context, usage.cost, usage.rateLimits)) {
98 return
99 }
100 if (reading !== null && prompts !== undefined) {
101 reading.prompts = prompts
102 }
103 stopLoading()
104 $.ui.invalidate('ui.render')
105 } catch {
106 // Keep the last reading.
107 }
108}
109
110function stopLoading(): void {
111 ticker?.cancel()
112 ticker = null
113}
114
115function startLoading($: EngineInterface): void {
116 if (ticker !== null) {
117 return
118 }
119 frame = 0
120 ticker = $.clock.every(FRAME_MS, () => {
121 frame += 1
122 if (frame > GIVE_UP_FRAMES) {
123 stopLoading()
124 $.ui.invalidate('ui.render')
125 return
126 }
127 if (frame % RETRY_FRAMES === 0 && !isRetrying) {
128 isRetrying = true
129 void takeReading($).finally(() => {
130 isRetrying = false
131 })
132 }
133 if (animatesTerminal) {
134 $.ui.invalidate('ui.render')
135 }
136 })
137}
138
139function startClock($: EngineInterface): void {
140 if (clockTicker !== null) {
141 return
142 }
143 clockTicker = $.clock.every(CLOCK_MS, () => {
144 void $.clock
145 .now()
146 .then(at => {
147 now = at
148 if (reading?.limits.some(limit => limit.resetsAt !== undefined)) {
149 $.ui.invalidate('ui.render')
150 }
151 })
152 .catch(() => {})
153 })
154}
155
156export const register: Register = on => {
157 on('session.start', async ($, e, next) => {
158 reading = null
159 animatesTerminal = false
160 startLoading($)
161 startClock($)
162 // Read right away rather than after the session has finished starting;
163 // a reading that is not ready yet is retried by the loading ticker.
164 void takeReading($)
165 const result = await next(e)
166 await takeReading($)
167
168 return result
169 })
170
171 on('turn.complete', async ($, e, next) => {
172 const result = await next(e)
173 await takeReading($)
174
175 return result
176 })
177
178 // Pushed when a figure moves, a rate-limit window among them.
179 on('session.measure', async ($, e, next) => {
180 const result = await next(e)
181 if (applyUsage(e.context, e.cost, e.rateLimits)) {
182 stopLoading()
183 $.ui.invalidate('ui.render')
184 }
185
186 return result
187 })
188
189 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
190 if (e.props.hasSurvey) {
191 return next(e)
192 }
193
194 const { Box, Text } = $.ui.resolve(e)
195 const current = reading
196
197 if (current === null) {
198 if (ticker === null) {
199 return next(e)
200 }
201 let spend
202 let figures
203 let meter
204 if (e.surface === 'desktop') {
205 const { Svg } = $.ui.resolve(e)
206 spend = <Svg source={skeletonSvg(36, 8)} alt="Loading cost" width={36} height={8} isInteractive />
207 figures = <Svg source={skeletonSvg(64, 8)} alt="Loading context" width={64} height={8} isInteractive />
208 meter = <Svg source={shimmerSvg()} alt="Loading context meter" width={72} height={6} isInteractive />
209 } else {
210 animatesTerminal = true
211 const { before, glow, after } = sweep(frame)
212 spend = <Text dimColor>{spinner(frame)}</Text>
213 figures = <Text dimColor>{dots(frame)}</Text>
214 meter = (
215 <Box key="meter" flexDirection="row">
216 <Text color="inactive">{before}</Text>
217 <Text color="subtle">{glow}</Text>
218 <Text color="inactive">{after}</Text>
219 </Box>
220 )
221 }
222
223 return (
224 <Box key="loading" flexDirection="row" justifyContent="space-between" alignItems="center" width="100%" paddingX={1}>
225 <Box key="spend" flexDirection="row">
226 {spend}
227 </Box>
228 <Box key="gauge" flexDirection="row" alignItems="center" gap={1}>
229 {figures}
230 {meter}
231 </Box>
232 </Box>
233 )
234 }
235
236 const columns = e.props.bodyColumns ?? 80
237 const showsPrompts = columns >= PROMPTS_MIN_COLUMNS && current.prompts !== undefined
238 const severity = colorFor(current.percent)
239
240 // A meter at any size: an SVG pill on desktop, a line of cells in the terminal.
241 const meterOf = (key: string, percent: number, alt: string, width: number, height: number, cells: number) => {
242 if (e.surface === 'desktop') {
243 const { Svg } = $.ui.resolve(e)
244 return <Svg source={meterSvg(percent, width, height)} alt={alt} width={width} height={height} />
245 }
246 const { fill, track } = bar(percent, cells)
247 return (
248 <Box key={key} flexDirection="row">
249 <Text color={colorFor(percent)}>{fill}</Text>
250 <Text color="inactive">{track}</Text>
251 </Box>
252 )
253 }
254
255 const { usd, prompts, limits } = current
256
257 // The rate limits sit in recessed pills, a translucent black that darkens
258 // any theme (desktop only; the terminal draws them on the band). Cost,
259 // prompts and the context gauge stay plain text.
260 const pillProps = e.surface === 'desktop' ? { backgroundColor: PILL_BG } : {}
261
262 return (
263 <Box flexDirection="row" justifyContent="space-between" alignItems="center" width="100%" paddingX={1}>
264 <Box key="left" flexDirection="row" alignItems="center" gap={2}>
265 {usd !== undefined ? (
266 <Box key="spend" flexDirection="row">
267 <Text>{`$${usd.toFixed(2)}`}</Text>
268 </Box>
269 ) : null}
270 {showsPrompts ? (
271 <Box key="prompts" flexDirection="row">
272 <Text dimColor>{`${prompts} ${prompts === 1 ? 'prompt' : 'prompts'}`}</Text>
273 </Box>
274 ) : null}
275 {limits.length > 0 ? (
276 <Box key="limits" flexDirection="row" alignItems="center" gap={1}>
277 {limits.map(limit => (
278 <Box key={`limit-${limit.kind}`} flexDirection="row" gap={1} paddingX={1} {...pillProps}>
279 <Text dimColor>{limitLabel(limit.kind)}</Text>
280 <Text color={colorFor(limit.percent)}>{`${limit.percent}%`}</Text>
281 {limit.resetsAt !== undefined ? (
282 <Text dimColor>{`↻ ${resetIn(limit.resetsAt - now)}`}</Text>
283 ) : null}
284 </Box>
285 ))}
286 </Box>
287 ) : null}
288 </Box>
289 <Box key="gauge" flexDirection="row" alignItems="center" gap={1}>
290 <Box key="context" flexDirection="row">
291 <Text>{short(current.tokens)}</Text>
292 <Text dimColor>{` / ${short(current.window)}`}</Text>
293 </Box>
294 <Text bold color={severity}>{`${current.percent}%`}</Text>
295 {meterOf('meter', current.percent, `Context ${current.percent}% full`, 72, 6, 10)}
296 </Box>
297 </Box>
298 )
299 })
300}
301hooks/format.ts 159 lines1export const BAR_CELLS = 10
2
3export type Severity = 'success' | 'warning' | 'claude' | 'error'
4
5// The theme's own hues, for the SVG meter, which cannot read theme keys.
6const SEVERITY_HEX: Record<Severity, string> = {
7 success: '#4eba65',
8 warning: '#e8b339',
9 claude: '#d77757',
10 error: '#ef5a6f',
11}
12
13function oneDecimal(n: number): string {
14 return n.toFixed(1).replace(/\.0$/, '')
15}
16
17function clampPercent(percent: number): number {
18 return Math.max(0, Math.min(100, percent))
19}
20
21export function short(n: number): string {
22 if (n >= 1_000_000) {
23 return `${oneDecimal(n / 1_000_000)}M`
24 }
25 if (n >= 1_000) {
26 return `${oneDecimal(n / 1_000)}k`
27 }
28 return String(n)
29}
30
31export function colorFor(percent: number): Severity {
32 if (percent >= 90) {
33 return 'error'
34 }
35 if (percent >= 75) {
36 return 'claude'
37 }
38 if (percent >= 50) {
39 return 'warning'
40 }
41 return 'success'
42}
43
44// The terminal meter: a heavy line for the fill over a light line for the track,
45// at least one cell filled once anything is used, as the desktop meter's dot.
46export function bar(percent: number, cells = BAR_CELLS): { fill: string; track: string } {
47 const p = clampPercent(percent)
48 const filled = p === 0 ? 0 : Math.max(1, Math.round((p / 100) * cells))
49 return { fill: '━'.repeat(filled), track: '─'.repeat(cells - filled) }
50}
51
52const LIMIT_LABELS: Record<string, string> = {
53 five_hour: '5h',
54 seven_day: 'week',
55 spend_limit: 'spend',
56}
57const LIMIT_ORDER = ['five_hour', 'seven_day', 'spend_limit']
58
59// A rate-limit window's short name: `five_hour` is "5h", an unknown kind is spelled out.
60export function limitLabel(kind: string): string {
61 return LIMIT_LABELS[kind] ?? kind.replace(/_/g, ' ')
62}
63
64// The 5-hour window first, then the weekly one, then any other.
65export function sortLimits<T extends { kind: string }>(limits: readonly T[]): T[] {
66 const rank = (kind: string) => {
67 const at = LIMIT_ORDER.indexOf(kind)
68 return at === -1 ? LIMIT_ORDER.length : at
69 }
70 return [...limits].sort((a, b) => rank(a.kind) - rank(b.kind))
71}
72
73// How long until a window resets: "37m", "4h 27m", "2d 5h"; "now" once it is due.
74export function resetIn(ms: number): string {
75 if (!(ms > 0)) {
76 return 'now'
77 }
78 const minutes = Math.ceil(ms / 60_000)
79 if (minutes < 60) {
80 return `${minutes}m`
81 }
82 const hours = Math.floor(minutes / 60)
83 if (hours < 24) {
84 return `${hours}h ${minutes % 60}m`
85 }
86 return `${Math.floor(hours / 24)}d ${hours % 24}h`
87}
88
89const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
90const DOTS = ['· ', '·· ', '···', ' ··', ' ·', ' ']
91export const GLOW_CELLS = 3
92
93export function spinner(frame: number): string {
94 return SPINNER[frame % SPINNER.length] ?? ''
95}
96
97export function dots(frame: number): string {
98 return DOTS[Math.floor(frame / 2) % DOTS.length] ?? ''
99}
100
101// The terminal loading meter: a short heavy segment sweeping back and forth
102// over the light track.
103export function sweep(frame: number): { before: string; glow: string; after: string } {
104 const span = BAR_CELLS - GLOW_CELLS
105 const step = frame % (2 * span)
106 const at = step <= span ? step : 2 * span - step
107 return {
108 before: '─'.repeat(at),
109 glow: '━'.repeat(GLOW_CELLS),
110 after: '─'.repeat(span - at),
111 }
112}
113
114// A placeholder for a figure still loading: a grey pill that pulses.
115export function skeletonSvg(width: number, height: number): string {
116 const r = height / 2
117 return (
118 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
119 `<rect width="${width}" height="${height}" rx="${r}" fill="#808080" fill-opacity="0.2">` +
120 `<animate attributeName="fill-opacity" values="0.12;0.34;0.12" dur="1.4s" repeatCount="indefinite"/>` +
121 `</rect></svg>`
122 )
123}
124
125// The desktop meter while loading: the empty track with a highlight gliding across.
126export function shimmerSvg(width = 72, height = 6): string {
127 const r = height / 2
128 const glow = Math.round(width / 3)
129 return (
130 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
131 `<defs><clipPath id="t"><rect width="${width}" height="${height}" rx="${r}"/></clipPath>` +
132 `<linearGradient id="g"><stop offset="0" stop-color="#b0b0b0" stop-opacity="0"/>` +
133 `<stop offset="0.5" stop-color="#b0b0b0" stop-opacity="0.75"/>` +
134 `<stop offset="1" stop-color="#b0b0b0" stop-opacity="0"/></linearGradient></defs>` +
135 `<rect width="${width}" height="${height}" rx="${r}" fill="#80808047"/>` +
136 `<g clip-path="url(#t)"><rect x="${-glow}" width="${glow}" height="${height}" fill="url(#g)">` +
137 `<animate attributeName="x" from="${-glow}" to="${width}" dur="1.2s" repeatCount="indefinite"/>` +
138 `</rect></g></svg>`
139 )
140}
141
142// The desktop meter: a rounded pill, its fill never thinner than its height
143// once anything is used, so 1% still shows as a dot.
144export function meterSvg(percent: number, width = 72, height = 6): string {
145 const p = clampPercent(percent)
146 const r = height / 2
147 const fillWidth = p === 0 ? 0 : Math.max(height, Math.round((p / 100) * width))
148 const fill =
149 fillWidth > 0
150 ? `<rect width="${fillWidth}" height="${height}" rx="${r}" fill="${SEVERITY_HEX[colorFor(p)]}"/>`
151 : ''
152 return (
153 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
154 `<rect width="${width}" height="${height}" rx="${r}" fill="#80808047"/>` +
155 fill +
156 `</svg>`
157 )
158}
159