Context window usage bar for the Claude Code prompt footer

Context window usage bar for the Claude Code prompt footer.
A Claude Code plugin (context-pane). The bar sits at the right end of the prompt footer, next to the permission mode. Hover it for the breakdown in tokens and percent.

Messages, which the built-in breakdown leaves out (#94436)./context-pane command that opens the breakdown as a side pane.In a terminal session:
/plugin install context-pane --marketplace koprjaa/claude-code-context-bar
From a shell:
claude plugin install context-pane --marketplace koprjaa/claude-code-context-bar
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with koprjaa/claude-code-context-bar, then Add plugin.
The bar shows up in the footer after install. Run /context-pane for the side pane.
With "tui": "fullscreen" the breakdown floats over the transcript. Without it, the breakdown should open in the band above the prompt, but Claude Code does not draw it there yet. The footer bar works in both modes.
hooks/register.tsx 230 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextBreakdown } from 'claude-code'
3
4import type { Row, Snapshot } from '../types'
5
6const PANE = 'context'
7const TITLE = 'Context'
8const COLS = 26
9const BAR = 20
10const FOOTER_BAR = 15
11// short labels so the pane fits a narrow dock
12const SHORT: Record<string, string> = {
13 'System prompt': 'System', 'System tools': 'Tools', 'Memory files': 'Memory', 'Custom agents': 'Agents',
14 'Autocompact buffer': 'Buffer', 'Free space': 'Free', 'MCP tools': 'MCP', 'Slash commands': 'Commands',
15}
16const short = (name: string) => (SHORT[name] ?? name).slice(0, 9)
17const snap = atom({ plugin: 'context-pane', key: 'snap' } as const, null)
18
19// Desktop's popover spelling: 529, 18.6k, 33k, 1M
20export const fmt = (n: number) =>
21 n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${+(n / 1000).toFixed(1)}k` : String(n)
22
23// the engine's keys are two greys (promptBorder, inactive) and *_FOR_SUBAGENTS_ONLY keys no plugin can paint,
24// so each used category gets its own colour; mid shades, >= 3:1 against both a black and a white terminal
25const PALETTE: Record<string, string> = {
26 'System prompt': '#6b7280', 'System tools': '#3b82f6', 'MCP server instructions': '#059669',
27 'MCP tools': '#0d9488', 'Custom agents': '#db2777', 'Memory files': '#ea580c', 'Skills': '#d97706',
28 'Messages': '#9333ea', 'Slash commands': '#0891b2',
29}
30// a category the palette does not know gets a hue hashed from its name: the same in every session
31const HUES = ['#dc2626', '#d97706', '#65a30d', '#16a34a', '#0891b2', '#6366f1', '#c026d3', '#e11d48']
32const hashed = (name: string) => HUES[[...name].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % HUES.length]!
33export const colorOf = (name: string, kind: string) => PALETTE[name] ?? (kind === 'used' ? hashed(name) : '#4b5563')
34
35const ORDER: Record<string, number> = { used: 0, buffer: 1, free: 2, deferred: 3 }
36
37export function summarize(b: SessionContextBreakdown, liveTokens?: number): Snapshot {
38 const perServer = new Map<string, number>()
39 for (const t of b.mcpTools) perServer.set(t.serverName, (perServer.get(t.serverName) ?? 0) + t.tokens)
40 const mcp: Row[] = [...perServer]
41 .map(([name, tokens]) => ({ name, tokens, kind: 'used', color: 'inactive' }))
42 .sort((a, c) => c.tokens - a.tokens)
43 // as Desktop: used rows biggest first, then Autocompact buffer, Free space last
44 // the breakdown's Messages leaves out tool results (anthropics/claude-code#94436); the live fill counts them,
45 // so whatever the live figure has beyond the other used rows is Messages
46 const others = b.categories.filter(c => c.kind === 'used' && c.name !== 'Messages').reduce((a, c) => a + c.tokens, 0)
47 const rows: Row[] = b.categories
48 .map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind, color: colorOf(c.name, c.kind) }))
49 .map(r => (r.name === 'Messages' && liveTokens ? { ...r, tokens: Math.max(r.tokens, liveTokens - others) } : r))
50 .sort((a, c) => (ORDER[a.kind] ?? 0) - (ORDER[c.kind] ?? 0) || c.tokens - a.tokens)
51 return { total: b.totalTokens, max: b.rawMaxTokens, percent: b.percentage, rows, mcp }
52}
53
54// cells per row summing to exactly `width` (largest remainder); every non-empty used row gets >= 1
55export function barCells(rows: Row[], max: number, width: number): number[] {
56 const exact = rows.map(r => (r.tokens / max) * width)
57 const cells = exact.map((x, i) => (rows[i]!.kind === 'used' && rows[i]!.tokens > 0 ? Math.max(1, Math.floor(x)) : Math.floor(x)))
58 let left = width - cells.reduce((a, b) => a + b, 0)
59 const byRemainder = exact.map((x, i) => [x - Math.floor(x), i] as const).sort((a, b) => b[0] - a[0])
60 for (const [, i] of byRemainder) if (left > 0) { cells[i]!++; left-- }
61 // the min-1 bumps can overshoot: take it back from the biggest row (Free space)
62 while (left < 0) { const i = cells.indexOf(Math.max(...cells)); cells[i]!--; left++ }
63 return cells
64}
65
66// the whole window: the used share (live fill, which counts tool results the breakdown misses) split by
67// category in proportion, the rest free; a category smaller than a cell shows once the context grows
68export function windowBar(used: Row[], percent: number, width: number) {
69 const usedCells = percent > 0 ? Math.min(width, Math.max(1, Math.round((percent / 100) * width))) : 0
70 const sum = used.reduce((a, r) => a + r.tokens, 0) || 1
71 const cells = barCells(used.map(r => ({ ...r, kind: 'part' })), sum, usedCells)
72 return { cells, free: width - usedCells }
73}
74
75// shortcut: the left half of the footer row (permission mode + its hint) is not measurable from here, so a
76// fixed reserve; raise it if the bar still wraps beside a longer mode
77const FOOTER_RESERVE = 48
78
79// the whole bar or none: where the row is too narrow the footer stays as the engine draws it
80export const footerFits = (columns: number, modes: string, label: string) =>
81 FOOTER_RESERVE + (modes ? modes.length + 2 : 0) + FOOTER_BAR + label.length <= columns
82
83// summary = local estimate, no token-count API calls; refreshed after every turn
84async function refresh($: EngineInterface) {
85 const { context } = await $.session.usage({ breakdown: 'summary' })
86 const b = context.breakdown
87 if (!b) return
88 // live figures as the status line has them; the breakdown undercounts tool results
89 const live = { tokens: context.tokens ?? b.totalTokens, window: context.window, percent: context.percent ?? b.percentage }
90 await update($, snap, () => ({ ...summarize(b, context.tokens), live }))
91}
92
93const HOVER = 'context-details'
94
95// the used-context rows of the pane
96function details(Text: any, s: Snapshot) {
97 const pct = (n: number) => {
98 const p = (n / s.max) * 100
99 return `${p >= 10 ? p.toFixed(0) : p.toFixed(1)}%`
100 }
101 const W = 'white'
102 // only what occupies the window: no free space, buffer, deferred tools or MCP
103 return s.rows.filter(r => r.kind === 'used').map(r => (
104 <Text wrap="truncate-end">
105 <Text color={r.color}>■</Text>
106 <Text color={W}> {short(r.name).padEnd(9)}{fmt(r.tokens).padStart(6)}</Text>
107 <Text color="gray">{pct(r.tokens).padStart(5)}</Text>
108 </Text>
109 ))
110}
111
112// the hover card: the used categories stacked in two columns, so it stays 4 rows tall;
113// the band above the prompt cuts a taller tree to its height and an "n more" row
114function compact(Box: any, Text: any, s: Snapshot) {
115 const pct = (n: number) => {
116 const p = (n / s.max) * 100
117 return `${p >= 10 ? p.toFixed(0) : p.toFixed(1)}%`
118 }
119 const used = s.rows.filter(r => r.kind === 'used')
120 const half = Math.ceil(used.length / 2)
121 const column = (rows: Row[]) => (
122 <Box flexDirection="column" marginRight={3}>
123 {rows.map(r => (
124 <Text wrap="truncate-end">
125 <Text color={r.color}>■</Text>
126 <Text color="white"> {short(r.name).padEnd(9)}{fmt(r.tokens).padStart(6)}</Text>
127 <Text color="gray">{pct(r.tokens).padStart(5)}</Text>
128 </Text>
129 ))}
130 </Box>
131 )
132 return (
133 <Box flexDirection="row" backgroundColor="black" paddingX={1}>
134 {column(used.slice(0, half))}
135 {column(used.slice(half))}
136 </Box>
137 )
138}
139
140export const register: Register = on => {
141 on('session.start', async ($, e, next) => {
142 await $.command.register({ name: 'context-pane', description: 'Open the live context window pane' })
143 const ran = await next(e)
144 await refresh($)
145 return ran
146 })
147
148 on('command.run', { command: 'context-pane' }, async $ => {
149 await $.ui.open({ id: PANE, title: TITLE, columns: COLS })
150 await refresh($)
151 return { text: 'Context window pane opened.' }
152 })
153
154 on('session.measure', async ($, e, next) => {
155 const ran = await next(e)
156 if (e.changed.includes('context')) await refresh($)
157 return ran
158 })
159
160 // right end of the footer row (beside "bypass permissions"): the modes, then the coloured bar
161 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
162 const s = await read($, snap)
163 if (!s) return next(e)
164 const { Box, Text } = $.ui.resolve(e)
165 const inWindow = s.rows.filter(r => r.kind === 'used')
166 const { tokens, window, percent } = s.live ?? { tokens: s.total, window: s.max, percent: s.percent }
167 const modes = e.props.modes.join(' & ')
168 const label = ` ${percent}% ${fmt(tokens)}/${fmt(window)}`
169 if (!footerFits(e.viewport?.columns ?? 80, modes, label)) return next(e)
170 const { cells, free } = windowBar(inWindow, percent, FOOTER_BAR)
171
172 // fullscreen: an absolute card floats over the transcript; the main screen clips it to the footer row,
173 // so there the hover reveals the AbovePrompt band instead (same hover scope)
174 const floats = e.viewport?.isFullscreen === true
175 return (
176 <Box key="context-bar" flexDirection="row" hover={{ scope: HOVER }}>
177 {floats && (
178 <Box position="absolute" bottom={1} right={0} display="none"
179 hover={{ display: 'flex' }}>
180 {compact(Box, Text, s)}
181 </Box>
182 )}
183 {modes && <Text dimColor>{modes} </Text>}
184 <Text>
185 {inWindow.map((r, i) => <Text color={r.color}>{'█'.repeat(cells[i]!)}</Text>)}
186 <Text color="#4b5563">{'░'.repeat(free)}</Text>
187 </Text>
188 <Text dimColor>{label}</Text>
189 </Box>
190 )
191 })
192
193 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
194 const { Box, Text } = $.ui.resolve(e)
195 const s = await read($, snap)
196 if (!s) return <Text dimColor>Waiting for the first measurement…</Text>
197
198 const inWindow = s.rows.filter(r => r.kind === 'used')
199 const { cells, free } = windowBar(inWindow, s.live?.percent ?? s.percent, BAR)
200 const W = 'white'
201
202 // black like a classic terminal and filling the whole pane; white text so a light theme stays readable
203 return (
204 <Box flexDirection="column" flexGrow={1} height="100%" backgroundColor="black">
205 <Text bold color={W}>{fmt(s.total)}/{fmt(s.max)} {s.percent}%</Text>
206 <Text wrap="truncate-end">
207 {inWindow.map((r, i) => <Text color={r.color}>{'█'.repeat(cells[i]!)}</Text>)}
208 <Text color="#4b5563">{'░'.repeat(free)}</Text>
209 </Text>
210 {details(Text, s)}
211 </Box>
212 )
213 })
214
215 // the details, hidden above the prompt until the footer bar is hovered
216 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
217 const s = await read($, snap)
218 if (!s || e.viewport?.isFullscreen === true) return next(e)
219 const { Box, Text } = $.ui.resolve(e)
220 return (
221 <Box flexDirection="column">
222 {await next(e)}
223 <Box display="none" hover={{ scope: HOVER, display: 'flex' }}>
224 {compact(Box, Text, s)}
225 </Box>
226 </Box>
227 )
228 })
229}
230types/index.d.ts 9 lines1export type Row = { name: string; tokens: number; kind: string; color: string }
2export type Snapshot = { total: number; max: number; percent: number; rows: Row[]; mcp: Row[]; live?: { tokens: number; window: number; percent: number } }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'context-pane': { snap: Snapshot | null }
7 }
8}
9