Your context window as a meter above the prompt: the conversation, the overhead every request carries, and the room left before compaction, with a drill-down…

A Claude Code mod that draws your context window as a meter above the prompt: the conversation, the overhead every request carries, and the room left before auto-compaction. It started as the context bar from the Claude Code newsletter, and grew a drill-down that shows what the overhead is made of.
Type /context-bar and a card appears in the band above the prompt:
Claude Code for VS Code doesn't draw a mod's band or panes yet, so there /context-bar answers with the card's figures as text instead. That answer is a snapshot, counted exactly, and /context-bar overhead <category> adds that category's biggest items. The card also has a pane ready for surfaces without the band, which the mod API says VS Code and the mobile app will be. Once one draws mod panes, /context-bar opens the card in a pane there, and closing the pane turns the card off.
Press overhead ▸ in the legend, or run /context-bar overhead, to open the overhead as ranked bars, one row per category. A category that lists what it is made of (mcp, skills, agents, memory files) shows ▸: press it, or run /context-bar overhead skills, to see its biggest items, then + N more for every item. show fewer folds the list back.
The figures match /context. After each turn the card reads Claude Code's free estimate of the window, $.session.usage({ breakdown: 'summary' }). That estimate takes its total from the last response, exactly, but its categories are local estimates, which in a long session ran to twice what /context counts. So the card counts the overhead the way /context does, with breakdown: 'full'. It counts when it first shows, and again only when the estimates show the overhead has changed, as when a tool loads. Between counts, the conversation is the total less the counted overhead.
| Hook | What it does |
|---|---|
session.start | Registers /context-bar, and shows the card again if you left it on. |
session.attach | Shows the card again, in its pane, when a surface without the band connects. |
command.run with {command: "context-bar"} | Shows or hides the card, or opens the overhead and its categories. Where nothing draws, it answers with the figures. |
session.measure | Measures the window again after each turn, and counts the overhead again if it changed. |
session.compact and session.end | Measure again after a compaction, a /clear or a /resume. |
ui.render with {component: "AbovePrompt"} | Draws the card in the band, in the terminal and the desktop app. |
ui.render with {component: "Pane"} and ui.close | Draws the card in its pane where a session has no band, and turns it off when you close the pane. |
Every request Claude Code sends carries more than your conversation, and the overhead is all of it, paid again on every turn:
| Category | What it is |
|---|---|
| tools | The definitions of Claude Code's built-in tools: Bash, Read, Edit and the rest. |
| system | Claude Code's own instructions: how to work, how to use its tools, and details about your environment. |
| mcp | What connected MCP servers cost: the instructions they add about using their tools, and the definitions of their tools loaded into the context. With tool search, most tools load only when needed and cost nothing until then. /context lists the two as MCP server instructions and MCP tools. |
| skills | The list of skills Claude can use, a name and a description for each, so every plugin that brings skills adds to it. |
| agents | The descriptions of the custom agents Claude can hand work to, most of them from plugins. They are listed whether or not one ever runs; Claude Code's built-in agents are not counted. |
| memory files | CLAUDE.md files and auto-memory, loaded when the session starts. |
Messages, the rest of what is in use, is the conversation itself: your prompts, Claude's replies, tool calls and their results, and any text a hook adds, such as a plugin's start-of-session summary. It is the part that grows, and what /compact summarizes.
To trim the overhead, open it, see which skills and agents cost the most, and disable the plugins you are not using with /plugin: their skills and agents leave every request.
After the first turn, which read two modules, 47% of the way to compaction:

After reading three more, 80%. The badge turns red as compaction nears:

/context-bar overhead, or pressing overhead ▸, opens the overhead as ranked bars:

Pressing skills ▸ lists the skills by what they cost, with the rest one press away:

