SLOPSHOPPER

context-bar

Shows the context window as a stacked bar above the prompt, one colour per /context category; /context-bar toggles it

newbandguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-bar
› fix the failing auth test and add an audit log call ⏺ 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-bar ⎿ context-bar: Hidden. /context-bar shows it again. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-bar

Shows the context window as a stacked bar above the prompt, one colour per /context category, so you see where the window goes without running /context.

32% · 317k / 1M ███▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
■ Messages 270k  ■ System tools 37k  ■ Skills 6.6k  ■ System prompt 2.2k  ■ Autocompact buffer 33k  ■ Free space 650k
/plugin install context-bar@claude-mods
  • Each segment is a /context category in the colour /context draws it in: used space █, the autocompact buffer ▒, free space ░; deferred tool schemas are left out, as /context leaves them out of the grid
  • The summary reads percent · used / window and turns bold from 80%
  • The legend lists used categories largest first, then the buffer and free space, as many as fit the width (+N more for the rest)
  • In a narrow terminal the band shows context 32% alone
  • It yields to surveys and is not drawn over a subagent's transcript

The numbers are the local estimate /context makes ($.session.usage({ breakdown: 'summary' })): no token-count requests. They refresh when the session starts, after every turn, after compaction and, during a long turn, after tool calls at most once per refreshSeconds.

/context-bar

/context-bar toggles the band, /context-bar on and /context-bar off set it. The choice is remembered across sessions.

Options

