A card above the prompt in the desktop app and the terminal: how full the context window is, what fills it, and what the session has cost, in Anthropic's…

A Claude Code mod for the Claude desktop app's Code tab and the terminal. It draws a card above the prompt that shows how full the context window is and what's filling it. The context window is everything Claude reads on each turn: its instructions, the tool descriptions, your memory files, and the conversation so far. When it fills up, Claude Code compacts the conversation and the older detail is lost.
/plugin marketplace add mgstegmaier/claude-plugins. You only need this once./plugin install context-band@mgstegmaier./reload-plugins, or start a new session.You need Claude Code 2.1.288 or later. To check your version, run claude --version in a terminal.
If the card doesn't appear, check these things:
allowManagedModsOnly managed setting. If it does, ask the Claude admins.The card starts collapsed. The header shows how many tokens are in use, the size of the window, the point where Claude Code compacts, and what the session has cost so far (~$4.13). The cost is the same figure /cost reports: every API response in the session priced at Anthropic's list API rates. Depending on your plan, that may not be what you're billed, but it shows what the session would cost on the API. It also shows the percent used in a pill that turns from green to light orange at 50 percent and to orange at 80 percent. The bar under the header has one colored segment per kind of content. In the desktop app, hover a segment to see its name and size. In the terminal, the bar is drawn in block characters across the card's width, with a │ mark at the compaction point.
Click context ▸ to open the legend. In the terminal, press ctrl+x tab to move focus to the card, then press Enter on context ▸. It lists every slice with its tokens and its share of the window, largest first, with free space last:
With the legend open, hover a row to see what that slice holds and what fills it most. The detail shows under the legend as soon as the pointer lands; the app applies hover itself, so there's no delay to set. It names the biggest memory files, the MCP servers with the most tool schema, skills grouped by the plugin that ships them, the tools that returned the most, and, for injected context, the hook event that wrote it (SessionStart, UserPromptSubmit) or engine for Claude Code's own reminders. The breakdown names the hook event but not the plugin or script behind it, so several SessionStart hooks show as one figure. In the desktop app, the bar's segments show the same detail when you hover them.
The numbers come from the same estimate /context uses. The split of the conversation is the mod's own estimate from the size of each message block, scaled so it adds up to the conversation total /context reports. The mod makes no network calls and no paid API calls.
claude plugin validate plugins/context-band
claude plugin test plugins/context-bandhooks/register.tsx 97 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import { barCells, barSvg, fmt, pillColor, toSnapshot } from './split'
6
7const snapshot = atom({ plugin: 'context-band', key: 'snapshot' } as const, null)
8const isOpen = atom({ plugin: 'context-band', key: 'isOpen' } as const, false) // collapsed each session
9
10async function refresh($: EngineInterface) {
11 try {
12 const usage = await $.session.usage({ breakdown: 'summary' }) // local estimate, no API calls
13 const b = usage.context.breakdown
14 if (!b) return
15 const messages = await $.session.messages({ as: 'api' })
16 const next: Snapshot = { ...toSnapshot(b, messages), ...(usage.cost ? { cost: usage.cost.usd } : {}) }
17 await update($, snapshot, () => next)
18 } catch {
19 // a missed refresh keeps the last card; the next turn tries again
20 }
21}
22
23export const register: Register = on => {
24 on('session.start', async ($, e, next) => {
25 const result = await next(e)
26 await refresh($)
27 return result
28 })
29
30 on('session.measure', async ($, e, next) => {
31 await refresh($)
32 return next(e)
33 })
34
35 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
36 if ((e.surface !== 'desktop' && e.surface !== 'terminal') || e.props.hasSurvey) return next(e)
37 const s = await read($, snapshot)
38 if (!s) return next(e)
39 const open = await read($, isOpen)
40
41 const { Box, Button, Text } = $.ui.resolve(e)
42 const Svg = e.surface === 'desktop' ? $.ui.resolve(e).Svg : undefined // the terminal has no Svg; it gets a text bar
43 // legend biggest first, free last; the bar keeps family order so its colors stay grouped
44 const legend = [...s.slices.filter(x => x.label !== 'free').sort((a, b) => b.tokens - a.tokens), ...s.slices.filter(x => x.label === 'free')]
45 const inner = e.props.bodyColumns - 4 // the card's border and padding take 4 columns
46 const half = Math.floor(inner / 2) // where the right column starts
47 const rows: (typeof s.slices)[] = []
48 for (let i = 0; i < legend.length; i += 2) rows.push(legend.slice(i, i + 2))
49
50 return (
51 <Box flexDirection="column" borderStyle="round" borderColor="#b0aea5" borderDimColor paddingX={1} gap={1}>
52 <Box flexDirection="row" justifyContent="space-between">
53 <Box flexDirection="row">
54 <Text color="#d97757">◆ </Text>
55 <Button key="toggle" plain label={`context ${open ? '▾' : '▸'}`} onPress={() => update($, isOpen, v => !v)} />
56 {open ? <Text dimColor> hover a row for detail</Text> : null}
57 </Box>
58 <Text>
59 <Text bold>{fmt(s.used)}</Text>
60 <Text dimColor> of {fmt(s.window)}{s.threshold ? ` · compacts at ${fmt(s.threshold)}` : ''}{s.cost !== undefined ? ` · ~$${s.cost.toFixed(2)}` : ''} </Text>
61 <Text bold color="#141413" backgroundColor={pillColor(s.percent)}> {s.percent}% </Text>
62 </Text>
63 </Box>
64 {Svg
65 ? <Svg source={barSvg(s)} alt={`context ${s.percent}% full`} height={10} isInteractive />
66 : <Text>{barCells(s, e.props.bodyColumns - 4).map((r, i) => <Text key={i} color={r.color}>{r.text}</Text>)}</Text>}
67 {open && (<Box flexDirection="column">
68 {rows.map((row, i) => (
69 <Box key={`row-${row[0]!.label}`} flexDirection="row">
70 {row.map((sl, col) => (
71 <Box key={sl.label} width="50%">
72 <Text wrap="truncate" hover={{ underline: true }}>
73 <Text color={sl.color}>■ </Text>
74 <Text>{sl.label} </Text>
75 <Text bold>{fmt(sl.tokens)}</Text>
76 {sl.label === 'free' ? null : <Text dimColor> {sl.percent}%</Text>}
77 {sl.note ? <Text dimColor> · {sl.note}</Text> : null}
78 </Text>
79 {/* hidden card in the row's own keyed Box, placed down into the slot under the legend */}
80 {sl.detail ? (
81 <Box position="absolute" top={rows.length - i + 1} left={col ? -half : 0} width={inner} height={2} display="none" hover={{ display: 'flex' }}>
82 <Text wrap="wrap"><Text bold color={sl.color}>{sl.label}</Text> {sl.detail}</Text>
83 </Box>
84 ) : null}
85 </Box>
86 ))}
87 </Box>
88 ))}
89 {/* ponytail: a fixed two-row slot the cards paint into, so a hover never reflows the band; a longer detail is cut.
90 Empty on purpose: anything drawn in it would show through a card */}
91 <Box height={2} marginTop={1} />
92 </Box>)}
93 </Box>
94 )
95 })
96}
97hooks/split.ts 228 lines1import type { ApiMessage, SessionContextBreakdown } from 'claude-code'
2
3import type { Slice, Snapshot } from '../types'
4
5// Anthropic palette (brand-guidelines skill): blue = system, green = setup you control,
6// orange = the conversation. Tints and shades are by eye; tune them in the app.
7const ENGINE_ROWS: Record<string, { label: string; color: string }> = {
8 'System prompt': { label: 'system prompt', color: '#6a9bcc' },
9 'System tools': { label: 'tools', color: '#9dbfe0' },
10 'MCP tools': { label: 'mcp tools', color: '#4a7aa8' },
11 'Custom agents': { label: 'agents', color: '#788c5d' },
12 'Memory files': { label: 'memory files', color: '#a3b48a' },
13 Skills: { label: 'skills', color: '#5a6b45' },
14}
15const MESSAGES_ROW = 'Messages'
16const UNKNOWN_ROW = '#b0aea5'
17
18export const BUCKETS = {
19 prompts: { label: 'your prompts', color: '#d97757' },
20 injected: { label: 'injected', color: '#e8a58c' },
21 replies: { label: 'replies', color: '#b85c3f' },
22 thinking: { label: 'thinking', color: '#f0c4b0' },
23 toolCalls: { label: 'tool calls', color: '#c98a6e' },
24 toolResults: { label: 'tool results', color: '#9c4a30' },
25 images: { label: 'images', color: '#e8e6dc' },
26} as const
27export type Bucket = keyof typeof BUCKETS
28
29export const FREE = '#b0aea5'
30export const TICK = '#e8a58c'
31// ponytail: base64 length says nothing about image tokens; ~1.6k tokens each is the usual
32const IMAGE_CHARS = 6400
33// Reminders the engine already counts in its own rows. Only the first copy is skipped:
34// a re-sent copy is context spent again, and that belongs in injected.
35// ponytail: matched on the engine's wording; if it changes, the block falls back to injected
36const ENGINE_COUNTED: [kind: string, marker: string][] = [
37 ['memory', 'Codebase and user instructions are shown below'],
38 ['skills', 'skills are available for use with the Skill tool'],
39 ['agents', 'Available agent types for the Agent tool'],
40]
41
42// What each slice holds, for the hover detail under the legend.
43export const ABOUT: Record<string, string> = {
44 'system prompt': "Claude Code's own instructions and your output style.",
45 tools: 'Schemas of the built-in tools (Bash, Read, Edit, and the rest).',
46 'mcp tools': 'Tool schemas from connected MCP servers.',
47 agents: 'Descriptions of the subagent types the Agent tool can start.',
48 'memory files': 'CLAUDE.md files, rules, and auto-memory loaded at session start.',
49 skills: "The skill listing: each skill's name and description.",
50 'your prompts': 'What you typed.',
51 injected: 'Text added to your turns: hook output, reminders, and re-sent listings.',
52 replies: "Claude's visible replies.",
53 thinking: "Claude's reasoning blocks.",
54 'tool calls': 'The input of every tool call Claude made.',
55 'tool results': 'What the tools returned.',
56 images: 'Images and PDFs, estimated at about 1.6k tokens each.',
57 'mcp server instructions': 'Usage notes MCP servers send with their tools, read on every turn.',
58 free: 'Room left in the window. Claude Code compacts at the tick on the bar.',
59}
60
61/** The biggest few of a name-to-tokens map, as `a 12k, b 3k, c 900`. */
62export function top(entries: Iterable<[string, number]>, n = 3): string | undefined {
63 const sums = new Map<string, number>()
64 for (const [k, v] of entries) sums.set(k, (sums.get(k) ?? 0) + v)
65 const best = [...sums].filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]).slice(0, n)
66 return best.length ? best.map(([k, v]) => `${k} ${fmt(v)}`).join(', ') : undefined
67}
68
69// ponytail: matched on the engine's wording for hook output ("SessionStart:startup hook success",
70// "UserPromptSubmit hook additional context"); it names the hook event, never the plugin or script
71const HOOK_EVENT = /\b([A-Z][A-Za-z]+)(?::[a-z_]+)? hook (?:success|additional context|blocking error)/
72/** Who put an injected block there: a hook event, or the engine itself. */
73export const injectedSource = (t: string) => HOOK_EVENT.exec(t)?.[1] ?? 'engine'
74
75const textOf = (v: unknown): string =>
76 typeof v === 'string' ? v
77 : Array.isArray(v) ? v.map(b => (b && typeof b === 'object' && 'text' in b ? String(b.text) : '')).join('')
78 : ''
79
80/** Character counts per bucket, plus tool-result characters per tool name. */
81export function measure(messages: readonly ApiMessage[]) {
82 const chars: Record<Bucket, number> = { prompts: 0, injected: 0, replies: 0, thinking: 0, toolCalls: 0, toolResults: 0, images: 0 }
83 const toolName = new Map<string, string>()
84 const byTool = new Map<string, number>()
85 const bySource = new Map<string, number>()
86 const seen = new Set<string>()
87 for (const m of messages) {
88 for (const b of m.content) {
89 if (b.type === 'text') {
90 const t = String(b.text ?? '')
91 const bucket: Bucket = m.role === 'assistant' ? 'replies' : t.includes('<system-reminder>') ? 'injected' : 'prompts'
92 const kind = bucket === 'injected' ? ENGINE_COUNTED.find(([, marker]) => t.includes(marker))?.[0] : undefined
93 if (kind && !seen.has(kind)) {
94 seen.add(kind)
95 continue
96 }
97 chars[bucket] += t.length
98 if (bucket === 'injected') bySource.set(injectedSource(t), (bySource.get(injectedSource(t)) ?? 0) + t.length)
99 } else if (b.type === 'thinking') {
100 chars.thinking += String(b.thinking ?? '').length
101 } else if (b.type === 'tool_use') {
102 toolName.set(String(b.id), String(b.name))
103 chars.toolCalls += String(b.name).length + JSON.stringify(b.input ?? {}).length
104 } else if (b.type === 'tool_result') {
105 const n = textOf(b.content).length
106 chars.toolResults += n
107 const name = toolName.get(String(b.tool_use_id)) ?? 'other'
108 byTool.set(name, (byTool.get(name) ?? 0) + n)
109 } else if (b.type === 'image' || b.type === 'document') {
110 chars.images += IMAGE_CHARS
111 }
112 }
113 }
114 return { chars, byTool, bySource }
115}
116
117/** Scale character counts to the engine's Messages tokens, so the split always sums to it. */
118export function splitMessages(messages: readonly ApiMessage[], messagesTokens: number) {
119 const { chars, byTool, bySource } = measure(messages)
120 const keys = Object.keys(chars) as Bucket[]
121 const total = keys.reduce((s, k) => s + chars[k], 0)
122 const tokens = {} as Record<Bucket, number>
123 for (const k of keys) tokens[k] = total ? Math.round((messagesTokens * chars[k]) / total) : 0
124 if (total) {
125 // rounding drift goes to the largest bucket
126 const biggest = keys.reduce((a, b) => (chars[b] > chars[a] ? b : a))
127 tokens[biggest] += messagesTokens - keys.reduce((s, k) => s + tokens[k], 0)
128 }
129 const topTool = [...byTool].sort((a, b) => b[1] - a[1])[0]?.[0]
130 const scale = (m: Map<string, number>): [string, number][] => [...m].map(([k, v]) => [k, total ? Math.round((messagesTokens * v) / total) : 0])
131 return { tokens, topTool, byTool: scale(byTool), bySource: scale(bySource) }
132}
133
134export const pct = (tokens: number, window: number) => (tokens > 0 && window > 0 ? Math.max(1, Math.round((tokens / window) * 100)) : 0)
135
136export function fmt(n: number): string {
137 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
138 if (n >= 10_000) return `${Math.round(n / 1000)}k`
139 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
140 return String(n)
141}
142
143const detail = (label: string, biggest?: string) => {
144 const d = [ABOUT[label] ?? 'A row /context reports; run /context for its detail.', biggest && `Biggest: ${biggest}.`].filter(Boolean).join(' ')
145 return d ? { detail: d } : {}
146}
147
148export function toSnapshot(b: SessionContextBreakdown, messages: readonly ApiMessage[]): Snapshot {
149 const window = b.rawMaxTokens
150 const slices: Slice[] = []
151 const add = (label: string, tokens: number, color: string, note?: string, biggest?: string) =>
152 slices.push({ label, tokens, percent: pct(tokens, window), color, ...(note ? { note } : {}), ...detail(label, biggest) })
153 const bigByRow: Record<string, string | undefined> = {
154 'mcp tools': top((b.mcpTools ?? []).filter(t => t.isLoaded).map(t => [t.serverName, t.tokens])),
155 agents: top((b.agents ?? []).map(a => [a.agentType, a.tokens])),
156 'memory files': top((b.memoryFiles ?? []).map(f => [f.path.split('/').slice(-2).join('/'), f.tokens])),
157 skills: top((b.skills?.skillFrontmatter ?? []).map(k => [k.pluginName ?? k.source, k.tokens])),
158 }
159 let free = 0
160 for (const row of b.categories) {
161 if (row.kind === 'free') free += row.tokens
162 if (row.kind !== 'used' || row.tokens <= 0) continue // buffer sits past the tick; deferred is outside the window
163 if (row.name === MESSAGES_ROW) {
164 const { tokens, topTool, byTool, bySource } = splitMessages(messages, row.tokens)
165 for (const k of Object.keys(BUCKETS) as Bucket[]) {
166 const biggest = k === 'toolResults' ? top(byTool) : k === 'injected' ? top(bySource) : undefined
167 if (tokens[k] > 0) add(BUCKETS[k].label, tokens[k], BUCKETS[k].color, k === 'toolResults' ? topTool : undefined, biggest)
168 }
169 } else {
170 const known = ENGINE_ROWS[row.name]
171 const label = known?.label ?? row.name.toLowerCase()
172 add(label, row.tokens, known?.color ?? UNKNOWN_ROW, undefined, bigByRow[label])
173 }
174 }
175 slices.push({ label: 'free', tokens: free, percent: pct(free, window), color: FREE, ...detail('free') })
176 return {
177 slices,
178 used: b.totalTokens,
179 window,
180 ...(b.autoCompactThreshold ? { threshold: b.autoCompactThreshold } : {}),
181 percent: Math.round(b.percentage),
182 }
183}
184
185export const pillColor = (percent: number) => (percent >= 80 ? '#d97757' : percent >= 50 ? '#e8a58c' : '#788c5d')
186
187/** The bar: a track for the whole window, one rect per used slice, a tick at the compaction point. */
188export function barSvg(s: Snapshot): string {
189 const W = 1000
190 const H = 12
191 let x = 0
192 const rects = s.slices
193 .filter(sl => sl.label !== 'free' && sl.tokens > 0)
194 .map(sl => {
195 const w = Math.max(2, (sl.tokens / s.window) * W)
196 const tip = `${sl.label} ${fmt(sl.tokens)} (${sl.percent}%)${sl.note ? ` · ${sl.note}` : ''}${sl.detail ? `\n${sl.detail}` : ''}`.replace(/[<&]/g, '')
197 const r = `<rect x="${x.toFixed(1)}" y="0" width="${w.toFixed(1)}" height="${H}" fill="${sl.color}"><title>${tip}</title></rect>`
198 x += w
199 return r
200 })
201 const tick = s.threshold ? `<rect x="${Math.min(W - 3, (s.threshold / s.window) * W).toFixed(1)}" y="0" width="3" height="${H}" fill="${TICK}"/>` : ''
202 return (
203 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="none">` +
204 `<defs><clipPath id="t"><rect width="${W}" height="${H}" rx="4"/></clipPath></defs>` +
205 `<g clip-path="url(#t)"><rect width="${W}" height="${H}" fill="${FREE}" fill-opacity="0.25"/>${rects.join('')}${tick}</g></svg>`
206 )
207}
208
209/** The bar for surfaces without Svg: `width` cells of █ per used slice, ░ for free, │ at the compaction point, as color runs. */
210export function barCells(s: Snapshot, width: number): { color: string; text: string }[] {
211 const n = Math.max(10, width)
212 const cells: { color: string; ch: string }[] = []
213 for (const sl of s.slices) {
214 if (sl.label === 'free' || sl.tokens <= 0) continue
215 const k = Math.max(1, Math.round((sl.tokens / s.window) * n))
216 for (let i = 0; i < k && cells.length < n; i++) cells.push({ color: sl.color, ch: '█' })
217 }
218 while (cells.length < n) cells.push({ color: FREE, ch: '░' })
219 if (s.threshold) cells[Math.min(n - 1, Math.round((s.threshold / s.window) * n))] = { color: TICK, ch: '│' }
220 const runs: { color: string; text: string }[] = []
221 for (const c of cells) {
222 const last = runs[runs.length - 1]
223 if (last && last.color === c.color) last.text += c.ch
224 else runs.push({ color: c.color, text: c.ch })
225 }
226 return runs
227}
228types/index.d.ts 17 lines1export type Slice = { label: string; tokens: number; percent: number; color: string; note?: string; detail?: string }
2
3export type Snapshot = {
4 slices: Slice[] // used slices in legend order, free last
5 used: number
6 window: number
7 threshold?: number
8 percent: number
9 cost?: number // US dollars so far at list API rates, as /cost totals it
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'context-band': { snapshot: Snapshot | null; isOpen: boolean }
15 }
16}
17