Claude's context window as a stacked bar above the prompt, a color per category, with token counts and the compaction point. /context-bar shows or hides it.

A Claude Code plugin that shows what is filling your context window, as a stacked bar above the prompt.

/context (system prompt, tools, MCP tools, memory files, skills, messages), then free space and the auto-compact buffer.Clone the repo and load it for one session:
git clone https://github.com/Shaurya-Sethi/ctx-bar
claude --plugin-dir ./ctx-bar
The bar appears when a session starts. Run /context-bar to hide or show it. Your choice persists across sessions.
The bar is not drawn when the terminal is too narrow (under 20 columns inside the box) or while a survey is showing. Other plugins that render above the prompt still render.
/context uses, estimated locally. Refreshing makes no API calls./clear, /compact, /resume or auto-compaction. Subagent activity is ignored, because subagents use their own context windows./resume can leave the bar stale until the next message..claude-plugin/plugin.json manifest
hooks/hooks.json module list
hooks/register.tsx hooks, rendering, and the pure helpers (toReading, cells, legend, tokens, share)
types/index.d.ts Reading and Slice types
tests/context-bar.test.tsx tests
tsconfig.json extends .claude-plugin/types/tsconfig.json, which the Claude Code host provides and this repo does not include. Get the host tooling before you typecheck.
claude plugin validate .
claude plugin test .
Issues are tracked at Shaurya-Sethi/ctx-bar.
hooks/register.tsx 256 lines1// Context Bar: what is filling my context window?
2// Above the prompt, the window as one stacked bar, a color per category as /context
3// draws them (system prompt, tools, MCP tools, memory files, skills, messages, free),
4// with a legend of tokens and shares and where auto-compaction runs. It refreshes as the
5// main conversation keeps each row (prompt, response, tool result, compaction), so it
6// grows steadily within a turn, and again once /clear, /compact or /resume has installed
7// the new transcript. /context-bar shows or hides it, and the choice is kept
8// across sessions.
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register } from 'claude-code'
11
12import type { Reading, Slice } from '../types'
13
14const MIN_WIDTH = 20 // narrower than this, the bar is not drawn
15const SPLIT = ' '
16// /context's theme gives several rows the same grey, so each used row gets its own color, in order.
17const PALETTE = ['#7aa2f7', '#7dcfff', '#bb9af7', '#9ece6a', '#e0af68', '#f7768e', '#73daca', '#ff9e64', '#c0caf5']
18const MESSAGES = '#d97757' // the row that grows, in the accent color
19const FREE = '#808080' // a mid grey thin line reads as empty on dark and light themes alike
20const BUFFER = '#808080'
21const GLYPH = { used: '█', free: '─', buffer: '░' } as const
22// An appended row reaches the in-memory transcript one tick after its append resolves
23// (measured on Claude Code 2.1.300); a 1ms timer runs after that tick. Host scheduling does
24// not promise that, so a later re-read catches a slower install; scheduleRefresh coalesces both.
25const AFTER_APPEND_MS = [1, 250]
26
27// Held by the host, so the bar survives a hot reload of this file. The owner must be the
28// manifest's name ('ctx-bar'), written as a literal in each ref: `claude plugin validate`
29// only reads atom sources whose plugin and key are string literals.
30const reading = atom({ plugin: 'ctx-bar', key: 'reading' } as const, null as Reading | null)
31const isHidden = atom({ plugin: 'ctx-bar', key: 'isHidden' } as const, false)
32
33export const register: Register = on => {
34 on('session.start', async ($, e, next) => {
35 const r = await next(e)
36 await $.command.register({ name: 'context-bar', description: 'Show or hide the context window bar above the prompt' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
37 const hidden = (await $.store.get('isHidden').catch(() => undefined)) === true
38 await update($, isHidden, () => hidden)
39 void scheduleRefresh($)
40 return r
41 })
42
43 // Every row the main conversation keeps changes what the window holds. The append resolves
44 // once the row is stored, but Claude Code installs it in the in-memory transcript the estimate
45 // reads only a tick later, so a refresh right here would measure the window one row behind.
46 // A timer past that tick waits for the install; it also keeps a slow estimate from holding up
47 // the append, and a failing hook still lets the row through (the handler replays what next
48 // settled to, or runs it).
49 on('session.append', async ($, e, next) => {
50 const r = await next(e)
51 // A subagent's rows fill its own window; queue records and notices are never in the model's.
52 if (!e.agentId && e.door !== 'queue' && e.door !== 'notice') refreshAfter($, AFTER_APPEND_MS)
53 return r
54 }).catch(($, e, next) => next(e))
55
56 on('turn.complete', async ($, e, next) => {
57 const r = await next(e)
58 if (!e.agentId) await scheduleRefresh($) // a subagent's turn fills its own window, not this one
59 return r
60 })
61
62 // /clear, /compact and /resume swap the transcript without a turn ending. The command resolves
63 // before Claude Code installs the new transcript, so the refresh waits past the install.
64 on('command.run', { command: ['clear', 'compact', 'resume'] }, async ($, e, next) => {
65 const r = await next(e)
66 refreshAfter($, AFTER_SWAP_MS)
67 return r
68 }).catch(($, e, next) => next(e))
69
70 // Best effort for compactions no command announces: auto-compaction and a plugin's own.
71 // A manual /compact is already covered by the command.run hook above, and `precompute`
72 // installs nothing; a subagent compacts its own window. An organization's managed settings
73 // may keep this hook from running in an installed plugin; the rows appended by the model's
74 // next reply then refresh the bar instead.
75 on('session.compact', async ($, e, next) => {
76 const r = await next(e)
77 if (!e.agentId && (e.trigger === 'auto' || e.trigger === 'plugin') && 'messages' in r) refreshAfter($, AFTER_SWAP_MS)
78 return r
79 }).catch(($, e, next) => next(e))
80
81 on('command.run', { command: 'context-bar' }, async $ => {
82 const hidden = await update($, isHidden, h => !h)
83 await $.store.set('isHidden', hidden).catch(() => {})
84 if (!hidden) await scheduleRefresh($)
85 return { text: hidden ? 'Context bar hidden. /context-bar shows it again' : 'Context bar on' }
86 })
87
88 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
89 const rest = await next(e) // what other mods and Claude Code draw here stays, drawn above the box so one-row mods stay visible
90 const r = await read($, reading)
91 if (e.props.hasSurvey || (await read($, isHidden)) || !r) return rest
92 const { Box, Text } = $.ui.resolve(e)
93 const inner = e.props.bodyColumns - 4 // the border and padding take 4 cells
94 if (inner < MIN_WIDTH) return rest
95
96 const head = `${tokens(r.total)} of ${tokens(r.window)}${r.compactsAt ? ` · compacts at ${tokens(r.compactsAt)}` : ''}`
97 const pct = ` ${r.percent}% `
98 const level = r.compactsAt ? r.total / r.compactsAt : r.total / r.window
99 return (
100 <Box flexDirection="column">
101 {rest}
102 <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
103 <Box flexDirection="row" justifyContent="space-between">
104 <Text wrap="truncate-end">
105 <Text color="#d97757">{'◆ '}</Text>
106 <Text bold>context</Text>
107 </Text>
108 <Text wrap="truncate-start">
109 <Text dimColor>{`${head} `}</Text>
110 <Text bold color="black" backgroundColor={level >= 0.9 ? 'red' : level >= 0.7 ? 'yellow' : 'green'}>{pct}</Text>
111 </Text>
112 </Box>
113 <Text>
114 {cells(r, inner).map(c => (
115 <Text color={c.color}>{c.text}</Text>
116 ))}
117 </Text>
118 {legend(r, inner).map(line => (
119 <Text wrap="truncate-end">
120 {line.map((s, i) => (
121 <Text>
122 {i > 0 && <Text>{SPLIT}</Text>}
123 <Text color={s.color}>{s.kind === 'used' ? '■ ' : `${GLYPH[s.kind]} `}</Text>
124 <Text dimColor={s.kind !== 'used'}>{`${s.name} `}</Text>
125 <Text bold={s.kind === 'used'}>{tokens(s.tokens)}</Text>
126 {s.kind === 'used' && <Text dimColor>{` ${share(s.tokens, r.window)}`}</Text>}
127 </Text>
128 ))}
129 </Text>
130 ))}
131 </Box>
132 </Box>
133 )
134 })
135}
136
137// One refresh at a time: a request while one runs marks the reading stale, and exactly one
138// more refresh follows. Concurrent reads could land out of order and leave an older total
139// over a newer one; this way the last reading drawn is always taken after the last request.
140// Resolves when the reading is current; never rejects (a failed estimate keeps the old one).
141// The follow-up pass reuses the first caller's `$`: the engine refuses a module that keeps `$`
142// in a variable, so the newest caller's cannot be swapped in.
143let running: Promise<void> | undefined
144let isStale = false
145function scheduleRefresh($: EngineInterface): Promise<void> {
146 if (running) {
147 isStale = true
148 return running
149 }
150 running = (async () => {
151 do {
152 isStale = false
153 await refresh($).catch(() => {})
154 } while (isStale)
155 running = undefined
156 })()
157 return running
158}
159
160// A swapped transcript is installed only after the chain announcing the swap (command.run,
161// session.compact) resolves, so a refresh there still measures the old one. The delays are a
162// guess with no install event to wait on: an install slower than the last one (a large
163// /resume) leaves the bar stale until the next row.
164const AFTER_SWAP_MS = [50, 500, 2000]
165
166// Re-reads on a few timers, for a change Claude Code installs only after the announcing chain
167// resolves: bounded, each a local estimate, coalesced by scheduleRefresh. Timers rather than
168// sleeps, as the hook's dispatch ends before they fire; a refused timer only loses that re-read.
169function refreshAfter($: EngineInterface, delays: readonly number[]) {
170 for (const ms of delays) $.clock.after(ms, () => void scheduleRefresh($))
171}
172
173// Asks the engine for /context's breakdown, estimated locally (no token-count calls).
174async function refresh($: EngineInterface) {
175 if (await read($, isHidden)) return
176 const usage = await $.session.usage({ breakdown: 'summary' })
177 const b = usage.context.breakdown
178 if (!b || !(b.rawMaxTokens > 0)) return // no window to measure against
179 await update($, reading, () => toReading(b))
180}
181
182export function toReading(b: {
183 categories: { name: string; tokens: number; color: string; kind: string }[]
184 totalTokens: number
185 rawMaxTokens: number
186 percentage: number
187 autoCompactThreshold?: number
188 isAutoCompactEnabled: boolean
189}): Reading {
190 const slices: Slice[] = b.categories
191 .filter(c => c.kind !== 'deferred' && c.tokens > 0)
192 .map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens, color: c.color, kind: c.kind as Slice['kind'] }))
193 const order = { used: 0, free: 1, buffer: 2 }
194 slices.sort((x, y) => order[x.kind] - order[y.kind]) // stable: used rows keep /context's order
195 let next = 0
196 for (const s of slices) {
197 s.color = s.kind === 'free' ? FREE : s.kind === 'buffer' ? BUFFER : s.name === 'messages' ? MESSAGES : PALETTE[next++ % PALETTE.length]!
198 }
199 return {
200 slices,
201 total: b.totalTokens,
202 window: b.rawMaxTokens,
203 percent: b.percentage,
204 compactsAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
205 }
206}
207
208// The bar as runs of cells: each slice gets its share of `width`, a used one at least one cell.
209export function cells(r: Reading, width: number) {
210 const sizes = r.slices.map(s => Math.max(s.kind === 'used' ? 1 : 0, Math.round((s.tokens / r.window) * width)))
211 // Rounding leaves the sum a few cells off: free space takes the difference first, then the largest slices.
212 let diff = width - sizes.reduce((a, n) => a + n, 0)
213 const isUsed = (i: number) => r.slices[i]!.kind === 'used'
214 const order = r.slices.map((_, i) => i).sort((a, b) => Number(isUsed(a)) - Number(isUsed(b)) || sizes[b]! - sizes[a]!)
215 for (const i of order) {
216 if (diff === 0) break
217 const size = Math.max(isUsed(i) ? 1 : 0, sizes[i]! + diff)
218 diff -= size - sizes[i]!
219 sizes[i] = size
220 }
221 return r.slices.map((s, i) => ({ color: s.color, kind: s.kind, text: GLYPH[s.kind].repeat(sizes[i]!) })).filter(c => c.text !== '')
222}
223
224// The legend, packed into lines no wider than `width`.
225export function legend(r: Reading, width: number) {
226 const lines: Slice[][] = [[]]
227 let used = 0
228 for (const s of r.slices) {
229 const size = 2 + s.name.length + 1 + tokens(s.tokens).length + (s.kind === 'used' ? 1 + share(s.tokens, r.window).length : 0)
230 const line = lines.at(-1)!
231 if (line.length > 0 && used + SPLIT.length + size > width) {
232 lines.push([s])
233 used = size
234 } else {
235 used += (line.length > 0 ? SPLIT.length : 0) + size
236 line.push(s)
237 }
238 }
239 return lines.filter(l => l.length > 0)
240}
241
242// Compact token count: '950', '3.4k', '186k', '1.2M'. Values that round to 1000k (from 999_500)
243// already read as a million, so the M branch starts there.
244export function tokens(n: number) {
245 if (n >= 999_500) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
246 if (n >= 10_000) return `${Math.round(n / 1000)}k`
247 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
248 return String(n)
249}
250
251export function share(n: number, window: number) {
252 const p = (n / window) * 100
253 if (p > 0 && p < 0.1) return '<0.1%'
254 return `${p >= 10 ? Math.round(p) : +p.toFixed(1)}%`
255}
256types/index.d.ts 17 lines1export type Slice = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
2
3export type Reading = {
4 slices: Slice[]
5 total: number // tokens in use
6 window: number // the window measured against
7 percent: number
8 compactsAt?: number // where auto-compaction runs, when it is on
9}
10
11declare module 'claude-code' {
12 // Keyed by the manifest's name: the host lets only that owner write these values.
13 interface PluginState {
14 'ctx-bar': { reading: Reading | null; isHidden: boolean }
15 }
16}
17