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

hooks/register.tsx 139 lines1import { 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}
139hooks/layout.ts 150 lines1import 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}
150types/index.d.ts 17 lines1/** 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