Draws the context window as a stacked bar above the prompt, one colour per /context category. Toggle with /context-bar.

Draws your context window as a stacked bar above the prompt (illustrative output, not real figures):
█████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒
Context 42% · 419.0k/1.0M ■ System prompt 3.7k ■ System tools 34.6k ■ Messages 380k
/context uses. Used categories are solid, the autocompact buffer is shaded, and free space is dim./context-bar toggles it. It starts on in every session.
$.session.usage({ breakdown: 'summary' }), the same breakdown /context shows. summary is a local estimate and sends no token-count requests.AbovePrompt band, on top of whatever other band mods draw there. It steps aside while a survey is showing.hooks/register.tsx 156 lines1import { atom, read, update } from 'claude-code'
2import type { Hook, Register } from 'claude-code'
3
4import type { Slice } from '../types'
5
6type Api = Parameters<Hook<'turn.complete'>>[0]
7
8const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
9const isOn = atom({ plugin: 'context-bar', key: 'isOn' } as const, true)
10
11const GLYPH = { used: '█', buffer: '▒', free: '░' } as const
12
13// A usage read costs a local estimate, so a burst of tool calls shares one.
14const MIN_GAP_MS = 1500
15
16const fmt = (n: number) =>
17 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
18
19// Whole cells per slice that add up to `cells`: every used category that holds
20// tokens keeps at least one, and the rounding slack lands on the widest slice.
21function layout(slices: readonly Slice[], cells: number): number[] {
22 const total = slices.reduce((n, s) => n + s.tokens, 0) || 1
23 const widths = slices.map(s => {
24 const share = Math.round((s.tokens / total) * cells)
25
26 return s.kind === 'used' && s.tokens > 0 ? Math.max(1, share) : share
27 })
28 let slack = cells - widths.reduce((n, w) => n + w, 0)
29
30 while (slack !== 0) {
31 const step = slack > 0 ? 1 : -1
32 let pick = -1
33
34 widths.forEach((w, i) => {
35 const canShrink = step > 0 || w > 1
36 if (canShrink && (pick < 0 || w > widths[pick]!)) pick = i
37 })
38 if (pick < 0) break
39 widths[pick]! += step
40 slack -= step
41 }
42
43 return widths
44}
45
46let lastReadAt = 0
47
48async function refresh($: Api, force = false) {
49 const now = await $.clock.now()
50
51 if (!force && now - lastReadAt < MIN_GAP_MS) return
52 lastReadAt = now
53
54 try {
55 const usage = await $.session.usage({ breakdown: 'summary' })
56 const b = usage.context.breakdown
57
58 if (!b) return
59
60 await update($, snapshot, () => ({
61 window: b.rawMaxTokens,
62 total: b.totalTokens,
63 percent: b.percentage,
64 slices: b.categories
65 .filter(c => c.kind !== 'deferred' && c.tokens > 0)
66 .map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind as Slice['kind'] })),
67 }))
68 } catch {
69 // No session bound yet, or the read failed: keep the last bar.
70 }
71}
72
73export const register: Register = on => {
74 on('session.start', async ($, e, next) => {
75 await $.command.register({
76 name: 'context-bar',
77 description: 'Toggle the context window bar above the prompt',
78 })
79 await refresh($, true)
80
81 return next(e)
82 })
83
84 on('prompt.submit', async ($, e, next) => {
85 const result = await next(e)
86 await refresh($)
87
88 return result
89 })
90
91 on('tool.call', async ($, e, next) => {
92 const result = await next(e)
93 await refresh($)
94
95 return result
96 })
97
98 on('turn.complete', async ($, e, next) => {
99 await refresh($, true)
100
101 return next(e)
102 })
103
104 on('session.compact', async ($, e, next) => {
105 const result = await next(e)
106 await refresh($, true)
107
108 return result
109 })
110
111 on('command.run', { command: 'context-bar' }, async $ => {
112 const next = !(await read($, isOn))
113 await update($, isOn, () => next)
114
115 if (next) await refresh($, true)
116
117 return { text: `Context bar ${next ? 'on' : 'off'}.` }
118 })
119
120 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
121 // Whatever another band hook drew stays: each hook draws on top of what is beneath it.
122 const below = await next(e)
123 const snap = await read($, snapshot)
124
125 if (e.props.hasSurvey || snap === null || !(await read($, isOn))) {
126 return below
127 }
128
129 const { Box, Text } = $.ui.resolve(e)
130 const widths = layout(snap.slices, Math.max(10, e.props.bodyColumns))
131 const used = snap.slices.filter(s => s.kind === 'used')
132
133 return (
134 <Box flexDirection="column">
135 {below}
136 <Text>
137 {snap.slices.map((s, i) => (
138 <Text key={s.name} color={s.color} dimColor={s.kind !== 'used'}>
139 {GLYPH[s.kind].repeat(widths[i]!)}
140 </Text>
141 ))}
142 </Text>
143 <Text dimColor>
144 {`Context ${Math.round(snap.percent)}% · ${fmt(snap.total)}/${fmt(snap.window)} `}
145 {used.map(s => (
146 <Text key={s.name}>
147 <Text color={s.color}>■</Text>
148 {` ${s.name} ${fmt(s.tokens)} `}
149 </Text>
150 ))}
151 </Text>
152 </Box>
153 )
154 })
155}
156types/index.d.ts 23 lines1export type Slice = {
2 name: string
3 tokens: number
4 /** The theme key /context draws this category in. */
5 color: string
6 kind: 'used' | 'free' | 'buffer'
7}
8
9export type Snapshot = {
10 /** The window the breakdown is measured against, in tokens. */
11 window: number
12 /** Tokens in use, as /context counts them. */
13 total: number
14 percent: number
15 slices: Slice[]
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'context-bar': { snapshot: Snapshot | null; isOn: boolean }
21 }
22}
23