Always-on band above the prompt: context fill and rate-limit windows

<img src="docs/band.svg" width="767" title="Drawn by the mod's own bar() from sample figures — not a screenshot." alt="The usage band, drawn by the mod's own bar() from sample figures rather than a screenshot: ctx at 42% with 84k of 200k tokens and a red tick at the 80% auto-compact point, 5h at 67% in amber with 1h48m to reset and a grey pace tick at 64%, wk at 89% in red with 2d21h to reset and a pace tick at 59%, then cache 83% and $1.23.">
Context fill and every rate-limit window, always on, right above the Claude Code prompt — in the desktop app's Code tab as well as the terminal.
No status-line script. No network requests. No model calls. One mod reading Claude Code's own session usage.
Paste this and walk away:
Fetch and follow https://raw.githubusercontent.com/zexion7873/usage-band/main/llms-install.md
It installs from the CLI and verifies the install. Recipe in llms-install.md.
From inside a Claude Code terminal session:
/plugin marketplace add zexion7873/usage-band
/plugin install usage-band@usage-band
Pick the user scope. The band appears in that session at once. Installed at the user scope, it also loads in sessions the desktop app starts — the desktop Code tab cannot run /plugin itself, so install from a terminal once.
[!IMPORTANT] Requirements: a Claude Code build with function-hook plugins (mods). CI validates and tests against Claude Code 2.1.291; older builds are unmeasured.
| | Item | Meaning | |:-:|------|---------| | 🧠 | ctx | Context window fill, tokens/window beside it. A red tick marks where auto-compact triggers. | | ⏱️ | 5h, wk | Each rate-limit window: percent used and time until it resets. A grey tick marks the share of the window already elapsed — a fill past the tick is burning faster than an even pace. | | 💳 | spend | The spend limit, when the account has one. | | ♻️ | cache N% | Cache reads as a share of all input tokens in the last response. | | 💵 | $N.NN | Session cost. |
Percent turns amber at 60% and red at 85%.
On the desktop each meter is a bar, and a screen reader reads its detail: for ctx, the compact point and the three largest context categories; for a rate-limit window, the pace. The terminal draws the headline figures as one line of text:
ctx 42% (84k/200k) · 5h 67% (1h48m) · wk 89% (2d21h) · cache 83% · $1.23
Reset countdowns and pace ticks keep moving while the session sits idle — the band redraws once a minute rather than waiting for the next response.
ctx fill with its red tick. Close to the tick, run /compact or start a fresh session now, instead of having auto-compact fire halfway through the work.5h fill runs past its grey tick, you are spending faster than an even pace and will hit the limit before it resets. The countdown beside it says how long the rest has to last.A statusLine command never runs in the desktop Code tab, so every status-line usage meter is invisible there. usage-band is a mod — a plugin of function hooks — and draws into the prompt area itself, which both surfaces render.
It steps aside while Claude Code shows a survey above the prompt, and keeps whatever other plugins drew beneath it.
flowchart LR
Start["session.start<br/>usage + breakdown"]
Measure["session.measure<br/>after every turn"]
Step["turn.step<br/>ctx after every response"]
Compact["session.compact<br/>size it leaves"]
State[("usage atom<br/>plugin state")]
Render["ui.render · AbovePrompt"]
Desk["🖥️ desktop<br/>SVG bars"]
Term["⌨️ terminal<br/>one line of text"]
Clock["every 60 s"]
Start --> State
Measure --> State
Step --> State
Compact --> State
State --> Render
Render --> Desk
Render --> Term
Clock -.->|"invalidate"| Render
Every figure comes from Claude Code's own session usage, read locally. The mod writes no files, opens no ports, and makes no network requests or model calls — claude plugin validate plugin prints every engine call it makes.
| Symptom | Check |
|---|---|
| No band at all | claude plugin list should show usage-band@usage-band as loaded. A session picks up plugins only when it starts, so open a new one after installing or updating. Mods also need a recent Claude Code — see Requirements. |
| Band missing for a moment | It steps aside while Claude Code shows a survey above the prompt, and draws nothing until the session has reported its first usage. |
No 5h / wk / spend | Only the windows Claude Code reports for your account are drawn; spend appears only with a spend limit. |
| A figure looks wrong | Compare it with Claude Code's own usage panel, then open a bug with both values and where you ran it. |
/plugin uninstall usage-band@usage-band
The mod writes nothing to disk of its own, so there is nothing else to clean up.
claude plugin validate --strict plugin
claude plugin test plugin
CONTRIBUTING.md has the dev loop and the traps worth knowing first; AGENTS.md has the rest.
Unofficial community project. Not affiliated with, endorsed by, or sponsored by Anthropic.
hooks/register.tsx 205 lines1import { atom, read, update } from 'claude-code'
2import type { Register, SessionContextBreakdown, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { UsageBand, UsageBandDetail } from '../types'
5import { BAR, bar, clampPct, COMPACT_TICK, PACE_TICK, tone } from './bar'
6
7const usage = atom({ plugin: 'usage-band', key: 'usage' } as const, null)
8
9
10const LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'wk', spend_limit: 'spend' }
11const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
12
13const TEXT_COLOR = { calm: undefined, hot: 'warning', over: 'error' } as const
14
15const untilReset = (resetsAt: string | undefined, now: number) => {
16 if (resetsAt === undefined) return ''
17 const minutes = Math.max(0, Math.round((Date.parse(resetsAt) - now) / 60000))
18 const days = Math.floor(minutes / 1440)
19 const hours = Math.floor((minutes % 1440) / 60)
20 return days > 0 ? `${days}d${hours}h` : `${hours}h${minutes % 60}m`
21}
22
23// Share of the window already elapsed: usage above it is ahead of an even burn.
24const pace = (limit: SessionRateLimit, now: number) => {
25 const span = WINDOW_MS[limit.kind]
26 if (span === undefined || limit.resetsAt === undefined) return undefined
27 return Math.round(clampPct(100 * (1 - (Date.parse(limit.resetsAt) - now) / span)))
28}
29
30const tokens = (n: number) =>
31 n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
32
33const detailOf = (breakdown: SessionContextBreakdown | undefined, window: number): UsageBandDetail | undefined => {
34 if (breakdown === undefined) return undefined
35 const api = breakdown.apiUsage
36 const read = api?.cache_read_input_tokens ?? 0
37 const input = api === null ? 0 : read + api.input_tokens + api.cache_creation_input_tokens
38 return {
39 compactPercent:
40 breakdown.isAutoCompactEnabled && breakdown.autoCompactThreshold !== undefined
41 ? Math.round((breakdown.autoCompactThreshold / window) * 100)
42 : undefined,
43 cacheHitPercent: input > 0 ? Math.round((read / input) * 100) : undefined,
44 contextParts: breakdown.categories
45 .filter(c => c.kind === 'used')
46 .sort((a, b) => b.tokens - a.tokens)
47 .slice(0, 3)
48 .map(c => ({ name: c.name, tokens: c.tokens })),
49 }
50}
51
52const snapshot = (
53 context: SessionContextUsage,
54 rateLimits: SessionRateLimit[],
55 cost: SessionCost | undefined,
56 detail: UsageBandDetail | undefined,
57): UsageBand => ({
58 contextPercent: context.percent,
59 contextTokens: context.tokens,
60 contextWindow: context.window,
61 rateLimits,
62 costUsd: cost?.usd,
63 detail,
64})
65
66const withContext = (prev: UsageBand, tokens: number): UsageBand => ({
67 ...prev,
68 contextTokens: tokens,
69 contextPercent: Math.round(clampPct((100 * tokens) / prev.contextWindow)),
70})
71
72export const register: Register = on => {
73 on('session.start', async ($, e, next) => {
74 const { context, rateLimits, cost } = await $.session.usage({ breakdown: 'summary' })
75 await update($, usage, () => snapshot(context, rateLimits, cost, detailOf(context.breakdown, context.window)))
76 // Reset countdowns and pace ticks read the clock while drawing; without this an idle session freezes them.
77 $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
78 return next(e)
79 })
80
81 on('session.measure', async ($, e, next) => {
82 await update($, usage, prev => snapshot(e.context, e.rateLimits, e.cost, prev?.detail))
83 // Second write so a failed breakdown leaves the headline figures current.
84 if (e.changed.includes('context')) {
85 const { context } = await $.session.usage({ breakdown: 'summary' })
86 const detail = detailOf(context.breakdown, e.context.window)
87 await update($, usage, prev => prev && { ...prev, detail })
88 }
89 return next(e)
90 })
91
92 on('session.compact', async ($, e, next) => {
93 const result = await next(e)
94 // usage() keeps the last response's fill until a new response lands, and the next measure waits for the turn's end.
95 if (result.skip !== undefined || result.tokensAfter === undefined || e.agentId !== undefined || e.trigger === 'precompute')
96 return result
97 const after = result.tokensAfter
98 await update($, usage, prev => prev && withContext(prev, after))
99 const { context } = await $.session.usage({ breakdown: 'summary' })
100 const detail = detailOf(context.breakdown, context.window)
101 await update($, usage, prev => prev && { ...prev, detail })
102 return result
103 })
104
105 // session.measure waits for the turn's end; a long turn would otherwise hold ctx at its starting fill.
106 on('turn.step', async function* ($, e, next) {
107 const result = yield* next(e)
108 const u = result.usage
109 if (u === null || e.agentId !== undefined) return result
110 const fill = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
111 await update($, usage, prev => prev && withContext(prev, fill))
112 return result
113 })
114
115 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
116 const below = await next(e)
117 const current = await read($, usage)
118 if (e.props.hasSurvey || current === null) return below
119
120 const now = await $.clock.now()
121 const detail = current.detail
122 const ctxPercent = current.contextPercent ?? 0
123 const ctxDetail =
124 current.contextTokens === undefined ? '' : `${tokens(current.contextTokens)}/${tokens(current.contextWindow)}`
125 const meters = [
126 {
127 key: 'ctx',
128 label: 'ctx',
129 percent: ctxPercent,
130 detail: ctxDetail,
131 extra: [
132 ...(detail?.compactPercent === undefined ? [] : [`compacts at ${detail.compactPercent}%`]),
133 ...(detail?.contextParts ?? []).map(p => `${p.name} ${tokens(p.tokens)}`),
134 ],
135 marks: detail?.compactPercent === undefined ? [] : [{ percent: detail.compactPercent, color: COMPACT_TICK }],
136 },
137 ...current.rateLimits.map(limit => {
138 const label = LABELS[limit.kind] ?? limit.kind
139 const reset = untilReset(limit.resetsAt, now)
140 const paceAt = pace(limit, now)
141 return {
142 key: limit.kind,
143 label,
144 percent: limit.percentUsed,
145 detail: reset,
146 extra: paceAt === undefined ? [] : [`pace ${paceAt}%`],
147 marks: paceAt === undefined ? [] : [{ percent: paceAt, color: PACE_TICK }],
148 }
149 }),
150 ]
151 const cache = detail?.cacheHitPercent === undefined ? '' : `cache ${detail.cacheHitPercent}%`
152 const cost = current.costUsd === undefined ? '' : `$${current.costUsd.toFixed(2)}`
153
154 if (e.surface === 'terminal') {
155 const { Box, Text } = $.ui.resolve(e)
156 return (
157 <Box flexDirection="column">
158 {below}
159 <Box>
160 {meters.map((m, i) => (
161 <Text key={m.key}>
162 <Text dimColor>{i > 0 ? ' · ' : ''}{m.label} </Text>
163 <Text color={TEXT_COLOR[tone(m.percent)]} dimColor={tone(m.percent) === 'calm'}>{m.percent}%</Text>
164 <Text dimColor>{m.detail && ` (${m.detail})`}</Text>
165 </Text>
166 ))}
167 {cache && <Text dimColor> · {cache}</Text>}
168 {cost && <Text dimColor> · {cost}</Text>}
169 </Box>
170 </Box>
171 )
172 }
173
174 const { Box, Text, Svg } = $.ui.resolve(e)
175 return (
176 <Box flexDirection="column">
177 {below}
178 <Box flexDirection="row" alignItems="center" justifyContent="space-between">
179 <Box flexDirection="row" alignItems="center" gap={3}>
180 {meters.map(m => (
181 <Box key={m.key} flexDirection="row" alignItems="center" gap={1}>
182 <Text dimColor>{m.label}</Text>
183 <Svg
184 source={bar(m.percent, m.marks)}
185 alt={[`${m.label} ${m.percent}%`, m.detail, ...m.extra].filter(Boolean).join(' · ')}
186 width={BAR.width}
187 height={BAR.tick}
188 />
189 {tone(m.percent) === 'calm' ? (
190 <Text dimColor>{m.percent}%</Text>
191 ) : (
192 <Text color={TEXT_COLOR[tone(m.percent)]}>{m.percent}%</Text>
193 )}
194 {m.detail && <Text dimColor>{m.detail}</Text>}
195 </Box>
196 ))}
197 {cache && <Text dimColor>{cache}</Text>}
198 </Box>
199 {cost && <Text dimColor>{cost}</Text>}
200 </Box>
201 </Box>
202 )
203 })
204}
205hooks/bar.ts 21 lines1// No 'claude-code' import here: tools/make-hero.mts runs this file under plain node.
2export const tone = (percent: number) => (percent >= 85 ? 'over' : percent >= 60 ? 'hot' : 'calm')
3
4// The bar is drawn as an image: theme keys don't reach it, so colors are literal (sampled from the app's usage panel).
5const FILL = { calm: '#4177d0', hot: '#bf882e', over: '#c04742' } as const
6export const PACE_TICK = '#9c9c9c'
7export const COMPACT_TICK = '#c04742'
8export const BAR = { width: 80, height: 8, tick: 12 }
9
10export const clampPct = (p: number) => Math.min(100, Math.max(0, p))
11const at = (percent: number) => Math.round((clampPct(percent) / 100) * BAR.width)
12
13const tick = (percent: number, color: string) =>
14 `<rect x="${Math.min(BAR.width - 2, Math.max(0, at(percent) - 1))}" width="2" height="${BAR.tick}" fill="${color}"/>`
15
16export const bar = (percent: number, marks: { percent: number; color: string }[]) => {
17 const r = BAR.height / 2
18 const y = (BAR.tick - BAR.height) / 2
19 return `<svg xmlns="http://www.w3.org/2000/svg" width="${BAR.width}" height="${BAR.tick}"><rect y="${y}" width="${BAR.width}" height="${BAR.height}" rx="${r}" fill="#888" fill-opacity="0.25"/><rect y="${y}" width="${at(percent)}" height="${BAR.height}" rx="${r}" fill="${FILL[tone(percent)]}"/>${marks.map(m => tick(m.percent, m.color)).join('')}</svg>`
20}
21types/index.d.ts 25 lines1export type UsageBandLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type UsageBandPart = { name: string; tokens: number }
4
5export type UsageBandDetail = {
6 compactPercent?: number
7 cacheHitPercent?: number
8 contextParts: UsageBandPart[]
9}
10
11export type UsageBand = {
12 contextPercent?: number
13 contextTokens?: number
14 contextWindow: number
15 rateLimits: UsageBandLimit[]
16 costUsd?: number
17 detail?: UsageBandDetail
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'usage-band': { usage: UsageBand | null }
23 }
24}
25