Pane that explains the context window for people new to Claude Code: the room left before auto-compact, the window as one stacked bar in category colours, what…

A pane that explains the context window to people new to Claude Code: how much room is left before auto-compact, what fills it, what each part is, what every request carries, and how it grows turn by turn.
/clear./plugin install context-budget --marketplace dboeter/cc-plugins
Answer y to add the marketplace, then pick a scope.
/contextbudget shows or hides the pane. It never opens by itself. In the pane, e estimates the breakdown again (local, no requests), r counts it exactly and n shows or hides the notes, which start hidden.
e. No requests.r): asks for the full breakdown, which sends one token-count request per tool and file. Only when you press it.claude --plugin-dir plugins/context-budget # loads it from source
tsc -p plugins # type-checks every plugin against plugins/types/
claude plugin test plugins/context-budget # tests
claude plugin validate plugins/context-budgethooks/context-budget.tsx 252 lines1import type { ContextBreakdownDetail, EngineInterface, Register, SessionContextBreakdown, SessionUsage } from 'claude-code'
2
3// A teaching view of the context window for people new to Claude Code: the same breakdown /context prints
4// ($.session.usage({ breakdown })), with a plain-language note per category, what each request carries, how the
5// window filled turn by turn, and where auto-compact starts
6
7const PANE = 'context-budget'
8const TITLE = 'Context budget'
9
10// What each /context row is, keyed by its label as /context prints it. Only this prose is keyed on a name: what a row does
11// (counts toward the window, free, compaction reserve, loaded on demand) is read from its `kind`. A label not here draws
12// without a note. grows: the row gets bigger as you work; every other row is set once and resent with each request
13export const ABOUT: Record<string, { text: string; grows?: boolean }> = {
14 'System prompt': { text: "Claude Code's built-in instructions. Sent with every request; you can't change them." },
15 'System tools': { text: 'How to call the built-in tools (Bash, Read, Edit and the rest). Fixed for the session.' },
16 'MCP tools': { text: "Schemas of the tools your MCP servers add. Disconnect a server you don't use to get this back." },
17 'Custom agents': { text: 'One description per subagent you defined, so Claude knows when to hand work off.' },
18 'Memory files': { text: 'CLAUDE.md and rules files, read at the start. Every line is resent with every request, so keep them short.' },
19 'Skills': { text: 'One line per installed skill. The full skill only loads when it is used.' },
20 'Messages': { text: 'The conversation: your prompts, the replies, every tool call and its output. This is the part that fills up.', grows: true },
21 'Free space': { text: 'Room left before auto-compact starts.' },
22 'Autocompact buffer': { text: 'Kept free so Claude Code can summarise the conversation before the window is full.' },
23}
24const DEFERRED = 'Tool schemas loaded only when Claude searches for one. Not in the window until then.'
25
26export const k = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
27
28// What every request carries before the conversation starts, biggest first: the rows a newcomer can act on
29export type Payer = { label: string; tokens: number }
30export const payersOf = (b: SessionContextBreakdown, n = 5): Payer[] => {
31 const out: Payer[] = []
32 const servers = new Map<string, number>()
33 for (const t of b.mcpTools) if (t.isLoaded) servers.set(t.serverName, (servers.get(t.serverName) ?? 0) + t.tokens) // an unloaded schema is outside the window
34 for (const [s, t] of servers) out.push({ label: `MCP server ${s}`, tokens: t })
35 for (const f of b.memoryFiles) out.push({ label: `${f.type} ${f.path.split('/').slice(-2).join('/')}`, tokens: f.tokens }) // the folder tells one CLAUDE.md from another
36 if (b.skills?.tokens) out.push({ label: `${b.skills.includedSkills} skill descriptions`, tokens: b.skills.tokens })
37 if (b.agents.length) out.push({ label: `${b.agents.length} agent descriptions`, tokens: b.agents.reduce((s, a) => s + a.tokens, 0) })
38 return out.filter(p => p.tokens > 0).sort((x, y) => y.tokens - x.tokens).slice(0, n)
39}
40
41// One turn's window as it stood when the turn ended: the counted rows only, for the per-turn bars and totals
42// `delta` is taken against the turn before when recorded, so the oldest row kept still has one
43export type TurnSnap = Pick<SessionContextBreakdown, 'categories' | 'rawMaxTokens' | 'totalTokens'> & { turn: number; delta?: number }
44const TURNS = 10 // turns drawn
45const snapOf = (b: SessionContextBreakdown, turn: number, before?: TurnSnap): TurnSnap => ({
46 turn, totalTokens: b.totalTokens, rawMaxTokens: b.rawMaxTokens, categories: b.categories.filter(c => c.kind === 'used'),
47 delta: before && b.totalTokens - before.totalTokens,
48})
49
50// The window as one stacked bar, `width` cells, in breakdown order. Every
51// non-empty row gets at least one cell so a small one stays visible; free space takes up the rounding (and gives way
52// first when those minimum cells overflow, then the biggest rows give a cell each until the bar fits)
53export const barOf = (b: Pick<SessionContextBreakdown, 'categories' | 'rawMaxTokens'>, width: number): { s: string; color?: string; dim?: boolean }[] => {
54 const rows = b.categories.filter(c => c.kind !== 'deferred' && c.kind !== 'free' && c.tokens > 0)
55 .map(c => ({ c, n: Math.max(1, Math.round((c.tokens / b.rawMaxTokens) * width)) }))
56 // a full or over-full window, or many small rows: still too wide once free space is gone
57 for (let over = rows.reduce((s, r) => s + r.n, 0) - width; over > 0; over--) {
58 const big = rows.reduce((m, r) => r.n > m.n ? r : m)
59 if (big.n <= 1) break // every row at its one cell: wider than `width` cannot be helped
60 big.n--
61 }
62 const free = b.categories.find(c => c.kind === 'free')
63 const left = Math.max(0, width - rows.reduce((s, r) => s + r.n, 0))
64 const out: { s: string; color?: string; dim?: boolean }[] = rows.map(({ c, n }) => ({ s: (c.kind === 'buffer' ? '▒' : '█').repeat(n), color: c.color }))
65 // free space sits before the reserve, as in the window: the reserve is its last stretch
66 const at = rows.findIndex(r => r.c.kind === 'buffer')
67 if (free && left) out.splice(at < 0 ? out.length : at, 0, { s: '·'.repeat(left), color: undefined, dim: true })
68 return out
69}
70
71let usage: SessionUsage | undefined
72let failed = ''
73let tried = false // the first draw fetched once; a failed fetch must not redraw-and-refetch forever
74let exact = false // the shown breakdown was counted with the token-count API (`full`), not estimated
75let notes = false // the per-category notes: off by default, n shows them
76// The window after each main turn, newest last. Module state: a reload starts it over
77let turns: TurnSnap[] = []
78let turnNo = 0
79// A turn ended and its row waits for the next breakdown that lands, whichever refresh brings it: one that `e` or `r`
80// overtook, or that failed, must not lose the turn
81let owed = false
82// Each refresh's number. A reply a newer refresh overtook is dropped, so a slow exact count that a turn's estimate
83// passed can't land pre-turn figures labelled as counted
84let seq = 0
85let counting = false // an exact count is in flight: r waits for it rather than paying for a second one
86
87async function refresh($: EngineInterface, detail: ContextBreakdownDetail = 'summary') {
88 const mine = ++seq
89 if (detail === 'full') { counting = true; $.ui.invalidate('ui.render') }
90 try {
91 const u = await $.session.usage({ breakdown: detail }) // `full` sends a token-count request per tool and file: only on the reader's ask
92 if (mine === seq) {
93 usage = u; exact = detail === 'full'; failed = ''
94 if (owed && u.context.breakdown) { owed = false; turns = [...turns, snapOf(u.context.breakdown, ++turnNo, turns.at(-1))].slice(-TURNS) }
95 }
96 } catch (err) { if (mine === seq) failed = String((err as Error)?.message ?? err) }
97 finally { if (detail === 'full') counting = false; $.ui.invalidate('ui.render') }
98}
99const isUp = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE)
100
101export const register: Register = on => {
102 on('session.start', async ($, e, next) => {
103 turns = []; turnNo = 0; owed = false // another session's turns are not this one's
104 try { await $.command.register({ name: 'contextbudget', description: 'Toggle a pane that explains what fills the context window and how it grows.', immediate: true }) }
105 catch (err) { $.ui.log(`context-budget: /contextbudget not registered: ${(err as Error)?.message ?? err}`, { to: 'debug' }) } // throws on hot reload (no session bound yet); anything else should be findable
106 return next(e)
107 })
108
109 on('command.run', { command: 'contextbudget' }, async $ => {
110 const up = await isUp($)
111 if (up) await $.ui.close({ id: PANE })
112 else { usage = undefined; tried = false; await $.ui.open({ id: PANE, title: TITLE, focus: true }) }
113 return { text: up ? 'context budget hidden' : 'context budget shown' }
114 })
115
116 // after each main turn: re-estimate the breakdown (local, no request) and keep it as that turn's row, pane open or
117 // not, so the per-turn rows reach back past when the pane was opened
118 on('session.measure', async ($, e, next) => {
119 const r = await next(e)
120 if (!e.changed.includes('context')) return r
121 owed = true
122 await refresh($)
123 return r
124 })
125
126 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
127 const { Box, Text, Button } = $.ui.resolve(e)
128 const w = e.props.bodyColumns ?? 60
129 // not awaited: the pane draws "Measuring…" at once and the reply draws it again
130 if (!tried) { tried = true; void refresh($) }
131 const b = usage?.context.breakdown
132 if (!b) return <Text dimColor>{failed ? `Could not measure the context: ${failed}` : 'Measuring the context window…'}</Text>
133
134 const pct = (n: number) => `${Math.round((n / b.rawMaxTokens) * 100)}%`
135 const legend = (
136 <Box flexDirection="column">
137 {b.categories.filter(c => c.kind !== 'deferred').map(c => {
138 const mark = c.kind === 'free' ? '·' : c.kind === 'buffer' ? '▒' : '█'
139 return (
140 <Text key={`l-${c.name}`}>
141 <Text color={c.kind === 'free' ? undefined : c.color} dimColor={c.kind === 'free'}>{mark}</Text>
142 <Text> {c.name.padEnd(19)}{k(c.tokens).padStart(6)}</Text>
143 <Text dimColor>{` ${pct(c.tokens).padStart(4)}`}</Text>
144 {/* a label ABOUT lacks gets no tag: guessing "each request" would be wrong if it is the conversation */}
145 {c.kind === 'used' && ABOUT[c.name] && (ABOUT[c.name]!.grows ? <Text color="yellow"> grows</Text> : <Text dimColor> each request</Text>)}
146 {c.kind === 'buffer' && <Text dimColor> reserved for compaction</Text>}
147 </Text>
148 )
149 })}
150 </Box>
151 )
152 const payers = payersOf(b)
153 const deferred = b.categories.filter(c => c.kind === 'deferred')
154 // The free row is already measured against the threshold (window - used - compaction reserve), so it is the room
155 // before auto-compact as it stands; free plus the reserve is the room left in the whole window
156 const free = b.categories.find(c => c.kind === 'free')?.tokens ?? 0
157 const buffer = b.categories.find(c => c.kind === 'buffer')?.tokens ?? 0
158 const compacts = b.isAutoCompactEnabled && b.autoCompactThreshold
159 const roomColor = free / b.rawMaxTokens < 0.1 ? 'red' : free / b.rawMaxTokens < 0.25 ? 'yellow' : 'green' // headroom, by its share of the window
160 const grows = b.categories.find(c => c.kind === 'used' && ABOUT[c.name]?.grows)
161 // The heading shows the header's total, which `e` and `r` move on from the latest row's after-turn estimate
162 const signed = (n: number) => `${n < 0 ? '-' : '+'}${k(Math.abs(n))}`
163 const deltaColor = (n: number) => n < 0 ? 'green' : 'yellow'
164 const last = turns.at(-1)
165 const span = Math.max(8, Math.min(w, 100) - 22) // turn 5, total 6, delta 7, gaps: the bar takes what is left
166 const newestFirst = [...turns].reverse()
167
168 return (
169 <Box flexDirection="column">
170 <Text>
171 <Text bold>{k(b.totalTokens)} of {k(b.rawMaxTokens)} tokens</Text>
172 <Text dimColor> · {b.percentage}% used · {b.model}</Text>
173 </Text>
174 {compacts
175 ? <Text><Text bold color={roomColor}>{k(free)} left before auto-compact</Text><Text dimColor> at {k(b.autoCompactThreshold!)} · {k(free + buffer)} in the window</Text></Text>
176 : <Text><Text bold color={roomColor}>{k(free)} left in the window</Text><Text dimColor> · auto-compact is off</Text></Text>}
177 {grows && b.totalTokens > 0 && <Text><Text color={grows.color}>{grows.name} {k(grows.tokens)}</Text><Text dimColor> is {Math.round((grows.tokens / b.totalTokens) * 100)}% of what is used, and the part that grows</Text></Text>}
178 {notes && <Text dimColor>The bar is the whole window, in the colours below. Every part gets at least one cell, however small.</Text>}
179 <Text> </Text>
180 <Text>{barOf(b, Math.min(w, 100)).map((r, i) => <Text key={`b${i}`} color={r.color} dimColor={r.dim}>{r.s}</Text>)}</Text>
181 <Text> </Text>
182 {legend}
183
184 {notes && (
185 <Box flexDirection="column" marginTop={1}>
186 <Text bold>What each part is</Text>
187 {b.categories.filter(c => c.kind !== 'deferred').map(c => {
188 // a label this table lacks (a new engine row, or a renamed one) says so, rather than vanishing from the list
189 const text = ABOUT[c.name]?.text ?? (c.kind === 'used' ? 'no note for this row yet' : undefined)
190 return text ? <Text key={`a-${c.name}`}><Text color={c.kind === 'free' ? undefined : c.color}>{c.name}</Text><Text dimColor>: {text}</Text></Text> : null
191 })}
192 </Box>
193 )}
194
195 {/* apart from the breakdown so nobody adds these to the total: they are not in the window */}
196 {deferred.length > 0 && (
197 <Box flexDirection="column" marginTop={1}>
198 <Text bold>Available tools outside context</Text>
199 {notes && <Text dimColor>{DEFERRED}</Text>}
200 {deferred.map(c => <Text key={`d-${c.name}`} dimColor> {k(c.tokens).padStart(6)} {c.name}</Text>)}
201 </Box>
202 )}
203
204 {payers.length > 0 && (
205 <Box flexDirection="column" marginTop={1}>
206 <Text bold>Context included in each request</Text>
207 {notes && <Text dimColor>Sent again with each message before the conversation even starts. The biggest are the easiest wins.</Text>}
208 {payers.map(p => <Text key={`p-${p.label}`}> {k(p.tokens).padStart(6)} {p.label}</Text>)}
209 </Box>
210 )}
211
212 <Box flexDirection="column" marginTop={1}>
213 <Text>
214 <Text bold>Per turn</Text>
215 <Text> {k(b.totalTokens)}</Text>
216 {last?.delta != null && <Text color={deltaColor(last.delta)}> {signed(last.delta)}</Text>}
217 {last?.delta != null && <Text dimColor> in the last turn</Text>}
218 </Text>
219 {notes && <Text dimColor>The window after each turn, newest first, in the colours above: the bar grows as the conversation does. A drop is a compaction or a /clear.</Text>}
220 {newestFirst.length ? newestFirst.map(t => (
221 <Text key={`t${t.turn}`}>
222 <Text dimColor> #{String(t.turn).padEnd(4)}</Text>
223 <Text>{k(t.totalTokens).padStart(6)}</Text>
224 <Text color={t.delta == null ? undefined : deltaColor(t.delta)}>{(t.delta == null ? '' : signed(t.delta)).padStart(7)} </Text>
225 {barOf(t, span).map((r, i) => <Text key={`t${t.turn}-${i}`} color={r.color}>{r.s}</Text>)}
226 </Text>
227 )) : <Text dimColor>Shows up after Claude's first reply.</Text>}
228 </Box>
229
230 <Box flexDirection="column" marginTop={1}>
231 <Text bold>When it fills up</Text>
232 <Text dimColor>
233 {b.isAutoCompactEnabled && b.autoCompactThreshold
234 ? `Auto-compact starts at ${k(b.autoCompactThreshold)} (${pct(b.autoCompactThreshold)}). Claude Code replaces the older conversation with a summary: Messages shrinks, the rest stays.`
235 : 'Auto-compact is off. Run /compact before the window fills, or /clear to start over.'}
236 </Text>
237 </Box>
238
239 <Box marginTop={1}>
240 <Button key="exact" label="Count exactly" hotkey="r" plain onPress={() => { if (!counting) void refresh($, 'full') }} />
241 <Text> </Text>
242 <Button key="estimate" label="Estimate again" hotkey="e" plain onPress={() => { void refresh($) }} />
243 <Text> </Text>
244 <Button key="notes" label={notes ? 'Hide notes' : 'Show notes'} hotkey="n" plain dimColor onPress={() => { notes = !notes; $.ui.invalidate('ui.render') }} />
245 </Box>
246 <Text dimColor>{counting ? 'Counting exactly…' : exact ? 'Counted with the token-count API; e estimates again, locally.' : 'Estimated locally (e again, no requests); r counts exactly (one request per tool and file).'}</Text>
247 {failed && <Text color="red">Could not refresh, showing the last figures: {failed}</Text>}
248 </Box>
249 )
250 })
251}
252