Stacked bar of the context window above or below the prompt, coloured by /context category, with rate limits, room before compaction and session length…

A bar above (or below) the prompt that shows how full your context window is, split into the same categories /context uses (system prompt, tools, memory files, messages and so on). I used to run /context every so often just to check how heavy a session was getting. Now it's always there, so I can wrap up before auto-compact kicks in, and I can spot what's eating my context, like MCP servers I forgot were still connected.
███████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒ 30% 60.0k/200.0k
■ System prompt 4.0k ■ Messages 56.0k
5h 42% (resets in 2h 14m) · 7d 18% · 100.0k to compact · session 2h 5m
/context-bar help shows it again./context-bar hides or shows the bar for the current session./context-bar below moves the bar under the prompt box, and /context-bar above moves it back. You can also change it under "Bar position" in /config. Above is the default, and the desktop app always keeps it above./context-bar details opens a pane with the full breakdown: each category and its share of the window, every memory file, MCP servers (and how many of their tools are loaded), your biggest skills, custom agents, and the cache split of the last API call. Press r to refresh it./context shows. It doesn't read your files or messages, and it doesn't send anything anywhere./context). So it only refreshes while the pane is open, after each turn or when you press r.MIT
hooks/register.tsx 398 lines1import { atom, read, update } from 'claude-code'
2import type { ConfigSetResult, EngineInterface, Register, ResolveInput, SessionRateLimit } from 'claude-code'
3
4import type { Details, Limit, Row, Segment, Snapshot } from '../types'
5
6// On at the start of every session; /context-bar hides it for that session only.
7const isOn = atom({ plugin: 'context-bar', key: 'isOn' } as const, true)
8const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
9// The time the band last read, bumped each minute so the session length and reset countdowns move.
10const now = atom({ plugin: 'context-bar', key: 'now' } as const, 0)
11const details = atom({ plugin: 'context-bar', key: 'details' } as const, null)
12const isDetailsOpen = atom({ plugin: 'context-bar', key: 'isDetailsOpen' } as const, false)
13
14const PANE = 'context-details'
15const GLYPH = { used: '█', buffer: '▒', free: '░' } as const
16const REFRESH_MS = 3000
17const TICK_MS = 60_000
18const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
19
20export const short = (n: number) =>
21 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
22
23export const span = (ms: number) => {
24 const m = Math.max(0, Math.floor(ms / 60_000))
25 if (m < 60) return `${m}m`
26 const hours = Math.floor(m / 60)
27 if (hours < 24) return `${hours}h ${m % 60}m`
28 return `${Math.floor(hours / 24)}d ${hours % 24}h`
29}
30
31// The band's third line: rate limits, room before compaction, session length.
32export const infoLine = (snap: Snapshot, at: number) => {
33 const parts = snap.limits.map(l => {
34 const label = LIMIT_LABEL[l.kind] ?? l.kind
35 const reset = l.resetsAt !== undefined && l.resetsAt > at ? ` (resets in ${span(l.resetsAt - at)})` : ''
36 return `${label} ${l.percentUsed}%${reset}`
37 })
38 if (snap.compactIn !== undefined) parts.push(`${short(snap.compactIn)} to compact`)
39 if (snap.startedAt > 0 && at >= snap.startedAt) parts.push(`session ${span(at - snap.startedAt)}`)
40 return parts.join(' · ')
41}
42
43// Split `width` cells across segments by largest remainder, giving every
44// non-empty used segment at least one cell so small categories stay visible.
45export const allocate = (segments: Segment[], max: number, width: number): number[] => {
46 if (max <= 0 || width <= 0) return segments.map(() => 0)
47 const exact = segments.map(s => (s.tokens / max) * width)
48 const cells = exact.map((x, i) => (segments[i]!.kind === 'used' && segments[i]!.tokens > 0 ? Math.max(1, Math.floor(x)) : Math.floor(x)))
49 let spare = width - cells.reduce((a, b) => a + b, 0)
50 const order = exact.map((x, i) => [x - Math.floor(x), i] as const).sort((a, b) => b[0] - a[0])
51 for (let k = 0; spare > 0 && order.length > 0; k = (k + 1) % order.length, spare--) cells[order[k]![1]]! += 1
52 // Over budget from the minimum-one rule: take cells back from the free space, then the largest.
53 while (spare < 0) {
54 const free = segments.findIndex((s, i) => s.kind === 'free' && cells[i]! > 0)
55 const i = free >= 0 ? free : cells.indexOf(Math.max(...cells))
56 cells[i]! -= 1
57 spare++
58 }
59 return cells
60}
61
62const toLimit = (l: SessionRateLimit): Limit => {
63 const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
64 return Number.isNaN(at) ? { kind: l.kind, percentUsed: l.percentUsed } : { kind: l.kind, percentUsed: l.percentUsed, resetsAt: at }
65}
66
67// The last two parts of a path, so memory files read as `.claude/CLAUDE.md`.
68const tail = (path: string) => {
69 const parts = path.split(/[\\/]+/).filter(Boolean)
70 return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : parts.join('/')
71}
72
73const byTokens = (a: Row, b: Row) => b.tokens - a.tokens
74
75let lastFetch = 0
76let isFetching = false
77let isFetchingDetails = false
78let tick: { cancel: () => void } | undefined
79
80async function refresh($: EngineInterface, force = false) {
81 const at = await $.clock.now()
82 if (isFetching || (!force && at - lastFetch < REFRESH_MS)) return
83 if (!(await read($, isOn))) return
84 isFetching = true
85 lastFetch = at
86 try {
87 const usage = await $.session.usage({ breakdown: 'summary' })
88 const b = usage.context.breakdown
89 if (!b) return
90 const segments: Segment[] = b.categories
91 .filter(c => c.kind !== 'deferred' && c.tokens > 0)
92 .map(c => ({ name: c.name, color: c.color, tokens: c.tokens, kind: c.kind as Segment['kind'] }))
93 const next: Snapshot = {
94 segments,
95 total: b.totalTokens,
96 max: b.rawMaxTokens,
97 percent: b.percentage,
98 startedAt: usage.startedAt,
99 limits: usage.rateLimits.map(toLimit),
100 }
101 if (b.isAutoCompactEnabled && b.autoCompactThreshold !== undefined) next.compactIn = Math.max(0, b.autoCompactThreshold - b.totalTokens)
102 // Time first, so the new snapshot never draws against a stale clock.
103 await update($, now, () => at)
104 await update($, snapshot, () => next)
105 } finally {
106 isFetching = false
107 }
108}
109
110// The full breakdown counts every tool and memory file, as /context does, so it
111// is read only for the details pane: on opening it and after each turn while it is open.
112async function refreshDetails($: EngineInterface) {
113 if (isFetchingDetails) return
114 isFetchingDetails = true
115 try {
116 const b = (await $.session.usage({ breakdown: 'full' })).context.breakdown
117 if (!b) return
118 const servers = new Map<string, { tokens: number; tools: number; loaded: number }>()
119 for (const t of b.mcpTools) {
120 const s = servers.get(t.serverName) ?? { tokens: 0, tools: 0, loaded: 0 }
121 servers.set(t.serverName, { tokens: s.tokens + t.tokens, tools: s.tools + 1, loaded: s.loaded + (t.isLoaded ? 1 : 0) })
122 }
123 const next: Details = {
124 model: b.model,
125 max: b.rawMaxTokens,
126 total: b.totalTokens,
127 categories: b.categories.filter(c => c.tokens > 0).map(c => ({ name: c.name, color: c.color, tokens: c.tokens, kind: c.kind })),
128 memoryFiles: b.memoryFiles.map(f => ({ name: tail(f.path), tokens: f.tokens, note: f.type })).sort(byTokens),
129 mcpServers: [...servers]
130 .map(([name, s]) => ({ name, tokens: s.tokens, note: `${s.loaded}/${s.tools} tools loaded` }))
131 .sort(byTokens),
132 agents: b.agents.map(a => ({ name: a.agentType, tokens: a.tokens, note: a.source })).sort(byTokens),
133 api: b.apiUsage && {
134 uncached: b.apiUsage.input_tokens,
135 cacheRead: b.apiUsage.cache_read_input_tokens,
136 cacheWrite: b.apiUsage.cache_creation_input_tokens,
137 output: b.apiUsage.output_tokens,
138 },
139 }
140 if (b.skills)
141 next.skills = {
142 total: b.skills.totalSkills,
143 included: b.skills.includedSkills,
144 tokens: b.skills.tokens,
145 top: b.skills.skillFrontmatter
146 .map(s => ({ name: s.name, tokens: s.tokens, note: s.pluginName ?? s.source }))
147 .sort(byTokens)
148 .slice(0, 10),
149 }
150 if (b.slashCommands)
151 next.commands = { total: b.slashCommands.totalCommands, included: b.slashCommands.includedCommands, tokens: b.slashCommands.tokens }
152 await update($, details, () => next)
153 } finally {
154 isFetchingDetails = false
155 }
156}
157
158async function openDetails($: EngineInterface) {
159 await update($, isDetailsOpen, () => true)
160 await $.ui.open({ id: PANE, title: 'Context details' })
161 await refreshDetails($).catch(() => {})
162}
163
164// The bar, its legend and (when there's room for it) the info line, laid out `columns` wide.
165async function drawBar($: EngineInterface, e: ResolveInput, snap: Snapshot, columns: number, showInfo: boolean) {
166 const { Box, Text } = $.ui.resolve(e)
167 const summary = ` ${snap.percent}% ${short(snap.total)}/${short(snap.max)}`
168 const width = Math.max(10, columns - summary.length)
169 const cells = allocate(snap.segments, snap.max, width)
170 const used = snap.segments.filter(s => s.kind === 'used')
171 const info = showInfo ? infoLine(snap, await read($, now)) : ''
172 return (
173 <Box flexDirection="column">
174 <Text wrap="truncate">
175 {snap.segments.map((s, i) =>
176 cells[i]! > 0 ? (
177 <Text color={s.color} dimColor={s.kind === 'free'}>
178 {GLYPH[s.kind].repeat(cells[i]!)}
179 </Text>
180 ) : null,
181 )}
182 <Text dimColor>{summary}</Text>
183 </Text>
184 <Text wrap="truncate">
185 {used.map(s => (
186 <Text>
187 <Text color={s.color}>■</Text>
188 <Text dimColor> {s.name} {short(s.tokens)} </Text>
189 </Text>
190 ))}
191 </Text>
192 {info ? (
193 <Text wrap="truncate" dimColor>
194 {info}
195 </Text>
196 ) : null}
197 </Box>
198 )
199}
200
201// The bar to draw now, or null when it's hidden or there's nothing to show yet.
202async function shown($: EngineInterface) {
203 if (!(await read($, isOn))) return null
204 const snap = await read($, snapshot)
205 return snap && snap.segments.length > 0 ? snap : null
206}
207
208const moved = (saved: ConfigSetResult, where: string) =>
209 saved.deny ? `Couldn't move the bar: ${saved.deny}` : `Context bar moved ${where} the prompt.`
210const failed = (err: unknown): ConfigSetResult => ({ deny: String(err) })
211
212// Shown once after install, and again when INTRO_VERSION goes up for a feature worth telling people about.
213const INTRO_VERSION = '0.3'
214export const INTRO = [
215 'context-bar: the bar above the prompt shows how full your context window is, split by /context category.',
216 ' Line 2 is the token count for each category; line 3 shows rate limits, tokens left before auto-compact and session length.',
217 ' /context-bar hides or shows it for this session.',
218 ' /context-bar details opens a full token breakdown (memory files, MCP servers, skills, cache).',
219 ' /context-bar below moves the bar under the prompt box, /context-bar above moves it back (also under /config).',
220 ' /context-bar help shows this again.',
221]
222
223const showIntro = ($: EngineInterface) => INTRO.forEach(line => $.ui.log(line))
224
225export const register: Register = (on, options) => {
226 // Anything but "below" keeps the bar where it has always been.
227 const isBelow = String(options.position ?? 'above').trim().toLowerCase() === 'below'
228
229 on('session.start', async ($, e, next) => {
230 await $.command.register({
231 name: 'context-bar',
232 description: 'Toggle the context window bar; /context-bar details opens a token breakdown, /context-bar above|below moves it, /context-bar help explains it',
233 })
234 // Only a session that draws the band needs the minute ticker; a reload drops
235 // the old module's timers, and the cancel covers session.start firing twice in one load.
236 tick?.cancel()
237 if (e.isInteractive)
238 tick = $.clock.every(TICK_MS, () => {
239 void $.clock.now().then(at => update($, now, () => at))
240 })
241 if (e.isInteractive && (await $.store.get('introSeen').catch(() => INTRO_VERSION)) !== INTRO_VERSION) {
242 showIntro($)
243 await $.store.set('introSeen', INTRO_VERSION).catch(() => {})
244 }
245 // Draw the bar as soon as the session opens; never let a failed read block startup.
246 await refresh($, true).catch(() => {})
247 return next(e)
248 })
249
250 on('command.run', { command: 'context-bar' }, async ($, e) => {
251 const arg = e.args.trim().toLowerCase()
252 if (arg === 'details') {
253 await openDetails($)
254 return { text: 'Context details opened.' }
255 }
256 if (arg === 'help') {
257 showIntro($)
258 return { text: 'Context bar help shown above.' }
259 }
260 // Each settings call is written out in full, so the plugin directory can read what it sets.
261 if (arg === 'below') return { text: moved(await $.config.set({ key: 'context-bar.position', value: 'below' }).catch(failed), 'below') }
262 if (arg === 'above') return { text: moved(await $.config.set({ key: 'context-bar.position', value: 'above' }).catch(failed), 'above') }
263 const nextOn = !(await read($, isOn))
264 await update($, isOn, () => nextOn)
265 if (nextOn) await refresh($, true)
266 return { text: nextOn ? 'Context bar on.' : 'Context bar off.' }
267 })
268
269 on('ui.close', async ($, e, next) => {
270 if (e.id === PANE) await update($, isDetailsOpen, () => false)
271 return next(e)
272 })
273
274 on('session.measure', ($, e, next) => {
275 void refresh($)
276 return next(e)
277 })
278
279 on('turn.complete', async ($, e, next) => {
280 void refresh($, true)
281 if (await read($, isDetailsOpen)) void refreshDetails($).catch(() => {})
282 return next(e)
283 })
284
285 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
286 // Below is a terminal layout; other surfaces keep the bar above.
287 if ((isBelow && e.surface === 'terminal') || e.props.hasSurvey) return next(e)
288 const snap = await shown($)
289 if (!snap) return next(e)
290 const bar = await drawBar($, e, snap, e.props.bodyColumns, e.props.maxRows >= 3)
291 // Whatever else draws above the prompt (another mod's band) stays, under the bar.
292 const beneath = await next(e)
293 const { Box } = $.ui.resolve(e)
294 return (
295 <Box flexDirection="column">
296 {bar}
297 {beneath}
298 </Box>
299 )
300 })
301
302 // Below the prompt: the bar takes the hint line's place, and the engine's hint line
303 // (shortcuts, esc to interrupt) draws under it.
304 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
305 if (!isBelow || e.surface !== 'terminal') return next(e)
306 const snap = await shown($)
307 if (!snap) return next(e)
308 const bar = await drawBar($, e, snap, (e.viewport?.columns ?? 80) - 2, true)
309 const hint = await next(e)
310 const { Box } = $.ui.resolve(e)
311 return (
312 <Box flexDirection="column">
313 {bar}
314 {hint}
315 </Box>
316 )
317 })
318
319 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
320 const { Box, Text, Button } = $.ui.resolve(e)
321 const d = await read($, details)
322 const refreshButton = <Button key="refresh" label="Refresh" hotkey="r" dimColor onPress={() => refreshDetails($).catch(() => {})} />
323 if (!d)
324 return (
325 <Box flexDirection="column">
326 <Text dimColor>Counting tokens…</Text>
327 {refreshButton}
328 </Box>
329 )
330
331 const pct = (n: number) => (d.max > 0 ? `${((n / d.max) * 100).toFixed(1)}%` : '')
332 const line = (r: Row, color?: string) => (
333 <Box flexDirection="row">
334 <Box flexGrow={1}>
335 <Text wrap="truncate">
336 {color ? <Text color={color}>■ </Text> : ' '}
337 {r.name}
338 {r.note ? <Text dimColor> {r.note}</Text> : null}
339 </Text>
340 </Box>
341 <Text>
342 {' '}
343 {short(r.tokens).padStart(6)} <Text dimColor>{pct(r.tokens).padStart(6)}</Text>
344 </Text>
345 </Box>
346 )
347 const section = (title: string, rows: Row[], extra?: string) =>
348 rows.length === 0 ? null : (
349 <Box flexDirection="column" marginTop={1}>
350 <Text bold>
351 {title}
352 {extra ? <Text dimColor> {extra}</Text> : null}
353 </Text>
354 {rows.map(r => line(r))}
355 </Box>
356 )
357
358 return (
359 <Box flexDirection="column">
360 <Text>
361 <Text bold>{d.model}</Text>
362 <Text dimColor>
363 {' '}
364 {short(d.total)}/{short(d.max)} ({pct(d.total)})
365 </Text>
366 </Text>
367 <Box flexDirection="column" marginTop={1}>
368 <Text bold>Categories</Text>
369 {d.categories.map(c => line({ name: c.name, tokens: c.tokens, note: c.kind === 'deferred' ? 'loaded on demand' : undefined }, c.color))}
370 </Box>
371 {section('Memory files', d.memoryFiles)}
372 {section('MCP servers', d.mcpServers)}
373 {d.skills ? section('Skills', d.skills.top, `${d.skills.included}/${d.skills.total} listed, ${short(d.skills.tokens)} in all; largest shown`) : null}
374 {section('Custom agents', d.agents)}
375 {d.commands ? (
376 <Box flexDirection="column" marginTop={1}>
377 <Text bold>Slash commands</Text>
378 <Text dimColor>
379 {' '}
380 {d.commands.included}/{d.commands.total} listed, {short(d.commands.tokens)} tokens
381 </Text>
382 </Box>
383 ) : null}
384 {d.api ? (
385 <Box flexDirection="column" marginTop={1}>
386 <Text bold>Last API call</Text>
387 <Text dimColor>
388 {' '}cache read {short(d.api.cacheRead)} · cache write {short(d.api.cacheWrite)} · uncached {short(d.api.uncached)} · output{' '}
389 {short(d.api.output)}
390 </Text>
391 </Box>
392 ) : null}
393 <Box marginTop={1}>{refreshButton}</Box>
394 </Box>
395 )
396 })
397}
398types/index.d.ts 33 lines1export type Segment = { name: string; color: string; tokens: number; kind: 'used' | 'free' | 'buffer' }
2export type Limit = { kind: string; percentUsed: number; resetsAt?: number }
3export type Snapshot = {
4 segments: Segment[]
5 total: number
6 max: number
7 percent: number
8 startedAt: number
9 limits: Limit[]
10 // Tokens left before auto-compaction runs; absent when it is off.
11 compactIn?: number
12}
13
14export type Row = { name: string; tokens: number; note?: string }
15export type Details = {
16 model: string
17 max: number
18 total: number
19 categories: { name: string; color: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }[]
20 memoryFiles: Row[]
21 mcpServers: Row[]
22 agents: Row[]
23 skills?: { total: number; included: number; tokens: number; top: Row[] }
24 commands?: { total: number; included: number; tokens: number }
25 api: { uncached: number; cacheRead: number; cacheWrite: number; output: number } | null
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'context-bar': { isOn: boolean; snapshot: Snapshot | null; now: number; details: Details | null; isDetailsOpen: boolean }
31 }
32}
33