Always-on context window meter above the prompt, with usage limits (terminal and desktop app)

A Claude Code mod that keeps your context window and usage limits in view, right above the prompt — in the terminal and in the desktop app.
Context ██▓▓▓▓▒▒░░░░░░░░░░░░ 20% · 40k / 200k Details 5h ██░░░ 42% ↻2h10
■ Messages 36k ■ System tools 14k ■ Skills 10k 7d ████░ 86% ↻3d
In a Claude Code terminal session:
/plugin install context-meter --marketplace vienna-bio-center/claude-vbc-mods
Answer y to add the marketplace, then pick the user scope. The mod is active right away and in every session you start afterwards — including the desktop app's Code tab. (The install command itself only works in the terminal.)
Nothing to start: the band appears on its own once a session has its first response.
For a breakdown, press Details in the band or run:
/context-details
The details pane lists:
| Section | What it shows |
|---|---|
| Categories | every context category with tokens and share of the window |
| Largest tool results | the 10 biggest tool outputs since the last /clear or compact |
| MCP servers | tokens per server for tool schemas currently loaded |
| Memory files | CLAUDE.md, rules and auto-memory files with their size |
| Skills / agents | what the skill listing and agent descriptions cost |
/context uses in summary mode. Tool result sizes are approximate (characters ÷ 4). Run /context for an exact count.claude plugin validate <path-to-this-folder>
claude plugin test <path-to-this-folder>
To run a working copy instead of the installed version, start Claude Code with claude --plugin-dir <path-to-this-folder>.
MIT © 2026 Vienna BioCenter
hooks/register.tsx 431 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 ModelUsage,
5 Register,
6 SessionContextBreakdown,
7 SessionContextUsage,
8} from 'claude-code'
9
10import type { Details, Fill, Heavy, Item, Limit, Part } from '../types'
11
12const PANE = 'context-meter'
13const fill = atom({ plugin: 'context-meter', key: 'fill' } as const, null)
14const details = atom({ plugin: 'context-meter', key: 'details' } as const, null)
15const heavy = atom({ plugin: 'context-meter', key: 'heavy' } as const, [])
16const limits = atom({ plugin: 'context-meter', key: 'limits' } as const, [])
17const now = atom({ plugin: 'context-meter', key: 'now' } as const, 0)
18
19const toFill = (c: SessionContextUsage): Fill => ({
20 tokens: c.tokens ?? null,
21 window: c.window,
22 percent: c.percent ?? null,
23})
24
25export const kilo = (n: number) =>
26 n >= 1_000_000
27 ? `${(n / 1_000_000).toFixed(1)}M`
28 : n >= 10_000
29 ? `${Math.round(n / 1000)}k`
30 : n >= 1000
31 ? `${(n / 1000).toFixed(1)}k`
32 : `${n}`
33
34export const bar = (percent: number, width: number) => {
35 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
36 return { filled: '█'.repeat(filled), empty: '░'.repeat(width - filled) }
37}
38
39export const tone = (percent: number) =>
40 percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'success'
41
42/** Splits `width` cells over the used rows in order, cumulative rounding so nothing gaps. */
43export const segments = (parts: Part[], max: number, width: number) => {
44 const out: { color: string; cells: number }[] = []
45 let sum = 0
46 let drawn = 0
47 for (const p of parts) {
48 if (p.kind !== 'used' || p.tokens <= 0) continue
49 sum += p.tokens
50 const end = Math.min(width, Math.round((sum / max) * width))
51 if (end > drawn) out.push({ color: p.color, cells: end - drawn })
52 drawn = Math.max(drawn, end)
53 }
54 return { used: out, rest: width - drawn }
55}
56
57const top = (items: Item[], n: number) =>
58 [...items].sort((a, b) => b.tokens - a.tokens).slice(0, n)
59
60export const toDetails = (b: SessionContextBreakdown, home: string): Details => {
61 const servers = new Map<string, Item>()
62 for (const t of b.mcpTools) {
63 if (!t.isLoaded) continue
64 const s = servers.get(t.serverName) ?? { name: t.serverName, detail: '', tokens: 0 }
65 s.tokens += t.tokens
66 s.detail = `${Number(s.detail || 0) + 1}`
67 servers.set(t.serverName, s)
68 }
69 const tilde = (p: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p)
70
71 return {
72 total: b.totalTokens,
73 max: b.rawMaxTokens,
74 percent: b.percentage,
75 parts: b.categories.map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind })),
76 compactAt: b.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
77 mcp: top([...servers.values()].map(s => ({ ...s, detail: `${s.detail} Tools` })), 8),
78 memory: top(b.memoryFiles.map(f => ({ name: tilde(f.path), detail: f.type, tokens: f.tokens })), 8),
79 skills: top(
80 (b.skills?.skillFrontmatter ?? []).map(s => ({ name: s.name, detail: s.pluginName ?? s.source, tokens: s.tokens })),
81 6,
82 ),
83 skillsTotal: b.skills?.tokens ?? 0,
84 agentsTotal: b.agents.reduce((n, a) => n + a.tokens, 0),
85 }
86}
87
88/** Keeps the 10 largest results; tokens estimated at 4 characters each. */
89export const addHeavy = (list: Heavy[], one: Heavy) =>
90 [...list, one].sort((a, b) => b.tokens - a.tokens).slice(0, 10)
91
92const LABEL_KEYS = ['file_path', 'command', 'pattern', 'url', 'query', 'description', 'prompt', 'path', 'skill']
93
94export const labelOf = (input: Record<string, unknown>) => {
95 for (const k of LABEL_KEYS) {
96 const v = input[k]
97 if (typeof v === 'string' && v.trim()) return v.replace(/\s+/g, ' ').trim()
98 }
99 return ''
100}
101
102const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'Budget' }
103
104export const limitLabel = (kind: string) => LIMIT_LABEL[kind] ?? kind.replace(/_/g, ' ')
105
106/** Time left until a window resets, coarse enough to stay true for a minute. */
107export const until = (at: number | null, nowMs: number) => {
108 if (at === null) return ''
109 const min = Math.max(0, Math.round((at - nowMs) / 60_000))
110 if (min < 60) return `${min}m`
111 const h = Math.floor(min / 60)
112 if (h < 48) return `${h}h${String(min % 60).padStart(2, '0')}`
113 return `${Math.round(h / 24)}d`
114}
115
116export const toLimits = (list: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Limit[] =>
117 list.map(l => {
118 const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
119 return { kind: l.kind, percent: l.percentUsed, resetsAt: Number.isNaN(at) ? null : at }
120 })
121
122/** Input side of one response: what the window held when the model answered. */
123export const stepTokens = (u: ModelUsage) =>
124 u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
125
126/** `tokens`, when given, is a fresher reading (a step that just answered) than the session's. */
127async function measure($: EngineInterface, tokens?: number) {
128 const usage = await $.session.usage({ breakdown: 'summary' })
129 const f = toFill(usage.context)
130 await update($, fill, () =>
131 tokens === undefined ? f : { ...f, tokens, percent: Math.round((tokens / f.window) * 100) },
132 )
133 await update($, limits, () => toLimits(usage.rateLimits))
134 await update($, now, () => Date.now())
135 const b = usage.context.breakdown
136 if (b) {
137 const home = (await $.env.get('HOME')) ?? ''
138 await update($, details, () => toDetails(b, home))
139 }
140}
141
142export const register: Register = on => {
143 on('session.start', async ($, e, next) => {
144 const result = await next(e)
145 await $.command.register({
146 name: 'context-details',
147 description: 'Shows what is using up the context window',
148 })
149 await measure($)
150 // keeps the reset countdowns honest while the session sits idle
151 $.clock.every(60_000, () => {
152 void update($, now, () => Date.now())
153 })
154 return result
155 })
156
157 on('session.measure', async ($, e, next) => {
158 const result = await next(e)
159 await measure($)
160 return result
161 })
162
163 // Live during a turn: each answered model call of the main loop measures again.
164 on('turn.step', async function* ($, e, next) {
165 const r = yield* next(e)
166 if (e.agentId === undefined && r.usage) {
167 // the bar is a nicety: a failed reading must never touch the turn
168 try {
169 await measure($, stepTokens(r.usage))
170 } catch {}
171 }
172 return r
173 })
174
175 on('session.compact', async ($, e, next) => {
176 const result = await next(e)
177 if (e.agentId === undefined && !('skip' in result)) await update($, heavy, () => [])
178 return result
179 }).catch(($, e, next) => next(e))
180
181 on('session.end', async ($, e, next) => {
182 if (e.reason === 'clear') await update($, heavy, () => [])
183 return next(e)
184 })
185
186 on('tool.call', async ($, e, next) => {
187 const ran = await next(e)
188 if (e.agentId === undefined && ran.text) {
189 const one: Heavy = {
190 id: e.tool_use_id,
191 tool: e.tool,
192 label: labelOf(e as unknown as Record<string, unknown>).slice(0, 120),
193 tokens: Math.round(ran.text.length / 4),
194 }
195 await update($, heavy, list => addHeavy(list, one))
196 }
197 return ran
198 }).catch(($, e, next) => next(e))
199
200 on('command.run', { command: 'context-details' }, async $ => {
201 await measure($)
202 await $.ui.open({ id: PANE, title: 'Context' })
203 return { text: 'Context details opened.' }
204 })
205
206 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
207 if (e.props.hasSurvey) return next(e)
208
209 const current = await read($, fill)
210 const d = await read($, details)
211 const { Box, Text, Button } = $.ui.resolve(e)
212
213 if (d === null && (current === null || current.percent === null || current.tokens === null)) {
214 return (
215 <Box>
216 <Text dimColor>
217 Context ░░░░░░░░░░ no reading yet
218 {current ? ` · window ${kilo(current.window)}` : ''}
219 </Text>
220 </Box>
221 )
222 }
223
224 const percent = d?.percent ?? current?.percent ?? 0
225 const used = d?.total ?? current?.tokens ?? 0
226 const max = d?.max ?? current?.window ?? 1
227 const cols = e.props.bodyColumns
228 const color = tone(percent)
229
230 const lim = await read($, limits)
231 const nowMs = (await read($, now)) || Date.now()
232 const showMini = cols >= 110
233 const showReset = cols >= 90
234 const shown = lim.map(l => ({
235 ...l,
236 label: limitLabel(l.kind),
237 pct: `${Math.round(l.percent)}%`,
238 reset: showReset ? until(l.resetsAt, nowMs) : '',
239 mini: showMini ? bar(l.percent, 5) : null,
240 }))
241 const lenOf = (l: (typeof shown)[number]) =>
242 l.label.length + 1 + (l.mini ? 6 : 0) + l.pct.length + (l.reset ? l.reset.length + 2 : 0)
243 // 5h on the first row, the week (and any other window) on the second
244 const first = shown.filter(l => l.kind === 'five_hour')
245 const second = shown.filter(l => l.kind !== 'five_hour')
246 const limitW = shown.length ? Math.max(...[first, second].map(g => g.reduce((n, l) => n + lenOf(l), 0) + Math.max(0, g.length - 1) * 3)) : 0
247 const width = Math.max(8, Math.min(40, cols - 30 - limitW - 10))
248
249 const barRow = d
250 ? (() => {
251 const sg = segments(d.parts, d.max, width)
252 return [
253 ...sg.used.map((seg, i) => (
254 <Text key={`s${i}`} color={seg.color}>
255 {'█'.repeat(seg.cells)}
256 </Text>
257 )),
258 <Text key="rest" dimColor>
259 {'░'.repeat(sg.rest)}
260 </Text>,
261 ]
262 })()
263 : (() => {
264 const b = bar(percent, width)
265 return [
266 <Text key="f" color={color}>{b.filled}</Text>,
267 <Text key="e" dimColor>{b.empty}</Text>,
268 ]
269 })()
270
271 const legendRoom = cols - (limitW ? limitW + 2 : 0)
272 const legend: Part[] = []
273 let taken = 0
274 for (const part of d ? d.parts.filter(x => x.kind === 'used' && x.tokens > 0).sort((a, b) => b.tokens - a.tokens) : []) {
275 const len = 2 + part.name.length + 1 + kilo(part.tokens).length + (legend.length ? 2 : 0)
276 if (legend.length >= 5 || taken + len > legendRoom) break
277 legend.push(part)
278 taken += len
279 }
280
281 const limitBox = (key: string, group: typeof shown) => (
282 <Box key={key} width={limitW}>
283 {group.map((l, i) => (
284 <Box key={`l-${l.kind}`}>
285 {i > 0 && <Text dimColor> │ </Text>}
286 <Text dimColor>{l.label} </Text>
287 {l.mini && <Text color={tone(l.percent)}>{l.mini.filled}</Text>}
288 {l.mini && <Text dimColor>{l.mini.empty} </Text>}
289 <Text color={tone(l.percent)} bold>
290 {l.pct}
291 </Text>
292 {l.reset && <Text dimColor> ↻{l.reset}</Text>}
293 </Box>
294 ))}
295 </Box>
296 )
297
298 return (
299 <Box flexDirection="column">
300 <Box justifyContent="space-between">
301 <Box>
302 <Text dimColor>Context </Text>
303 {barRow}
304 <Text color={color} bold>
305 {' '}
306 {percent}%
307 </Text>
308 <Text dimColor wrap="truncate-end">
309 {' '}
310 · {kilo(used)} / {kilo(max)}
311 </Text>
312 </Box>
313 <Box>
314 <Button
315 key="details"
316 label="Details"
317 plain
318 dimColor
319 onPress={() => $.ui.open({ id: PANE, title: 'Context' })}
320 />
321 {limitW > 0 && <Text> </Text>}
322 {limitW > 0 && limitBox('row1', first)}
323 </Box>
324 </Box>
325 {(legend.length > 0 || second.length > 0) && (
326 <Box justifyContent="space-between">
327 <Box columnGap={2}>
328 {legend.map(part => (
329 <Box key={part.name}>
330 <Text color={part.color}>■ </Text>
331 <Text dimColor>
332 {part.name} {kilo(part.tokens)}
333 </Text>
334 </Box>
335 ))}
336 </Box>
337 {limitW > 0 && limitBox('row2', second)}
338 </Box>
339 )}
340 </Box>
341 )
342 })
343
344 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
345 const { Box, Text } = $.ui.resolve(e)
346 const d = await read($, details)
347 const big = await read($, heavy)
348 const cols = e.props.bodyColumns
349 const nameW = Math.max(16, Math.min(48, cols - 28))
350 const cut = (s: string) => (s.length > nameW ? `${s.slice(0, nameW - 1)}…` : s)
351
352 const row = (key: string, name: string, tokens: number, extra: string, color?: string) => (
353 <Box key={key}>
354 {color !== undefined && <Text color={color}>■ </Text>}
355 <Box width={nameW}>
356 <Text wrap="truncate-end">{cut(name)}</Text>
357 </Box>
358 <Box width={8} justifyContent="flex-end">
359 <Text bold>{kilo(tokens)}</Text>
360 </Box>
361 <Text dimColor> {extra}</Text>
362 </Box>
363 )
364
365 const section = (key: string, title: string) => (
366 <Box key={key} marginTop={1}>
367 <Text bold underline>
368 {title}
369 </Text>
370 </Box>
371 )
372
373 if (d === null) {
374 return <Text dimColor>No breakdown yet — it arrives with the first response.</Text>
375 }
376
377 const share = (n: number) => `${((n / d.max) * 100).toFixed(1)}%`
378
379 return (
380 <Box flexDirection="column">
381 <Text>
382 <Text bold color={tone(d.percent)}>
383 {d.percent}%
384 </Text>
385 <Text dimColor>
386 {' '}
387 · {kilo(d.total)} of {kilo(d.max)} tokens
388 {d.compactAt ? ` · auto-compact at ${kilo(d.compactAt)}` : ' · auto-compact off'}
389 </Text>
390 </Text>
391
392 {section('h-cat', 'Categories')}
393 {d.parts
394 .filter(p => p.tokens > 0)
395 .map(p =>
396 row(
397 `c-${p.name}`,
398 p.name,
399 p.tokens,
400 p.kind === 'deferred' ? 'on demand, not counted' : share(p.tokens),
401 p.color,
402 ),
403 )}
404
405 {section('h-heavy', 'Largest tool results (since /clear or compact, ≈)')}
406 {big.length === 0 ? (
407 <Text dimColor>none yet</Text>
408 ) : (
409 big.map(h => row(`h-${h.id}`, h.label ? `${h.tool}: ${h.label}` : h.tool, h.tokens, ''))
410 )}
411
412 {d.mcp.length > 0 && section('h-mcp', 'MCP servers (loaded tool schemas)')}
413 {d.mcp.map(m => row(`m-${m.name}`, m.name, m.tokens, m.detail))}
414
415 {d.memory.length > 0 && section('h-mem', 'Memory files')}
416 {d.memory.map(m => row(`f-${m.name}`, m.name, m.tokens, m.detail))}
417
418 {d.skills.length > 0 && section('h-sk', `Skills (listing total ${kilo(d.skillsTotal)})`)}
419 {d.skills.map(s => row(`k-${s.name}`, s.name, s.tokens, s.detail))}
420
421 {d.agentsTotal > 0 && section('h-ag', 'Agent descriptions')}
422 {d.agentsTotal > 0 && row('a-total', 'all together', d.agentsTotal, '')}
423
424 <Box marginTop={1}>
425 <Text dimColor>Estimated like /context (summary). Exact count: /context</Text>
426 </Box>
427 </Box>
428 )
429 })
430}
431types/index.d.ts 35 lines1export type Fill = { tokens: number | null; window: number; percent: number | null }
2
3export type Part = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' | 'deferred' }
4
5export type Item = { name: string; detail: string; tokens: number }
6
7export type Details = {
8 total: number
9 max: number
10 percent: number
11 parts: Part[]
12 compactAt: number | null
13 mcp: Item[]
14 memory: Item[]
15 skills: Item[]
16 skillsTotal: number
17 agentsTotal: number
18}
19
20export type Limit = { kind: string; percent: number; resetsAt: number | null }
21
22export type Heavy = { id: string; tool: string; label: string; tokens: number }
23
24declare module 'claude-code' {
25 interface PluginState {
26 'context-meter': {
27 fill: Fill | null
28 details: Details | null
29 heavy: Heavy[]
30 limits: Limit[]
31 now: number
32 }
33 }
34}
35