Live, colour-segmented context window bar above the prompt, split into ACTIVE and PASSIVE halves with an auto-compact marker.

A live view of the context window in the band above the prompt. The bar is split into colour-coded segments by category (system prompt, tools, memory files, skills, agents, messages, free space, the auto-compact buffer), using the same colours as /context. A divider at 50% separates the ACTIVE half from the PASSIVE half, and a second mark shows where auto-compaction will run.
◆ context 142k / 200k · 71% · passive split estimated
████████████████████████████████████┃██████████████░░░░░░░░░╎▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
ACTIVE ┃ PASSIVE ▲ auto-compact
█ System prompt 3k █ System tools 12k █ Memory files 2k █ Messages 125k
░ Free space 25k ▒ Autocompact buffer 33k
142k / 200k · 71%) is exact: it comes from the input tokens of the last API response. It turns green, then yellow at 50%, then red at 80%.$.session.usage({ breakdown: 'summary' }), which is computed locally and makes no network request.~17k / 200k · estimate, no response yet)./clear./compact, an auto-compaction or /clear.ctx 142k / 200k · 71% · passive). Short bands drop the legend first, then the label row.| Surface | Band above the prompt | /context-bar card | Status line |
|---|---|---|---|
| Terminal | Yes | Yes | ctx 71% while the band is hidden |
| Desktop (Code tab) | Yes | Yes | ctx 71% while the band is hidden |
| Claude mobile app | No, the app does not raise it | Yes, inline in the transcript | 🟡 ctx 71% · passive whenever a phone is attached |
| VS Code | No | Yes | Same as mobile |
/context-bar draws a compact card in its output row: the headline, the segmented bar sized to the transcript's width (with the 50% divider and auto-compact mark), and the four largest categories with their share. It is the way to see the full picture on a phone.◆ context 142k / 200k · 71% · passive
██████████████████████████┃████████░░░░░╎▒▒▒▒▒▒▒▒▒▒
ACTIVE ┃ PASSIVE ▲ auto-compact
■ Messages 125k · 63%
■ System tools 12k · 6%
■ System prompt 3k · 2%
■ Memory files 2k · 1%
Category split is estimated.
/plugin install context-bar --marketplace mrjk05/modemon
Answer y to add the marketplace, then pick a scope.
| Command | What it does |
|---|---|
/context-bar or /context-bar show | Draws the context card inline (every surface, mobile included). The model reads a one-line summary of it. |
/context-bar on / /context-bar off | Shows or hides the band above the prompt (terminal and desktop). |
/context-bar toggle | Flips the band. |
When the band is hidden, the status line under the prompt shows ctx 71% instead.
Changed in this version: a bare /context-bar used to toggle the band. It now shows the card; use toggle for the old behaviour.
Set these from the /config menu, or under pluginConfigs["context-bar"].options in settings.
| Field | Default | Meaning |
|---|---|---|
legend | true | Show the per-category legend under the bar. |
warnAtHalf | true | Show a toast once per session when usage passes 50%. |
statusLine | auto | When to pin ctx 71% under the prompt. auto pins it while the band is hidden or a surface without the band is attached (mobile, VS Code: 🟡 ctx 71% · passive), always pins it all the time, off never pins it. |
summary breakdown), so the segments may not add up exactly to the headline number.▲./context-bar card stand in for it. The card is drawn with coloured text blocks, not an SVG, so it looks the same on every surface; how evenly the blocks line up depends on the app's font.hooks/register.tsx 291 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderNode } from 'claude-code'
3
4import {
5 WARNING,
6 barCells,
7 cardText,
8 formatTokens,
9 headlineRuns,
10 labelCells,
11 legendItems,
12 packLegend,
13 parseCommand,
14 richStatusText,
15 shareOf,
16 shouldWarn,
17 statusText,
18 toRuns,
19 toSnapshot,
20 topCategories,
21} from './lib'
22import type { Run } from './lib'
23
24const COMMAND = 'context-bar'
25const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
26const isHidden = atom({ plugin: 'context-bar', key: 'isHidden' } as const, false)
27const hasWarned = atom({ plugin: 'context-bar', key: 'hasWarned' } as const, false)
28
29/** A Text's style props from a run, leaving out the ones it does not set. */
30function style(run: Run): { color?: string; bold?: boolean; dimColor?: boolean } {
31 return {
32 ...(run.color !== undefined && { color: run.color }),
33 ...(run.bold && { bold: true }),
34 ...(run.dim && { dimColor: true }),
35 }
36}
37
38/**
39 * Whether a tree from beneath draws nothing: absent, empty strings and empty
40 * Boxes or Texts, or the engine's own band while no survey holds it (the
41 * band draws only when none does).
42 */
43function drawsNothing(node: RenderNode | undefined): boolean {
44 if (node === undefined) return true
45 if (typeof node === 'string') return node === ''
46 if (node.type === 'engine') return true
47 if (node.type === 'Box' || node.type === 'Text') return (node.children ?? []).every(drawsNothing)
48
49 return false
50}
51
52type Settings = { warnAtHalf: boolean; statusMode: string }
53
54/** Surfaces that never raise the band: mobile, VS Code. */
55function isBandless(surface: string): boolean {
56 return surface !== 'terminal' && surface !== 'desktop'
57}
58
59/**
60 * Pins the status entry when the band cannot be seen: hidden, or a surface
61 * without it attached (mobile), where it reads `🟡 ctx 71% · passive`.
62 */
63async function syncStatus($: EngineInterface, settings: Settings): Promise<void> {
64 if (settings.statusMode === 'off') return $.ui.status(undefined)
65 const bandless = (await $.session.surfaces()).some(isBandless)
66 const show = settings.statusMode === 'always' || bandless || (await read($, isHidden))
67 const s = await read($, snapshot)
68 $.ui.status(show ? (bandless ? richStatusText(s) : statusText(s)) : undefined)
69}
70
71/** Re-measures the window (the free, local `summary` breakdown) and warns once past 50%. */
72async function refresh($: EngineInterface, settings: Settings): Promise<void> {
73 try {
74 const { context } = await $.session.usage({ breakdown: 'summary' })
75 const measured = toSnapshot(context)
76 await update($, snapshot, () => measured)
77
78 let fire = false
79 await update($, hasWarned, warned => {
80 fire = settings.warnAtHalf && shouldWarn(measured.percent, warned)
81 return warned || fire
82 })
83 if (fire) $.ui.toast(WARNING, { timeoutMs: 8000 })
84
85 await syncStatus($, settings)
86 } catch {
87 // A failed measurement leaves the last drawing in place.
88 }
89}
90
91/** A fresh conversation: forget the old figures and re-arm the warning. */
92async function rearm($: EngineInterface): Promise<void> {
93 await update($, snapshot, () => null)
94 await update($, hasWarned, () => false)
95}
96
97export const register: Register = (on, options) => {
98 const showLegend = options.legend !== false
99 const settings: Settings = {
100 warnAtHalf: options.warnAtHalf !== false,
101 statusMode: typeof options.statusLine === 'string' ? options.statusLine : 'auto',
102 }
103
104 on('session.start', async ($, e, next) => {
105 await $.command.register({
106 name: COMMAND,
107 description: 'Show a context window card; on|off shows or hides the bar above the prompt',
108 argumentHint: '[show|on|off|toggle]',
109 })
110 await refresh($, settings)
111
112 return next(e)
113 })
114
115 on('session.measure', async ($, e, next) => {
116 const result = await next(e)
117 if (e.changed.includes('context')) await refresh($, settings)
118
119 return result
120 })
121
122 on('session.compact', async ($, e, next) => {
123 const result = await next(e)
124 const isMainCompaction = e.agentId === undefined && e.trigger !== 'precompute'
125 if (isMainCompaction && result.skip === undefined) {
126 await update($, hasWarned, () => false)
127 await refresh($, settings)
128 }
129
130 return result
131 }).catch(($, e, next) => next(e))
132
133 on('session.end', async ($, e, next) => {
134 if (e.reason === 'clear') await rearm($)
135
136 return next(e)
137 })
138
139 // A phone (or another bandless surface) joining or leaving moves the status entry.
140 on('session.attach', async ($, e, next) => {
141 const result = await next(e)
142 await syncStatus($, settings).catch(() => undefined)
143
144 return result
145 })
146
147 on('session.detach', async ($, e, next) => {
148 const result = await next(e)
149 if (e.reason === 'detach') await syncStatus($, settings).catch(() => undefined)
150
151 return result
152 })
153
154 // A /clear raises no session.start; the settings hook event carries it instead.
155 on('classic.SessionStart', async ($, e, next) => {
156 const result = await next(e)
157 if (e.source === 'clear') {
158 await rearm($)
159 await refresh($, settings)
160 }
161
162 return result
163 }).catch(($, e, next) => next(e))
164
165 on('command.run', { command: COMMAND }, async ($, e) => {
166 const action = parseCommand(e.args)
167 if (action === 'show') {
168 await refresh($, settings)
169
170 return { text: cardText(await read($, snapshot)) }
171 }
172 if (action === 'unknown') return { text: 'Usage: /context-bar [show|on|off|toggle]' }
173
174 const hidden = await update($, isHidden, was => (action === 'on' ? false : action === 'off' ? true : !was))
175 await syncStatus($, settings)
176
177 return { text: hidden ? 'Context bar hidden.' : 'Context bar shown.' }
178 })
179
180 // `/context-bar` (or `show`) draws a compact card in its output row, on every surface.
181 on('ui.render', { component: 'CommandOutput', props: { command: COMMAND } }, async ($, e, next) => {
182 const s = await read($, snapshot)
183 if (e.props.isErrored || parseCommand(e.props.args) !== 'show' || s === null) return next(e)
184
185 const { Box, Text } = $.ui.resolve(e)
186 const width = Math.min(100, Math.max(16, (e.viewport?.columns ?? 60) - 2))
187 const row = (runs: Run[]) => (
188 <Box flexDirection="row">
189 {runs.map(run => (
190 <Text wrap="truncate-end" {...style(run)}>
191 {run.text}
192 </Text>
193 ))}
194 </Box>
195 )
196
197 return (
198 <Box flexDirection="column">
199 <Box flexDirection="row" flexWrap="wrap">
200 <Text color="claude" bold>
201 ◆ context{' '}
202 </Text>
203 {headlineRuns(s).map(run => (
204 <Text {...style(run)}>{run.text}</Text>
205 ))}
206 </Box>
207 {row(toRuns(barCells(s, width)))}
208 {width >= 24 && row(toRuns(labelCells(s, width)))}
209 {topCategories(s, 4).map(seg => (
210 <Text wrap="truncate-end">
211 <Text color={seg.color}>■</Text>
212 <Text> {seg.name} </Text>
213 <Text bold>{formatTokens(seg.tokens)}</Text>
214 <Text dimColor> · {shareOf(s, seg.tokens)}%</Text>
215 </Text>
216 ))}
217 {s.segments.length > 0 && <Text dimColor>Category split is estimated.</Text>}
218 </Box>
219 )
220 })
221
222 // The band is one instance for every plugin: draw ours, then stack what is beneath under it.
223 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
224 const hidden = await read($, isHidden)
225 const s = await read($, snapshot)
226 const below = await next(e)
227 if (e.props.hasSurvey || hidden || s === null) return below
228
229 const { Box, Text } = $.ui.resolve(e)
230 const width = Math.max(1, e.props.bodyColumns)
231 const head = headlineRuns(s).map(run => <Text {...style(run)}>{run.text}</Text>)
232
233 const stack = (mine: RenderElement): RenderElement =>
234 drawsNothing(below) ? mine : (
235 <Box flexDirection="column">
236 {mine}
237 {below}
238 </Box>
239 )
240
241 if (width < 24) {
242 return stack(
243 <Box flexDirection="row">
244 <Text color="claude" bold>
245 ctx{' '}
246 </Text>
247 {head}
248 </Box>,
249 )
250 }
251
252 const row = (runs: Run[]) => (
253 <Box flexDirection="row">
254 {runs.map(run => (
255 <Text wrap="truncate-end" {...style(run)}>
256 {run.text}
257 </Text>
258 ))}
259 </Box>
260 )
261 const legendRoom = Math.max(0, e.props.maxRows - 3)
262 const legend = showLegend ? packLegend(legendItems(s), width).slice(0, legendRoom) : []
263
264 return stack(
265 <Box flexDirection="column">
266 <Box flexDirection="row" justifyContent="space-between">
267 <Box flexDirection="row">
268 <Text color="claude" bold>
269 ◆ context{' '}
270 </Text>
271 {head}
272 </Box>
273 {s.segments.length > 0 && width >= 64 && <Text dimColor>split estimated</Text>}
274 </Box>
275 {row(toRuns(barCells(s, width)))}
276 {e.props.maxRows >= 3 && row(toRuns(labelCells(s, width)))}
277 {legend.map(line => (
278 <Box flexDirection="row" columnGap={2}>
279 {line.map(item => (
280 <Text>
281 <Text color={item.color}>{item.glyph}</Text>
282 <Text dimColor> {item.label}</Text>
283 </Text>
284 ))}
285 </Box>
286 ))}
287 </Box>,
288 )
289 })
290}
291hooks/lib.ts 327 lines1import type { ContextCategory, SessionContextUsage } from 'claude-code'
2
3import type { ContextBarSegment, ContextBarSnapshot } from '../types'
4
5/** One character cell of a drawn row. */
6export type Cell = { ch: string; color?: string; bold?: boolean; dim?: boolean }
7
8/** Consecutive cells of one style, drawn as one Text. */
9export type Run = { text: string; color?: string; bold?: boolean; dim?: boolean }
10
11export type Zone = 'active' | 'passive'
12
13export type LegendItem = { glyph: string; color: string; label: string }
14
15export const HALF = 50
16export const WARNING = 'Past 50%, consider /compact or a fresh session'
17
18const GLYPH = { used: '█', free: '░', buffer: '▒' } as const
19const DIVIDER: Cell = { ch: '┃', color: 'text', bold: true }
20const COMPACT_MARK: Cell = { ch: '╎', color: 'error', bold: true }
21
22/** 850, 8.5k, 142k, 1.2M. */
23export function formatTokens(n: number): string {
24 if (n < 1000) return String(Math.round(n))
25 if (n < 10_000) return `${trim(n / 1000)}k`
26 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
27 return `${trim(n / 1_000_000)}M`
28}
29
30function trim(n: number): string {
31 return n.toFixed(1).replace(/\.0$/, '')
32}
33
34/** Used categories in the engine's order, then free space, then the compaction buffer. */
35export function orderSegments(categories: readonly ContextCategory[]): ContextBarSegment[] {
36 const shown = categories.filter(c => c.kind !== 'deferred' && c.tokens > 0)
37 const pick = (kind: ContextBarSegment['kind']) =>
38 shown
39 .filter(c => c.kind === kind)
40 .map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind }))
41
42 return [...pick('used'), ...pick('free'), ...pick('buffer')]
43}
44
45/** Reduces `$.session.usage({ breakdown })`'s context to what the band draws. */
46export function toSnapshot(context: SessionContextUsage): ContextBarSnapshot {
47 const b = context.breakdown
48
49 return {
50 tokens: context.tokens ?? null,
51 percent: context.percent ?? null,
52 window: context.window,
53 scale: b?.rawMaxTokens || context.window,
54 estimatedTokens: b?.totalTokens ?? null,
55 autoCompactAt: b?.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
56 segments: b ? orderSegments(b.categories) : [],
57 }
58}
59
60/** The fill to judge zones by: exact when a response reported it, else the estimate. */
61export function fillPercent(s: ContextBarSnapshot): number | null {
62 if (s.percent !== null) return s.percent
63 if (s.estimatedTokens === null || s.scale <= 0) return null
64
65 return Math.round((s.estimatedTokens / s.scale) * 100)
66}
67
68export function zoneOf(percent: number): Zone {
69 return percent < HALF ? 'active' : 'passive'
70}
71
72export function zoneColor(percent: number): string {
73 if (percent < HALF) return 'success'
74 if (percent < 80) return 'warning'
75
76 return 'error'
77}
78
79export function shouldWarn(percent: number | null, hasWarned: boolean): boolean {
80 return percent !== null && percent >= HALF && !hasWarned
81}
82
83export function statusText(s: ContextBarSnapshot | null): string {
84 return s?.percent != null ? `ctx ${s.percent}%` : 'ctx --'
85}
86
87/** 🟢 under 50%, 🟡 under 80%, 🔴 from there; ⚪ before the first response. */
88export function zoneEmoji(percent: number | null): string {
89 if (percent === null) return '⚪'
90 if (percent < HALF) return '🟢'
91 if (percent < 80) return '🟡'
92
93 return '🔴'
94}
95
96/** The status entry for surfaces without the band (mobile): `🟡 ctx 71% · passive`. */
97export function richStatusText(s: ContextBarSnapshot | null): string {
98 const percent = s?.percent ?? null
99 if (percent === null) return `${zoneEmoji(null)} ctx --`
100
101 return `${zoneEmoji(percent)} ctx ${percent}% · ${zoneOf(percent)}`
102}
103
104/** What `/context-bar <args>` asks for. */
105export type CommandAction = 'show' | 'on' | 'off' | 'toggle' | 'unknown'
106
107export function parseCommand(args: string): CommandAction {
108 const arg = args.trim().toLowerCase()
109 if (arg === '' || arg === 'show' || arg === 'card') return 'show'
110 if (arg === 'on' || arg === 'off' || arg === 'toggle') return arg
111
112 return 'unknown'
113}
114
115/** The biggest content categories, largest first (free space and the buffer left out). */
116export function topCategories(s: ContextBarSnapshot, count: number): ContextBarSegment[] {
117 return s.segments
118 .filter(seg => seg.kind === 'used')
119 .sort((a, b) => b.tokens - a.tokens)
120 .slice(0, count)
121}
122
123/** A category's share of the window, as a whole percentage. */
124export function shareOf(s: ContextBarSnapshot, tokens: number): number {
125 return s.scale > 0 ? Math.round((tokens / s.scale) * 100) : 0
126}
127
128/** The card as plain text: what the model reads from the command's row. */
129export function cardText(s: ContextBarSnapshot | null): string {
130 if (s === null) return 'Context: not measured yet.'
131 const head = headlineRuns(s)
132 .map(r => r.text)
133 .join('')
134 const top = topCategories(s, 4).map(seg => `${seg.name} ${formatTokens(seg.tokens)}`)
135
136 return top.length > 0 ? `Context: ${head}. Largest (estimated): ${top.join(', ')}.` : `Context: ${head}.`
137}
138
139/**
140 * Splits `cells` across `weights` by largest remainder, then gives every
141 * positive weight at least one cell, taken from the widest share.
142 */
143export function apportion(weights: readonly number[], cells: number): number[] {
144 const clean = weights.map(w => (Number.isFinite(w) && w > 0 ? w : 0))
145 const total = clean.reduce((a, b) => a + b, 0)
146 if (total <= 0 || cells <= 0) return clean.map(() => 0)
147
148 const exact = clean.map(w => (w / total) * cells)
149 const out = exact.map(Math.floor)
150 let left = cells - out.reduce((a, b) => a + b, 0)
151 const byRemainder = exact
152 .map((x, i) => ({ i, r: x - Math.floor(x) }))
153 .sort((a, b) => b.r - a.r)
154 for (const { i } of byRemainder) {
155 if (left <= 0) break
156 out[i] = (out[i] ?? 0) + 1
157 left -= 1
158 }
159
160 clean.forEach((w, i) => {
161 if (w === 0 || out[i] !== 0) return
162 const widest = out.reduce((best, n, j) => (n > (out[best] ?? 0) ? j : best), 0)
163 if ((out[widest] ?? 0) > 1) {
164 out[widest] = (out[widest] ?? 0) - 1
165 out[i] = 1
166 }
167 })
168
169 return out
170}
171
172/** The cell a fraction of the bar lands on. */
173export function markerIndex(fraction: number, width: number): number {
174 return Math.min(width - 1, Math.max(0, Math.round(fraction * width)))
175}
176
177export function halfIndex(width: number): number {
178 return Math.floor(width / 2)
179}
180
181export function compactIndex(s: ContextBarSnapshot, width: number): number | null {
182 if (s.autoCompactAt === null || s.scale <= 0) return null
183
184 return markerIndex(s.autoCompactAt / s.scale, width)
185}
186
187/** The segments to draw: the breakdown's, or a plain used/free split when there is none. */
188function drawnSegments(s: ContextBarSnapshot): ContextBarSegment[] {
189 if (s.segments.length > 0) return s.segments
190 if (s.tokens === null) return [{ name: 'Free space', tokens: s.scale, color: 'inactive', kind: 'free' }]
191
192 return [
193 { name: 'Used', tokens: s.tokens, color: 'claude', kind: 'used' },
194 { name: 'Free space', tokens: Math.max(0, s.window - s.tokens), color: 'inactive', kind: 'free' },
195 ]
196}
197
198/** The bar: one cell per column, coloured by category, with the 50% and auto-compact marks. */
199export function barCells(s: ContextBarSnapshot, width: number): Cell[] {
200 const segments = drawnSegments(s)
201 const shares = apportion(
202 segments.map(seg => seg.tokens),
203 width,
204 )
205 const cells: Cell[] = []
206 segments.forEach((seg, i) => {
207 for (let n = 0; n < (shares[i] ?? 0); n++) cells.push({ ch: GLYPH[seg.kind], color: seg.color })
208 })
209 while (cells.length < width) cells.push({ ch: GLYPH.free, color: 'inactive' })
210
211 const compact = compactIndex(s, width)
212 if (compact !== null) cells[compact] = COMPACT_MARK
213 cells[halfIndex(width)] = DIVIDER
214
215 return cells.slice(0, width)
216}
217
218/** Writes `text` at `start` if every cell it needs, and one on each side, is blank. */
219function place(cells: Cell[], start: number, text: string, style: Omit<Cell, 'ch'>): boolean {
220 const end = start + text.length
221 if (start < 0 || end > cells.length) return false
222 for (let i = Math.max(0, start - 1); i < Math.min(cells.length, end + 1); i++) {
223 if (cells[i]?.ch !== ' ') return false
224 }
225 ;[...text].forEach((ch, i) => (cells[start + i] = { ch, ...style }))
226
227 return true
228}
229
230/** The row under the bar: ACTIVE and PASSIVE centred in their halves, and the auto-compact label. */
231export function labelCells(s: ContextBarSnapshot, width: number): Cell[] {
232 const cells: Cell[] = Array.from({ length: width }, () => ({ ch: ' ' }))
233 const mid = halfIndex(width)
234 const percent = fillPercent(s)
235 const zone = percent === null ? null : zoneOf(percent)
236 const style = (z: Zone, color: string) => (zone === z ? { color, bold: true } : { color, dim: true })
237
238 cells[mid] = DIVIDER
239 const active = width >= 40 ? 'ACTIVE' : 'A'
240 const passive = width >= 40 ? 'PASSIVE' : 'P'
241 place(cells, Math.floor((mid - active.length) / 2), active, style('active', 'success'))
242
243 const rightStart = mid + 1 + Math.floor((width - mid - 1 - passive.length) / 2)
244 if (!place(cells, rightStart, passive, style('passive', 'warning'))) {
245 place(cells, mid + 2, passive, style('passive', 'warning'))
246 }
247
248 const compact = compactIndex(s, width)
249 if (compact !== null && compact !== mid) {
250 // The label right of its mark, else left of it, else the bare arrow.
251 const mark = { color: 'error' }
252 if (!place(cells, compact, '▲ auto-compact', mark) && !place(cells, compact - 13, 'auto-compact ▲', mark)) {
253 place(cells, compact, '▲', mark)
254 }
255 }
256
257 return cells
258}
259
260/** Merges consecutive cells of the same style. */
261export function toRuns(cells: readonly Cell[]): Run[] {
262 const runs: Run[] = []
263 for (const { ch, ...style } of cells) {
264 const last = runs[runs.length - 1]
265 if (last && last.color === style.color && last.bold === style.bold && last.dim === style.dim) {
266 last.text += ch
267 } else {
268 runs.push({ text: ch, ...style })
269 }
270 }
271
272 return runs
273}
274
275/** `142k / 200k · 71% · passive`, or the estimate before the first response. */
276export function headlineRuns(s: ContextBarSnapshot): Run[] {
277 const sep: Run = { text: ' · ', dim: true }
278 if (s.tokens !== null && s.percent !== null) {
279 const color = zoneColor(s.percent)
280
281 return [
282 { text: `${formatTokens(s.tokens)} / ${formatTokens(s.window)}`, bold: true },
283 sep,
284 { text: `${s.percent}%`, color, bold: true },
285 sep,
286 { text: zoneOf(s.percent), color },
287 ]
288 }
289 if (s.estimatedTokens !== null) {
290 return [
291 { text: `~${formatTokens(s.estimatedTokens)} / ${formatTokens(s.window)}`, bold: true },
292 sep,
293 { text: 'estimate, no response yet', dim: true },
294 ]
295 }
296
297 return [{ text: 'waiting for the first response', dim: true }]
298}
299
300export function legendItems(s: ContextBarSnapshot): LegendItem[] {
301 return s.segments.map(seg => ({
302 glyph: GLYPH[seg.kind],
303 color: seg.color,
304 label: `${seg.name} ${formatTokens(seg.tokens)}`,
305 }))
306}
307
308/** Packs legend items into lines of at most `width` cells, two spaces apart. */
309export function packLegend(items: readonly LegendItem[], width: number): LegendItem[][] {
310 const lines: LegendItem[][] = []
311 let line: LegendItem[] = []
312 let used = 0
313 for (const item of items) {
314 const size = item.label.length + 2
315 if (line.length > 0 && used + 2 + size > width) {
316 lines.push(line)
317 line = []
318 used = 0
319 }
320 used += (line.length > 0 ? 2 : 0) + size
321 line.push(item)
322 }
323 if (line.length > 0) lines.push(line)
324
325 return lines
326}
327types/index.d.ts 39 lines1/** What a bar segment stands for: content in the window, free room, or the compaction reserve. */
2export type ContextBarSegmentKind = 'used' | 'free' | 'buffer'
3
4/** One category of the context window, as the bar draws it. */
5export type ContextBarSegment = {
6 name: string
7 tokens: number
8 /** A theme key (or raw colour) from the engine's /context breakdown. */
9 color: string
10 kind: ContextBarSegmentKind
11}
12
13/** The context window as the band last measured it. */
14export type ContextBarSnapshot = {
15 /** Exact input tokens of the last API response; null before the first one. */
16 tokens: number | null
17 /** `tokens` over the model's window as a whole percentage; null before the first response. */
18 percent: number | null
19 /** The model's context window, in tokens. */
20 window: number
21 /** The window the categories fill (the compaction window), in tokens. */
22 scale: number
23 /** The estimated total of the categories, used while `tokens` is null. */
24 estimatedTokens: number | null
25 /** Token count at which auto-compaction runs; null when it is off. */
26 autoCompactAt: number | null
27 segments: ContextBarSegment[]
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 'context-bar': {
33 snapshot: ContextBarSnapshot | null
34 isHidden: boolean
35 hasWarned: boolean
36 }
37 }
38}
39