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

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
/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 gridpercent · used / window and turns bold from 80%+N more for the rest)context 32% aloneThe 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.
| Option | Default | |
|---|---|---|
legend | true | Show the legend row |
refreshSeconds | 5 | During a turn, re-measure after a tool call at most this often |
hooks/register.tsx 152 lines1import { 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}
152hooks/layout.ts 111 lines1// 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}
111types/index.d.ts 30 lines1export 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