The context window in one quiet row above the prompt: a mini bar coloured by category, the fill and the window, turning into an alert when compaction is near…

The context window in one quiet row above the Claude Code prompt, aligned to the right so it stays out of the way.
ctx ▕█████─────────────▏ 26% · 256k/1.0M
▲ context 85% · compacts in 117k · /jrcontext details
/jrcontext details opens the full view in a pane: header, the stacked bar across the width, and the legend with each category's tokens and share.◆ context 272k of 1.0M · compacts at 967k 27%
███████████████████────────────────────────────────────────────────▓▓
■ system prompt 2.2k 0.2% ■ system tools 21k 2.1% ■ messages 233k 23%
─ free space 695k █ autocompact buffer 33k
/context.| Command | |
|---|---|
/jrcontext | Hide the band above the prompt, or bring it back |
/jrcontext hide · show | Hide it or show it explicitly (off and on work too) |
/jrcontext details | Open the full bar, legend included, in a pane |
/plugin install jr-context --marketplace jrlprost/claude-code-mods
/config)| Option | Default | |
|---|---|---|
| Warn at (%) | 80 | Share of the compaction point from which the row becomes an alert |
Reads the session's context usage. Writes nothing, runs nothing, sends nothing over the network.
hooks/register.tsx 207 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrContextFill } from '../types'
5import { compactsAtOf, fillOf, isNearCompaction, miniBar, segmentColor, share, stackedBar } from './fill'
6import { gradientText } from './jr/draw'
7import { GRADIENTS, colorAt, formatTokens } from './jr/gradient'
8
9const fill = atom({ plugin: 'jr-context', key: 'fill' } as const, null)
10const bandHidden = atom({ plugin: 'jr-context', key: 'isBandHidden' } as const, false)
11const band = { isLoaded: false }
12
13/** `hide`/`off`, `show`/`on`, or a bare command, which toggles; anything else is not about the band. */
14const bandVerb = (args: string) => {
15 const arg = args.trim().toLowerCase()
16 if (arg === 'hide' || arg === 'off') return 'hide' as const
17 if (arg === 'show' || arg === 'on') return 'show' as const
18 return arg === '' ? ('toggle' as const) : null
19}
20
21/** Hidden or shown is one choice for every session, kept across restarts. */
22async function isBandHidden($: EngineInterface) {
23 if (!band.isLoaded) {
24 band.isLoaded = true
25 if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
26 }
27 return read($, bandHidden)
28}
29
30async function setBand($: EngineInterface, verb: 'hide' | 'show' | 'toggle') {
31 const hide = verb === 'toggle' ? !(await isBandHidden($)) : verb === 'hide'
32 await $.store.set('isBandHidden', hide)
33 await update($, bandHidden, () => hide)
34 return { text: hide ? 'jr context · hidden (/jrcontext to bring it back)' : 'jr context · shown' }
35}
36const PANE = 'jr-context'
37const REFRESH_EVERY_MS = 4000
38const last = { refreshedAt: 0, isScheduled: false }
39
40async function refresh($: EngineInterface, force = false) {
41 const now = await $.clock.now()
42 if (!force && now - last.refreshedAt < REFRESH_EVERY_MS) return
43 last.refreshedAt = now
44 const usage = await $.session.usage({ breakdown: 'summary' })
45 await update($, fill, () => fillOf(usage.context.breakdown))
46}
47
48type Elements = ReturnType<EngineInterface['ui']['resolve']>
49
50const AMBER = '#ff8c42'
51const RED = '#ef4565'
52const MINI_BAR_CELLS = 18
53
54/** The full view of the pane: the header, the stacked bar and the legend. */
55function drawContext(Box: Elements['Box'], Text: Elements['Text'], now: JrContextFill, width: number, warnAt: number) {
56 const heat = colorAt(GRADIENTS.heat, now.percent / 100)
57 const compactsAt = compactsAtOf(now)
58 const isWarning = isNearCompaction(now, warnAt)
59
60 const header = (
61 <Box justifyContent="space-between" width={width}>
62 <Box>
63 <Text color="#ff8c42">{'◆ '}</Text>
64 {gradientText(Text, 'context', GRADIENTS.brand)}
65 {isWarning && <Text color={heat}>{' compaction is near'}</Text>}
66 </Box>
67 <Box>
68 <Text dimColor>{`${formatTokens(now.total)} of ${formatTokens(now.max)} · compacts at ${formatTokens(compactsAt)} `}</Text>
69 <Text backgroundColor={heat} color="black" bold>{` ${now.percent}% `}</Text>
70 </Box>
71 </Box>
72 )
73
74 const bar = (
75 <Text>
76 {stackedBar(now, width).map(cell =>
77 cell.kind === 'free' ? <Text color={cell.color} dimColor>{cell.char}</Text> : <Text color={cell.color}>{cell.char}</Text>,
78 )}
79 </Text>
80 )
81
82 const legend = (
83 <Box flexWrap="wrap" columnGap={3} width={width}>
84 {now.used.map((segment, i) => (
85 <Text>
86 <Text color={segmentColor(i, now.used.length)}>{'■ '}</Text>
87 {`${segment.name} `}
88 <Text bold>{formatTokens(segment.tokens)}</Text>
89 <Text dimColor>{` ${share(segment.tokens, now.max)}`}</Text>
90 </Text>
91 ))}
92 <Text dimColor>{`─ free space ${formatTokens(Math.max(0, compactsAt - now.total))}`}</Text>
93 <Text>
94 <Text color="#3b3d4f">{'█ '}</Text>
95 <Text dimColor>{`autocompact buffer ${formatTokens(now.buffer)}`}</Text>
96 </Text>
97 </Box>
98 )
99
100 return (
101 <Box flexDirection="column">
102 {header}
103 {bar}
104 {legend}
105 </Box>
106 )
107}
108
109/**
110 * The band: one row. Quiet and right-aligned while there is room; once compaction is near,
111 * an alert on the left with what is left before it.
112 */
113function drawRow(Box: Elements['Box'], Text: Elements['Text'], now: JrContextFill, width: number, warnAt: number) {
114 if (isNearCompaction(now, warnAt)) {
115 const compactsAt = compactsAtOf(now)
116 const color = isNearCompaction(now, 95) ? RED : AMBER
117 return (
118 <Box key="jr-context-row" width={width}>
119 <Text wrap="truncate-end">
120 <Text color={color}>{`▲ context ${now.percent}%`}</Text>
121 <Text dimColor>{` · compacts in ${formatTokens(Math.max(0, compactsAt - now.total))} · /jrcontext details`}</Text>
122 </Text>
123 </Box>
124 )
125 }
126 return (
127 <Box key="jr-context-row" width={width} justifyContent="flex-end">
128 <Text wrap="truncate-end">
129 <Text dimColor>{'ctx ▕'}</Text>
130 {miniBar(now, MINI_BAR_CELLS).map(cell =>
131 cell.kind === 'free' ? <Text color={cell.color} dimColor>{cell.char}</Text> : <Text color={cell.color}>{cell.char}</Text>,
132 )}
133 <Text dimColor>{'▏ '}</Text>
134 <Text color={colorAt(GRADIENTS.heat, now.percent / 100)}>{`${now.percent}%`}</Text>
135 <Text dimColor>{` · ${formatTokens(now.total)}/${formatTokens(now.max)}`}</Text>
136 </Text>
137 </Box>
138 )
139}
140
141export const register: Register = (on, options) => {
142 const warnAt = Number(options.warnAt ?? 80)
143
144 on('session.start', async ($, e, next) => {
145 const started = await next(e)
146 await $.command.register({ name: 'jrcontext', description: 'Show or hide the context band; /jrcontext details opens the full bar' })
147 await refresh($, true)
148 return started
149 })
150
151 on('command.run', { command: 'jrcontext' }, async ($, e) => {
152 const verb = bandVerb(e.args)
153 if (verb) return setBand($, verb)
154 await refresh($, true)
155 await $.ui.open({ id: PANE, title: 'jr context', rows: 8 })
156 return { text: 'jr context opened.' }
157 })
158
159 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
160 const { Box, Text } = $.ui.resolve(e)
161 const now = await read($, fill)
162 if (!now) return <Text dimColor>Reading the context window…</Text>
163 return drawContext(Box, Text, now, Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80), warnAt)
164 })
165
166 // The window grows with every tool result; refreshed at most every few seconds.
167 on('tool.call', async ($, e, next) => {
168 const ran = await next(e)
169 await refresh($)
170 return ran
171 })
172
173 on('turn.complete', async ($, e, next) => {
174 const done = await next(e)
175 await refresh($, true)
176 return done
177 })
178
179 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
180 const below = await next(e)
181 // Never write state while drawing: the saved choice is loaded just after.
182 if (!band.isLoaded) {
183 $.clock.after(0, () => {
184 void isBandHidden($)
185 })
186 }
187 if (await read($, bandHidden)) return below
188 const now = await read($, fill)
189 // Installed or reloaded mid-session, the mod saw no session.start: read the figures once now.
190 if (!now && !last.isScheduled) {
191 last.isScheduled = true
192 $.clock.after(0, () => {
193 void refresh($, true)
194 })
195 }
196 if (!now || e.props.hasSurvey) return below
197
198 const { Box, Text } = $.ui.resolve(e)
199 return (
200 <Box flexDirection="column">
201 {drawRow(Box, Text, now, Math.max(20, e.props.bodyColumns), warnAt)}
202 {below}
203 </Box>
204 )
205 })
206}
207hooks/fill.ts 79 lines1import type { JrContextFill } from '../types'
2
3// One hue per category, in the order the engine lists them; messages usually come last.
4export const SEGMENT_COLORS = ['#7f5af0', '#3da9fc', '#c084fc', '#2cb67d', '#f4d35e', '#ff5ca8', '#4fd6be', '#ff8c42']
5
6export const segmentColor = (index: number, count: number) =>
7 // The last used row (the conversation itself) always takes the warm accent.
8 index === count - 1 ? '#ff8c42' : (SEGMENT_COLORS[index % (SEGMENT_COLORS.length - 1)] ?? '#7f5af0')
9
10type Breakdown = {
11 categories: readonly { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }[]
12 totalTokens: number
13 rawMaxTokens: number
14 percentage: number
15}
16
17/** The engine's /context breakdown, reduced to what the bar draws. */
18export const fillOf = (breakdown: Breakdown | undefined): JrContextFill | null => {
19 if (!breakdown || breakdown.rawMaxTokens <= 0) return null
20 return {
21 used: breakdown.categories.filter(c => c.kind === 'used' && c.tokens > 0).map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens })),
22 total: breakdown.totalTokens,
23 max: breakdown.rawMaxTokens,
24 buffer: breakdown.categories.find(c => c.kind === 'buffer')?.tokens ?? 0,
25 percent: breakdown.percentage,
26 }
27}
28
29export type BarCell = { char: string; color: string; kind: 'used' | 'free' | 'buffer' }
30
31/**
32 * The stacked bar, `width` cells: each used category gets its share (at least one
33 * cell when it holds anything), then the free space as a thin line, then the
34 * compaction buffer as a dim block at the end.
35 */
36export const stackedBar = (fill: JrContextFill, width: number): BarCell[] => {
37 const perCell = fill.max / width
38 const bufferCells = fill.buffer > 0 ? Math.max(1, Math.round(fill.buffer / perCell)) : 0
39 const cells: BarCell[] = []
40 fill.used.forEach((segment, i) => {
41 const n = Math.max(1, Math.round(segment.tokens / perCell))
42 const color = segmentColor(i, fill.used.length)
43 for (let k = 0; k < n && cells.length < width - bufferCells; k++) cells.push({ char: '█', color, kind: 'used' })
44 })
45 while (cells.length < width - bufferCells) cells.push({ char: '─', color: '#5c5f77', kind: 'free' })
46 while (cells.length < width) cells.push({ char: '█', color: '#3b3d4f', kind: 'buffer' })
47 return cells
48}
49
50export const share = (tokens: number, max: number) => {
51 const p = (tokens / max) * 100
52 if (p < 0.1) return '<0.1%'
53 return p < 10 ? `${p.toFixed(1)}%` : `${Math.round(p)}%`
54}
55
56/** Where compaction starts: the window less the buffer the engine keeps free. */
57export const compactsAtOf = (fill: JrContextFill) => fill.max - fill.buffer
58
59/** True once the fill (the engine's percentage) reaches `warnAt` percent of where compaction starts. */
60export const isNearCompaction = (fill: JrContextFill, warnAt: number) => (fill.percent / 100) * fill.max >= (compactsAtOf(fill) * warnAt) / 100
61
62/**
63 * The mini bar of the one-row band, `width` cells: the filled share coloured by category
64 * in proportion (a tiny category may get no cell), then the free space as a thin line.
65 */
66export const miniBar = (fill: JrContextFill, width: number): BarCell[] => {
67 const usedCells = Math.min(width, Math.max(fill.total > 0 ? 1 : 0, Math.round((fill.total / fill.max) * width)))
68 const cells: BarCell[] = []
69 let sum = 0
70 fill.used.forEach((segment, i) => {
71 sum += segment.tokens
72 const upTo = Math.min(usedCells, Math.round((sum / Math.max(1, fill.total)) * usedCells))
73 while (cells.length < upTo) cells.push({ char: '█', color: segmentColor(i, fill.used.length), kind: 'used' })
74 })
75 while (cells.length < usedCells) cells.push({ char: '█', color: '#ff8c42', kind: 'used' })
76 while (cells.length < width) cells.push({ char: '─', color: '#5c5f77', kind: 'free' })
77 return cells
78}
79hooks/jr/draw.tsx 35 lines1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10 <Text bold={bold}>
11 {paint(text, stops).map(({ char, color }) => (
12 <Text color={color}>{char}</Text>
13 ))}
14 </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19 <Text>
20 {bar(ratio, width, stops).map(cell =>
21 cell.empty ? (
22 <Text color={cell.color} dimColor>
23 {cell.char}
24 </Text>
25 ) : (
26 <Text color={cell.color}>{cell.char}</Text>
27 ),
28 )}
29 </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34 gradientText(Text, `${label} `, stops)
35hooks/jr/gradient.ts 75 lines1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7 const n = Number.parseInt(hex.replace('#', ''), 16)
8 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12 `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15 // The jr signature: violet to pink to amber.
16 brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17 // Calm to alarm, for gauges where full is bad.
18 heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19 // Cool, for neutral figures.
20 ice: ['#3da9fc', '#7f5af0'],
21 // Money and growth.
22 money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29 if (stops.length === 1) return stops[0]!
30 const clamped = Math.min(1, Math.max(0, t))
31 const span = clamped * (stops.length - 1)
32 const i = Math.min(stops.length - 2, Math.floor(span))
33 const a = hexToRgb(stops[i]!)
34 const b = hexToRgb(stops[i + 1]!)
35 const local = span - i
36 return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41 const chars = [...text]
42 return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53 const filled = Math.min(1, Math.max(0, ratio)) * width
54 const full = Math.floor(filled)
55 const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56 const cells: { char: string; color: string; empty?: true }[] = []
57 for (let i = 0; i < width; i++) {
58 const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59 if (i < full) cells.push({ char: '█', color })
60 else if (i === full && partial) cells.push({ char: partial, color })
61 else cells.push({ char: '·', color, empty: true })
62 }
63 return cells
64}
65
66export const formatTokens = (n: number) =>
67 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70 const minutes = Math.max(0, Math.round(ms / 60_000))
71 const h = Math.floor(minutes / 60)
72 const m = minutes % 60
73 return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75types/index.d.ts 20 lines1export type JrContextSegment = { name: string; tokens: number }
2
3export type JrContextFill = {
4 /** What occupies the window, in the order the engine lists it. */
5 used: JrContextSegment[]
6 /** Tokens in use. */
7 total: number
8 /** The window measured against. */
9 max: number
10 /** The compaction reserve at the end of the window. */
11 buffer: number
12 percent: number
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'jr-context': { fill: JrContextFill | null; isBandHidden: boolean }
18 }
19}
20