SLOPSHOPPER

context-band

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…

newband
A shopper browsing a rack in a slop shop
README

context-band

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.

Getting started

  1. In the Claude desktop app's Code tab or in a terminal session, run /plugin marketplace add mgstegmaier/claude-plugins. You only need this once.
  2. Run /plugin install context-band@mgstegmaier.
  3. Run /reload-plugins, or start a new session.
  4. Send any message. After Claude replies, the card appears above the prompt.

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:

  • The card draws in the desktop app and the terminal. VS Code and mobile keep their usual prompt.
  • The card fills in after Claude's first reply in a session.
  • Your organization may restrict mods through the allowManagedModsOnly managed setting. If it does, ask the Claude admins.

What it shows

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:

  • Blue slices are Claude Code's own: the system prompt, the built-in tools, and MCP tools.
  • Green slices are setup you control: agents, memory files (CLAUDE.md and rules), and skills.
  • Orange slices are the conversation. The mod splits it into your prompts, injected context (hook output, notices, and any re-sent copy of the skill listing or CLAUDE.md), Claude's replies, thinking, tool calls, and tool results. The tool results slice names the tool that produced the most, because that's usually where a session's context goes.

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.

Limits

  • The card draws only in the desktop app and the terminal, not in VS Code or on mobile.
  • The card refreshes after each turn, not while Claude is working.
  • Subagents' context isn't shown. Only the main conversation is.

Development

claude plugin validate plugins/context-band
claude plugin test plugins/context-band
Source 3 files
hooks/register.tsx 97 lines
1import { 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}
97
hooks/split.ts 228 lines
1import 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}
228
types/index.d.ts 17 lines
1export 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