A gauge above the prompt: the context window broken down by category, as /context breaks it down.

A Claude Code mod that draws the context window as a one-line gauge above the prompt, broken down by category the way /context breaks it down. Click it for the details beneath it.
[ ▸ Context ] █████████████████████████████░░░░░░░▒▒▒▒▒▒▒▒▒▒▒ 124k / 200k (62%)
Each category is drawn in the theme colour /context gives it, so the segments match its grid.
░ and the autocompact buffer ▒. A category that holds tokens always gets at least one cell.used / window are measured against the window auto-compaction counts from, which can be smaller than the model's limit. It's the same figure /context prints./context-gauge, press ctrl+x tab then Enter, or click [ ▸ Context ] to show the breakdown under the bar; the same again hides it. It lists each category with its tokens and share of the window, then the memory files, the MCP servers, the custom agents and the skills listing with what each one costs. It also lists deferred tool schemas, which sit outside the window and aren't on the bar. It's as wide as its widest line. In the fullscreen layout the band gets at most half the screen, so a long breakdown scrolls: use the wheel, or the arrows after ctrl+x tab.Clicks reach the band only in Claude Code's fullscreen layout. Inside tmux, Claude Code uses the main-screen layout by default, so there use the command or the keys, or start Claude Code with CLAUDE_CODE_NO_FLICKER=1 (and set -g mouse on in tmux) to get the fullscreen layout.
[ ▾ Context ] █████████████████████████████░░░░░░░▒▒▒▒▒▒▒▒▒▒▒ 124k / 200k (62%)
claude-opus-5-5 · 124k of 200k (62%) · auto-compacts at 155k
█ System prompt 3.1k 1.6%
█ System tools 12k 6.2%
█ Messages 107k 53.3%
░ Free space 31k 15.5%
▒ Autocompact buffer 45k 22.5%
MCP tools (deferred) 9.0k
Memory files
Project /repo/CLAUDE.md 2.0k
It updates at session start, after each turn and after a /compact. A /clear hides it until the next turn. Counts are local estimates, so measuring sends no token-count requests, and they can differ a little from /context's. Collapse the band with its [-] or ctrl+x ctrl+a.
claude plugin marketplace add youngin-abel-kim/context-gauge
claude plugin install context-gauge@context-gauge
Mods need Claude Code 2.1.287 or later. Installing at the user scope turns it on in every session.
hooks/register.tsx: the hooks, which measure the window and draw the band.hooks/gauge.ts: the breakdown → bar layout, with no engine calls.types/index.d.ts: the $.state contract.tests/: run by claude plugin test.hooks/.bash claude plugin validate . && claude plugin test . ``version in .claude-plugin/plugin.json. Pushing without a bump doesn't update installed copies.bash claude plugin marketplace update context-gauge && claude plugin update context-gauge@context-gauge ` then run /reload-plugins`.claude --plugin-dir ~/workspace/context-gauge/main
Saving a file reloads the mod in that session. When the engine loads the mod it writes its type declarations to .claude-plugin/types/ (ignored by git), so tsc -p . type-checks it.
If the installed copy also loads there, run claude plugin disable context-gauge@context-gauge --scope local inside the repo.
hooks/register.tsx 140 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { barRows, cells, COLUMNS, details, detailsWidth, fromBreakdown, GLYPH, tokens } from './gauge'
5
6const gauge = atom({ plugin: 'context-gauge', key: 'gauge' } as const, null)
7const isOpen = atom({ plugin: 'context-gauge', key: 'isOpen' } as const, false)
8
9/**
10 * Measures the window by category. `summary` estimates locally, as the status
11 * line does, so a measurement sends no token-count requests.
12 */
13async function measure($: EngineInterface) {
14 const { breakdown } = (await $.session.usage({ breakdown: 'summary' })).context
15 await update($, gauge, () => (breakdown ? fromBreakdown(breakdown) : null))
16}
17
18/** Shows the details below the bar, or hides them. */
19function toggle($: EngineInterface) {
20 return update($, isOpen, open => !open)
21}
22
23export const register: Register = on => {
24 on('session.start', async ($, e, next) => {
25 await $.command.register({
26 name: 'context-gauge',
27 description: 'Show or hide the context window breakdown',
28 })
29 const started = await next(e)
30 await measure($)
31
32 return started
33 })
34
35 // Clicks reach the band only in the fullscreen layout; the command works on the main screen too.
36 on('command.run', { command: 'context-gauge' }, async $ => {
37 await toggle($)
38
39 return {}
40 })
41
42 on('session.measure', async ($, e, next) => {
43 if (e.changed.includes('context')) {
44 await measure($)
45 }
46
47 return next(e)
48 })
49
50 // No turn follows a /compact, so no `session.measure` either.
51 on('session.compact', async ($, e, next) => {
52 const compacted = await next(e)
53 if (e.trigger !== 'precompute') {
54 await measure($)
55 }
56
57 return compacted
58 }).catch(($, e, next) => next(e))
59
60 // A /clear empties the window; the band waits for the next turn's measurement.
61 on('session.end', { reason: 'clear' }, async ($, e, next) => {
62 await update($, gauge, () => null)
63
64 return next(e)
65 })
66
67 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
68 const shown = await read($, gauge)
69 if (e.props.hasSurvey || shown === null) {
70 return next(e)
71 }
72
73 const { Box, Button, Text } = $.ui.resolve(e)
74 const isOpenNow = await read($, isOpen)
75 const rows = barRows(shown)
76 const label = `${isOpenNow ? '▾' : '▸'} Context`
77 const tail = `${tokens(shown.totalTokens)} / ${tokens(shown.maxTokens)} (${shown.percentage}%)`
78 // The terminal draws the button as `[ label ]`. One cell short of the row:
79 // a band exactly as wide as its row blanks the engine's effort indicator
80 // beneath it while the prompt holds text.
81 const columns = e.props.bodyColumns - 1
82 const width = Math.max(10, columns - (label.length + 4) - tail.length - 2)
83 const widths = cells(rows, width)
84 const lines = isOpenNow ? details(shown) : []
85
86 return (
87 <Box flexDirection="column" width={columns}>
88 <Box columnGap={1}>
89 <Button key="details" label={label} autoFocus onPress={() => toggle($)} />
90 <Box key="bar">
91 {rows.map((row, i) => (
92 <Text color={row.color}>{GLYPH[row.kind].repeat(widths[i] ?? 0)}</Text>
93 ))}
94 </Box>
95 <Text dimColor>{tail}</Text>
96 </Box>
97 {isOpenNow && (
98 // As wide as its widest line, so the figures stay beside their labels.
99 <Box key="breakdown" flexDirection="column" width={Math.min(columns, detailsWidth(lines))}>
100 {lines.map(line => {
101 switch (line.kind) {
102 case 'heading':
103 return <Text bold>{line.text}</Text>
104 case 'note':
105 return <Text dimColor>{line.text}</Text>
106 case 'blank':
107 return <Text> </Text>
108 case 'row':
109 return (
110 <Box columnGap={1}>
111 <Box width={COLUMNS.glyph} flexShrink={0}>
112 <Text color={line.color}>{line.glyph}</Text>
113 </Box>
114 <Box width={COLUMNS.label} flexShrink={0}>
115 <Text dimColor={line.isDim} wrap="truncate-end">
116 {line.label}
117 </Text>
118 </Box>
119 <Box flexGrow={1} flexShrink={1}>
120 <Text dimColor wrap="truncate-start">
121 {line.detail}
122 </Text>
123 </Box>
124 <Box width={COLUMNS.tokens} flexShrink={0} justifyContent="flex-end">
125 <Text dimColor={line.isDim}>{line.tokens}</Text>
126 </Box>
127 <Box width={COLUMNS.share} flexShrink={0} justifyContent="flex-end">
128 <Text dimColor>{line.share}</Text>
129 </Box>
130 </Box>
131 )
132 }
133 })}
134 </Box>
135 )}
136 </Box>
137 )
138 })
139}
140hooks/gauge.ts 215 lines1import type { SessionContextBreakdown } from 'claude-code'
2
3import type { Gauge, GaugeRow, GaugeServer } from '../types'
4
5/** Solid for what the window holds, shaded for what is left and for the reserve. */
6export const GLYPH = { used: '█', free: '░', buffer: '▒', deferred: ' ' } as const
7
8/** Where a row sits: what the window holds, then what is left, then the reserve at its end. */
9const PLACE = { used: 0, free: 1, buffer: 2, deferred: 3 } as const
10
11/**
12 * Keeps every row that holds tokens, in /context's grid order, and the lists
13 * the details draw.
14 */
15export function fromBreakdown(breakdown: SessionContextBreakdown): Gauge {
16 const rows: GaugeRow[] = []
17 for (const { name, tokens, color, kind } of breakdown.categories) {
18 if (tokens > 0) {
19 rows.push({ name, tokens, color, kind })
20 }
21 }
22 rows.sort((a, b) => PLACE[a.kind] - PLACE[b.kind])
23
24 const servers = new Map<string, GaugeServer>()
25 for (const tool of breakdown.mcpTools) {
26 const server = servers.get(tool.serverName) ?? { name: tool.serverName, tools: 0, loaded: 0, tokens: 0 }
27 server.tools += 1
28 if (tool.isLoaded) {
29 server.loaded += 1
30 server.tokens += tool.tokens
31 }
32 servers.set(tool.serverName, server)
33 }
34
35 const { skills } = breakdown
36
37 return {
38 rows,
39 totalTokens: breakdown.totalTokens,
40 maxTokens: breakdown.rawMaxTokens,
41 percentage: breakdown.percentage,
42 model: breakdown.model,
43 autoCompactAt: breakdown.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null,
44 memoryFiles: breakdown.memoryFiles.map(({ type, path, tokens }) => ({ type, path, tokens })),
45 mcpServers: [...servers.values()].sort((a, b) => b.tokens - a.tokens),
46 skills: skills ? { listed: skills.includedSkills, total: skills.totalSkills, tokens: skills.tokens } : null,
47 agents: breakdown.agents.map(({ agentType, tokens }) => ({ name: agentType, tokens })),
48 }
49}
50
51/** The rows the bar draws: deferred schemas sit outside the window. */
52export function barRows(gauge: Gauge): GaugeRow[] {
53 return gauge.rows.filter(row => row.kind !== 'deferred')
54}
55
56/**
57 * Splits `width` cells among the rows in proportion to their tokens, the
58 * largest remainders rounding up, so the cells add up to `width` exactly.
59 * A used row too small for a cell still gets one, taken from the widest row,
60 * so every category that holds tokens shows on the bar.
61 */
62export function cells(rows: readonly GaugeRow[], width: number): number[] {
63 const total = rows.reduce((sum, row) => sum + row.tokens, 0)
64 if (total <= 0 || width <= 0) {
65 return rows.map(() => 0)
66 }
67
68 const exact = rows.map(row => (row.tokens / total) * width)
69 const out = exact.map(Math.floor)
70 const remainder = (i: number) => (exact[i] ?? 0) - (out[i] ?? 0)
71 const byRemainder = rows.map((_, i) => i).sort((a, b) => remainder(b) - remainder(a))
72 const left = width - out.reduce((sum, n) => sum + n, 0)
73 for (const i of byRemainder.slice(0, left)) {
74 out[i] = (out[i] ?? 0) + 1
75 }
76
77 for (const [i, row] of rows.entries()) {
78 if (row.kind !== 'used' || out[i] !== 0) {
79 continue
80 }
81 const widest = out.indexOf(Math.max(...out))
82 if ((out[widest] ?? 0) <= 1) {
83 break
84 }
85 out[widest] = (out[widest] ?? 0) - 1
86 out[i] = 1
87 }
88
89 return out
90}
91
92/** A token count in at most four characters: `850`, `3.1k`, `124k`, `1.0M`. */
93export function tokens(n: number): string {
94 if (n < 1_000) {
95 return String(Math.round(n))
96 }
97 if (n < 10_000) {
98 return `${(n / 1_000).toFixed(1)}k`
99 }
100 if (n < 1_000_000) {
101 return `${Math.round(n / 1_000)}k`
102 }
103
104 return `${(n / 1_000_000).toFixed(1)}M`
105}
106
107/** One line of the details. */
108export type Line =
109 | { kind: 'heading'; text: string }
110 | { kind: 'note'; text: string }
111 | { kind: 'blank' }
112 | {
113 kind: 'row'
114 glyph: string
115 color?: string
116 label: string
117 detail: string
118 tokens: string
119 share: string
120 isDim: boolean
121 }
122
123/** A details row's fixed columns, in cells: the glyph, the label, the tokens and the share. */
124export const COLUMNS = { glyph: 1, label: 24, tokens: 5, share: 6 } as const
125
126/**
127 * The details, line by line: the categories with their share of the
128 * window as /context lists them, then what the memory files, MCP servers,
129 * skills and custom agents each carry.
130 */
131export function details(gauge: Gauge): Line[] {
132 const share = (n: number) => `${((n / gauge.maxTokens) * 100).toFixed(1)}%`
133 const item = (label: string, detail: string, n: number, isDim = false): Line => ({
134 kind: 'row',
135 glyph: '',
136 label,
137 detail,
138 tokens: tokens(n),
139 share: '',
140 isDim,
141 })
142
143 const summary = [gauge.model, `${tokens(gauge.totalTokens)} of ${tokens(gauge.maxTokens)} (${gauge.percentage}%)`]
144 if (gauge.autoCompactAt !== null) {
145 summary.push(`auto-compacts at ${tokens(gauge.autoCompactAt)}`)
146 }
147
148 const lines: Line[] = [{ kind: 'note', text: summary.join(' · ') }, { kind: 'blank' }]
149 for (const row of gauge.rows) {
150 const isDeferred = row.kind === 'deferred'
151 lines.push({
152 kind: 'row',
153 glyph: GLYPH[row.kind],
154 color: row.color,
155 label: row.name,
156 detail: '',
157 tokens: tokens(row.tokens),
158 share: isDeferred ? '' : share(row.tokens),
159 isDim: row.kind !== 'used',
160 })
161 }
162
163 const section = (heading: string, rows: Line[]) => {
164 if (rows.length > 0) {
165 lines.push({ kind: 'blank' }, { kind: 'heading', text: heading }, ...rows)
166 }
167 }
168 section(
169 'Memory files',
170 gauge.memoryFiles.map(file => item(file.type, file.path, file.tokens)),
171 )
172 section(
173 'MCP tools',
174 gauge.mcpServers.map(server => {
175 const loaded = server.loaded < server.tools ? `, ${server.loaded} loaded` : ''
176 return item(server.name, `${server.tools} tools${loaded}`, server.tokens, server.loaded === 0)
177 }),
178 )
179 section(
180 'Custom agents',
181 gauge.agents.map(agent => item(agent.name, '', agent.tokens)),
182 )
183 if (gauge.skills) {
184 const { listed, total } = gauge.skills
185 section('Skills', [item(`${listed} of ${total} listed`, '', gauge.skills.tokens)])
186 }
187
188 lines.push({ kind: 'blank' }, { kind: 'note', text: 'Estimated locally; /context counts exactly.' })
189
190 return lines
191}
192
193/**
194 * The cells the details need: the widest of a row's fixed columns, the gaps
195 * between its five and its detail, and a note's or heading's text.
196 */
197export function detailsWidth(lines: readonly Line[]): number {
198 const fixed = COLUMNS.glyph + COLUMNS.label + COLUMNS.tokens + COLUMNS.share + 4
199
200 return Math.max(
201 0,
202 ...lines.map(line => {
203 switch (line.kind) {
204 case 'row':
205 return fixed + line.detail.length
206 case 'heading':
207 case 'note':
208 return line.text.length
209 case 'blank':
210 return 0
211 }
212 }),
213 )
214}
215types/index.d.ts 46 lines1/**
2 * One /context row: content that occupies the window (`used`), the window
3 * left (`free`), the compaction reserve (`buffer`), or tool schemas loaded on
4 * demand and outside the window (`deferred`).
5 */
6export type GaugeRow = {
7 name: string
8 tokens: number
9 /** The theme key /context draws the row in. */
10 color: string
11 kind: 'used' | 'free' | 'buffer' | 'deferred'
12}
13
14/** One MCP server's tool schemas, summed. */
15export type GaugeServer = {
16 name: string
17 tools: number
18 /** How many of its schemas are in the window rather than deferred. */
19 loaded: number
20 /** The loaded schemas' tokens. */
21 tokens: number
22}
23
24/** The context window broken down, as the band last measured it. */
25export type Gauge = {
26 rows: GaugeRow[]
27 totalTokens: number
28 /** The window measured against: the model's limit, or a smaller compaction window. */
29 maxTokens: number
30 /** `totalTokens` over `maxTokens`, a whole percentage. */
31 percentage: number
32 model: string
33 /** The token count auto-compaction runs at; null while it is off. */
34 autoCompactAt: number | null
35 memoryFiles: { type: string; path: string; tokens: number }[]
36 mcpServers: GaugeServer[]
37 skills: { listed: number; total: number; tokens: number } | null
38 agents: { name: string; tokens: number }[]
39}
40
41declare module 'claude-code' {
42 interface PluginState {
43 'context-gauge': { gauge: Gauge | null; isOpen: boolean }
44 }
45}
46