Draws the context window as a stacked bar above the prompt, one color per /context category; toggle with /context-bar

A Claude Code mod that draws your context window as a stacked bar above the prompt, one colour per /context category, so you can see usage without running /context.

◆ context 212k of 1M · compacts at 950k 21% $1.23
████▉███████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│░░▒▒▒
▌ system prompt 3.4k 0.3% ▌ messages 186k 19% ▌ skills 2.2k 0.2%
/context's theme colours░ free space, ▒ autocompact buffer, │ where auto-compaction kicks in/cost totals it, at the far right. Claude Code only reports cost in US dollars, so it is always shown in $It refreshes at session start, after each tool call, after each turn and after compaction, using the local summary estimate (no token-count API requests), so figures can differ slightly from /context.
In a Claude Code terminal session:
/plugin install context-bar --marketplace fredrikbonde/claude-context-bar
Answer y to add the marketplace, then pick the user scope. The bar shows straight away and in every session after.
Mods are an early-access Claude Code feature; this was built on Claude Code 2.1.294.
/context-bar hides or shows the bar for the current session.
The [-] at the right of the header is Claude Code's own control for hiding the bar; the plugin doesn't draw it.
claude plugin update context-bar
then /reload-plugins in a running session.
claude plugin validate .
claude plugin test .hooks/register.tsx 230 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ContextBarCategory, ContextBarSnapshot } from '../types'
5
6const COMMAND = 'context-bar'
7const isShown = atom({ plugin: 'context-bar', key: 'isShown' } as const, true)
8const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
9
10const CELL_CHAR: Record<ContextBarCategory['kind'], string> = {
11 used: '█',
12 free: '░',
13 buffer: '▒',
14}
15
16// 'summary' estimates locally; 'full' would send a token-count request per tool and memory file on every refresh.
17async function refresh($: EngineInterface): Promise<void> {
18 const { context, cost } = await $.session.usage({ breakdown: 'summary' })
19 const breakdown = context.breakdown
20 if (!breakdown) {
21 return
22 }
23
24 const categories: ContextBarCategory[] = []
25 for (const c of breakdown.categories) {
26 if (c.kind !== 'deferred') {
27 categories.push({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind })
28 }
29 }
30
31 const next: ContextBarSnapshot = {
32 categories,
33 totalTokens: breakdown.totalTokens,
34 maxTokens: breakdown.rawMaxTokens,
35 percentage: breakdown.percentage,
36 autoCompactThreshold:
37 breakdown.isAutoCompactEnabled && breakdown.autoCompactThreshold !== undefined
38 ? breakdown.autoCompactThreshold
39 : null,
40 costUsd: cost?.usd ?? null,
41 }
42 await update($, snapshot, () => next)
43}
44
45// Background refreshes run after the engine's own work: a failed measurement must not fail the tool call, turn or
46// compaction it rides on, so it is reported in the transcript and the band keeps its last reading.
47async function refreshAfter($: EngineInterface, where: string): Promise<void> {
48 try {
49 await refresh($)
50 } catch (error) {
51 $.ui.log(`context-bar: refresh after ${where} failed: ${error instanceof Error ? error.message : String(error)}`)
52 }
53}
54
55export function formatTokens(tokens: number): string {
56 if (tokens >= 1_000_000) {
57 return `${trimZero((tokens / 1_000_000).toFixed(1))}M`
58 }
59 if (tokens >= 10_000) {
60 return `${Math.round(tokens / 1000)}k`
61 }
62 if (tokens >= 1000) {
63 return `${trimZero((tokens / 1000).toFixed(1))}k`
64 }
65 return `${tokens}`
66}
67
68// The engine's cost ledger is in US dollars only; there is no account currency to convert to.
69export function formatCost(usd: number): string {
70 return `$${usd.toFixed(2)}`
71}
72
73function trimZero(text: string): string {
74 return text.endsWith('.0') ? text.slice(0, -2) : text
75}
76
77function formatShare(tokens: number, max: number): string {
78 const share = (tokens / max) * 100
79 return share < 1 ? `${share.toFixed(1)}%` : `${Math.round(share)}%`
80}
81
82export type BarRun = { color: string; text: string }
83
84// Largest-remainder rounding so the runs fill exactly `width` cells, and any non-empty category keeps at least one.
85export function layoutBar(s: ContextBarSnapshot, width: number): BarRun[] {
86 const visible = s.categories.filter(c => c.tokens > 0)
87 const exact = visible.map(c => (c.tokens / s.maxTokens) * width)
88 const cells = exact.map(x => Math.max(1, Math.floor(x)))
89 let spare = width - cells.reduce((a, b) => a + b, 0)
90 const order = exact
91 .map((x, i) => ({ i, rest: x - Math.floor(x) }))
92 .sort((a, b) => b.rest - a.rest)
93 for (let k = 0; spare > 0 && order.length > 0; k = (k + 1) % order.length) {
94 cells[order[k]!.i]! += 1
95 spare -= 1
96 }
97 // Over the window, or many 1-cell minimums: take the excess from the widest runs.
98 while (spare < 0) {
99 const widest = cells.indexOf(Math.max(...cells))
100 cells[widest]! -= 1
101 spare += 1
102 }
103
104 const glyphs: { color: string; char: string }[] = []
105 visible.forEach((c, i) => {
106 for (let n = 0; n < cells[i]!; n++) {
107 glyphs.push({ color: c.color, char: CELL_CHAR[c.kind] })
108 }
109 })
110
111 if (s.autoCompactThreshold !== null && s.autoCompactThreshold < s.maxTokens) {
112 const at = Math.min(width - 1, Math.floor((s.autoCompactThreshold / s.maxTokens) * width))
113 if (glyphs[at]) {
114 glyphs[at] = { color: 'warning', char: '│' }
115 }
116 }
117
118 const runs: BarRun[] = []
119 for (const g of glyphs) {
120 const last = runs[runs.length - 1]
121 if (last && last.color === g.color) {
122 last.text += g.char
123 } else {
124 runs.push({ color: g.color, text: g.char })
125 }
126 }
127 return runs
128}
129
130function percentColor(percentage: number): string {
131 if (percentage >= 80) return 'error'
132 if (percentage >= 50) return 'warning'
133 return 'success'
134}
135
136export const register: Register = on => {
137 on('session.start', async ($, e, next) => {
138 await $.command.register({
139 name: COMMAND,
140 description: 'Toggle the context window bar above the prompt',
141 })
142 const result = await next(e)
143 await refreshAfter($, 'session start')
144
145 return result
146 })
147
148 on('command.run', { command: COMMAND }, async $ => {
149 const shown = !(await read($, isShown))
150 await update($, isShown, () => shown)
151 if (shown) {
152 await refresh($)
153 }
154
155 return { text: shown ? 'Context bar shown.' : 'Context bar hidden.' }
156 })
157
158 on('tool.call', async ($, e, next) => {
159 const result = await next(e)
160 if (await read($, isShown)) {
161 await refreshAfter($, e.tool)
162 }
163
164 return result
165 })
166
167 on('turn.complete', async ($, e, next) => {
168 const result = await next(e)
169 await refreshAfter($, 'turn')
170
171 return result
172 })
173
174 on('session.compact', async ($, e, next) => {
175 const result = await next(e)
176 await refreshAfter($, 'compaction')
177
178 return result
179 })
180
181 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
182 if (e.props.hasSurvey || !(await read($, isShown))) {
183 return next(e)
184 }
185
186 const { Box, Text } = $.ui.resolve(e)
187 const s = await read($, snapshot)
188 if (s === null) {
189 return <Text dimColor>◆ context · measuring…</Text>
190 }
191
192 const width = Math.max(10, e.props.bodyColumns)
193 const runs = layoutBar(s, width)
194 const legend = s.categories.filter(c => c.kind === 'used' && c.tokens > 0)
195
196 return (
197 <Box flexDirection="column" width={width}>
198 <Box justifyContent="space-between">
199 <Text color="claude">◆ context</Text>
200 <Text>
201 <Text bold>{formatTokens(s.totalTokens)}</Text>
202 <Text dimColor> of {formatTokens(s.maxTokens)}</Text>
203 {s.autoCompactThreshold !== null && (
204 <Text dimColor> · compacts at {formatTokens(s.autoCompactThreshold)}</Text>
205 )}{' '}
206 <Text inverse color={percentColor(s.percentage)}>
207 {' '}
208 {Math.round(s.percentage)}%{' '}
209 </Text>
210 {s.costUsd !== null && <Text bold> {formatCost(s.costUsd)}</Text>}
211 </Text>
212 </Box>
213 <Box>
214 {runs.map(r => (
215 <Text color={r.color}>{r.text}</Text>
216 ))}
217 </Box>
218 <Box flexWrap="wrap" columnGap={3}>
219 {legend.map(c => (
220 <Text>
221 <Text color={c.color}>▌</Text> {c.name.toLowerCase()} {formatTokens(c.tokens)}{' '}
222 <Text dimColor>{formatShare(c.tokens, s.maxTokens)}</Text>
223 </Text>
224 ))}
225 </Box>
226 </Box>
227 )
228 })
229}
230types/index.d.ts 23 lines1export type ContextBarCategory = {
2 name: string
3 tokens: number
4 color: string
5 kind: 'used' | 'free' | 'buffer'
6}
7
8export type ContextBarSnapshot = {
9 categories: ContextBarCategory[]
10 totalTokens: number
11 maxTokens: number
12 percentage: number
13 autoCompactThreshold: number | null
14 // Null where the host keeps no cost ledger, so the bar shows nothing rather than $0.00.
15 costUsd: number | null
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'context-bar': { isShown: boolean; snapshot: ContextBarSnapshot | null }
21 }
22}
23