create a mod that draws my context window as a stacked bar above the prompt, one color per category like /context, toggled with /context-bar
Then it was steered in conversation: match the newsletter's card, use only documented theme colors, reimagine the colors with UX principles, show the cells, and let me drill into the overhead.
/context category its own color. /context itself reuses colors (the system prompt and the free space share one), and Claude Code lists the autocompact buffer before the free space, which the first test data hadn't. The tests now use the engine's exact rows.ThemeKey list. They work today, but an update could change them silently, so the palette is now typed as ThemeKey and tsc refuses anything else.▉ glyph, whose last eighth leaves a deliberate gap between cells./context counted (60k against 32k). The card now counts the overhead as /context does, and only when it changes.mcp entry. The legend's swatches are now the bar's own seven-eighths cell. As full blocks, they ran into the gap between cells and sat out of line with the bar./context-bar answers with an exact snapshot there, and the card has a pane ready for when VS Code draws one.claude plugin test (100 tests) and by breaking the code on purpose to check the tests catch it. The screenshots come from driving a real Claude Code session; tools/screenshots takes them again from screenshots/scenario.json.Requirements:
/context-bar answers with the figures as text until the extension draws mod panes. Built and tested on 2.1.290 and 2.1.291.No environment variables or configuration.
Steps:
/plugin install context-bar --marketplace davidlambl/claude-extensions
Answer y to add the marketplace, then choose a scope.
/context-bar. The card stays on in later sessions until you run it again.To try it for one session from a clone instead:
git clone https://github.com/davidlambl/claude-extensions.git
claude --plugin-dir ./claude-extensions/mods/context-bar
/context does, so the card counts when it first shows and whenever the overhead changes. A change under 2% of a category, or under 200 tokens, waits for the next count./context's figures, until the overhead changes again./clear and a /resume, not during a turn./compact needs, so its free space and its percentage both read as /context's do./context's category names, with MCP server instructions and MCP tools together as mcp. If an update renames a category, it shows under its own name, lowercased./context-bar overhead [category] works anywhere.-p run or under the Agent SDK. The card never measures there on its own, whatever you left on. /context-bar answers with a snapshot instead.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
None.
hooks/register.tsx 504 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { ContextBarCount, ContextBarSegment, ContextBarUsage } from '../types'
5import {
6 CELL,
7 FILL,
8 badgeColor,
9 barRuns,
10 breakdownLines,
11 formatTokens,
12 hasShifted,
13 meter,
14 orList,
15 overheadOf,
16 packRows,
17 rankRows,
18 toUsage,
19 topItems,
20 withCount,
21} from './layout'
22
23const isVisible = atom({ plugin: 'context-bar', key: 'isVisible' } as const, false)
24const isDrilled = atom({ plugin: 'context-bar', key: 'isDrilled' } as const, false)
25const openCategory = atom({ plugin: 'context-bar', key: 'openCategory' } as const, null)
26const expandedCategory = atom({ plugin: 'context-bar', key: 'expandedCategory' } as const, null)
27const usage = atom({ plugin: 'context-bar', key: 'usage' } as const, null)
28const counted = atom({ plugin: 'context-bar', key: 'counted' } as const, null)
29
30/**
31 * Whether an exact count is scheduled or running, so a second one waits for
32 * the next turn. Not drawn from, so a module variable will do: a reload clears
33 * it, and the most that costs is one count more.
34 */
35let isCounting = false
36
37/** Long enough for the engine to install a compaction or a /clear before we measure. */
38const SETTLE_MS = 250
39
40/**
41 * The surfaces that draw the band above the prompt. Everywhere else (Claude
42 * Code for VS Code, the mobile app) the card opens in a pane under this id.
43 */
44const BAND_SURFACES: readonly RenderSurface[] = ['terminal', 'desktop']
45const PANE = 'context-bar'
46
47/**
48 * How this session draws the card: in the band, where a terminal or the
49 * desktop app shows the session; in a pane, where only other surfaces do; or
50 * nowhere, in a `-p` run or under the SDK, where nothing is drawn at all.
51 */
52async function form($: EngineInterface): Promise<'band' | 'pane' | null> {
53 const surfaces = await $.session.surfaces()
54 if (surfaces.length === 0) return null
55
56 return surfaces.some(surface => BAND_SURFACES.includes(surface)) ? 'band' : 'pane'
57}
58
59/** Where the choice to show the card is kept: one for the band, one for the pane, so each form keeps its own. */
60const CHOICE = { band: 'isVisible', pane: 'isPaneVisible' } as const
61
62/** The card's border and padding take two cells on each side. */
63const CHROME = 4
64
65const TITLE = '◆ context'
66
67/** Cells between legend entries. */
68const GAP = 3
69
70/**
71 * A legend entry's swatch: one of the bar's own cells, so it stands exactly as
72 * wide as a cell of the bar, with the same gap after it. A full block, or a
73 * square from the font, runs into that gap and sits out of line with the bar.
74 */
75const SWATCH = CELL
76
77/** How far the ranked bars sit in from the card's edge; a category's items sit two further. */
78const INDENT = 2
79
80/** The items a category opens to before the rest are summed up in one line. */
81const MAX_ITEMS = 8
82
83/** The most an item's name may take before it is cut. */
84const ITEM_LABEL = 28
85
86/**
87 * Measures the window as /context breaks it down. The `summary` breakdown is
88 * free and takes its total from the last response's usage, exactly, but its
89 * categories are local estimates that can run to twice what /context counts.
90 * So the overhead comes from the last exact count, taken again in the
91 * background whenever the estimates show the overhead has changed.
92 */
93async function refresh($: EngineInterface) {
94 const { context } = await $.session.usage({ breakdown: 'summary' })
95 if (context.breakdown === undefined) {
96 await update($, usage, () => null)
97 return
98 }
99
100 const estimate = toUsage(context.breakdown)
101 const count = await read($, counted)
102 await update($, usage, () => withCount(estimate, count))
103 if (!isCounting && (count === null || hasShifted(overheadOf(estimate), count.basis))) {
104 isCounting = true
105 $.clock.after(0, () => void countExactly($))
106 }
107}
108
109/**
110 * Counts the overhead with the token-count API, as /context does: one request
111 * per tool and memory file, which is why it runs only when the overhead
112 * changed. Should the count fail, the estimates stand until they shift again.
113 */
114async function countExactly($: EngineInterface) {
115 try {
116 const { context } = await $.session.usage({ breakdown: 'summary' })
117 if (context.breakdown === undefined) return
118 const estimate = toUsage(context.breakdown)
119
120 const count = await countOverhead($, estimate)
121 await update($, counted, () => count)
122 await update($, usage, () => withCount(estimate, count))
123 } finally {
124 isCounting = false
125 }
126}
127
128/** One exact count of the overhead, with the estimates it was taken against as its basis. */
129async function countOverhead($: EngineInterface, estimate: ContextBarUsage): Promise<ContextBarCount> {
130 let segments: ContextBarSegment[] | null = null
131 try {
132 const exact = (await $.session.usage({ breakdown: 'full' })).context.breakdown
133 if (exact !== undefined) segments = overheadOf(toUsage(exact))
134 } catch {
135 // Keeps the estimates; the basis holds the next attempt off until they move.
136 }
137
138 return { basis: overheadOf(estimate).map(({ name, tokens }) => ({ name, tokens })), segments }
139}
140
141/**
142 * The card's figures as text, for a session that draws nowhere: today Claude
143 * Code for VS Code, whose panel draws no mod's band or pane yet, and `-p`
144 * runs. Measured now, exactly; the last count serves while the overhead holds.
145 * With a category, its biggest items too.
146 */
147async function snapshot($: EngineInterface, category: string | null): Promise<string> {
148 const { context } = await $.session.usage({ breakdown: 'summary' })
149 if (context.breakdown === undefined) return 'the context window could not be measured'
150
151 const estimate = toUsage(context.breakdown)
152 const last = await read($, counted)
153 const count = last !== null && !hasShifted(overheadOf(estimate), last.basis) ? last : await countOverhead($, estimate)
154 await update($, counted, () => count)
155
156 const measured = withCount(estimate, count)
157 const m = meter(measured)
158 const limit = measured.compactsAt === null ? ` of ${formatTokens(measured.maxTokens)}` : ' used'
159 const compacts = measured.compactsAt === null ? '' : ` · compacts at ${formatTokens(measured.compactsAt)}`
160 const lines = [
161 `${TITLE} ${formatTokens(m.used)}${limit}${compacts} · ${m.percent}%`,
162 `overhead ${formatTokens(m.overhead.tokens)} · messages ${formatTokens(m.messages)} · free ${formatTokens(m.free)}`,
163 ...breakdownLines(m.overhead.parts, Infinity),
164 ]
165
166 if (category !== null) {
167 const openable = m.overhead.parts.filter(part => (part.items?.length ?? 0) > 0)
168 const part = openable.find(one => one.label === category)
169 if (part === undefined) {
170 return openable.length === 0
171 ? 'the overhead has nothing to open'
172 : `nothing to open in "${category}": try ${orList(openable.map(one => one.label))}`
173 }
174 const { shown, rest } = topItems(part.items!, MAX_ITEMS)
175 const items = shown.map(item => `${item.name} ${formatTokens(item.tokens)}`)
176 if (rest !== null) items.push(`+ ${rest.count} more ${formatTokens(rest.tokens)}`)
177 lines.push(`${part.label} ${formatTokens(part.tokens)}: ${items.join(', ')}`)
178 }
179
180 return [...lines, "a snapshot: this session can't keep the card on screen; run /context-bar again to measure again"].join('\n')
181}
182
183/**
184 * Shows or hides the card, as a pane where the session has no band, and
185 * remembers which for the next session; says which form it took.
186 */
187async function show($: EngineInterface, shown: boolean): Promise<'band' | 'pane' | null> {
188 const where = await form($)
189 if (shown) await refresh($)
190 await update($, isVisible, () => shown)
191 if (where === 'pane') {
192 if (shown) await $.ui.open({ id: PANE, title: 'context' })
193 else await $.ui.close({ id: PANE })
194 }
195 await $.store.set(CHOICE[where ?? 'band'], shown)
196
197 return where
198}
199
200/**
201 * Shows the card again if the person left it on in an earlier session, in the
202 * form this session draws it. A session that draws nowhere measures nothing.
203 */
204async function restore($: EngineInterface) {
205 const where = await form($)
206 if (where === null) return
207
208 const shown = (await $.store.get(CHOICE[where])) === true
209 if (shown) await refresh($)
210 await update($, isVisible, () => shown)
211 if (shown && where === 'pane') void $.ui.open({ id: PANE, title: 'context' })
212}
213
214/** Opens the overhead to one category's items, `/context-bar overhead <category>`. */
215async function openOverhead($: EngineInterface, category: string) {
216 if (!(await read($, isVisible))) await show($, true)
217
218 const measured = await read($, usage)
219 const openable = measured === null ? [] : meter(measured).overhead.parts.filter(part => (part.items?.length ?? 0) > 0)
220 if (!openable.some(part => part.label === category)) {
221 return openable.length === 0
222 ? 'the overhead has nothing to open'
223 : `nothing to open in "${category}": try ${orList(openable.map(part => part.label))}`
224 }
225
226 await update($, isDrilled, () => true)
227 await update($, expandedCategory, () => null)
228 await update($, openCategory, () => category)
229
230 return `showing ${category}`
231}
232
233export const register: Register = on => {
234 on('session.start', async ($, e, next) => {
235 await $.command.register({
236 name: 'context-bar',
237 description: 'Show or hide your context window as a bar above the prompt (its figures where none can draw); overhead opens its breakdown',
238 argumentHint: '[overhead [category]]',
239 immediate: true,
240 })
241 await restore($)
242
243 return next(e)
244 })
245
246 on('command.run', { command: 'context-bar' }, async ($, e) => {
247 const option = e.args.trim().replace(/\s+/g, ' ')
248
249 // Where nothing draws, a card left on would only measure for nobody: answer with the figures instead.
250 if ((await form($)) === null && (option === '' || option === 'overhead' || option.startsWith('overhead '))) {
251 await update($, isVisible, () => false)
252
253 return { text: await snapshot($, option.startsWith('overhead ') ? option.slice('overhead '.length) : null) }
254 }
255
256 if (option === 'overhead') {
257 const drilled = !(await read($, isDrilled))
258 if (drilled && !(await read($, isVisible))) await show($, true)
259 await update($, isDrilled, () => drilled)
260
261 return { text: drilled ? 'overhead shown' : 'overhead hidden' }
262 }
263 if (option.startsWith('overhead ')) return { text: await openOverhead($, option.slice('overhead '.length)) }
264 if (option !== '') return { text: `unknown option "${option}": try /context-bar or /context-bar overhead` }
265
266 const shown = !(await read($, isVisible))
267 const where = await show($, shown)
268 if (!shown) return { text: 'off' }
269
270 return { text: where === 'pane' ? 'on, in a pane' : 'on' }
271 })
272
273 on('session.measure', async ($, e, next) => {
274 if (e.changed.includes('context') && (await read($, isVisible))) await refresh($)
275
276 return next(e)
277 })
278
279 on('session.compact', async ($, e, next) => {
280 const compacted = await next(e)
281 if (await read($, isVisible)) $.clock.after(SETTLE_MS, () => void refresh($))
282
283 return compacted
284 }).catch(($, e, next) => next(e)) // replays the compaction already run; never runs it twice
285
286 // A /clear or a /resume puts another conversation in place, and no session.start follows a /clear.
287 on('session.end', async ($, e, next) => {
288 const ended = await next(e)
289 if (e.reason === 'clear' || e.reason === 'resume') $.clock.after(SETTLE_MS, () => void restore($))
290
291 return ended
292 })
293
294 // Claude Code for VS Code and the mobile app join as they connect, after session.start.
295 on('session.attach', async ($, e, next) => {
296 const attached = await next(e)
297 await restore($)
298
299 return attached
300 })
301
302 // Closing the pane is the person's /context-bar off, kept for the next session that draws a pane.
303 on('ui.close', { id: PANE }, async ($, e, next) => {
304 const closed = await next(e)
305 if (e.origin.kind === 'person') {
306 await update($, isVisible, () => false)
307 await $.store.set(CHOICE.pane, false)
308 }
309
310 return closed
311 }).catch(($, e, next) => next(e)) // replays the close already made; never closes twice
312
313 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
314 if (e.props.hasSurvey || !(await read($, isVisible))) return next(e)
315
316 const measured = await read($, usage)
317 if (measured === null) return next(e)
318
319 return card($, $.ui.resolve(e), measured, e.props.bodyColumns, true)
320 })
321
322 // Where the session has no band (Claude Code for VS Code, the mobile app), the card is a pane's body.
323 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
324 const measured = await read($, usage)
325 if (measured === null) {
326 const { Text } = $.ui.resolve(e)
327 return <Text dimColor>measuring the context window…</Text>
328 }
329
330 return card($, $.ui.resolve(e), measured, e.props.bodyColumns, false)
331 })
332}
333
334/**
335 * The card: the header, the meter, its legend, and the overhead's breakdown or
336 * its ranked bars, laid out `columns` cells wide. Framed for the band; a
337 * pane's own frame holds it there.
338 */
339async function card($: EngineInterface, ui: ElementTable, measured: ContextBarUsage, columns: number, isFramed: boolean) {
340 const { Box, Button, Text } = ui
341 const drilled = await read($, isDrilled)
342 const open = await read($, openCategory)
343 const expanded = await read($, expandedCategory)
344 const m = meter(measured)
345 const inner = isFramed ? columns - CHROME : columns
346 const total = formatTokens(m.used)
347 const limit = measured.compactsAt === null ? ` of ${formatTokens(measured.maxTokens)}` : ' used'
348 const compacts = measured.compactsAt === null ? '' : ` · compacts at ${formatTokens(measured.compactsAt)}`
349 const badge = ` ${m.percent}% `
350 const hasRoom = TITLE.length + 1 + total.length + limit.length + compacts.length + 1 + badge.length <= inner
351
352 const fills = [
353 { label: 'overhead', tokens: m.overhead.tokens, color: FILL.overhead },
354 { label: 'messages', tokens: m.messages, color: FILL.messages },
355 { label: 'free', tokens: m.free, color: FILL.free },
356 ].map(fill => ({ ...fill, amount: formatTokens(fill.tokens) }))
357 const opener = `overhead ${fills[0]!.amount} ${drilled ? '▾' : '▸'}`
358 const rows = packRows(
359 fills.map((fill, i) => 2 + (i === 0 ? opener.length : fill.label.length + 1 + fill.amount.length)),
360 inner,
361 GAP,
362 )
363
364 const parts = m.overhead.parts
365 const isOpenable = (part: (typeof parts)[number]) => (part.items?.length ?? 0) > 0
366 const arrow = (part: (typeof parts)[number]) => (isOpenable(part) ? (open === part.label ? ' ▾' : ' ▸') : '')
367 const ranked = rankRows(
368 parts.map(part => ({ label: `${part.label}${arrow(part)}`, tokens: part.tokens })),
369 inner - INDENT,
370 )
371 const opened = parts.find(part => part.label === open && isOpenable(part))
372 const showsAll = opened !== undefined && opened.label === expanded
373 const top = opened === undefined ? null : topItems(opened.items!, showsAll ? Infinity : MAX_ITEMS)
374 const canFold = showsAll && opened.items!.length > MAX_ITEMS
375 const items =
376 top === null
377 ? null
378 : rankRows(
379 top.shown.map(item => ({ label: item.name, tokens: item.tokens })),
380 inner - INDENT - 2,
381 ITEM_LABEL,
382 )
383 const breakdown = breakdownLines(parts, inner)
384
385 return (
386 <Box flexDirection="column" {...(isFramed ? { borderStyle: 'round', borderColor: 'subtle', paddingX: 1 } : {})}>
387 <Box key="header" justifyContent="space-between">
388 <Text>
389 <Text color="claude">◆</Text> <Text bold>context</Text>
390 </Text>
391 <Text>
392 <Text bold>{total}</Text>
393 <Text dimColor>
394 {limit}
395 {hasRoom ? compacts : ''}
396 </Text>{' '}
397 <Text color="inverseText" backgroundColor={badgeColor(m)}>
398 {badge}
399 </Text>
400 </Text>
401 </Box>
402 <Box key="bar">
403 {barRuns(fills, inner).map(({ glyph, length, color }) => (
404 <Text color={color}>{glyph.repeat(length)}</Text>
405 ))}
406 </Box>
407 <Box key="legend" flexDirection="column">
408 {rows.map(row => (
409 <Box columnGap={GAP}>
410 {row.map(i => {
411 const fill = fills[i]!
412 if (i === 0) {
413 return (
414 <Box>
415 <Text>
416 <Text color={fill.color}>{SWATCH}</Text>{' '}
417 </Text>
418 <Button key="overhead" plain label={opener} onPress={() => update($, isDrilled, shown => !shown)} />
419 </Box>
420 )
421 }
422
423 return (
424 <Text>
425 <Text color={fill.color}>{SWATCH}</Text> {fill.label} <Text bold>{fill.amount}</Text>
426 </Text>
427 )
428 })}
429 </Box>
430 ))}
431 </Box>
432 {drilled && parts.length > 0 && (
433 <Box key="drill" flexDirection="column">
434 {parts.flatMap((part, i) => {
435 const row = ranked.rows[i]!
436 const line = (
437 <Box key={`row:${part.label}`}>
438 <Text>{' '.repeat(INDENT)}</Text>
439 {isOpenable(part) ? (
440 <Button
441 key={`category:${part.label}`}
442 plain
443 label={row.label}
444 onPress={async () => {
445 await update($, expandedCategory, () => null)
446 await update($, openCategory, current => (current === part.label ? null : part.label))
447 }}
448 />
449 ) : (
450 <Text>{row.label}</Text>
451 )}
452 <Text>
453 {' '.repeat(row.pad + 2)}
454 <Text color={FILL.overhead}>{CELL.repeat(row.cells)}</Text> <Text bold>{row.amount}</Text>
455 </Text>
456 </Box>
457 )
458 if (part !== opened || top === null || items === null) return [line]
459
460 return [
461 line,
462 <Box key="items" flexDirection="column">
463 {items.rows.map((item, j) => (
464 <Box key={`row:item:${j}`}>
465 <Text>
466 {' '.repeat(INDENT + 2)}
467 {item.label}
468 {' '.repeat(item.pad + 2)}
469 <Text color={FILL.overhead}>{CELL.repeat(item.cells)}</Text> <Text bold>{item.amount}</Text>
470 </Text>
471 </Box>
472 ))}
473 {(top.rest !== null || canFold) && (
474 <Box key="more-row">
475 <Text>{' '.repeat(INDENT + 2)}</Text>
476 <Button
477 key="more"
478 plain
479 dimColor
480 label={
481 top.rest === null
482 ? 'show fewer ▴'
483 : `+ ${top.rest.count} more ${formatTokens(top.rest.tokens)} ▸`
484 }
485 onPress={() => update($, expandedCategory, () => (top.rest === null ? null : part.label))}
486 />
487 </Box>
488 )}
489 </Box>,
490 ]
491 })}
492 </Box>
493 )}
494 {!drilled && breakdown.length > 0 && (
495 <Box key="breakdown" flexDirection="column">
496 {breakdown.map(line => (
497 <Text dimColor>{line}</Text>
498 ))}
499 </Box>
500 )}
501 </Box>
502 )
503}
504hooks/layout.ts 347 lines1import type { SessionContextBreakdown, ThemeKey } from 'claude-code'
2
3import type { ContextBarCount, ContextBarItem, ContextBarSegment, ContextBarUsage } from '../types'
4
5/**
6 * The meter's fills, from the theme keys the mod API documents: the
7 * conversation in the accent, the overhead every request carries in quiet
8 * gray, and the room left as the track.
9 */
10export const FILL = { overhead: 'inactive', messages: 'claude', free: 'subtle' } as const satisfies Record<
11 string,
12 ThemeKey
13>
14
15/** The /context category that is the conversation; every other one is overhead. */
16const MESSAGES = 'Messages'
17
18/**
19 * Shorter names for /context's categories; one it does not know keeps its own,
20 * lowercased. Both of MCP's categories go under one name, so the card shows one
21 * entry for what MCP servers cost, not two that read alike.
22 */
23const LABELS = new Map([
24 ['System prompt', 'system'],
25 ['System tools', 'tools'],
26 ['MCP tools', 'mcp'],
27 ['MCP server instructions', 'mcp'],
28 ['Custom agents', 'agents'],
29 ['Memory files', 'memory files'],
30 ['Skills', 'skills'],
31])
32
33/**
34 * The breakdown as the card keeps it, leaving out the tool schemas deferred
35 * until searched for, each category with what it is made of where listed.
36 */
37export function toUsage(breakdown: SessionContextBreakdown): ContextBarUsage {
38 return {
39 segments: breakdown.categories.flatMap(({ name, tokens, kind }) => {
40 if (kind === 'deferred') return []
41 const items = itemsOf({ name, tokens }, breakdown)
42
43 return [{ name, tokens, kind, ...(items === undefined ? {} : { items }) }]
44 }),
45 totalTokens: breakdown.totalTokens,
46 maxTokens: breakdown.rawMaxTokens,
47 compactsAt: breakdown.autoCompactThreshold ?? null,
48 }
49}
50
51/**
52 * What a /context category is made of, for those the breakdown lists: the MCP
53 * server instructions as one item and the MCP tools in the window by server
54 * (the two make up `mcp`), the agents, the memory files, the skills.
55 */
56function itemsOf(category: ContextBarItem, breakdown: SessionContextBreakdown): ContextBarItem[] | undefined {
57 switch (category.name) {
58 case 'MCP server instructions':
59 return [{ name: 'instructions', tokens: category.tokens }]
60 case 'MCP tools': {
61 const servers = new Map<string, number>()
62 for (const tool of breakdown.mcpTools) {
63 if (tool.isLoaded) servers.set(tool.serverName, (servers.get(tool.serverName) ?? 0) + tool.tokens)
64 }
65
66 return [...servers].map(([name, tokens]) => ({ name, tokens }))
67 }
68 case 'Custom agents':
69 return breakdown.agents.map(agent => ({ name: agent.agentType, tokens: agent.tokens }))
70 case 'Memory files':
71 return breakdown.memoryFiles.map(file => ({
72 name: `${file.path.split(/[\\/]/).pop()} (${file.type.toLowerCase()})`,
73 tokens: file.tokens,
74 }))
75 case 'Skills':
76 return breakdown.skills?.skillFrontmatter.map(skill => ({ name: skill.name, tokens: skill.tokens }))
77 default:
78 return undefined
79 }
80}
81
82export type OverheadPart = { label: string; tokens: number; items?: ContextBarItem[] }
83
84export type Meter = {
85 /**
86 * What the meter fills toward: where auto-compaction runs, or, when it is
87 * off, the window's end less the buffer held back for /compact.
88 */
89 capacity: number
90 used: number
91 /**
92 * `used` as a whole percentage of where auto-compaction runs, or, when it is
93 * off, of the whole window, as /context gives it; past 100 once over.
94 */
95 percent: number
96 messages: number
97 /** Every other category in use, largest first, each with its items largest first. */
98 overhead: { tokens: number; parts: OverheadPart[] }
99 free: number
100}
101
102/**
103 * The window as a meter: the conversation, the overhead, and the room left
104 * before compaction. The compaction point already sits a buffer below the
105 * window's end; with auto-compaction off, /context still holds a smaller
106 * buffer back for /compact, so the meter stops short of the end by as much
107 * and its free space reads as /context's does, while its percentage stays of
108 * the whole window, as /context's does.
109 */
110export function meter(usage: ContextBarUsage): Meter {
111 const held = usage.segments.filter(segment => segment.kind === 'buffer').reduce((sum, segment) => sum + segment.tokens, 0)
112 const capacity = usage.compactsAt ?? (held < usage.maxTokens ? usage.maxTokens - held : usage.maxTokens)
113 const scale = usage.compactsAt ?? usage.maxTokens
114 const inUse = usage.segments.filter(segment => segment.kind === 'used')
115 const byLabel = new Map<string, OverheadPart>()
116 for (const { name, tokens, items } of inUse.filter(segment => segment.name !== MESSAGES)) {
117 const label = LABELS.get(name) ?? name.toLowerCase()
118 const part = byLabel.get(label)
119 const merged = [...(part?.items ?? []), ...(items ?? [])]
120 byLabel.set(label, {
121 label,
122 tokens: (part?.tokens ?? 0) + tokens,
123 ...(part?.items === undefined && items === undefined ? {} : { items: merged.sort(largestFirst) }),
124 })
125 }
126 const parts = [...byLabel.values()].sort(largestFirst)
127
128 return {
129 capacity,
130 used: usage.totalTokens,
131 percent: Math.round((usage.totalTokens / scale) * 100),
132 messages: inUse.find(segment => segment.name === MESSAGES)?.tokens ?? 0,
133 overhead: { tokens: parts.reduce((sum, part) => sum + part.tokens, 0), parts },
134 free: Math.max(0, capacity - usage.totalTokens),
135 }
136}
137
138function largestFirst(a: { tokens: number }, b: { tokens: number }): number {
139 return b.tokens - a.tokens
140}
141
142/** A breakdown's overhead: every category in use but the conversation. */
143export function overheadOf(usage: ContextBarUsage): ContextBarSegment[] {
144 return usage.segments.filter(segment => segment.kind === 'used' && segment.name !== MESSAGES)
145}
146
147/**
148 * Whether the overhead's estimates have moved since it was counted: a category
149 * came or went, or one moved by more than 2% of itself (and at least 200
150 * tokens), as when a tool loads; smaller drift leaves the count standing.
151 */
152export function hasShifted(estimates: readonly ContextBarItem[], basis: readonly ContextBarItem[]): boolean {
153 if (estimates.length !== basis.length) return true
154 const before = new Map(basis.map(item => [item.name, item.tokens]))
155
156 return estimates.some(({ name, tokens }) => {
157 const was = before.get(name)
158
159 return was === undefined || Math.abs(tokens - was) > Math.max(200, was * 0.02)
160 })
161}
162
163/**
164 * The window with the overhead as counted: the total and the room left stay
165 * the estimate's (it takes the total from the last response's usage, exactly),
166 * and the conversation is whatever of the total the counted overhead leaves.
167 */
168export function withCount(estimate: ContextBarUsage, count: ContextBarCount | null): ContextBarUsage {
169 if (count === null || count.segments === null) return estimate
170 const overhead = count.segments.reduce((sum, segment) => sum + segment.tokens, 0)
171
172 return {
173 ...estimate,
174 segments: [
175 ...count.segments,
176 { name: MESSAGES, tokens: Math.max(0, estimate.totalTokens - overhead), kind: 'used' },
177 ...estimate.segments.filter(segment => segment.kind !== 'used'),
178 ],
179 }
180}
181
182/**
183 * The overhead's one-line breakdown (`overhead: tools 14.6k, mcp 11k, …`)
184 * broken into lines `width` cells wide, only ever between entries.
185 */
186export function breakdownLines(parts: readonly { label: string; tokens: number }[], width: number): string[] {
187 const entries = parts.map(
188 (part, i) => `${i === 0 ? 'overhead: ' : ''}${part.label} ${formatTokens(part.tokens)}${i < parts.length - 1 ? ',' : ''}`,
189 )
190
191 return packRows(
192 entries.map(entry => entry.length),
193 width,
194 1,
195 ).map(row => row.map(i => entries[i]).join(' '))
196}
197
198/** The first `max` items, and how many more there are and what they come to, if any. */
199export function topItems(
200 items: readonly ContextBarItem[],
201 max: number,
202): { shown: ContextBarItem[]; rest: { count: number; tokens: number } | null } {
203 const rest = items.slice(max)
204
205 return {
206 shown: items.slice(0, max),
207 rest: rest.length === 0 ? null : { count: rest.length, tokens: rest.reduce((sum, item) => sum + item.tokens, 0) },
208 }
209}
210
211export type RankRow = { label: string; pad: number; cells: number; amount: string }
212
213/**
214 * A ranked bar chart laid out `width` cells wide: each label padded to the
215 * widest (cut with an ellipsis past `maxLabel`), two cells, its bar scaled to
216 * the largest value, a cell, and its amount.
217 */
218export function rankRows(
219 entries: readonly { label: string; tokens: number }[],
220 width: number,
221 maxLabel = Infinity,
222): { labelWidth: number; rows: RankRow[] } {
223 const labelWidth = Math.min(maxLabel, Math.max(0, ...entries.map(entry => entry.label.length)))
224 const labels = entries.map(({ label }) => (label.length > labelWidth ? `${label.slice(0, labelWidth - 1)}…` : label))
225 const amounts = entries.map(entry => formatTokens(entry.tokens))
226 const amountWidth = Math.max(0, ...amounts.map(amount => amount.length))
227 const cells = scaleBars(
228 entries.map(entry => entry.tokens),
229 width - labelWidth - 2 - 1 - amountWidth,
230 )
231
232 return {
233 labelWidth,
234 rows: labels.map((label, i) => ({ label, pad: labelWidth - label.length, cells: cells[i]!, amount: amounts[i]! })),
235 }
236}
237
238/** Words joined as a sentence lists them: `a, b or c`. */
239export function orList(words: readonly string[]): string {
240 return words.length < 2 ? (words[0] ?? '') : `${words.slice(0, -1).join(', ')} or ${words[words.length - 1]}`
241}
242
243/**
244 * The badge's status color, from the percentage it shows: green while
245 * compaction (or the window's end) is far off, yellow nearing it, red close.
246 */
247export function badgeColor(m: Meter): 'success' | 'warning' | 'error' {
248 return m.percent < 50 ? 'success' : m.percent < 80 ? 'warning' : 'error'
249}
250
251/**
252 * One cell of a bar: seven-eighths of a block, so the eighth left over draws a
253 * thin gap after every cell, and every cell stands at the same height.
254 */
255export const CELL = '▉'
256
257export type BarRun = { glyph: string; length: number; color: string }
258
259/** The bar as runs of cells, `width` long, each segment's cells in its color. */
260export function barRuns(segments: readonly { tokens: number; color: string }[], width: number): BarRun[] {
261 const cells = allocateCells(
262 segments.map(segment => segment.tokens),
263 width,
264 )
265
266 return segments.flatMap((segment, i) => (cells[i]! > 0 ? [{ glyph: CELL, color: segment.color, length: cells[i]! }] : []))
267}
268
269/**
270 * Bars for a ranked chart: the largest value's bar fills `width` cells and the
271 * rest scale to it, each that holds anything at least one cell long.
272 */
273export function scaleBars(tokens: readonly number[], width: number): number[] {
274 const largest = Math.max(0, ...tokens)
275 if (width <= 0 || largest === 0) return tokens.map(() => 0)
276
277 return tokens.map(n => (n > 0 ? Math.max(1, Math.round((n / largest) * width)) : 0))
278}
279
280/** Packs entries of the given widths into rows `width` cells wide, `gap` cells apart, as few rows as fit. */
281export function packRows(widths: readonly number[], width: number, gap: number): number[][] {
282 const rows: number[][] = []
283 let filled = 0
284 widths.forEach((entry, i) => {
285 const row = rows[rows.length - 1]
286 if (row !== undefined && filled + gap + entry <= width) {
287 row.push(i)
288 filled += gap + entry
289 } else {
290 rows.push([i])
291 filled = entry
292 }
293 })
294
295 return rows
296}
297
298/**
299 * Splits `width` cells among segments in proportion to their tokens, as whole
300 * cells adding up to `width`. While there are cells enough, every segment that
301 * holds tokens gets at least one, as /context's grid gives every row a square;
302 * those cells come out of the segments drawn widest past their share.
303 */
304export function allocateCells(tokens: readonly number[], width: number): number[] {
305 const total = tokens.reduce((sum, n) => sum + n, 0)
306 if (width <= 0 || total <= 0) return tokens.map(() => 0)
307
308 const exact = tokens.map(n => (n / total) * width)
309 const cells = exact.map(Math.floor)
310
311 if (tokens.filter(n => n > 0).length <= width) {
312 tokens.forEach((n, i) => {
313 if (n > 0 && cells[i] === 0) cells[i] = 1
314 })
315 }
316
317 let spare = width - cells.reduce((sum, n) => sum + n, 0)
318 while (spare > 0) {
319 const i = widest(exact.map((x, j) => x - cells[j]!))
320 cells[i]! += 1
321 spare -= 1
322 }
323 while (spare < 0) {
324 const i = widest(cells.map((n, j) => (n > 1 ? n - exact[j]! : -Infinity)))
325 cells[i]! -= 1
326 spare += 1
327 }
328
329 return cells
330}
331
332/** The index of the largest value, the first on a tie. */
333function widest(values: readonly number[]): number {
334 return values.reduce((best, value, i) => (value > values[best]! ? i : best), 0)
335}
336
337/** A token count as the card prints one: `999`, `1.2k`, `62.4k`, from 100k whole (`212k`), `1.5M`. */
338export function formatTokens(tokens: number): string {
339 if (tokens < 1_000) return String(Math.round(tokens))
340 if (tokens < 100_000) return `${Math.round(tokens / 100) / 10}k`
341
342 const thousands = Math.round(tokens / 1_000)
343 if (thousands < 1_000) return `${thousands}k`
344
345 return `${Math.round(tokens / 100_000) / 10}M`
346}
347types/index.d.ts 53 lines1/** One thing a category is made of: an MCP server, an agent, a memory file, a skill. */
2export type ContextBarItem = {
3 name: string
4 tokens: number
5}
6
7/**
8 * One row of /context's breakdown that sits inside the window: a category
9 * in use, the free space, or the autocompact buffer.
10 */
11export type ContextBarSegment = {
12 name: string
13 tokens: number
14 kind: 'used' | 'free' | 'buffer'
15 /** What the category is made of, where the breakdown itemizes it. */
16 items?: ContextBarItem[]
17}
18
19/** The window as the card draws it, measured after each turn. */
20export type ContextBarUsage = {
21 /** /context's rows inside the window, in its order. */
22 segments: ContextBarSegment[]
23 totalTokens: number
24 maxTokens: number
25 /** The token count auto-compaction runs at, or null when it is off. */
26 compactsAt: number | null
27}
28
29/** The overhead as last counted with the token-count API, as /context counts it. */
30export type ContextBarCount = {
31 /** The overhead's local estimates when it was counted; the count is taken again once they shift. */
32 basis: ContextBarItem[]
33 /** The overhead's categories as counted, or null when the count failed and the estimates stand. */
34 segments: ContextBarSegment[] | null
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'context-bar': {
40 isVisible: boolean
41 /** Whether the overhead is open as ranked bars; for the session only. */
42 isDrilled: boolean
43 /** The overhead category open to its items, by its label; for the session only. */
44 openCategory: string | null
45 /** The open category showing every item, not only the largest; for the session only. */
46 expandedCategory: string | null
47 usage: ContextBarUsage | null
48 /** The last exact count of the overhead; for the session only. */
49 counted: ContextBarCount | null
50 }
51 }
52}
53