OptionDefault
legendtrueShow the legend row
refreshSeconds5During a turn, re-measure after a tool call at most this often
Source 3 files
hooks/register.tsx 152 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ContextSnapshot } from '../types'
5import { GLYPH, allocate, legend, summary } from './layout'
6
7const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
8const isHidden = atom({ plugin: 'context-bar', key: 'isHidden' } as const, false)
9
10/** The person's /context-bar choice, kept across sessions. */
11const HIDDEN_KEY = 'isHidden'
12
13/** From this share of the window the summary is drawn bold. */
14const HIGH_PERCENT = 80
15
16/** Cells kept free at the right end, so the row never reaches the band edge and wraps. */
17const MARGIN = 2
18
19/** Below this many cells for the bar, the band shows the summary alone. */
20const MIN_BAR = 10
21
22let measuredAt = 0
23
24// The summary breakdown is estimated locally, the same rows /context lists,
25// without a token-count request.
26async function measure($: EngineInterface) {
27  const usage = await $.session.usage({ breakdown: 'summary' })
28  const breakdown = usage.context.breakdown
29  if (!breakdown) return
30
31  measuredAt = await $.clock.now()
32  const next: ContextSnapshot = {
33    categories: breakdown.categories.map(({ name, tokens, color, kind }) => ({ name, tokens, color, kind })),
34    usedTokens: breakdown.totalTokens,
35    maxTokens: breakdown.rawMaxTokens,
36    percent: breakdown.percentage,
37    at: measuredAt,
38  }
39  await update($, snapshot, () => next)
40}
41
42async function measureIfStale($: EngineInterface, refreshMs: number) {
43  if ((await $.clock.now()) - measuredAt >= refreshMs) await measure($)
44}
45
46export const register: Register = (on, options) => {
47  const isLegendShown = options.legend !== false
48  const refreshMs = Math.max(1, Number(options.refreshSeconds ?? 5)) * 1000
49
50  on('session.start', async ($, e, next) => {
51    await $.command.register({
52      name: 'context-bar',
53      description: 'Show or hide the context window bar above the prompt',
54      argumentHint: '[on|off]',
55    })
56    const stored = await $.store.get(HIDDEN_KEY)
57    if (stored === true) await update($, isHidden, () => true)
58
59    const started = await next(e)
60    await measure($)
61
62    return started
63  })
64
65  on('turn.complete', async ($, e, next) => {
66    const done = await next(e)
67    await measure($)
68
69    return done
70  })
71
72  on('session.compact', async ($, e, next) => {
73    const compacted = await next(e)
74    await measure($)
75
76    return compacted
77  })
78
79  // Tool results are what grows a long turn: re-measure after them, at most
80  // once per refreshSeconds.
81  on('tool.call', async ($, e, next) => {
82    const ran = await next(e)
83    await measureIfStale($, refreshMs)
84
85    return ran
86  })
87
88  on('command.run', { command: 'context-bar' }, async ($, e) => {
89    const arg = e.args.trim().toLowerCase()
90    const wasHidden = await read($, isHidden)
91    const hide = arg === 'off' ? true : arg === 'on' ? false : arg === '' ? !wasHidden : undefined
92    if (hide === undefined) return { text: 'Usage: /context-bar [on|off]' }
93
94    await update($, isHidden, () => hide)
95    await $.store.set(HIDDEN_KEY, hide)
96    if (!hide) await measure($)
97
98    return { text: hide ? 'Hidden. /context-bar shows it again.' : 'Shown.' }
99  })
100
101  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
102    // The breakdown is the main conversation's: not over a subagent's transcript
103    if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
104    if (await read($, isHidden)) return next(e)
105    const snap = await read($, snapshot)
106    if (snap === null) return next(e)
107
108    const { Box, Text } = $.ui.resolve(e)
109    const width = e.props.bodyColumns
110    const label = summary(snap)
111    const isHigh = snap.percent >= HIGH_PERCENT
112    const barWidth = width - label.length - 1 - MARGIN
113
114    if (barWidth < MIN_BAR) {
115      return (
116        <Box>
117          <Text dimColor={!isHigh} bold={isHigh}>
118            context {label}
119          </Text>
120        </Box>
121      )
122    }
123
124    const items = isLegendShown ? legend(snap, width) : []
125
126    return (
127      <Box flexDirection="column">
128        {/* The summary leads, so a squeezed row cuts the end of the bar, never the numbers */}
129        <Box>
130          <Text dimColor={!isHigh} bold={isHigh}>
131            {label}{' '}
132          </Text>
133          {allocate(snap, barWidth).map(segment => (
134            <Text color={segment.color} dimColor={segment.kind !== 'used'}>
135              {GLYPH[segment.kind].repeat(segment.cells)}
136            </Text>
137          ))}
138        </Box>
139        {items.length > 0 && (
140          <Box>
141            {items.flatMap((item, i) => [
142              ...(i > 0 ? [<Text>{'  '}</Text>] : []),
143              ...(item.name === '' ? [] : [<Text color={item.color}>■ </Text>]),
144              <Text dimColor>{item.label}</Text>,
145            ])}
146          </Box>
147        )}
148      </Box>
149    )
150  })
151}
152
hooks/layout.ts 111 lines
1// The bar's layout as pure functions: no `$`, so tests call them directly.
2
3import type { ContextSnapshot, SnapshotCategory } from '../types'
4
5export type Segment = {
6  name: string
7  color: string
8  kind: SnapshotCategory['kind']
9  cells: number
10}
11
12export type LegendItem = { name: string; color: string; kind: SnapshotCategory['kind']; label: string }
13
14/** The glyph a segment is drawn with: used space solid, the buffer shaded, free space light. */
15export const GLYPH: Record<SnapshotCategory['kind'], string> = {
16  used: '█',
17  buffer: '▒',
18  free: '░',
19  deferred: '',
20}
21
22/** `950`, `12.3k`, `124k`, `1.2M`: short enough for a band. */
23export function formatTokens(tokens: number): string {
24  if (tokens < 1000) return String(Math.round(tokens))
25  if (tokens < 10_000) return `${(tokens / 1000).toFixed(1).replace(/\.0$/, '')}k`
26  if (tokens < 1_000_000) return `${Math.round(tokens / 1000)}k`
27  return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
28}
29
30/** The categories the bar draws: deferred tool schemas are not in the window, empty rows take no room. */
31export const drawn = (snapshot: ContextSnapshot) =>
32  snapshot.categories.filter(category => category.kind !== 'deferred' && category.tokens > 0)
33
34/**
35 * Splits `width` cells between the categories in proportion to their tokens,
36 * by largest remainder so the cells add up to `width` exactly. Every used
37 * category keeps at least one cell while there is room, taken from the
38 * largest segment, so a small one does not vanish.
39 */
40export function allocate(snapshot: ContextSnapshot, width: number): Segment[] {
41  const categories = drawn(snapshot)
42  const total = categories.reduce((sum, category) => sum + category.tokens, 0)
43  if (width <= 0 || total <= 0) return []
44
45  const exact = categories.map(category => (category.tokens / total) * width)
46  const cells = exact.map(Math.floor)
47  let left = width - cells.reduce((sum, n) => sum + n, 0)
48  const byRemainder = exact.map((value, i) => ({ i, rest: value - Math.floor(value) })).sort((a, b) => b.rest - a.rest)
49  for (const { i } of byRemainder) {
50    if (left <= 0) break
51    cells[i]! += 1
52    left -= 1
53  }
54
55  categories.forEach((category, i) => {
56    if (category.kind !== 'used' || cells[i]! > 0) return
57    const largest = cells.indexOf(Math.max(...cells))
58    if (cells[largest]! > 1) {
59      cells[largest]! -= 1
60      cells[i] = 1
61    }
62  })
63
64  return categories
65    .map((category, i) => ({ name: category.name, color: category.color, kind: category.kind, cells: cells[i]! }))
66    .filter(segment => segment.cells > 0)
67}
68
69/** `62% · 124k / 200k` */
70export function summary(snapshot: ContextSnapshot): string {
71  return `${snapshot.percent}% · ${formatTokens(snapshot.usedTokens)} / ${formatTokens(snapshot.maxTokens)}`
72}
73
74/** Legend order: what takes the space first, then the buffer, free space last. */
75const KIND_ORDER: Record<SnapshotCategory['kind'], number> = { used: 0, buffer: 1, free: 2, deferred: 3 }
76
77/**
78 * The legend's items, used categories largest first, then the buffer and free
79 * space, as many as fit in `width` cells (`■ name 12k` and two spaces between
80 * items); the rest are counted in a final `+N more` when that fits.
81 */
82export function legend(snapshot: ContextSnapshot, width: number): LegendItem[] {
83  const items = drawn(snapshot)
84    .slice()
85    .sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || b.tokens - a.tokens)
86    .map(category => ({
87      name: category.name,
88      color: category.color,
89      kind: category.kind,
90      label: `${category.name} ${formatTokens(category.tokens)}`,
91    }))
92
93  const fitted: LegendItem[] = []
94  let used = 0
95  for (const [i, item] of items.entries()) {
96    const gap = fitted.length > 0 ? 2 : 0
97    const after = items.length - i - 1
98    const reserve = after > 0 ? 2 + `+${after} more`.length : 0
99    if (used + gap + 2 + item.label.length + reserve <= width) {
100      fitted.push(item)
101      used += gap + 2 + item.label.length
102      continue
103    }
104    const more = `+${items.length - i} more`
105    if (used + gap + more.length <= width) fitted.push({ name: '', color: '', kind: 'free', label: more })
106    break
107  }
108
109  return fitted
110}
111
types/index.d.ts 30 lines
1export type SnapshotCategory = {
2  /** The row's label as /context prints it. */
3  name: string
4  tokens: number
5  /** The theme colour /context draws the row in, by its key in the theme. */
6  color: string
7  kind: 'used' | 'free' | 'buffer' | 'deferred'
8}
9
10export type ContextSnapshot = {
11  categories: SnapshotCategory[]
12  /** Tokens in use, as /context counts them. */
13  usedTokens: number
14  /** The window measured against (the compaction window). */
15  maxTokens: number
16  /** usedTokens over maxTokens, 0 to 100. */
17  percent: number
18  /** When it was measured, in ms since the epoch. */
19  at: number
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    'context-bar': {
25      snapshot: ContextSnapshot | null
26      isHidden: boolean
27    }
28  }
29}
30