Your Claude plan usage above the prompt: session and weekly limits, reset countdowns, pace, context fill and limit warnings.

A Claude Code mod that shows your plan usage right above the prompt, so you never have to dig through settings to find out how much of your limit is left.
Session ━━━━━━━━━━ 34% 2h 14m Week ━━━━━━━━━━ 12% 3d 8h Context ━━━━━━━━━━ 41% Details Hide
Works in the Claude Code terminal and in the Claude desktop app's Code tab.
Type this at the prompt of a Claude Code terminal session:
/plugin install usage-band --marketplace Tanish-Dev/claude-usage-band
Answer y to add the marketplace, then pick the user scope. The band appears in that session straight away and in every session you start afterwards, the desktop app's included.
Each meter has its own colour (blue, purple, green) and turns orange at 75% and red at 90%.
Details for one row per meter, with the reset countdown, token counts, and how long the chat has run.on pace for ~57% by reset or limit in ~1h 20m at this pace. A meter turns orange early when your pace would hit the limit.| Command | What it does |
|---|---|
/usage-band | Prints a usage summary and brings a hidden band back |
/usage-band full | Detailed view |
/usage-band compact | One-line view |
/usage-band hide | Hides the band |
claude plugin validate .
claude plugin test .
To run a working copy, start a session with claude --plugin-dir <this folder>.
MIT. An unofficial community mod, not affiliated with or endorsed by Anthropic.
hooks/register.tsx 504 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ContextFill, Limit, Mode, Reading } from '../types'
5
6const reading = atom({ plugin: 'usage-band', key: 'reading' } as const, null)
7const mode = atom({ plugin: 'usage-band', key: 'mode' } as const, 'compact')
8const tick = atom({ plugin: 'usage-band', key: 'tick' } as const, 0)
9const alerted = atom({ plugin: 'usage-band', key: 'alerted' } as const, {})
10
11const MINUTE = 60_000
12const HOUR = 60 * MINUTE
13const DAY = 24 * HOUR
14const THRESHOLDS = [75, 90, 95]
15const FILL = '━'
16// Clawd as pixels, for the surfaces that draw them (the terminal has none): a body with two eye holes,
17// two arms and four legs. It blinks, taps its feet, hops and waves a claw now
18// and then, and squints as the limits fill.
19const clawdSvg = (isSquinting: boolean): string => {
20 const body = '#D97757'
21 const move = (values: string, keyTimes: string, dur: string) =>
22 `<animateTransform attributeName="transform" type="translate" values="${values}" keyTimes="${keyTimes}" dur="${dur}" repeatCount="indefinite"/>`
23 const lids = isSquinting
24 ? `<path fill="${body}" d="M4 2h1v1H4zM11 2h1v1h-1z"/>`
25 : `<path fill="${body}" opacity="0" d="M4 2h1v2H4zM11 2h1v2h-1z"><animate attributeName="opacity" values="0;0;1;0" keyTimes="0;0.95;0.975;1" dur="5s" repeatCount="indefinite"/></path>`
26
27 return [
28 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1 16 12" width="24" height="18" shape-rendering="crispEdges">',
29 `<g fill="${body}">`,
30 move('0 0;0 0;0 -1;0 0;0 0', '0;0.6;0.66;0.72;1', '4s'),
31 '<path fill-rule="evenodd" d="M2 0h12v9H2zM4 2v2h1V2zM11 2v2h1V2z"/>',
32 lids,
33 '<path d="M0 4h2v2H0z"/>',
34 `<path d="M14 4h2v2h-2z">${move('0 0;0 -1;0 0;0 -1;0 0;0 0', '0;0.04;0.08;0.12;0.16;1', '7s')}</path>`,
35 `<path d="M3 9h1v2H3zM10 9h1v2h-1z">${move('0 0;0 -1;0 0;0 0', '0;0.05;0.1;1', '4s')}</path>`,
36 `<path d="M5 9h1v2H5zM12 9h1v2h-1z">${move('0 0;0 0;0 -1;0 0;0 0', '0;0.1;0.15;0.2;1', '4s')}</path>`,
37 '</g>',
38 '</svg>',
39 ].join('')
40}
41
42// The system palette: one hue pair per meter, warm ones as a window fills.
43type Hues = readonly [string, string]
44const BLUE: Hues = ['#0A84FF', '#64D2FF']
45const PURPLE: Hues = ['#BF5AF2', '#FF6BD6']
46const GREEN: Hues = ['#28BD52', '#63E6E2']
47const INDIGO: Hues = ['#5E5CE6', '#0A84FF']
48const ORANGE: Hues = ['#FF9F0A', '#FFD60A']
49const RED: Hues = ['#FF453A', '#FF375F']
50
51type Meter = {
52 key: string
53 label: string
54 percent: number
55 hues: Hues
56 // The short tail of the one-line view, and the sentence of the detailed one.
57 brief: string
58 detail: string
59}
60
61const windowMs = (kind: string): number | undefined =>
62 kind.startsWith('five_hour') ? 5 * HOUR : kind.startsWith('seven_day') ? 7 * DAY : undefined
63
64const labelOf = (kind: string): string => {
65 if (kind === 'five_hour') return 'Session'
66 if (kind === 'seven_day') return 'Week'
67 if (kind === 'spend_limit') return 'Spend'
68 if (kind.startsWith('seven_day_')) return `Week ${kind.slice(10).replace(/_/g, ' ')}`
69
70 return kind.replace(/_/g, ' ')
71}
72
73const restHues = (kind: string): Hues =>
74 kind === 'context'
75 ? GREEN
76 : kind.startsWith('five_hour')
77 ? BLUE
78 : kind.startsWith('seven_day')
79 ? PURPLE
80 : INDIGO
81
82const huesOf = (kind: string, percent: number, isHot = false): Hues =>
83 percent >= 90 ? RED : percent >= 75 || isHot ? ORANGE : restHues(kind)
84
85const span = (ms: number): string => {
86 const minutes = Math.max(1, Math.round(ms / MINUTE))
87 if (minutes >= 24 * 60) {
88 const hours = Math.round(minutes / 60)
89
90 return hours % 24 === 0 ? `${hours / 24}d` : `${Math.floor(hours / 24)}d ${hours % 24}h`
91 }
92 if (minutes >= 60) {
93 return minutes % 60 === 0 ? `${minutes / 60}h` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
94 }
95
96 return `${minutes}m`
97}
98
99const tokens = (count: number): string =>
100 count >= 1_000_000
101 ? `${(count / 1_000_000).toFixed(1)}M`
102 : count >= 1000
103 ? `${Math.round(count / 1000)}k`
104 : String(count)
105
106const whole = (percent: number): string => `${Math.round(percent)}%`
107
108// Where the window ends up if the rest of it is spent as fast as its start.
109const pace = (limit: Limit, left: number): { text: string; isHot: boolean } | undefined => {
110 const length = windowMs(limit.kind)
111 if (length === undefined || left <= 0 || limit.percentUsed <= 0) return undefined
112 const elapsed = length - left
113 if (elapsed < length * 0.05) return undefined
114 const rate = limit.percentUsed / elapsed
115 const projected = limit.percentUsed + rate * left
116 if (limit.percentUsed >= 100) return undefined
117 if (projected >= 100) {
118 return { text: `limit in ~${span((100 - limit.percentUsed) / rate)} at this pace`, isHot: true }
119 }
120
121 return { text: `on pace for ~${whole(projected)} by reset`, isHot: false }
122}
123
124const limitMeter = (limit: Limit, now: number): Meter => {
125 const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
126 const left = resetsAt - now
127 // A window whose reset has passed is empty until the next reply says otherwise.
128 if (left <= 0) {
129 return {
130 key: limit.kind,
131 label: labelOf(limit.kind),
132 percent: 0,
133 hues: restHues(limit.kind),
134 brief: 'reset',
135 detail: 'window has reset',
136 }
137 }
138 const ahead = pace(limit, left)
139 const resets = Number.isNaN(left) ? '' : `resets in ${span(left)}`
140
141 return {
142 key: limit.kind,
143 label: labelOf(limit.kind),
144 percent: limit.percentUsed,
145 hues: huesOf(limit.kind, limit.percentUsed, ahead?.isHot),
146 brief: Number.isNaN(left) ? '' : span(left),
147 detail: [resets, limit.percentUsed >= 100 ? 'limit reached' : ahead?.text]
148 .filter(part => part !== undefined && part !== '')
149 .join(' · '),
150 }
151}
152
153const contextMeter = (context: ContextFill): Meter | undefined => {
154 if (context.percent === undefined) return undefined
155
156 return {
157 key: 'context',
158 label: 'Context',
159 percent: context.percent,
160 hues: huesOf('context', context.percent),
161 brief: '',
162 detail:
163 context.tokens === undefined
164 ? `of a ${tokens(context.window)} window`
165 : `${tokens(context.tokens)} of ${tokens(context.window)} tokens`,
166 }
167}
168
169const metersOf = (data: Reading, now: number): Meter[] => {
170 const meters = data.limits.map(limit => limitMeter(limit, now))
171 const context = data.context === undefined ? undefined : contextMeter(data.context)
172
173 return context === undefined ? meters : [...meters, context]
174}
175
176const sessionLine = (data: Reading, now: number): string =>
177 [
178 data.startedAt === undefined ? undefined : `This chat ${span(now - data.startedAt)}`,
179 data.costUsd === undefined || data.costUsd <= 0
180 ? undefined
181 : `$${data.costUsd.toFixed(2)} at API rates`,
182 ]
183 .filter(part => part !== undefined)
184 .join(' · ')
185
186const summary = (data: Reading | null, now: number): string => {
187 const meters = data === null ? [] : metersOf(data, now)
188 if (data === null || meters.length === 0) return 'No usage reading yet: it arrives with the first reply.'
189
190 return [
191 ...meters.map(m => `${m.label}: ${whole(m.percent)}${m.detail === '' ? '' : ` (${m.detail})`}`),
192 sessionLine(data, now),
193 ]
194 .filter(line => line !== '')
195 .join('\n')
196}
197
198// What Clawd says when hovered: lines made from the reading itself, so they
199// change as the numbers do.
200const sayings = (data: Reading, now: number): string[] => {
201 const lines: string[] = []
202 for (const limit of data.limits) {
203 const name =
204 limit.kind === 'five_hour'
205 ? 'session'
206 : limit.kind === 'seven_day'
207 ? 'weekly'
208 : labelOf(limit.kind).toLowerCase()
209 const left = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt) - now
210 if (left <= 0) {
211 lines.push(`The ${name} limit just reset. Fresh claws, full tank.`)
212 continue
213 }
214 const percent = limit.percentUsed
215 const used = whole(percent)
216 lines.push(
217 percent >= 100
218 ? `The ${name} limit is spent. Time to bask on a rock.`
219 : percent >= 90
220 ? `${used} of the ${name} limit gone. Pinching every token now.`
221 : percent >= 75
222 ? `${used} of the ${name} limit used. Getting snappy, pace yourself.`
223 : percent >= 50
224 ? `${used} into the ${name} limit. Steady as she scuttles.`
225 : percent >= 25
226 ? `${whole(100 - percent)} of the ${name} limit still free. Cruising sideways.`
227 : `Only ${used} of the ${name} limit used. Plenty of room in the shell.`,
228 )
229 if (Number.isNaN(left)) continue
230 lines.push(
231 left < 30 * MINUTE
232 ? `The ${name} limit resets in ${span(left)}. Fresh claws incoming.`
233 : percent >= 75
234 ? `${span(left)} until the ${name} limit resets. Hang in there.`
235 : `${span(left)} until the ${name} limit resets. No rush.`,
236 )
237 const ahead = pace(limit, left)
238 if (ahead?.isHot === true) {
239 lines.push(`Careful: ${ahead.text} on the ${name} limit. Maybe slow the scuttle.`)
240 } else if (ahead !== undefined) {
241 lines.push(`You are ${ahead.text} on the ${name} limit. Carry on.`)
242 }
243 }
244
245 const context = data.context
246 if (context?.percent !== undefined) {
247 const size =
248 context.tokens === undefined ? '' : ` (${tokens(context.tokens)} of ${tokens(context.window)})`
249 lines.push(
250 context.percent >= 80
251 ? `Context is ${whole(context.percent)} full${size}. A /compact would feel nice.`
252 : context.percent >= 50
253 ? `Context is ${whole(context.percent)} full${size}. Getting cosy in here.`
254 : `Context is ${whole(context.percent)} full${size}. Lots of room to think.`,
255 )
256 }
257 if (data.costUsd !== undefined && data.costUsd > 0) {
258 lines.push(`$${data.costUsd.toFixed(2)} of tokens at API rates this chat. I am not judging.`)
259 }
260 if (data.startedAt !== undefined) {
261 const age = now - data.startedAt
262 lines.push(
263 age >= 2 * HOUR
264 ? `${span(age)} in this chat. Stretch your claws.`
265 : `${span(age)} into this chat. Nice and fresh.`,
266 )
267 }
268
269 return lines.length === 0 ? ['Snip snip.'] : lines
270}
271
272// JSON data only: a field the engine left out is left out, never `undefined`.
273const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
274
275export const register: Register = on => {
276 on('session.start', async ($, e, next) => {
277 await $.command.register({
278 name: 'usage-band',
279 description: 'Show, hide or expand the usage band above the prompt',
280 argumentHint: '[show|hide|compact|full]',
281 immediate: true,
282 })
283
284 const kept = await $.store.get('mode').catch(() => undefined)
285 if (kept === 'compact' || kept === 'full' || kept === 'hidden') {
286 await update($, mode, () => kept)
287 }
288
289 // Rate limits are the account's, so the last session's reading stands in
290 // until this one's first reply; context and cost are this session's alone.
291 const last = (await $.store.get('limits').catch(() => undefined)) as Limit[] | undefined
292 const usage = await $.session.usage().catch(() => undefined)
293 const limits = usage !== undefined && usage.rateLimits.length > 0 ? usage.rateLimits : last
294 await update($, reading, held =>
295 plain<Reading>({
296 limits: limits ?? held?.limits ?? [],
297 context: usage?.context ?? held?.context,
298 costUsd: usage?.cost?.usd ?? held?.costUsd,
299 startedAt: usage?.startedAt ?? held?.startedAt,
300 }),
301 )
302
303 // The countdowns move with the clock, not with a reply.
304 $.clock.every(30_000, () => void update($, tick, n => n + 1))
305
306 return next(e)
307 })
308
309 on('session.measure', async ($, e, next) => {
310 const held = await read($, reading)
311 const limits = e.rateLimits.length > 0 ? e.rateLimits : (held?.limits ?? [])
312 await update($, reading, was =>
313 plain<Reading>({
314 limits,
315 context: { window: e.context.window, tokens: e.context.tokens, percent: e.context.percent },
316 costUsd: e.cost?.usd ?? was?.costUsd,
317 startedAt: was?.startedAt,
318 }),
319 )
320
321 if (e.rateLimits.length > 0) {
322 await $.store.set('limits', plain(e.rateLimits)).catch(() => undefined)
323 }
324
325 // One warning per threshold per window: the key carries the reset, so the
326 // next window warns afresh.
327 const now = await $.clock.now()
328 const seen = await read($, alerted)
329 const nextSeen: Record<string, number> = {}
330 for (const limit of e.rateLimits) {
331 const key = `${limit.kind}@${limit.resetsAt ?? ''}`
332 const before = seen[key] ?? 0
333 const crossed = THRESHOLDS.filter(t => limit.percentUsed >= t).pop() ?? 0
334 nextSeen[key] = Math.max(before, crossed)
335 if (crossed > before) {
336 const meter = limitMeter(limit, now)
337 $.ui.toast(
338 `${meter.label} limit ${whole(limit.percentUsed)} used${meter.detail === '' ? '' : ` · ${meter.detail}`}`,
339 { timeoutMs: 8000 },
340 )
341 }
342 }
343 await update($, alerted, () => nextSeen)
344
345 return next(e)
346 })
347
348 on('command.run', { command: 'usage-band' }, async ($, e) => {
349 const arg = e.args.trim().toLowerCase()
350 const wanted: Mode | undefined =
351 arg === 'hide' || arg === 'off'
352 ? 'hidden'
353 : arg === 'full' || arg === 'details'
354 ? 'full'
355 : arg === 'compact' || arg === 'show' || arg === 'on'
356 ? 'compact'
357 : undefined
358
359 if (arg !== '' && wanted === undefined) {
360 return { text: `Unknown option "${arg}". Use /usage-band show, hide, compact or full.` }
361 }
362
363 // Bare, it reports and brings a hidden band back.
364 const current = await read($, mode)
365 const target = wanted ?? (current === 'hidden' ? 'compact' : current)
366 await update($, mode, () => target)
367 await $.store.set('mode', target).catch(() => undefined)
368
369 if (target === 'hidden') return { text: `Usage band hidden. /usage-band brings it back.` }
370
371 return { text: summary(await read($, reading), await $.clock.now()) }
372 })
373
374 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
375 const view = await read($, mode)
376 if (e.props.hasSurvey || view === 'hidden') return next(e)
377
378 const data = await read($, reading)
379 const beat = await read($, tick)
380 const now = await $.clock.now()
381 const meters = data === null ? [] : metersOf(data, now)
382 const table = $.ui.resolve(e)
383 const { Box, Button, Text } = table
384 // Pixels where the surface draws them, glyphs on the terminal.
385 const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : undefined
386
387 const setMode = async (to: Mode) => {
388 await update($, mode, () => to)
389 await $.store.set('mode', to).catch(() => undefined)
390 }
391 const hide = async () => {
392 await setMode('hidden')
393 $.ui.toast(`Usage hidden · /usage-band brings it back`)
394 }
395
396 if (data === null || meters.length === 0) {
397 return (
398 <Box gap={2}>
399 <Text dimColor>Usage appears after the first reply.</Text>
400 <Button key="hide" role="dismiss" dimColor label="Hide" onPress={hide} />
401 </Box>
402 )
403 }
404
405 const columns = e.props.bodyColumns
406 const fullest = Math.max(...meters.map(meter => meter.percent))
407 const lines = sayings(data, now)
408 const saying = lines[(beat + Math.floor(now / 30_000)) % lines.length] ?? ''
409 // Hovering Clawd shows what it has to say in a talk bubble laid over the
410 // meters beside it, so nothing in the band moves. The terminal has no Clawd.
411 const clawd = Svg !== undefined && (
412 <Box key="clawd" position="relative" alignItems="center" flexShrink={0}>
413 <Svg source={clawdSvg(fullest >= 75)} alt="Clawd" width={24} height={18} />
414 <Box
415 position="absolute"
416 top={0}
417 left={4}
418 display="none"
419 hover={{ display: 'flex' }}
420 paddingX={1}
421 >
422 <Text color="claude">
423 {'◂ '}
424 </Text>
425 <Text color="text" wrap="truncate-end">
426 {saying}
427 </Text>
428 </Box>
429 </Box>
430 )
431 const bar = (meter: Meter, width: number) => {
432 const filled =
433 meter.percent <= 0 ? 0 : Math.min(width, Math.max(1, Math.round((meter.percent / 100) * width)))
434
435 return (
436 <Text>
437 <Text color={meter.hues[0]}>{FILL.repeat(filled)}</Text>
438 <Text color="subtle">{FILL.repeat(width - filled)}</Text>
439 </Text>
440 )
441 }
442
443 if (view === 'compact') {
444 // One line, always: a proportional font draws the bar's glyphs wide, so
445 // the desktop gets fewer of them, and nothing here may wrap.
446 const width = Svg !== undefined ? 5 : columns >= 100 ? 8 : columns >= 72 ? 5 : 0
447
448 return (
449 <Box columnGap={2} flexWrap="nowrap" alignItems="center">
450 {clawd}
451 {meters.map(meter => (
452 <Box gap={1} flexShrink={0}>
453 <Text dimColor wrap="truncate-end">
454 {meter.label}
455 </Text>
456 {width > 0 && bar(meter, width)}
457 <Text bold color={meter.hues[0]}>
458 {whole(meter.percent)}
459 </Text>
460 {meter.brief !== '' && (
461 <Text dimColor wrap="truncate-end">
462 {meter.brief}
463 </Text>
464 )}
465 </Box>
466 ))}
467 <Button key="details" plain dimColor label="Details" onPress={() => setMode('full')} />
468 </Box>
469 )
470 }
471
472 const labelWidth = Math.max(...meters.map(meter => meter.label.length)) + 2
473 const width = Math.max(8, Math.min(28, columns - labelWidth - 48))
474 const footer = sessionLine(data, now)
475
476 return (
477 <Box flexDirection="column">
478 {meters.map(meter => (
479 <Box gap={1}>
480 <Box width={labelWidth}>
481 <Text>{meter.label}</Text>
482 </Box>
483 {bar(meter, width)}
484 <Box width={5} justifyContent="flex-end">
485 <Text bold color={meter.hues[0]}>
486 {whole(meter.percent)}
487 </Text>
488 </Box>
489 <Text dimColor wrap="truncate-end">
490 {meter.detail}
491 </Text>
492 </Box>
493 ))}
494 <Box gap={2}>
495 {clawd}
496 {footer !== '' && <Text dimColor>{footer}</Text>}
497 <Button key="compact" dimColor label="Less" onPress={() => setMode('compact')} />
498 <Button key="hide" role="dismiss" dimColor label="Hide" onPress={hide} />
499 </Box>
500 </Box>
501 )
502 })
503}
504types/index.d.ts 24 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type ContextFill = { window: number; tokens?: number; percent?: number }
4
5export type Reading = {
6 limits: Limit[]
7 context?: ContextFill
8 costUsd?: number
9 startedAt?: number
10}
11
12export type Mode = 'compact' | 'full' | 'hidden'
13
14declare module 'claude-code' {
15 interface PluginState {
16 'usage-band': {
17 reading: Reading | null
18 mode: Mode
19 tick: number
20 alerted: Record<string, number>
21 }
22 }
23}
24