SLOPSHOPPER

context-budget

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…

panecommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-budget
│ ┃ Context budget ✕ › fix the failing auth test and add an audit log call │ ┃ Measuring the context window… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /contextbudget │ ⎿ context-budget: context budget shown │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context budget
Measuring the context window…
README

context-budget

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.

  • Room left: the free space before auto-compact and where it starts, the room left in the whole window, and how much of what is used is the conversation. The room before auto-compact turns yellow under a quarter of the window and red under a tenth.
  • The bar: the window as one stacked bar in the category colours; every non-empty part gets at least one cell. Below it, each category's tokens and share of the window. Each counted row is marked grows (the conversation) or each request (sent again with every message); the autocompact buffer is marked as reserved for compaction.
  • What each part is: a plain-language note per category: System prompt, System tools, MCP tools, Custom agents, Memory files, Skills, Messages, Free space, Autocompact buffer.
  • Available tools outside context: tool schemas loaded on demand, listed apart from the breakdown because they are not in the window.
  • Context included in each request: the five biggest fixed parts among each MCP server's loaded tool schemas, each memory file, the skill descriptions and the agent descriptions.
  • Per turn: the context total now (the header's) and how much the last turn added, then one row per turn for the last ten, newest first: the total after that turn, the change from the turn before, and a bar of the window in the same category colours as the top bar, so you see which part grew. A drop is a compaction or a /clear.
  • When it fills up: where auto-compact starts, or that it is off.

Install

/plugin install context-budget --marketplace dboeter/cc-plugins

Answer y to add the marketplace, then pick a scope.

Use

/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.

What it does on your machine

  • Estimates locally: the breakdown comes from Claude Code's own estimate, read when the pane opens, after every turn (pane open or not, so the per-turn rows reach back) and on e. No requests.
  • Count exactly (r): asks for the full breakdown, which sends one token-count request per tool and file. Only when you press it.
  • Per-turn rows: kept in memory from the estimate after each turn; reloading the plugin starts them over.
  • Storage: none.

Develop

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-budget
Source 1 files
hooks/context-budget.tsx 252 lines
1import 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