Your 5-hour and weekly usage limits, context window and session cost as colour-graded bars above the prompt

Usage Bar is a Claude Code plugin that shows your 5-hour and weekly usage limits, context window and session cost as colour-graded bars above the prompt. You can see how close you are to a Claude Pro or Max limit, and when it resets, without running /usage.

/plugin install usage-bar --marketplace overvac/usage-bar
Requires a recent Claude Code with plugin mod support (tested on 2.1.301).
| Where | How |
|---|---|
| Claude Code prompt | /plugin install usage-bar --marketplace overvac/usage-bar, then y to add the marketplace and Enter to install |
| Shell | claude plugin install usage-bar --marketplace overvac/usage-bar |
| Claude desktop app | + next to the prompt box → Plugins → Add marketplace → overvac/usage-bar → Add plugin |
The bar appears once Claude Code has numbers to show, after your first message. Update with claude plugin update usage-bar; remove with /plugin uninstall usage-bar.
◈ $13.41 · ◰ c ━━━━━━╸━━━━━ 58% · ◷ 5h ━━━━━━━━╸━━━ 71% ↻ 2h 04m · ◫ 7d ━━━━━━━━━━━ 46% ↻ 3d 11h · ● 42s ago
| Segment | Meaning |
|---|---|
◈ $13.41 | Estimated session cost in US dollars, at list prices. Resets on /clear. |
◰ c | How full the context window is. Claude Code compacts the conversation near 100%. |
◷ 5h | Share of your rolling 5-hour limit used, and time until it resets. |
◫ 7d | Share of your weekly limit used, and time until it resets. |
● 42s ago | Time since any figure last changed. |
Open /config and type usage. Every metric and the pace shading can be switched off; the remaining bars take the freed space. /usage-bar hides or shows the whole bar, and the choice is remembered.
| Setting | Default |
|---|---|
| Show session cost · context window · 5-hour limit · weekly limit · last change | on |
| Show pace on limit bars | on |
The whole plugin is one readable file, hooks/register.tsx, and runs only through Claude Code's plugin API. It reads the usage figures Claude Code already tracks and your theme setting, and stores a single value: whether the bar is hidden. It makes no network requests, reads or writes no files, runs no commands, uses no tokens and sends nothing to the model. claude plugin validate lists every call it makes.
It hooks four Claude Code events, and passes each one on unchanged:
/usage-bar, reads whether the bar is hidden and your theme, and takes the first usage figures./usage-bar run: hides or shows the bar. It answers only its own command, never another.Why don't the 5-hour and weekly bars show? Claude Code only reports usage limits for Claude Pro and Max subscribers signed in with claude.ai, and only after the session's first response. With an API key you get the cost and context bars.
Does it replace my status line? No. It draws its own row above the prompt, so a custom statusLine keeps working.
How accurate is the cost? It's Claude Code's own estimate at list prices and may differ from your bill. On Pro or Max, treat it as a measure of how heavy the session is.
git clone https://github.com/overvac/usage-bar.git
claude --plugin-dir ./usage-bar # run Claude Code with your working copy
claude plugin validate ./usage-bar # check the manifest and module
claude plugin test ./usage-bar # run the tests
MIT. Usage Bar is a community project, not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic.
hooks/register.tsx 505 lines1// Usage Bar: a row above the prompt showing the session's cost, the context
2// window, and the 5-hour and weekly plan limits with their reset countdowns,
3// then how long ago any of those figures last changed.
4//
5// What it touches, all through Claude Code's own plugin API:
6// - reads the usage figures Claude Code already keeps ($.session.usage and
7// the session.measure event), the same ones a status line receives;
8// - reads the `theme` setting, to pick backgrounds that suit it;
9// - stores one value of its own, whether the bar is hidden ($.store);
10// - registers the /usage-bar command and draws the row.
11// It makes no network requests, reads or writes no files, runs no commands,
12// and sends nothing to the model.
13
14import type { Color, Elements, Register, SessionMeasureInput } from 'claude-code'
15
16type Limit = {
17 /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
18 kind: string
19 percentUsed: number
20 /** ISO 8601 time the window resets. */
21 resetsAt?: string
22}
23
24/** The figures the bar shows, as Claude Code last reported them. */
25type Usage = {
26 costUsd?: number
27 contextPercent?: number
28 /** Input tokens the last response was answered over. */
29 contextTokens?: number
30 /** The model's context window, in tokens. */
31 contextWindow?: number
32 limits: Limit[]
33 /** `$.clock.now()` milliseconds when the session began. */
34 startedAt?: number
35 /** `$.clock.now()` milliseconds when any figure last changed. */
36 changedAt: number
37 /** Which figures changed then. */
38 changed: ('context' | 'rateLimits' | 'cost')[]
39}
40
41/** The $.store key that remembers /usage-bar's choice across sessions. */
42const HIDDEN_KEY = 'hidden'
43
44// Cell colours along a bar, from empty to full: green through amber to red.
45const GRADIENT = ['#87d787', '#87d787', '#afd787', '#d7d787', '#ffd787', '#ffd75f', '#ffaf5f', '#ff875f', '#ff5f5f', '#ff5f5f']
46const LABEL = '#8787af'
47const COST = '#87d7ff'
48const MUTED: Color = 'inactive'
49const FAINT: Color = 'subtle'
50// The empty track up to the pace point, a step lighter than the rest.
51const PACE: Color = 'inactive'
52// Backgrounds, by the theme's lightness: behind a segment whose figure has
53// just changed, and behind the details line a hovered segment shows.
54const SHADES = {
55 dark: { flash: '#2c313c', details: '#22252d' },
56 light: { flash: '#e3e7ef', details: '#eef0f4' },
57}
58const FLASH_MS = 2000
59
60const MIN_WIDTH = 4
61const DEFAULT_WIDTH = 10
62const SEP = ' · '
63
64const WINDOWS = [
65 { kind: 'five_hour', icon: '◷', label: '5h', title: '5-hour limit', seconds: 5 * 3600 },
66 { kind: 'seven_day', icon: '◫', label: '7d', title: 'Weekly limit', seconds: 7 * 86400 },
67] as const
68
69/** One run of text in one style. */
70type Run = { text: string; color?: Color; isBold?: boolean; isDim?: boolean }
71
72/**
73 * A segment of the bar: its runs, given the cells its bar may take (0 for
74 * none); the figure whose change lights it; the details line hovering shows.
75 */
76type Segment = {
77 id: string
78 runs: (width: number) => Run[]
79 hasBar: boolean
80 unit?: 'context' | 'rateLimits' | 'cost'
81 details: Run[]
82}
83
84/** The segments placed on one line, with where each one starts. */
85type Line = { width: number; parts: { segment: Segment; runs: Run[]; column: number }[] }
86
87/** What the person's settings show: each segment by its id, and the pace on limit bars. */
88type Shown = Readonly<Record<'cost' | 'context' | 'five_hour' | 'seven_day' | 'updated' | 'pace', boolean>>
89
90const ALL: Shown = { cost: true, context: true, five_hour: true, seven_day: true, updated: true, pace: true }
91
92/** Reads the manifest's `show_*` options; an option left unset shows its segment. */
93export function shownOf(options: Readonly<Record<string, unknown>>): Shown {
94 const isSwitchedOn = (key: string) => options[key] !== false
95
96 return {
97 cost: isSwitchedOn('show_cost'),
98 context: isSwitchedOn('show_context'),
99 five_hour: isSwitchedOn('show_five_hour'),
100 seven_day: isSwitchedOn('show_weekly'),
101 updated: isSwitchedOn('show_last_change'),
102 pace: isSwitchedOn('show_pace'),
103 }
104}
105
106/** Whether the person's theme is a light one; read at session start. */
107let isLight = false
108
109function tone(pct: number): string {
110 return GRADIENT[Math.min(GRADIENT.length - 1, Math.floor((pct / 100) * GRADIENT.length))]
111}
112
113/** When a limit resets, in milliseconds, or undefined when it does not say. */
114function resetOf(limit: Limit): number | undefined {
115 const at = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
116
117 return Number.isNaN(at) ? undefined : at
118}
119
120/**
121 * The bar's cells as runs: filled cells shaded along the gradient, then the
122 * track, lighter up to the pace point (a fraction of the bar).
123 */
124function cells(pct: number, width: number, pace?: number): Run[] {
125 if (width === 0) {
126 return []
127 }
128 const filled = (pct / 100) * width
129 const full = Math.floor(filled)
130 const isHalf = filled - full >= 0.5 && full < width
131 const paced = pace === undefined ? 0 : Math.round(pace * width)
132 const runs: Run[] = []
133 for (let i = 0; i < width; i++) {
134 if (i < full) {
135 runs.push({ text: '━', color: GRADIENT[Math.floor((i * GRADIENT.length) / width)] })
136 } else if (i === full && isHalf) {
137 runs.push({ text: '╸', color: GRADIENT[Math.floor((i * GRADIENT.length) / width)] })
138 } else {
139 runs.push({ text: '━', color: i < paced ? PACE : FAINT })
140 }
141 }
142 runs.push({ text: ' ' })
143
144 return runs
145}
146
147/** A labelled bar: its icon, an optional short label, the bar, and the percentage. */
148function meter(icon: string, label: string | undefined, percent: number, pace?: number): (width: number) => Run[] {
149 const pct = Math.max(0, Math.min(100, percent))
150 const figure = `${Math.round(pct)}%`
151
152 return width => [
153 { text: label ? `${icon} ${label} ` : `${icon} `, color: LABEL },
154 ...cells(pct, width, pace),
155 { text: figure, color: tone(pct), isBold: true },
156 ]
157}
158
159/** A length of time, as `3d 11h`, `2h 04m` or `45m`. */
160function span(ms: number): string {
161 const seconds = Math.max(0, Math.floor(ms / 1000))
162 const days = Math.floor(seconds / 86400)
163 const hours = Math.floor((seconds % 86400) / 3600)
164 const minutes = Math.floor((seconds % 3600) / 60)
165
166 if (days) {
167 return `${days}d ${hours}h`
168 }
169
170 return hours ? `${hours}h ${String(minutes).padStart(2, '0')}m` : `${minutes}m`
171}
172
173function ago(ms: number): string {
174 const seconds = Math.max(0, Math.floor(ms / 1000))
175 if (seconds < 5) {
176 return 'just now'
177 }
178 if (seconds < 60) {
179 return `${seconds}s ago`
180 }
181 const minutes = Math.floor(seconds / 60)
182 if (minutes < 60) {
183 return minutes < 10 ? `${minutes}m ${String(seconds % 60).padStart(2, '0')}s ago` : `${minutes}m ago`
184 }
185
186 return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m ago`
187}
188
189const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
190
191/** A local clock time, led by the weekday when it is not today. */
192function clock(ms: number, now: number, isSeconds = false): string {
193 const at = new Date(ms)
194 const pad = (n: number) => String(n).padStart(2, '0')
195 let text = `${pad(at.getHours())}:${pad(at.getMinutes())}`
196 if (isSeconds) {
197 text += `:${pad(at.getSeconds())}`
198 }
199
200 return at.toDateString() === new Date(now).toDateString() ? text : `${DAYS[at.getDay()]} ${text}`
201}
202
203const grouped = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
204
205/** A details line: the bold title, then muted labels with their values, joined. */
206function details(title: string, ...rows: [label: string, value: string, color?: Color][]): Run[] {
207 return [
208 { text: title, isBold: true },
209 ...rows.flatMap(([label, value, color]) => [
210 { text: SEP, color: FAINT },
211 { text: `${label} `, color: MUTED },
212 { text: value, color, isBold: color !== undefined },
213 ]),
214 ]
215}
216
217function limitDetails(title: string, limit: Limit, pace: number | undefined, now: number): Run[] {
218 const rows: [string, string, Color?][] = [['used', `${Math.round(limit.percentUsed)}%`, tone(limit.percentUsed)]]
219 const at = resetOf(limit)
220 if (at !== undefined) {
221 rows.push(['resets', `${clock(at, now)}, in ${span(at - now)}`])
222 }
223 if (pace !== undefined) {
224 const gone = Math.round(pace * 100)
225 const ahead = limit.percentUsed - gone
226 const verdict =
227 ahead > 5 ? `${Math.round(ahead)} points ahead of pace` : ahead < -5 ? `${Math.round(-ahead)} points under pace` : 'on pace'
228 rows.push(['window', `${gone}% gone, ${verdict}`])
229 }
230
231 return details(title, ...rows)
232}
233
234function segments(figures: Usage, now: number, shown: Shown): Segment[] {
235 const list: Segment[] = []
236 if (shown.cost && figures.costUsd !== undefined) {
237 const text = `$${figures.costUsd.toFixed(2)}`
238 const running: [string, string][] = figures.startedAt === undefined ? [] : [['running', span(now - figures.startedAt)]]
239 list.push({
240 id: 'cost',
241 unit: 'cost',
242 hasBar: false,
243 runs: () => [
244 { text: '◈ ', color: LABEL },
245 { text, color: COST, isBold: true },
246 ],
247 details: details('Session cost', ['estimate', text, COST], ...running),
248 })
249 }
250 if (shown.context && figures.contextPercent !== undefined) {
251 const tokens: [string, string][] =
252 figures.contextTokens === undefined || figures.contextWindow === undefined
253 ? []
254 : [['tokens', `${grouped(figures.contextTokens)} of ${grouped(figures.contextWindow)}`]]
255 list.push({
256 id: 'context',
257 unit: 'context',
258 hasBar: true,
259 runs: meter('◰', 'c', figures.contextPercent),
260 details: details('Context window', ['used', `${Math.round(figures.contextPercent)}%`, tone(figures.contextPercent)], ...tokens),
261 })
262 }
263 for (const { kind, icon, label, title, seconds } of WINDOWS) {
264 const limit = figures.limits.find(one => one.kind === kind)
265 if (!shown[kind] || !limit) {
266 continue
267 }
268 const at = resetOf(limit)
269 // How far through its window the limit is, 0 to 1.
270 const pace = at === undefined ? undefined : Math.max(0, Math.min(1, 1 - (at - now) / 1000 / seconds))
271 const bar = meter(icon, label, limit.percentUsed, shown.pace ? pace : undefined)
272 const countdown: Run[] =
273 at === undefined
274 ? []
275 : [
276 { text: ' ↻ ', color: MUTED, isDim: true },
277 { text: span(at - now), color: MUTED },
278 ]
279 list.push({
280 id: kind,
281 unit: 'rateLimits',
282 hasBar: true,
283 runs: width => [...bar(width), ...countdown],
284 details: limitDetails(title, limit, pace, now),
285 })
286 }
287 if (shown.updated) {
288 const since = now - figures.changedAt
289 // Padded to its longest form so the bars don't resize as it ticks.
290 const text = ago(since).padEnd(10)
291 list.push({
292 id: 'updated',
293 hasBar: false,
294 runs: () => [
295 { text: '● ', color: since < 60_000 ? GRADIENT[0] : FAINT },
296 { text, color: MUTED },
297 ],
298 details: details('Last change', ['at', clock(figures.changedAt, now, true)]),
299 })
300 }
301
302 return list
303}
304
305function length(runs: Run[]): number {
306 return runs.reduce((sum, run) => sum + [...run.text].length, 0)
307}
308
309/** The segments laid out on one line, their bars sharing what room is left. */
310function place(figures: Usage, now: number, columns: number | undefined, shown: Shown): Line {
311 const list = segments(figures, now, shown)
312 const bars = list.filter(one => one.hasBar).length
313 // Cells for each bar, in order: the room the rest leaves, shared out so the
314 // line ends one column short of the edge however many bars there are.
315 let widths = list.map(() => DEFAULT_WIDTH)
316 if (columns !== undefined && bars > 0) {
317 const fixed = list.reduce((sum, one) => sum + length(one.runs(0)), 0) + SEP.length * (list.length - 1)
318 // Each bar also brings a space between it and its percentage.
319 const room = columns - 1 - fixed - bars
320 const base = Math.floor(room / bars)
321 let extra = room - base * bars
322 widths = list.map(() => (base >= MIN_WIDTH ? base + (extra-- > 0 ? 1 : 0) : 0))
323 }
324 let column = 0
325 let bar = 0
326 const parts = list.map((segment, i) => {
327 if (i > 0) {
328 column += SEP.length
329 }
330 const runs = segment.runs(segment.hasBar ? widths[bar++] : 0)
331 const part = { segment, runs, column }
332 column += length(runs)
333
334 return part
335 })
336 // The last segment's padding would only push the line's end out.
337 const last = parts.at(-1)?.runs
338 if (last) {
339 last[last.length - 1] = { ...last[last.length - 1], text: last[last.length - 1].text.trimEnd() }
340 }
341
342 return { width: column, parts }
343}
344
345/** The whole line as runs, for reading it as text (the tests do). */
346export function layout(figures: Usage, now: number, columns: number | undefined, shown: Shown = ALL): Run[] {
347 return place(figures, now, columns, shown).parts.flatMap((part, i) => (i > 0 ? [{ text: SEP, color: FAINT }, ...part.runs] : part.runs))
348}
349
350function figuresOf(e: Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'>) {
351 return {
352 costUsd: e.cost?.usd,
353 contextPercent: e.context.percent,
354 contextTokens: e.context.tokens,
355 contextWindow: e.context.window,
356 limits: e.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
357 }
358}
359
360export const register: Register = (on, options) => {
361 const shown = shownOf(options)
362 // The latest figures, and whether /usage-bar hid the bar. A change to
363 // either asks Claude Code to draw the bar again.
364 let usage: Usage | null = null
365 let isHidden = false
366 let isDrawn = false
367
368 on('session.start', async ($, e, next) => {
369 await $.command.register({
370 name: 'usage-bar',
371 description: 'Hide or show the usage bar',
372 })
373 isHidden = (await $.store.get(HIDDEN_KEY)) === true
374 try {
375 isLight = String((await $.settings.read()).theme ?? '').includes('light')
376 } catch {
377 // Settings unreadable: keep the dark shades.
378 }
379
380 const current = await $.session.usage()
381 const figures = figuresOf(current)
382 if (figures.costUsd !== undefined || figures.contextPercent !== undefined || figures.limits.length > 0) {
383 usage = { ...figures, startedAt: current.startedAt, changedAt: await $.clock.now(), changed: [] }
384 }
385
386 // Keeps the countdowns, "ago" and the change highlight moving while
387 // idle. Claude Code drops the timer when it reloads or ends the session.
388 $.clock.every(1000, () => {
389 if (isDrawn) {
390 $.ui.invalidate('ui.render')
391 }
392 })
393
394 return next(e)
395 })
396
397 on('session.measure', async ($, e, next) => {
398 usage = { ...figuresOf(e), startedAt: usage?.startedAt, changedAt: await $.clock.now(), changed: [...e.changed] }
399 $.ui.invalidate('ui.render')
400
401 return next(e)
402 })
403
404 on('command.run', { command: 'usage-bar' }, async $ => {
405 isHidden = !isHidden
406 await $.store.set(HIDDEN_KEY, isHidden)
407 $.ui.invalidate('ui.render')
408
409 return { text: isHidden ? 'Usage bar hidden. Run /usage-bar to show it again.' : 'Usage bar shown.' }
410 })
411
412 // The band's last row, under whatever else draws there.
413 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
414 if (e.props.hasSurvey) {
415 return next(e)
416 }
417 isDrawn = false
418 const figures = usage
419 if (figures === null || isHidden) {
420 return next(e)
421 }
422 const now = await $.clock.now()
423 const columns = e.props.bodyColumns
424 const line = place(figures, now, columns, shown)
425 if (line.parts.length === 0) {
426 return next(e)
427 }
428 const others = await next(e)
429 const { Box, Text } = $.ui.resolve(e)
430 isDrawn = true
431 const bar = draw(Box, Text, line, figures, now, columns)
432
433 return others ? (
434 <Box flexDirection="column">
435 {others}
436 {bar}
437 </Box>
438 ) : (
439 bar
440 )
441 })
442}
443
444/** The hover group a segment and its details line share. */
445const scopeOf = (segment: Segment) => `usage-bar-${segment.id}`
446
447/**
448 * The bar as a tree. Each segment lights a moment when its figure changes.
449 * Hovering one reveals its details line over the whole row: inside the bar's
450 * own row, so nothing clips it.
451 */
452function draw(Box: Elements['terminal']['Box'], Text: Elements['terminal']['Text'], line: Line, figures: Usage, now: number, columns: number | undefined) {
453 const isFlashing = now - figures.changedAt < FLASH_MS
454 const shades = isLight ? SHADES.light : SHADES.dark
455 const width = Math.max(line.width, columns ?? 0)
456 const text = (run: Run) => (
457 <Text color={run.color} weight={run.isBold ? 'bold' : undefined} dimColor={run.isDim}>
458 {run.text}
459 </Text>
460 )
461
462 return (
463 // The line's Box spans the whole room, so the details lines placed in it,
464 // after the segments and so painted over them, span it too.
465 <Box flexDirection="row" width={width}>
466 {line.parts.map(({ segment, runs }, i) => {
467 const isLit = isFlashing && segment.unit !== undefined && figures.changed.includes(segment.unit)
468
469 return (
470 <Box flexDirection="row">
471 {i > 0 && <Text color={FAINT}>{SEP}</Text>}
472 <Box
473 key={segment.id}
474 flexDirection="row"
475 backgroundColor={isLit ? shades.flash : undefined}
476 hover={{ scope: scopeOf(segment) }}
477 >
478 {runs.map(text)}
479 </Box>
480 </Box>
481 )
482 })}
483 {line.parts.map(({ segment }) => (
484 // Revealed while its segment is hovered (the two share a hover scope),
485 // and kept while the pointer rests on it.
486 <Box
487 position="absolute"
488 top={0}
489 left={0}
490 width={width}
491 overflow="hidden"
492 display="none"
493 hover={{ scope: scopeOf(segment), display: 'flex' }}
494 backgroundColor={shades.details}
495 >
496 <Text wrap="truncate-end">
497 {segment.details.map(text)}
498 {' '.repeat(Math.max(0, width - length(segment.details)))}
499 </Text>
500 </Box>
501 ))}
502 </Box>
503 )
504}
505