SLOPSHOPPER

context-bar

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…

newpanebandcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-bar
│ ┃ context-bar ✕ › fix the failing auth test and add an audit log call │ ┃ measuring the context window… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /context-bar │ ⎿ context-bar: on │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · context-bar
measuring the context window…
README

Context Bar

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.

What this shows

Type /context-bar and a card appears in the band above the prompt:

  • a header with the tokens in use, where auto-compaction starts, and a badge with how far along you are: green under half, yellow under 80%, red past that;
  • a meter, drawn in cells, that fills toward the compaction point: the overhead in gray, the conversation (messages) in orange, and the room left as the dark track;
  • a legend, and a line breaking the overhead down, largest first.

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.

HookWhat it does
session.startRegisters /context-bar, and shows the card again if you left it on.
session.attachShows 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.measureMeasures the window again after each turn, and counts the overhead again if it changed.
session.compact and session.endMeasure 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.closeDraws the card in its pane where a session has no band, and turns it off when you close the pane.

What the overhead is

Every request Claude Code sends carries more than your conversation, and the overhead is all of it, paid again on every turn:

CategoryWhat it is
toolsThe definitions of Claude Code's built-in tools: Bash, Read, Edit and the rest.
systemClaude Code's own instructions: how to work, how to use its tools, and details about your environment.
mcpWhat 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.
skillsThe list of skills Claude can use, a name and a description for each, so every plugin that brings skills adds to it.
agentsThe 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 filesCLAUDE.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.

Demo

After the first turn, which read two modules, 47% of the way to compaction:

Context Bar after one turn: 78.1k used, compaction at 167k, 47%

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

Context Bar after two turns: 134k used, 80%

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

The overhead opened: tools, system, skills, agents and mcp as ranked bars

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

Skills opened to their biggest items, with 121 more behind a button

How it was built

  • Model: built with Claude Opus 5.5 in Claude Code. The screenshots come from a session on Claude Haiku 4.5, whose 200k window fills within a couple of turns. The mod itself doesn't call a model.
  • Prompt(s): the build started from the prompt in the Claude Code newsletter:

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.

  • Transcript: not shared.
  • Iterations:
  • The first version gave every /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.
  • Matching the newsletter's card used theme colors outside the mod API's documented 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.
  • Half-cell slices mixed block glyphs with background colors. Terminals that draw glyphs from the font, such as Apple Terminal, draw them shorter than the row, so the bar looked uneven. Every cell is now the same ▉ glyph, whose last eighth leaves a deliberate gap between cells.
  • The colors still blended. The dataviz skill's palette validator failed the per-category palette on four of five checks, and only four documented theme colors pass even on their own. So the bar became a meter with emphasis: one accent for the conversation, gray for the rest, filling toward compaction rather than the end of the window, which also dropped the reserve band.
  • The overhead became something to open: ranked bars per category, then the items inside the categories the API lists, then every item.
  • The overhead read from the free local estimates, which in a long session came to nearly twice what /context counted (60k against 32k). The card now counts the overhead as /context does, and only when it changes.
  • MCP's two categories read alike on the card, so they became one 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.
  • In VS Code the card had nowhere to draw, because the extension doesn't draw mod UI yet. /context-bar answers with an exact snapshot there, and the card has a pane ready for when VS Code draws one.
  • Tested with 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.

Run it

Requirements:

  • Claude Code 2.1.287 or later, in the terminal or the desktop app, where the card sits above the prompt. In VS Code, /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:

  1. Install it from a Claude Code prompt in a terminal:
   /plugin install context-bar --marketplace davidlambl/claude-extensions

Answer y to add the marketplace, then choose a scope.

  1. Type /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

Notes / limitations

  • The overhead is counted only when it changes. Each count sends one token-count request per tool and memory file, as /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.
  • If a count fails, the card shows the estimates, which can run well over /context's figures, until the overhead changes again.
  • The card updates after each turn, a compaction, a /clear and a /resume, not during a turn.
  • The meter fills toward compaction, so its percentage is of the auto-compact point, not the whole window. With auto-compaction off, it fills toward the end of the window, less the small buffer /compact needs, so its free space and its percentage both read as /context's do.
  • Only some categories open to items. The mod API lists the parts of mcp (its instructions, and its loaded tools by server), skills, agents and memory files, but not of tools or system.
  • The short labels follow /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.
  • Pressing a button needs the fullscreen terminal for a click; /context-bar overhead [category] works anywhere.
  • Long lists scroll inside the band, which takes at most half the terminal's height.
  • In 16-color terminal themes, the overhead and the free space share a gray.
  • One band per session. Another mod that draws above the prompt competes for the same band.
  • The band and the pane each remember their own choice, so turning the card off in VS Code leaves it on in the terminal.
  • A session that a terminal or the desktop app shows draws the band. A phone attached to it alongside sees no card.
  • Nothing is drawn in VS Code today, in a -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.

Dependencies

NameVersionLicense (SPDX)Source
None

Third-party notices

None.

Source 3 files
hooks/register.tsx 504 lines
1import { 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}
504
hooks/layout.ts 347 lines
1import 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}
347
types/index.d.ts 53 lines
1/** 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