How full the context window is, as a one-line bar above the prompt, so you know when to /compact or /clear.

How full the context window is, as a one-line bar above the prompt, so you know when to /compact or /clear.
Context ▰▰▰▰▰▰▰▰▱▱ 82% · 164k/200k · time to /compact
/compact to summarize, /clear to start fresh./ctx prints the figures; /ctx demo calm|warm|hot previews a level, /ctx demo off ends it.The percentage is the last reply's input tokens against the model's full window, as the status line counts it. /context measures against the compaction window, which can be smaller, so its percentage can read a little higher.
src/register.tsx 150 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage } from 'claude-code'
3
4import type { ContextDemo, ContextLevel, ContextView } from '../types'
5import { DEFAULT_THRESHOLDS, levelFor, percentOf } from './logic.ts'
6import type { Thresholds } from './logic.ts'
7import * as text from './text.ts'
8
9const demo = atom({ plugin: 'context-band', key: 'demo' } as const, null)
10const view = atom({ plugin: 'context-band', key: 'view' } as const, null)
11
12const BAR_CELLS = 10
13// Calm teal, warming amber, red once it is time to compact.
14const BAR_COLORS: Record<ContextLevel, string> = { calm: '#5DCAA5', warm: '#EF9F27', hot: '#D85A30' }
15const DEMOS: readonly ContextDemo[] = ['calm', 'warm', 'hot']
16const DEMO_PERCENT: Record<ContextLevel, number> = { calm: 24, warm: 58, hot: 86 }
17const DEMO_WINDOW = 200_000
18// From this width the band sits beside the one beneath it; narrower, it goes on a line of its own.
19const SIDE_BY_SIDE_COLUMNS = 110
20
21// Module state: a reload starts it over, the host keeps what the drawing reads in $.state.
22let thresholds: Thresholds = DEFAULT_THRESHOLDS
23// The last real reading; null before the live window's first reply.
24let real: { tokens: number; window: number } | null = null
25// Whether the last reading was hot: crossing into hot raises the toast once.
26let wasHot = false
27
28function readingOf(context: SessionContextUsage) {
29 if (context.tokens === undefined) return null
30 return { tokens: context.tokens, window: context.window }
31}
32
33function shownView(window: number): ContextView {
34 if (real === null) return { level: 'calm', percent: 0, tokens: 0, window, isPending: true }
35 const percent = percentOf(real.tokens, real.window)
36 return { level: levelFor(percent, thresholds), percent, tokens: real.tokens, window: real.window }
37}
38
39// Draws the real reading unless a demo holds the band; crossing into hot says what to do.
40async function showReal($: EngineInterface, context: SessionContextUsage, mayToast: boolean) {
41 real = readingOf(context)
42 const next = shownView(context.window)
43 const isHot = next.isPending !== true && next.level === 'hot'
44 if (mayToast && isHot && !wasHot) $.ui.toast(text.HOT_TOAST(next.percent))
45 wasHot = isHot
46 if ((await read($, demo)) !== null) return
47 await update($, view, () => next)
48}
49
50const barOf = (percent: number): string => {
51 const lit = Math.round((Math.min(100, Math.max(0, percent)) / 100) * BAR_CELLS)
52 return '▰'.repeat(lit) + '▱'.repeat(BAR_CELLS - lit)
53}
54
55export const register: Register = (on, options) => {
56 const { warnAtPercent, compactAtPercent } = options
57 thresholds = {
58 warnAtPercent: typeof warnAtPercent === 'number' ? warnAtPercent : DEFAULT_THRESHOLDS.warnAtPercent,
59 compactAtPercent: typeof compactAtPercent === 'number' ? compactAtPercent : DEFAULT_THRESHOLDS.compactAtPercent,
60 }
61
62 on('session.start', async ($, e, next) => {
63 await $.command.register({
64 name: 'ctx',
65 description: text.COMMAND_DESCRIPTION,
66 argumentHint: text.COMMAND_HINT,
67 })
68 const result = await next(e)
69 // No toast on a start or reload: only a reply that crosses the line raises it.
70 await showReal($, (await $.session.usage()).context, false)
71 return result
72 })
73
74 on('session.measure', async ($, e, next) => {
75 if (e.changed.includes('context')) await showReal($, e.context, true)
76 return next(e)
77 })
78
79 on('command.run', { command: 'ctx' }, async ($, e) => {
80 const [sub, arg] = e.args.trim().split(/\s+/)
81
82 if (sub === 'demo' && arg === 'off') {
83 await update($, demo, () => null)
84 await update($, view, () => shownView(real?.window ?? DEMO_WINDOW))
85 return { text: text.DEMO_OFF }
86 }
87
88 // A bare /ctx reports the figures, like /ctx status.
89 if (sub === undefined || sub === '' || sub === 'status') {
90 const v = await read($, view)
91 if (v === null || v.isPending === true) return { text: text.NO_READING }
92 return { text: `Context Band: ${text.fillLine(v.percent, v.tokens, v.window)}` }
93 }
94
95 const chosen = DEMOS.find(d => d === arg)
96 if (sub === 'demo' && chosen !== undefined) {
97 const percent = DEMO_PERCENT[chosen]
98 const tokens = Math.round((percent / 100) * DEMO_WINDOW)
99 await update($, demo, () => chosen)
100 await update($, view, () => ({ level: chosen, percent, tokens, window: DEMO_WINDOW }))
101 return { text: text.DEMO_ON(chosen) }
102 }
103
104 return { text: text.USAGE }
105 })
106
107 // The band draws its line and keeps what the plugins beneath draw beside it, so it shares the
108 // spot above the prompt (with usage-torch, say) instead of taking it.
109 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
110 if (e.props.hasSurvey) return next(e)
111 const v = await read($, view)
112 if (v === null) return next(e)
113 const below = await next(e)
114
115 const fill = v.isPending === true ? text.WAITING : text.fillLine(v.percent, v.tokens, v.window)
116
117 if (e.surface !== 'terminal') {
118 const { Box, Text } = $.ui.resolve(e)
119 const head = v.isPending === true ? text.LABEL : text.oneLine(v.percent)
120 return (
121 <Box flexDirection="column">
122 <Text dimColor>{`${head} · ${fill}`}</Text>
123 {below}
124 </Box>
125 )
126 }
127
128 const { Box, Text } = $.ui.resolve(e)
129 const isWide = e.props.bodyColumns >= SIDE_BY_SIDE_COLUMNS
130 const color = BAR_COLORS[v.level]
131 const line = (
132 <Box flexDirection="row" gap={1} flexShrink={0}>
133 <Text color={color} bold>
134 {text.LABEL}
135 </Text>
136 {v.isPending !== true && <Text color={color}>{barOf(v.percent)}</Text>}
137 <Text dimColor>{fill}</Text>
138 {v.isPending !== true && v.level === 'hot' && <Text color={color}>{`· ${text.HOT_HINT}`}</Text>}
139 </Box>
140 )
141
142 return (
143 <Box flexDirection={isWide ? 'row' : 'column'} columnGap={3}>
144 {line}
145 {below}
146 </Box>
147 )
148 })
149}
150src/logic.ts 31 lines1// Pure rules: fill to level, token counts to short text.
2
3export type Level = 'calm' | 'warm' | 'hot'
4
5export type Thresholds = { warnAtPercent: number; compactAtPercent: number }
6
7export const DEFAULT_THRESHOLDS: Thresholds = { warnAtPercent: 50, compactAtPercent: 75 }
8
9/** The level for a window's fill: hot from the compact threshold, warm from the warn one. */
10export function levelFor(percent: number, t: Thresholds = DEFAULT_THRESHOLDS): Level {
11 if (percent >= t.compactAtPercent) return 'hot'
12 if (percent >= t.warnAtPercent) return 'warm'
13 return 'calm'
14}
15
16/** The fill as a whole percent, 0 to 100. */
17export function percentOf(tokens: number, window: number): number {
18 if (window <= 0) return 0
19 return Math.min(100, Math.max(0, Math.round((tokens / window) * 100)))
20}
21
22/** A token count in a few characters: 950, 12k, 124k, 1.2M. */
23export function formatTokens(n: number): string {
24 if (n >= 1_000_000) {
25 const m = Math.round(n / 100_000) / 10
26 return `${Number.isInteger(m) ? m : m.toFixed(1)}M`
27 }
28 if (n >= 1000) return `${Math.round(n / 1000)}k`
29 return String(Math.max(0, Math.round(n)))
30}
31src/text.ts 24 lines1// Every string the mod shows. English only; no em dashes (a test holds this).
2
3import { formatTokens } from './logic.ts'
4
5export const LABEL = 'Context'
6
7/** The figures in one line: the fill and the tokens against the window. */
8export const fillLine = (percent: number, tokens: number, window: number): string =>
9 `${percent}% · ${formatTokens(tokens)}/${formatTokens(window)}`
10
11export const HOT_HINT = 'time to /compact'
12export const WAITING = 'fills after the first reply'
13export const oneLine = (percent: number): string => `Context ${percent}%`
14
15export const HOT_TOAST = (percent: number): string =>
16 `Context ${percent}% full: /compact to summarize or /clear to start fresh`
17
18export const COMMAND_DESCRIPTION = 'Show Context Band figures, or simulate a level'
19export const COMMAND_HINT = '[status|demo calm|warm|hot|off]'
20export const DEMO_ON = (level: string): string => `Context Band demo: ${level}. Real figures are ignored until /ctx demo off.`
21export const DEMO_OFF = 'Context Band demo off.'
22export const USAGE = 'Usage: /ctx status, /ctx demo calm|warm|hot, /ctx demo off'
23export const NO_READING = 'Context Band has no reading yet. It arrives with the first reply.'
24types/index.d.ts 26 lines1export type ContextLevel = 'calm' | 'warm' | 'hot'
2
3export type ContextDemo = ContextLevel
4
5/** What the band shows: how much of the window the last reply was answered over. */
6export type ContextView = {
7 level: ContextLevel
8 /** Whole percent of the window in use, 0 to 100. */
9 percent: number
10 /** Input tokens of the last reply. */
11 tokens: number
12 /** The model's context window, in tokens. */
13 window: number
14 /** True before the live window's first reply (a new session, right after /clear or /compact). */
15 isPending?: boolean
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'context-band': {
21 demo: ContextDemo | null
22 view: ContextView | null
23 }
24 }
25}
26