SLOPSHOPPER

context-bar

Context window as a stacked bar above the prompt, one colour per /context category; toggle with /context-bar

newbandguardcommand
v0.1.0no licenseupdated 2026-10-06bzatrok/ClaudeMods/context-bar
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: Context bar on. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
Source 3 files
hooks/register.tsx 139 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ContextBarSnapshot } from '../types'
5import {
6  barSegments,
7  formatTokens,
8  legendItems,
9  legendLines,
10  percentColor,
11  toSnapshot,
12  FREE,
13} from './layout'
14
15const COMMAND = 'context-bar'
16
17/** Per session on purpose: every session starts hidden, `/context-bar` shows it for that session only. */
18const isVisible = atom({ plugin: 'context-bar', key: 'isVisible' } as const, false)
19const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null as ContextBarSnapshot | null)
20
21/** `summary` estimates locally: no token-count requests, so it is free to run every turn. */
22async function refresh($: EngineInterface): Promise<void> {
23  const usage = await $.session.usage({ breakdown: 'summary' })
24  const breakdown = usage.context.breakdown
25  if (!breakdown) return
26  await update($, snapshot, () => toSnapshot(breakdown))
27}
28
29export const register: Register = on => {
30  on('session.start', async ($, e, next) => {
31    await $.command.register({
32      name: COMMAND,
33      description: 'Show the context window bar above the prompt (/context-bar off hides it)',
34    })
35
36    return next(e)
37  })
38
39  /** Bare `/context-bar` always shows (and refreshes) the bar; `/context-bar off` hides it. */
40  on('command.run', { command: COMMAND }, async ($, e) => {
41    const shown = e.args.trim().toLowerCase() !== 'off'
42    await update($, isVisible, () => shown)
43    if (shown) await refresh($)
44
45    return { text: shown ? 'Context bar on.' : 'Context bar off.' }
46  })
47
48  /** Keeps the bar moving during long agent runs instead of only at turn end. */
49  on('tool.call', async ($, e, next) => {
50    const result = await next(e)
51    if (await read($, isVisible)) await refresh($)
52
53    return result
54  })
55
56  on('turn.complete', async ($, e, next) => {
57    const result = await next(e)
58    if (await read($, isVisible)) await refresh($)
59
60    return result
61  })
62
63  on('session.compact', async ($, e, next) => {
64    const result = await next(e)
65    if (await read($, isVisible)) await refresh($)
66
67    return result
68  })
69
70  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
71    const shown = await read($, isVisible)
72    const snap = await read($, snapshot)
73    if (e.props.hasSurvey || !shown || snap === null) return next(e)
74
75    const { Box, Text } = $.ui.resolve(e)
76    // round border (2) + paddingX (2)
77    const inner = Math.max(10, e.props.bodyColumns - 4)
78    const percent = snap.max > 0 ? Math.round((snap.used / snap.max) * 100) : 0
79    const lines = legendLines(legendItems(snap), inner)
80
81    // other mods (tldr) draw in the same band: stack above them, never replace them
82    const rest = await next(e)
83
84    return (
85      <Box flexDirection="column">
86        <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
87          <Box justifyContent="space-between">
88            <Text>
89              <Text color="yellow">◆ </Text>
90              <Text bold>context</Text>
91            </Text>
92            <Text>
93              <Text bold>{formatTokens(snap.used)}</Text>
94              <Text dimColor> of {formatTokens(snap.max)}</Text>
95              {snap.compactAt !== null && (
96                <Text dimColor> · compacts at {formatTokens(snap.compactAt)}</Text>
97              )}
98              <Text> </Text>
99              <Text color="black" backgroundColor={percentColor(percent)}>
100                {' '}
101                {percent}%{' '}
102              </Text>
103            </Text>
104          </Box>
105          <Text wrap="truncate">
106            {barSegments(snap, inner).map(seg =>
107              seg.isMarker ? (
108                <Text color="yellow">{seg.text}</Text>
109              ) : seg.color === null ? (
110                <Text dimColor>{seg.text}</Text>
111              ) : (
112                <Text color={seg.color}>{seg.text}</Text>
113              ),
114            )}
115          </Text>
116          {lines.map(line => (
117            <Text wrap="truncate">
118              {line.map(item => (
119                <Text>
120                  {item.color === null ? (
121                    <Text dimColor>{FREE} </Text>
122                  ) : (
123                    <Text color={item.color}>▌ </Text>
124                  )}
125                  <Text>{item.name} </Text>
126                  <Text>{item.tokens}</Text>
127                  {item.share !== '' && <Text dimColor> {item.share}</Text>}
128                  <Text>{'  '}</Text>
129                </Text>
130              ))}
131            </Text>
132          ))}
133        </Box>
134        {rest}
135      </Box>
136    )
137  })
138}
139
hooks/layout.ts 150 lines
1import type { SessionContextBreakdown } from 'claude-code'
2
3import type { ContextBarRow, ContextBarSnapshot } from '../types'
4
5/** One run of identical cells in the bar. `color` null is free space. */
6export type BarSegment = { color: string | null; text: string; isMarker: boolean }
7
8const FILLED = '█'
9export const FREE = '░'
10const MARKER = '▏'
11
12/**
13 * Our own palette rather than /context's theme colours: several of those are greys
14 * (system prompt, system tools) that read as one block beside each other.
15 * Known categories keep a fixed colour; any other takes the next unused one.
16 */
17const CATEGORY_COLORS: Record<string, string> = {
18  'system prompt': '#7aa2f7',
19  'system tools': '#2ac3de',
20  'mcp tools': '#bb9af7',
21  'mcp server instructions': '#9d7cd8',
22  'custom agents': '#9ece6a',
23  'memory files': '#e0af68',
24  skills: '#f7768e',
25  messages: '#ff9e64',
26}
27const SPARE_COLORS = ['#73daca', '#b4f9f8', '#c0caf5', '#db4b4b', '#41a6b5', '#ff007c']
28
29export function categoryColors(names: string[]): string[] {
30  const taken = new Set(names.map(n => CATEGORY_COLORS[n]).filter(Boolean))
31  const spare = SPARE_COLORS.filter(c => !taken.has(c))
32  return names.map(n => CATEGORY_COLORS[n] ?? spare.shift() ?? '#c0caf5')
33}
34
35export function toSnapshot(breakdown: SessionContextBreakdown): ContextBarSnapshot {
36  const used = breakdown.categories.filter(c => c.kind === 'used' && c.tokens > 0)
37  const names = used.map(c => c.name.toLowerCase())
38  const colors = categoryColors(names)
39  const rows: ContextBarRow[] = used.map((c, i) => ({
40    name: names[i] ?? c.name,
41    tokens: c.tokens,
42    color: colors[i] ?? c.color,
43  }))
44
45  return {
46    used: breakdown.totalTokens,
47    max: breakdown.rawMaxTokens,
48    compactAt: breakdown.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null,
49    rows,
50  }
51}
52
53/** 512, 3.4k, 212k, 1M, 1.2M — the way /context prints counts. */
54/** Exact up to five digits; from 100,000 one fixed decimal, so the width stays steady. */
55export function formatTokens(n: number): string {
56  if (n < 100_000) return String(Math.round(n))
57  if (n < 999_950) return `${(n / 1000).toFixed(1)}k`
58  return `${(n / 1_000_000).toFixed(1)}M`
59}
60
61/** Under 1% keeps one decimal, so small categories do not all read 0%. */
62export function formatShare(tokens: number, max: number): string {
63  if (max <= 0) return '0%'
64  const pct = (tokens / max) * 100
65  // 0.97% rounds to "1.0%" with one decimal; from 0.95% up it reads as a whole percent.
66  return pct < 0.95 ? `${pct.toFixed(1)}%` : `${Math.round(pct)}%`
67}
68
69export function percentColor(percent: number): string {
70  if (percent >= 80) return 'red'
71  if (percent >= 50) return 'yellow'
72  return 'green'
73}
74
75/**
76 * Lays the snapshot out over `width` cells: each used category gets its share,
77 * at least one cell so a small one still shows; the rest is free space, with the
78 * compaction threshold drawn as a marker.
79 */
80export function barSegments(snapshot: ContextBarSnapshot, width: number): BarSegment[] {
81  if (width <= 0 || snapshot.max <= 0) return []
82
83  const cells: (string | null)[] = []
84  for (const row of snapshot.rows) {
85    const share = Math.max(1, Math.round((row.tokens / snapshot.max) * width))
86    for (let i = 0; i < share && cells.length < width; i++) cells.push(row.color)
87  }
88  while (cells.length < width) cells.push(null)
89
90  const markerAt =
91    snapshot.compactAt === null
92      ? -1
93      : Math.min(width - 1, Math.round((snapshot.compactAt / snapshot.max) * width))
94
95  const segments: BarSegment[] = []
96  cells.forEach((color, i) => {
97    const isMarker = i === markerAt
98    const char = isMarker ? MARKER : color === null ? FREE : FILLED
99    const last = segments[segments.length - 1]
100    if (last && last.color === color && last.isMarker === isMarker) {
101      last.text += char
102    } else {
103      segments.push({ color, text: char, isMarker })
104    }
105  })
106
107  return segments
108}
109
110/** One legend entry: `▌ name 3.4k 0.3%`; free space carries no share. */
111export type LegendItem = { color: string | null; name: string; tokens: string; share: string }
112
113export function legendItems(snapshot: ContextBarSnapshot): LegendItem[] {
114  const items: LegendItem[] = snapshot.rows.map(row => ({
115    color: row.color,
116    name: row.name,
117    tokens: formatTokens(row.tokens),
118    share: formatShare(row.tokens, snapshot.max),
119  }))
120  const free = Math.max(0, snapshot.max - snapshot.used)
121  items.push({ color: null, name: 'free', tokens: formatTokens(free), share: '' })
122
123  return items
124}
125
126export function legendItemWidth(item: LegendItem): number {
127  // "▌ " + name + " " + tokens + (" " + share) + two-cell gap
128  return 2 + item.name.length + 1 + item.tokens.length + (item.share ? 1 + item.share.length : 0) + 2
129}
130
131/** Packs legend items into lines no wider than `width`. */
132export function legendLines(items: LegendItem[], width: number): LegendItem[][] {
133  const lines: LegendItem[][] = []
134  let line: LegendItem[] = []
135  let used = 0
136  for (const item of items) {
137    const w = legendItemWidth(item)
138    if (line.length > 0 && used + w > width) {
139      lines.push(line)
140      line = []
141      used = 0
142    }
143    line.push(item)
144    used += w
145  }
146  if (line.length > 0) lines.push(line)
147
148  return lines
149}
150
types/index.d.ts 17 lines
1/** One used category of the window, as /context lists it. */
2export type ContextBarRow = { name: string; tokens: number; color: string }
3
4/** The window at the end of the last turn, measured against the compaction window. */
5export type ContextBarSnapshot = {
6  used: number
7  max: number
8  compactAt: number | null
9  rows: ContextBarRow[]
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'context-bar': { isVisible: boolean; snapshot: ContextBarSnapshot | null }
15  }
16}
17