SLOPSHOPPER

context-pane

Context window usage bar for the Claude Code prompt footer

panebandspinnercommand
v0.1.2no licenseupdated 2026-10-25koprjaa/claude-code-context-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-pane
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ Waiting for the first measurement… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /context-pane │ ⎿ context-pane: Context window pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context
Waiting for the first measurement…
README

claude-code-context-bar

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.

Footer bar with the breakdown open

What it does

  • Fills to the live figure, the same one the status line uses.
  • Colours the used part by category: messages, system tools, MCP, memory, skills, agents, system prompt.
  • Counts tool results in Messages, which the built-in breakdown leaves out (#94436).
  • Estimates locally, with no extra API calls, and refreshes after every turn.
  • Adds a /context-pane command that opens the breakdown as a side pane.

Install

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.

Use

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.

Source 2 files
hooks/register.tsx 230 lines
1import { 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}
230
types/index.d.ts 9 lines
1export 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