A context-window breakdown bar above the prompt, toggled with /context-bar

A live breakdown of the context window, drawn in the band above Claude Code's prompt.
In the terminal:

In the desktop app:

/context uses.5h, 7d, or a gateway's spend limit), each with the share used, coloured like the context badge, and a countdown to when it resets. The row only appears on a subscription, where Claude Code receives these figures.| Command | Does |
|---|---|
/context-bar | Shows or hides the bar |
/context-bar on | Shows the bar |
/context-bar off | Hides the bar |
The command runs at once, even while Claude is working. The choice is remembered across sessions. Claude Code's own [-] control on the band collapses it as well.
| Hook | What it does |
|---|---|
session.start | Registers /context-bar, restores the saved on/off choice, takes a first reading, and starts a once-a-minute timer for the reset countdowns. |
turn.complete | Takes a reading after each turn of the main conversation. Subagent turns are skipped. |
session.compact | Takes a reading after the conversation is compacted. |
session.measure | Updates the usage row when a rate-limit window moves, between turns too. |
ui.render on AbovePrompt | Draws the band. It steps aside while Claude Code shows a survey there. |
Readings come from $.session.usage({ breakdown: 'summary' }), the same per-category breakdown /context shows. The summary mode estimates locally, so a reading sends no requests and costs nothing. The same call returns the rate-limit windows from the last API response; the usage row shows those, so it sends no requests either.
The bar is built from flex boxes, each growing by its share of the window, so it splits in exact proportion at any width and never wraps. Small categories still get at least one cell. Free space is drawn differently per surface:
The compaction reserve is drawn as part of the free track; the header shows where compaction runs.
/context, so the total can differ slightly from the status line.tests/bar.test.tsx feeds the mod a sample breakdown, draws the band on the terminal and desktop surfaces, and fails if either surface refuses the drawing. Claude Code drops a drawing it can't validate without an error you'd see, so run the tests after changing how the bar is drawn:
claude plugin test .hooks/register.tsx 288 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Limit, Segment, Snapshot } from '../types'
5
6const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
7const isShown = atom({ plugin: 'context-bar', key: 'isShown' } as const, true)
8const limits = atom({ plugin: 'context-bar', key: 'limits' } as const, [] as Limit[])
9// The time the reset countdowns are measured from, moved on once a minute.
10const now = atom({ plugin: 'context-bar', key: 'now' } as const, 0)
11
12// Persisted across sessions, so the bar stays hidden once turned off.
13const SHOWN_KEY = 'isShown'
14const ACCENT = '#D97757'
15// Longer than any band is wide; the free track's box clips it to its share.
16const FREE_FILL = '─'.repeat(400)
17
18// Shorter legend labels for /context's category names; others show as given.
19const LABELS: Record<string, string> = {
20 'system prompt': 'system',
21 'system tools': 'tools',
22 'mcp tools': 'mcp',
23 'mcp server instructions': 'mcp instr',
24 'custom agents': 'agents',
25 'memory files': 'memory',
26}
27
28// Short names for the rate-limit windows; others show with spaces for underscores.
29const LIMIT_LABELS: Record<string, string> = {
30 five_hour: '5h',
31 seven_day: '7d',
32 spend_limit: 'spend',
33}
34const TICK_MS = 60_000
35
36const short = (n: number) => {
37 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
38 if (n >= 1_000) return `${+(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`
39 return String(n)
40}
41
42// A segment's share of the window as a flex weight, in hundredths of a
43// percent: the surfaces cap flexGrow at 10000.
44const growOf = (tokens: number, max: number) => Math.min(10_000, (tokens / max) * 10_000)
45
46const badgeColor = (percent: number) =>
47 percent < 50 ? 'success' : percent < 80 ? 'warning' : 'error'
48
49// Time left as `45m`, `2h 14m` or `3d 4h`, rounded up to the minute; null once
50// it has passed.
51const until = (ms: number) => {
52 const minutes = Math.ceil(ms / 60_000)
53 if (minutes <= 0) return null
54 if (minutes < 60) return `${minutes}m`
55 const hours = Math.floor(minutes / 60)
56 if (hours < 24) return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`
57 const days = Math.floor(hours / 24)
58 return hours % 24 ? `${days}d ${hours % 24}h` : `${days}d`
59}
60
61const toLimits = (windows: readonly SessionRateLimit[]): Limit[] =>
62 windows.map(w => {
63 const resetsAt = w.resetsAt === undefined ? NaN : Date.parse(w.resetsAt)
64 return { kind: w.kind, percent: w.percentUsed, resetsAt: Number.isNaN(resetsAt) ? null : resetsAt }
65 })
66
67const setLimits = async ($: EngineInterface, windows: readonly SessionRateLimit[]) => {
68 const time = await $.clock.now()
69 await update($, now, () => time)
70 await update($, limits, () => toLimits(windows))
71}
72
73// Moves the countdowns on; skipped while there is nothing on screen to move.
74const tick = async ($: EngineInterface) => {
75 if (!(await read($, isShown)) || !(await read($, limits)).some(l => l.resetsAt !== null)) {
76 return
77 }
78 const time = await $.clock.now()
79 await update($, now, () => time)
80}
81
82const refresh = async ($: EngineInterface) => {
83 try {
84 const { context, rateLimits } = await $.session.usage({ breakdown: 'summary' })
85 await setLimits($, rateLimits)
86 const b = context.breakdown
87 if (!b) {
88 return
89 }
90 const segments: Segment[] = b.categories
91 .filter(c => c.kind !== 'deferred')
92 .map(c => ({
93 name: c.name.toLowerCase(),
94 tokens: c.tokens,
95 color: c.color,
96 kind: c.kind === 'free' ? 'free' : c.kind === 'buffer' ? 'buffer' : 'used',
97 }))
98 const next: Snapshot = {
99 total: b.totalTokens,
100 max: b.rawMaxTokens,
101 threshold: b.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
102 segments,
103 }
104 await update($, snapshot, () => next)
105 } catch {
106 // No reading this time; the bar keeps the last one.
107 }
108}
109
110const setShown = async ($: EngineInterface, value: boolean) => {
111 await $.store.set(SHOWN_KEY, value)
112 await update($, isShown, () => value)
113 if (value) {
114 await refresh($)
115 }
116}
117
118export const register: Register = on => {
119 on('session.start', async ($, e, next) => {
120 const result = await next(e)
121 await $.command.register({
122 name: 'context-bar',
123 description: 'Toggle the context-window bar above the prompt',
124 argumentHint: '[on|off]',
125 immediate: true,
126 })
127 const stored = await $.store.get(SHOWN_KEY)
128 await update($, isShown, () => stored !== false)
129 $.clock.after(0, () => void refresh($))
130 $.clock.every(TICK_MS, () => void tick($))
131
132 return result
133 })
134
135 on('command.run', { command: 'context-bar' }, async ($, e) => {
136 const arg = e.args.trim().toLowerCase()
137 if (arg !== '' && arg !== 'on' && arg !== 'off') {
138 return { text: 'Usage: /context-bar [on|off]' }
139 }
140 const value = arg === 'on' ? true : arg === 'off' ? false : !(await read($, isShown))
141 await setShown($, value)
142
143 return { text: value ? 'Context bar on.' : 'Context bar off.' }
144 })
145
146 on('turn.complete', async ($, e, next) => {
147 const result = await next(e)
148 if (e.agentId === undefined) {
149 $.clock.after(0, () => void refresh($))
150 }
151 return result
152 })
153
154 on('session.compact', async ($, e, next) => {
155 const result = await next(e)
156 if (e.agentId === undefined) {
157 $.clock.after(0, () => void refresh($))
158 }
159 return result
160 })
161
162 // Rate-limit windows move between turns too; the engine pushes them here.
163 on('session.measure', async ($, e, next) => {
164 const result = await next(e)
165 if (e.changed.includes('rateLimits')) {
166 await setLimits($, e.rateLimits)
167 }
168 return result
169 })
170
171 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
172 if (e.props.hasSurvey || !(await read($, isShown))) {
173 return next(e)
174 }
175 const { Box, Text } = $.ui.resolve(e)
176 const snap = await read($, snapshot)
177 const windows = await read($, limits)
178 const time = await read($, now)
179
180 // One row of plan usage, each window with its share used and when it
181 // resets. Absent off a subscription, where no window is reported.
182 const usage = windows.length > 0 && (
183 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
184 <Box key="usage-head" flexDirection="row">
185 <Text color={ACCENT}>◆ </Text>
186 <Text bold>usage</Text>
187 </Box>
188 {windows.map((l, i) => {
189 const percent = Math.round(l.percent)
190 const left = l.resetsAt === null ? null : until(l.resetsAt - time)
191 return (
192 <Box key={`lim${i}`} flexDirection="row">
193 <Text dimColor>{LIMIT_LABELS[l.kind] ?? l.kind.replace(/_/g, ' ')} </Text>
194 <Text bold color={badgeColor(percent)}>{`${percent}%`}</Text>
195 {l.resetsAt !== null && (
196 <Text dimColor>{left === null ? ' · resetting' : ` · resets in ${left}`}</Text>
197 )}
198 </Box>
199 )
200 })}
201 </Box>
202 )
203
204 if (snap === null || snap.max <= 0) {
205 return (
206 <Box flexDirection="column" paddingX={1}>
207 <Box flexDirection="row">
208 <Text color={ACCENT}>◆ </Text>
209 <Text bold>context</Text>
210 <Text dimColor> waiting for the first reading…</Text>
211 </Box>
212 {usage}
213 </Box>
214 )
215 }
216
217 const percent = Math.round((snap.total / snap.max) * 100)
218 const used = snap.segments.filter(s => s.kind === 'used' && s.tokens > 0)
219 const free = snap.segments.find(s => s.kind === 'free')
220 const isTerminal = e.surface === 'terminal'
221 // Everything past the used segments, the compaction reserve included, is
222 // drawn as one free track; the header says where compaction runs.
223 const rest = Math.max(0, snap.max - used.reduce((sum, s) => sum + s.tokens, 0))
224
225 // Each segment grows by its token count from a zero basis, so the row
226 // splits in exact proportion (the surfaces accept only whole-number
227 // percentage widths).
228 // The badge sits beside the totals, not at the row's right edge, where the
229 // engine draws the band's own [-] control over it.
230 return (
231 <Box flexDirection="column" paddingX={1}>
232 <Box flexDirection="row">
233 <Text color={ACCENT}>◆ </Text>
234 <Text bold>context </Text>
235 <Text bold color="black" backgroundColor={badgeColor(percent)}>
236 {` ${percent}% `}
237 </Text>
238 <Text bold> {short(snap.total)}</Text>
239 <Text dimColor>/{short(snap.max)}</Text>
240 {snap.threshold !== null && <Text dimColor> · compacts {short(snap.threshold)}</Text>}
241 </Box>
242 <Box flexDirection="row" height={1} overflow="hidden">
243 {used.map((s, i) => (
244 <Box
245 key={`seg${i}`}
246 width={0}
247 flexGrow={growOf(s.tokens, snap.max)}
248 minWidth={1}
249 height={1}
250 backgroundColor={s.color}
251 />
252 ))}
253 {/* On the terminal the free track is a dim line of text, not filled:
254 the engine's colour for free space there is bright enough to read
255 as used. Other surfaces wrap a long text run rather than clip it,
256 and draw that colour dark, so it is filled there. */}
257 {rest > 0 && (
258 isTerminal ? (
259 <Box key="free" width={0} flexGrow={growOf(rest, snap.max)} height={1} overflow="hidden">
260 <Text dimColor>{FREE_FILL}</Text>
261 </Box>
262 ) : (
263 <Box key="free" width={0} flexGrow={growOf(rest, snap.max)} height={1} backgroundColor={free?.color} />
264 )
265 )}
266 </Box>
267 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
268 {used.map((s, i) => (
269 <Box key={`leg${i}`} flexDirection="row">
270 <Text color={s.color}>■ </Text>
271 <Text dimColor>{LABELS[s.name] ?? s.name} </Text>
272 <Text>{short(s.tokens)}</Text>
273 </Box>
274 ))}
275 {free && free.tokens > 0 && (
276 <Box key="leg-free" flexDirection="row">
277 {isTerminal ? <Text dimColor>─ </Text> : <Text color={free.color}>■ </Text>}
278 <Text dimColor>free </Text>
279 <Text>{short(free.tokens)}</Text>
280 </Box>
281 )}
282 </Box>
283 {usage}
284 </Box>
285 )
286 })
287}
288types/index.d.ts 21 lines1export type SegmentKind = 'used' | 'free' | 'buffer'
2
3export type Segment = { name: string; tokens: number; color: string; kind: SegmentKind }
4
5export type Snapshot = {
6 total: number
7 max: number
8 threshold: number | null
9 segments: Segment[]
10}
11
12// One plan rate-limit window: `five_hour`, `seven_day`, or a gateway's
13// `spend_limit`. `resetsAt` is in epoch milliseconds, null when not reported.
14export type Limit = { kind: string; percent: number; resetsAt: number | null }
15
16declare module 'claude-code' {
17 interface PluginState {
18 'context-bar': { snapshot: Snapshot | null; isShown: boolean; limits: Limit[]; now: number }
19 }
20}
21