SLOPSHOPPER

context-gauge

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

newbandcommand
v0.2.0no licenseupdated 2026-10-08youngin-abel-kim/context-gauge
A shopper browsing a rack in a slop shop
README

context-gauge

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.

What it shows

  • The bar: one segment per category that occupies the window (system prompt, system tools, MCP tools, custom agents, memory files, skills, messages), then the free space ░ and the autocompact buffer ▒. A category that holds tokens always gets at least one cell.
  • The figures: the percentage and 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.
  • The details: run /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.

Install

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.

Layout

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

Release

  1. Edit hooks/.
  2. Check it: ``bash claude plugin validate . && claude plugin test . ``
  3. Bump version in .claude-plugin/plugin.json. Pushing without a bump doesn't update installed copies.
  4. Commit and push.
  5. Installed copies update via auto-update, or right away with: ``bash claude plugin marketplace update context-gauge && claude plugin update context-gauge@context-gauge ` then run /reload-plugins`.

Try local edits

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.

Source 3 files
hooks/register.tsx 140 lines
1import { 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}
140
hooks/gauge.ts 215 lines
1import 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}
215
types/index.d.ts 46 lines
1/**
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