Context window as a stacked bar above the prompt, one color per category, toggled with /context-bar

Personal Claude Code mods and the setup around them.
git clone https://github.com/letnonesurvive/claude-code-mods.git ~/.claude/mods && ~/.claude/mods/install.sh
Open a new terminal afterwards. The script registers every mod of this repository through CLAUDE_CODE_PLUGIN_DIRS in ~/.zshrc and is safe to run again. Update with git -C ~/.claude/mods pull.
Mods are an early-access Claude Code feature: the API may change between releases. Written against Claude Code 2.1.286.
A band above the prompt, toggled with /context-bar (the choice is remembered across sessions):
/context draws them, with tokens and share per category;session, week) with the time until each resets.The breakdown is estimated locally; it sends no token-count requests, so the figures may differ slightly from /context.
On a Russian layout ctrl+ч (the ctrl+x key) does not trigger Claude Code hotkeys in iTerm2, because iTerm2 reports the Cyrillic letter. To fix it, quit iTerm2 and run from Terminal.app:
~/.claude/mods/install.sh --iterm
For every profile this turns off "Apps can change how keys are reported" and maps shift+enter to send LF, which keeps it inserting a newline. The previous preferences are saved to ~/iterm2-prefs-backup-*.plist. Add --dry-run to see what would change.
hooks/register.tsx 227 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Row, Snapshot } from '../types'
5
6const COMMAND = 'context-bar'
7const isOn = atom({ plugin: 'context-bar', key: 'isOn' } as const, false)
8const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
9
10const tokens = (n: number): string => {
11 if (n >= 1_000_000) {
12 return `${+(n / 1_000_000).toFixed(1)}M`
13 }
14
15 if (n >= 1000) {
16 return `${+(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
17 }
18
19 return String(n)
20}
21
22const share = (n: number, max: number): string => {
23 const percent = max > 0 ? (n / max) * 100 : 0
24
25 return `${+percent.toFixed(percent < 1 ? 1 : 0)}%`
26}
27
28// Locally estimated breakdown: no token-count requests are sent.
29const measure = async ($: EngineInterface): Promise<void> => {
30 const { context, rateLimits } = await $.session.usage({ breakdown: 'summary' })
31 const breakdown = context.breakdown
32
33 if (!breakdown) {
34 return
35 }
36
37 const rows: Row[] = []
38
39 for (const one of breakdown.categories) {
40 if (one.kind !== 'deferred' && one.tokens > 0) {
41 rows.push({
42 name: one.name.toLowerCase(),
43 tokens: one.tokens,
44 color: one.color,
45 kind: one.kind,
46 })
47 }
48 }
49
50 const next: Snapshot = {
51 rows,
52 total: breakdown.totalTokens,
53 max: breakdown.rawMaxTokens,
54 percent: breakdown.percentage,
55 compactsAt: breakdown.isAutoCompactEnabled
56 ? (breakdown.autoCompactThreshold ?? null)
57 : null,
58 limits: rateLimits.map(
59 (one): Limit => ({
60 kind: one.kind,
61 percent: one.percentUsed,
62 resetsAt: one.resetsAt ?? null,
63 }),
64 ),
65 }
66 await update($, snapshot, () => next)
67}
68
69// Splits `width` cells among the rows by tokens; every row keeps one cell.
70const cells = (rows: Row[], width: number): number[] => {
71 const sum = rows.reduce((all, row) => all + row.tokens, 0) || 1
72 const out = rows.map(row => Math.max(1, Math.round((row.tokens / sum) * width)))
73 let spare = width - out.reduce((all, n) => all + n, 0)
74
75 while (spare !== 0) {
76 const widest = out.indexOf(Math.max(...out))
77
78 const cellsNow = out[widest] ?? 0
79
80 if (spare < 0 && cellsNow <= 1) {
81 break
82 }
83
84 out[widest] = cellsNow + Math.sign(spare)
85 spare -= Math.sign(spare)
86 }
87
88 return out
89}
90
91const LIMIT_NAMES: Record<string, string> = {
92 five_hour: 'session',
93 seven_day: 'week',
94 spend_limit: 'spend',
95}
96
97const until = (resetsAt: string | null, now: number): string => {
98 const left = resetsAt === null ? NaN : Date.parse(resetsAt) - now
99
100 if (!(left > 0)) {
101 return ''
102 }
103
104 const minutes = Math.ceil(left / 60_000)
105 const days = Math.floor(minutes / 1440)
106 const hours = Math.floor((minutes % 1440) / 60)
107
108 if (days > 0) {
109 return ` · resets in ${days}d ${hours}h`
110 }
111
112 return ` · resets in ${hours > 0 ? `${hours}h ` : ''}${minutes % 60}m`
113}
114
115const tone = (percent: number): string =>
116 percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success'
117
118const GLYPH = { used: '█', free: '░', buffer: '▒' } as const
119
120export const register: Register = on => {
121 on('session.start', async ($, e, next) => {
122 await $.command.register({
123 name: COMMAND,
124 description: 'Toggle the context window bar above the prompt',
125 })
126
127 // The choice outlives the session in $.store; $.state is per session.
128 if ((await read($, isOn)) || (await $.store.get('isOn')) === true) {
129 await update($, isOn, () => true)
130 void measure($)
131 }
132
133 return next(e)
134 })
135
136 on('command.run', { command: COMMAND }, async $ => {
137 const shown = !(await read($, isOn))
138
139 if (shown) {
140 await measure($)
141 }
142
143 await update($, isOn, () => shown)
144 await $.store.set('isOn', shown)
145
146 return { text: shown ? 'Context bar shown.' : 'Context bar hidden.' }
147 })
148
149 on('session.measure', async ($, e, next) => {
150 if (await read($, isOn)) {
151 void measure($)
152 }
153
154 return next(e)
155 })
156
157 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
158 const shown = await read($, isOn)
159 const snap = await read($, snapshot)
160
161 if (e.props.hasSurvey || !shown || snap === null) {
162 return next(e)
163 }
164
165 const { Box, Text } = $.ui.resolve(e)
166 // Border and padding take four cells of the band.
167 const width = Math.max(10, e.props.bodyColumns - 4)
168 const widths = cells(snap.rows, width)
169 const level = tone(snap.percent)
170 const now = await $.clock.now()
171 const meter = Math.max(8, Math.min(40, width - 40))
172
173 return (
174 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
175 <Box justifyContent="space-between">
176 <Text bold>
177 <Text color="claude">◆</Text> context
178 </Text>
179 <Text>
180 <Text bold>{tokens(snap.total)}</Text>
181 <Text dimColor>
182 {' '}
183 of {tokens(snap.max)}
184 {snap.compactsAt === null ? '' : ` · compacts at ${tokens(snap.compactsAt)}`}{' '}
185 </Text>
186 <Text color={level} inverse bold>
187 {' '}
188 {snap.percent}%{' '}
189 </Text>
190 </Text>
191 </Box>
192 <Text wrap="truncate">
193 {snap.rows.map((row, i) => (
194 <Text color={row.color} dimColor={row.kind === 'free'}>
195 {GLYPH[row.kind].repeat(widths[i] ?? 0)}
196 </Text>
197 ))}
198 </Text>
199 <Box flexWrap="wrap" columnGap={3}>
200 {snap.rows.map(row => (
201 <Text>
202 <Text color={row.color} dimColor={row.kind === 'free'}>
203 {GLYPH[row.kind]}
204 </Text>{' '}
205 {row.name} <Text bold>{tokens(row.tokens)}</Text>
206 {row.kind === 'free' ? '' : <Text dimColor> {share(row.tokens, snap.max)}</Text>}
207 </Text>
208 ))}
209 </Box>
210 {(snap.limits ?? []).map(limit => {
211 const filled = Math.min(meter, Math.round((limit.percent / 100) * meter))
212
213 return (
214 <Text wrap="truncate">
215 {(LIMIT_NAMES[limit.kind] ?? limit.kind).padEnd(8)}
216 <Text color={tone(limit.percent)}>{'━'.repeat(filled)}</Text>
217 <Text dimColor>{'─'.repeat(meter - filled)}</Text>{' '}
218 <Text bold>{limit.percent}%</Text>
219 <Text dimColor> used{until(limit.resetsAt, now)}</Text>
220 </Text>
221 )
222 })}
223 </Box>
224 )
225 })
226}
227types/index.d.ts 24 lines1export type Row = {
2 name: string
3 tokens: number
4 color: string
5 kind: 'used' | 'free' | 'buffer'
6}
7
8export type Limit = { kind: string; percent: number; resetsAt: string | null }
9
10export type Snapshot = {
11 rows: Row[]
12 total: number
13 max: number
14 percent: number
15 compactsAt: number | null
16 limits: Limit[]
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'context-bar': { isOn: boolean; snapshot: Snapshot | null }
22 }
23}
24