Your context window as a stacked bar above the prompt, a color per category, with token counts and the compaction point. Click the ▾ to open every row's share…

Curated developer mods for Claude Code by See Stack.
Upgrade your terminal with live context window telemetry, compaction headroom alerts, and personal voice narration with media transport controls.
Add the See Stack Marketplace to your Claude Code installation:
claude plugin marketplace add see-stack/claude-code-mods
Then install any of the mods:
# 1. Interactive Context Bar with Headroom Tracking
claude plugin install context-bar@seestack-mods
# 2. Text-to-Speech Voice Player with Transport Scrubber & Queue
claude plugin install read-aloud@seestack-mods
To update mods anytime:
claude plugin update context-bar@seestack-mods
claude plugin update read-aloud@seestack-mods
Before installing, make sure your environment meets these requirements:
v2.1.287 or higher)Native plugin and mod support was introduced in Claude Code v2.1.287. Check your version:
claude --version
If you are on an older build, upgrade to the latest version:
npm install -g @anthropic-ai/claude-code
Once installed, verify that Claude Code recognizes the mods:
claude plugin list
If a mod shows as disabled, enable it with:
claude plugin enable context-bar@seestack-mods
claude plugin enable read-aloud@seestack-mods
read-aloud mod only)say / AVSpeechSynthesizer). System Settings > Accessibility > Spoken Content.context-barA live stacked context window audit HUD placed directly above your prompt with an interactive collapsible accordion dropdown so you never burn 40,000 tokens on stale logs or get surprised by auto-compaction.

detail), or hit [ Minimize ] to collapse back to a single compact line.▶ directly on memory files or skills to expand their itemized breakdown without repeating headers.compacts at 167k [38%])./context-bar inside any Claude Code session to toggle visibility.┌──────────────────────────────────────────────────────────────┐
│ ◆ context 75k of 200k · compacts at 167k 38% ▾ │
│ █■■■███████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ detail │
│ ▶ ■ system prompt █························· 2k 1% │
│ ▶ ■ system tools ██████████················ 19k 9.5% │
│ ▶ ■ memory files █························· 3.1k 1.5% │
│ ▶ ■ skills █························· 2.6k 1.3% │
│ ▶ ■ messages ██████████████████········ 49k 24% │
│ ─ free space ────────────────────────── 92k 46% │
│ ░ autocompact buf… ░░░░░░░░·················· 33k 17% │
│ │
│ slash commands 42 of 42 · 1.4k │
│ window claude-sonnet-5-5 · compacts at 167k │
│ │
│ [ Minimize ] │
└──────────────────────────────────────────────────────────────┘
read-aloudBrings a high-quality audio deck and Text-to-Speech into Claude Code with full media transport controls.

/read-aloud or click the read button above your prompt.| Key | Action | Description |
|---|---|---|
p | Pause / Resume | Instantly toggles speech playback |
s | Stop | Halts audio and clears speech buffer |
◀ / ▶ | Sentence Step | Steps one sentence backward or forward |
⏮ / ⏭ | Reply Step | Jumps across queued assistant replies |
r | Speed Multiplier | Cycles playback speed: 1× ➔ 1.25× ➔ 1.5× ➔ 2× |
| Slash Command / Key | What it does |
|---|---|
/context-bar | Toggles the Context Bar HUD above prompt |
/read-aloud | Speaks the latest response out loud |
/plugins | Opens the interactive Claude Code plugin manager |
/reload-plugins | Hot-reloads all mods without restarting your terminal session |
To inspect, tweak, or test these mods locally:
git clone https://github.com/see-stack/claude-code-mods.git
cd claude-code-mods
# Load mods in development mode:
claude --plugin-dir ./mods/context-bar
claude --plugin-dir ./mods/read-aloud
Reload changes instantly in your active session with /reload-plugins.
hooks/register.tsx 599 lines1// Context Bar: what is filling my context window?
2// Above the prompt, the window as one stacked bar, a color per category as /context
3// draws them (system prompt, tools, MCP tools, memory files, skills, messages, free),
4// with a legend of tokens and shares and where auto-compaction runs. It refreshes after
5// each turn. The ▾ at the header's end opens the box up: the legend gives way to every row
6// with its tokens and share. The rows counted for something with a list behind them — the
7// system prompt, system tools, memory files, MCP tools, agents, skills, messages — lead with a ▶ that unfolds that list under the row
8// itself, the mark turning to a ▼. Only one is open at a time, so the box is the rows plus a
9// single list however long that list is. Nothing is repeated below the rows: a row is where
10// its detail is. What /context counts with nothing under it — the command listing, and the
11// window it measured against — is drawn as a figure, there to be read and with no mark to
12// press. One box throughout, growing away from the prompt — no second frame, no repeated
13// header. The ▴ it becomes, or the Minimize button, folds it back. /context-bar shows or
14// hides the bar, and the choice is kept across sessions.
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, Register } from 'claude-code'
17
18import type { Block, Drilldown, Item, List, Reading, Slice } from '../types'
19
20const MIN_WIDTH = 20 // narrower than this, the bar is not drawn
21const SPLIT = ' '
22const MAX_ITEMS = 12 // a list longer than this is cut, with a count of what it left out
23const INDENT = 4 // what a list's own rows are indented by, under the row they open from
24const CONTENT_WIDTH = 84 // the widest the detail is laid out at, so it sits centered in a wider box
25// /context's theme gives several rows the same grey, so each used row gets its own color, in order.
26const PALETTE = ['#7aa2f7', '#7dcfff', '#bb9af7', '#9ece6a', '#e0af68', '#f7768e', '#73daca', '#ff9e64', '#c0caf5']
27const MESSAGES = '#d97757' // the row that grows, in the accent color
28const FREE = '#808080' // a mid grey thin line reads as empty on dark and light themes alike
29const BUFFER = '#808080'
30const SWATCH = { used: '■', free: '─', buffer: '░' } as const // what a legend and a detail row lead with
31const GLYPH = { used: '█', free: '─', buffer: '░' } as const // what the bar and a share meter fill with
32const REST = '·' // what a share meter fills the room it has left with
33
34// Held by the host, so the bar survives a hot reload of this file.
35const reading = atom({ plugin: 'context-bar', key: 'reading' } as const, null as Reading | null)
36const isHidden = atom({ plugin: 'context-bar', key: 'isHidden' } as const, false)
37const isOpen = atom({ plugin: 'context-bar', key: 'isOpen' } as const, false)
38// The one list that is open under its row, if any. One at a time, so the box is the rows plus a
39// single list however long the band is; nothing open, it is the rows alone.
40const openRow = atom({ plugin: 'context-bar', key: 'openRow' } as const, null as string | null)
41// The system prompt's own sections. The breakdown carries no list for the System prompt row, so
42// these are read off the composition instead — the engine's ids and text, this mod's token guess.
43const promptSections = atom({ plugin: 'context-bar', key: 'promptSections' } as const, [] as Item[])
44// The built-in tools the model can call. The engine counts the System tools row whole and hands
45// over no count per tool, so these rows are a roll-call with the tool's own words and no figure.
46const toolList = atom({ plugin: 'context-bar', key: 'toolList' } as const, [] as Item[])
47// The conversation, biggest message first. A transcript runs to hundreds of messages where the
48// box shows twelve, so what it shows are the twelve that cost the most.
49const messageList = atom({ plugin: 'context-bar', key: 'messageList' } as const, [] as Item[])
50
51export const register: Register = on => {
52 on('session.start', async ($, e, next) => {
53 const r = await next(e)
54 await $.command.register({ name: 'context-bar', description: 'Show or hide the context window bar above the prompt' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
55 const hidden = (await $.store.get('isHidden').catch(() => undefined)) === true
56 await update($, isHidden, () => hidden)
57 void refresh($).catch(() => {})
58 return r
59 })
60
61 on('turn.complete', async ($, e, next) => {
62 const r = await next(e)
63 if (!e.agentId) await refresh($).catch(() => {}) // a subagent's turn fills its own window, not this one
64 return r
65 })
66
67 on('session.compact', async ($, e, next) => {
68 const r = await next(e)
69 if (!e.agentId && 'messages' in r) void refresh($).catch(() => {}) // a /compact empties the window without a turn ending
70 return r
71 })
72
73 // The engine fires this for every prompt it sends, so the sections come free here; asking
74 // `$.prompt.compose` instead would compose the whole prompt again on every redraw. The
75 // result goes back untouched: this reads the composition, it does not change it.
76 on('prompt.compose', async ($, e, next) => {
77 const r = await next(e)
78 void update($, promptSections, () => promptItems(r.sections)).catch(() => {})
79 return r
80 })
81
82 on('command.run', { command: 'context-bar' }, async $ => {
83 const hidden = await update($, isHidden, h => !h)
84 await $.store.set('isHidden', hidden).catch(() => {})
85 if (hidden) await update($, isOpen, () => false) // hiding takes the opened box down with it
86 else await refresh($).catch(() => {})
87 return { text: hidden ? 'Context bar hidden. /context-bar shows it again' : 'Context bar on' }
88 })
89
90 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
91 const rest = await next(e) // what other mods and Claude Code draw here stays
92 const r = await read($, reading)
93 if (e.props.hasSurvey || (await read($, isHidden)) || !r) return rest
94 const { Box, Text, Button } = $.ui.resolve(e)
95 const inner = e.props.bodyColumns - 4 // the border and padding take 4 cells
96 if (inner < MIN_WIDTH) return rest
97
98 const opened = await read($, isOpen)
99 const show = await read($, openRow)
100 const prompt = await read($, promptSections)
101 const tools = await read($, toolList)
102 const messages = await read($, messageList)
103 // A list opens under its own row, and one at a time: opening another shuts the first.
104 const toggle = (id: string) => update($, openRow, (now: string | null) => (now === id ? null : id))
105 const head = `${tokens(r.total)} of ${tokens(r.window)}${r.compactsAt ? ` · compacts at ${tokens(r.compactsAt)}` : ''}`
106 const badge = ` ${r.percent}% `
107 // The detail is laid out in a block narrower than a wide box, and centered in it.
108 const m = metrics(frameWidth(inner))
109 return (
110 <Box flexDirection="column">
111 <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
112 <Box flexDirection="row" justifyContent="space-between">
113 <Text wrap="truncate-end">
114 <Text color="#d97757">{'◆ '}</Text>
115 <Text bold>context</Text>
116 </Text>
117 <Box flexDirection="row">
118 <Text wrap="truncate-start">
119 <Text dimColor>{`${head} `}</Text>
120 <Text bold color="black" backgroundColor={levelColor(r)}>{badge}</Text>
121 <Text> </Text>
122 </Text>
123 <Button key="toggle" plain label={opened ? '▴' : '▾'} onPress={() => update($, isOpen, v => !v)} />
124 </Box>
125 </Box>
126 <Text>
127 {cells(r, inner).map(c => (
128 <Text color={c.color}>{c.text}</Text>
129 ))}
130 </Text>
131 {!opened &&
132 legend(r, inner).map(line => (
133 <Text wrap="truncate-end">
134 {line.map((s, i) => (
135 <Text>
136 {i > 0 && <Text>{SPLIT}</Text>}
137 <Text color={s.color}>{`${SWATCH[s.kind]} `}</Text>
138 <Text dimColor={s.kind !== 'used'}>{`${s.name} `}</Text>
139 <Text bold={s.kind === 'used'}>{tokens(s.tokens)}</Text>
140 {s.kind === 'used' && <Text dimColor>{` ${share(s.tokens, r.window)}`}</Text>}
141 </Text>
142 ))}
143 </Text>
144 ))}
145 {opened && (
146 <Box flexDirection="column" alignItems="center">
147 <Box flexDirection="column" width={m.total}>
148 {blocks(r, show, prompt, tools, messages).map(b => {
149 if (b.kind === 'gap') return <Text> </Text>
150 if (b.kind === 'entry') {
151 const list = b.list
152 return (
153 <Box flexDirection="row">
154 <Button key={list.id} plain label={b.open ? '▼' : '▶'} onPress={() => toggle(list.id)} />
155 <Text wrap="truncate-end">
156 <Text>{' '}</Text>
157 <Text bold>{list.head}</Text>
158 {list.note !== undefined && <Text dimColor>{` ${list.note}`}</Text>}
159 </Text>
160 </Box>
161 )
162 }
163 if (b.kind === 'figure')
164 return (
165 <Text wrap="truncate-end">
166 <Text>{' '}</Text>
167 <Text bold>{b.text}</Text>
168 <Text dimColor>{` ${b.note}`}</Text>
169 </Text>
170 )
171 if (b.kind === 'head')
172 return (
173 <Text wrap="truncate-end">
174 <Text bold>{b.text}</Text>
175 {b.note !== undefined && <Text dimColor>{` ${b.note}`}</Text>}
176 </Text>
177 )
178 if (b.kind === 'note')
179 return (
180 <Text dimColor wrap="truncate-end">
181 {`${' '.repeat(2 + m.num)}${b.text}`}
182 </Text>
183 )
184 if (b.kind === 'item')
185 return (
186 <Text wrap="truncate-end">
187 <Text>{' '.repeat(INDENT)}</Text>
188 {/* A `~` wherever the figure is this mod's estimate. A tool has no figure at all:
189 the engine counts its row whole, so the cells are left bare rather than guessed. */}
190 <Text bold>
191 {b.item.tokens === undefined ? ' '.repeat(m.num) : lpad(`${b.item.estimated ? '~' : ''}${tokens(b.item.tokens)}`, m.num)}
192 </Text>
193 <Text>{' '}</Text>
194 <Text>{cut(b.item.name, itemRoom(m.total, m.num, b.item))}</Text>
195 {b.item.note !== undefined && <Text dimColor>{` ${b.item.note}`}</Text>}
196 </Text>
197 )
198 const filled = barFill(b.slice.tokens, b.max, m.bar)
199 const list = b.list
200 return (
201 <Box flexDirection="row">
202 {/* The row that has detail under it leads with a mark; a bare row keeps the cells. */}
203 {list ? (
204 <Button key={list.id} plain label={b.open ? '▼' : '▶'} onPress={() => toggle(list.id)} />
205 ) : (
206 <Text>{' '}</Text>
207 )}
208 <Text>{' '}</Text>
209 <Text wrap="truncate-end">
210 <Text color={b.slice.color}>{`${SWATCH[b.slice.kind]} `}</Text>
211 <Text dimColor={b.slice.kind !== 'used'}>{pad(b.slice.name, m.name)}</Text>
212 {m.bar > 0 && <Text>{' '}</Text>}
213 {m.bar > 0 && (
214 <Text>
215 <Text color={b.slice.color}>{GLYPH[b.slice.kind].repeat(filled)}</Text>
216 {m.bar - filled > 0 && <Text dimColor>{REST.repeat(m.bar - filled)}</Text>}
217 </Text>
218 )}
219 <Text>{' '}</Text>
220 <Text bold={b.slice.kind === 'used'}>{lpad(tokens(b.slice.tokens), m.num)}</Text>
221 <Text>{' '}</Text>
222 <Text dimColor>{lpad(share(b.slice.tokens, r.window), m.pct)}</Text>
223 </Text>
224 </Box>
225 )
226 })}
227 <Text> </Text>
228 <Button key="minimize" label="Minimize" hotkey="q" onPress={() => update($, isOpen, () => false)} />
229 </Box>
230 </Box>
231 )}
232 </Box>
233 {rest}
234 </Box>
235 )
236 })
237}
238
239// Asks the engine for /context's breakdown, estimated locally (no token-count calls).
240async function refresh($: EngineInterface) {
241 if (await read($, isHidden)) return
242 const usage = await $.session.usage({ breakdown: 'summary' })
243 const b = usage.context.breakdown
244 if (!b || !(b.rawMaxTokens > 0)) return // no window to measure against
245 await update($, reading, () => toReading(b))
246 // The tools can change mid-session — an MCP server connects, a plugin registers one — so this
247 // is read on the same beat as the breakdown rather than once at the start.
248 const tools = await $.tool.list().catch(() => [])
249 await update($, toolList, () => toolItems(tools))
250 const said = await $.session.messages().catch(() => [])
251 await update($, messageList, () => messageItems(said))
252}
253
254export function toReading(b: {
255 categories: { name: string; tokens: number; color: string; kind: string }[]
256 totalTokens: number
257 rawMaxTokens: number
258 percentage: number
259 autoCompactThreshold?: number
260 isAutoCompactEnabled: boolean
261 autocompactSource?: string
262 model?: string
263 memoryFiles?: { path: string; type: string; tokens: number }[]
264 mcpTools?: { name: string; serverName: string; tokens: number; isLoaded: boolean }[]
265 agents?: { agentType: string; source: string; tokens: number }[]
266 skills?: {
267 totalSkills: number
268 includedSkills: number
269 tokens: number
270 skillFrontmatter: { name: string; source: string; pluginName?: string; tokens: number }[]
271 }
272 slashCommands?: { totalCommands: number; includedCommands: number; tokens: number }
273}): Reading {
274 const slices: Slice[] = b.categories
275 .filter(c => c.kind !== 'deferred' && c.tokens > 0)
276 .map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens, color: c.color, kind: c.kind as Slice['kind'] }))
277 const order = { used: 0, free: 1, buffer: 2 }
278 slices.sort((x, y) => order[x.kind] - order[y.kind]) // stable: used rows keep /context's order
279 let next = 0
280 for (const s of slices) {
281 s.color = s.kind === 'free' ? FREE : s.kind === 'buffer' ? BUFFER : s.name === 'messages' ? MESSAGES : PALETTE[next++ % PALETTE.length]!
282 }
283 return {
284 slices,
285 total: b.totalTokens,
286 window: b.rawMaxTokens,
287 percent: b.percentage,
288 compactsAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
289 drilldown: toDrilldown(b),
290 }
291}
292
293function toDrilldown(b: Parameters<typeof toReading>[0]): Drilldown {
294 const servers = new Map<string, { tokens: number; tools: string[]; onDemand: number }>()
295 for (const t of b.mcpTools ?? []) {
296 const server = servers.get(t.serverName) ?? { tokens: 0, tools: [], onDemand: 0 }
297 server.tokens += t.tokens
298 // The wire name leads with its server; the box is already under that server's line.
299 if (t.isLoaded) server.tools.push(t.name.replace(/^mcp__.+?__/, ''))
300 else server.onDemand++
301 servers.set(t.serverName, server)
302 }
303 return {
304 memoryFiles: (b.memoryFiles ?? []).map(m => ({ name: shortPath(m.path), tokens: m.tokens, note: m.type })),
305 mcpServers: [...servers.entries()]
306 .map(([name, s]) => ({ name, tokens: s.tokens, note: mcpNote(s.tools, s.onDemand) }))
307 .sort((x, y) => y.tokens - x.tokens),
308 agents: (b.agents ?? []).map(a => ({ name: a.agentType, tokens: a.tokens, note: a.source })),
309 skills: (b.skills?.skillFrontmatter ?? []).map(s => ({
310 name: s.name,
311 tokens: s.tokens,
312 note: s.pluginName ? `${s.source} · ${s.pluginName}` : s.source,
313 })),
314 skillsTokens: b.skills?.tokens,
315 skillsIncluded: b.skills?.includedSkills,
316 slashCommands: b.slashCommands
317 ? { tokens: b.slashCommands.tokens, included: b.slashCommands.includedCommands, total: b.slashCommands.totalCommands }
318 : undefined,
319 model: b.model ?? 'unknown',
320 autoCompactSource: b.autocompactSource ?? 'unknown',
321 }
322}
323
324// Which row a list opens under, by the row's own name as /context prints it. A name the engine
325// changes leaves its list without a row, where it keeps an entry of its own rather than going.
326const UNDER: Record<string, string> = {
327 'system prompt': 'prompt',
328 'system tools': 'tools',
329 'memory files': 'memory',
330 'mcp tools': 'mcp',
331 agents: 'agents',
332 'custom agents': 'agents',
333 skills: 'skills',
334 'slash commands': 'slash',
335 messages: 'messages',
336}
337
338// What each row holds in detail, and what it comes to. A list with nothing in it is left out.
339export function lists(r: Reading, prompt: Item[] = [], tools: Item[] = [], messages: Item[] = []): List[] {
340 const d = r.drilldown
341 const out: List[] = []
342 const add = (id: string, head: string, note: string | undefined, items: Item[]) => {
343 if (items.length > 0) out.push({ id, head, note, items })
344 }
345 // The System prompt and System tools rows are the two the breakdown carries no list for: each
346 // note is a count of what is there, so neither sets this mod's number against the engine's.
347 add('prompt', 'system prompt', `${prompt.length} sections`, prompt)
348 add('tools', 'system tools', `${tools.length} built-in`, tools)
349 add('memory', 'memory files', tokens(held(d.memoryFiles)), d.memoryFiles)
350 add('mcp', 'mcp tools', `by server · ${tokens(held(d.mcpServers))}`, d.mcpServers)
351 add('agents', 'agents', tokens(held(d.agents)), d.agents)
352 // What the window carries of each, and the tokens those come to: /context counts the whole set.
353 add('skills', 'skills', d.skillsTokens !== undefined ? `${d.skills.length} of ${d.skillsIncluded ?? d.skills.length} · ${tokens(d.skillsTokens)}` : tokens(held(d.skills)), d.skills)
354 add('messages', 'messages', `${messages.length} messages, biggest first`, messages)
355 return out
356}
357
358// The figures that are no list at all: /context counts the command listing and hands over the
359// count with nothing under it, and the window's own line is how it was measured. Both are read
360// at a glance and have nothing to open, so neither carries a mark.
361export function figures(r: Reading): { text: string; note: string }[] {
362 const d = r.drilldown
363 const out: { text: string; note: string }[] = []
364 if (d.slashCommands && d.slashCommands.tokens > 0)
365 out.push({ text: 'slash commands', note: `${d.slashCommands.included} of ${d.slashCommands.total} · ${tokens(d.slashCommands.tokens)}` })
366 out.push({ text: 'window', note: r.compactsAt ? `${d.model} · compacts at ${tokens(r.compactsAt)}` : `${d.model} · auto-compaction off` })
367 return out
368}
369
370const held = (items: Item[]) => items.reduce((n, i) => n + (i.tokens ?? 0), 0)
371
372/**
373 * The system prompt's sections as the System prompt row's items: the engine's own id for each,
374 * the cache boundary it sits on, and the tokens off its text. The text is all `prompt.compose`
375 * hands over — no count of its own — so these tokens are this mod's estimate and are marked as
376 * one. They compare a section against its neighbours; they are not the engine's figures.
377 */
378export function promptItems(sections: readonly { id: string; text: string; scope: string }[]): Item[] {
379 return sections.map(s => ({ name: s.id, tokens: Math.round(s.text.length / 4), note: s.scope, estimated: true }))
380}
381
382/**
383 * The built-in tools as the System tools row's items: the name, and the tool's own words for
384 * what it does. MCP tools are left out — the breakdown counts them under their own row, and
385 * listing them here would put the same schemas in the box twice.
386 *
387 * No figure is drawn: the engine counts the System tools row whole, and what a tool costs is its
388 * input schema, which nothing hands over. A number off the description would be a guess off the
389 * wrong quantity, so this row carries none.
390 */
391export function toolItems(tools: readonly { name: string; description: string; mcp: boolean }[]): Item[] {
392 return tools.filter(t => !t.mcp).map(t => ({ name: t.name, note: oneLine(t.description) }))
393}
394
395/** One message of the transcript, as `$.session.messages()` returns it. */
396type Said = {
397 role: 'user' | 'assistant'
398 text: string
399 toolUses: readonly { tool_use_id: string; tool: string; input: Record<string, unknown>; text?: string }[]
400 toolResults?: readonly { tool_use_id: string; text: string }[]
401}
402
403/**
404 * The conversation as the Messages row's items, biggest first. That row is the one the bar is
405 * read for, and a transcript holds hundreds of messages where the box shows twelve: the twelve
406 * it shows are the twelve that cost the most, which is the question the row raises.
407 *
408 * The tokens are estimated off the length of what each message holds — its own text, the
409 * arguments its tool calls were given, and what those calls came back with — and marked as
410 * estimates. The row above is the engine's own count, reconciled to the API; these are
411 * proportions of one another and will not add up to it.
412 */
413export function messageItems(messages: readonly Said[]): Item[] {
414 // A tool result can be read off either side of the transcript — the assistant's call carries it
415 // and the user's message does too — so every result is met once here and counted where the call is.
416 const results = new Map<string, number>()
417 for (const m of messages) for (const u of m.toolUses) if (u.text !== undefined) results.set(u.tool_use_id, u.text.length)
418 for (const m of messages) for (const r of m.toolResults ?? []) if (!results.has(r.tool_use_id)) results.set(r.tool_use_id, r.text.length)
419
420 const out: Item[] = []
421 for (const m of messages) {
422 let chars = m.text.length
423 for (const u of m.toolUses) chars += JSON.stringify(u.input ?? {}).length + (results.get(u.tool_use_id) ?? 0)
424 const calls = m.toolUses.length
425 out.push({
426 name: m.role === 'user' ? 'you' : calls > 0 ? `assistant · ${calls} ${calls === 1 ? 'call' : 'calls'}` : 'assistant',
427 tokens: Math.round(chars / 4),
428 note: oneLine(m.text),
429 estimated: true,
430 })
431 }
432 return out.sort((a, b) => (b.tokens ?? 0) - (a.tokens ?? 0))
433}
434
435// A description cut to what one row can carry: its first sentence, and no more than a line of it.
436function oneLine(text: string, room = 44) {
437 const said = text.replace(/\s+/g, ' ').trim()
438 const end = said.search(/\.(?:\s|$)/)
439 const head = end > 0 ? said.slice(0, end) : said
440 return head.length <= room ? head : `${head.slice(0, room - 1).trimEnd()}…`
441}
442
443// The opened box's body as blocks, in the order drawn: every row of the breakdown, each with the
444// list it names opening under it, then any list the rows do not account for. `open` is the one
445// list unfolded; one at a time keeps the box the rows plus a single list, whatever the band has.
446export function blocks(r: Reading, open: string | null = null, prompt: Item[] = [], tools: Item[] = [], messages: Item[] = []): Block[] {
447 const used = r.slices.filter(s => s.kind === 'used')
448 const max = Math.max(...used.map(s => s.tokens), 1) // the meter is scaled to the largest row, so rows compare
449 const all = lists(r, prompt, tools, messages)
450 const rows = new Set<string>()
451 const out: Block[] = [{ kind: 'head', text: 'detail' }]
452 for (const slice of r.slices) {
453 const id = UNDER[slice.name] // the list this row is counted for, where it is one that has detail
454 const list = id !== undefined ? all.find(l => l.id === id) : undefined
455 const isOpen = list !== undefined && list.id === open
456 if (list) rows.add(list.id)
457 out.push({ kind: 'row', slice, max, list, open: isOpen })
458 if (isOpen) out.push(...items(list))
459 }
460 // A list no row accounts for — the agents, where the breakdown counts no row for them — keeps
461 // an entry of its own, opened the same way.
462 for (const list of all) {
463 if (rows.has(list.id)) continue
464 const isOpen = list.id === open
465 out.push({ kind: 'gap' }, { kind: 'entry', list, open: isOpen })
466 if (isOpen) out.push(...items(list))
467 }
468 const figs = figures(r)
469 if (figs.length > 0) {
470 out.push({ kind: 'gap' })
471 for (const f of figs) out.push({ kind: 'figure', text: f.text, note: f.note })
472 }
473 return out
474}
475
476// A list's rows, shown with at most `MAX_ITEMS` of its items and a count of the rest.
477function items(list: List): Block[] {
478 const out: Block[] = list.items.slice(0, MAX_ITEMS).map(item => ({ kind: 'item', item }) as Block)
479 const left = list.items.length - MAX_ITEMS
480 if (left > 0) out.push({ kind: 'note', text: `+${left} more` })
481 return out
482}
483
484// The cells a detail row is laid out in: `total` is what the widest row comes to, and the
485// block the box centers its opened content as.
486export function metrics(width: number) {
487 const mark = 2 // the ▶ a row with detail under it leads with; a bare row keeps the cells in its place
488 const num = 7 // tokens, right-aligned (`186.0k`)
489 const pct = 6 // the share of the window, right-aligned (`19.0%`)
490 const swatch = 2
491 const fixed = mark + swatch + 2 + num + 1 + pct
492 let name = Math.min(28, Math.max(8, Math.round(width * 0.28)))
493 // Too narrow for the name it wanted: it gives way to the figures, which are what the row is for.
494 if (fixed + name > width) name = Math.max(1, width - fixed)
495 const bar = Math.max(0, Math.min(40, width - (fixed + name)))
496 // The two cells the meter is spaced by are only spent when there is a meter to space.
497 const total = mark + swatch + name + (bar > 0 ? 1 + bar : 0) + 1 + num + 1 + pct
498 return { mark, swatch, name, num, pct, bar, total }
499}
500
501// The block the opened detail is laid out in: the room it has, never wider than it reads
502// well at, so what the box centers is a column of even margins rather than the whole screen.
503export function frameWidth(room: number) {
504 return Math.max(MIN_WIDTH, Math.min(room, CONTENT_WIDTH))
505}
506
507// How much of a meter `tokens` fills against the largest row, at least one cell in use.
508export function barFill(tokens: number, max: number, width: number) {
509 if (width <= 0 || max <= 0 || tokens <= 0) return 0
510 return Math.max(1, Math.min(width, Math.round((tokens / max) * width)))
511}
512
513// The cells an item's name may take, once its indent, its tokens, its note and the gaps are placed.
514function itemRoom(width: number, num: number, item: Item) {
515 const fixed = INDENT + num + 2
516 const note = item.note ? item.note.length + 2 : 0
517 return Math.max(6, width - fixed - note)
518}
519
520// The bar as runs of cells: each slice gets its share of `width`, a used one at least one cell.
521export function cells(r: Reading, width: number) {
522 const sizes = r.slices.map(s => Math.max(s.kind === 'used' ? 1 : 0, Math.round((s.tokens / r.window) * width)))
523 // Rounding leaves the sum a few cells off: free space takes the difference first, then the largest slices.
524 let diff = width - sizes.reduce((a, n) => a + n, 0)
525 const isUsed = (i: number) => r.slices[i]!.kind === 'used'
526 const order = r.slices.map((_, i) => i).sort((a, b) => Number(isUsed(a)) - Number(isUsed(b)) || sizes[b]! - sizes[a]!)
527 for (const i of order) {
528 if (diff === 0) break
529 const size = Math.max(isUsed(i) ? 1 : 0, sizes[i]! + diff)
530 diff -= size - sizes[i]!
531 sizes[i] = size
532 }
533 return r.slices.map((s, i) => ({ color: s.color, kind: s.kind, text: GLYPH[s.kind].repeat(sizes[i]!) })).filter(c => c.text !== '')
534}
535
536// The legend, packed into lines no wider than `width`. Shown while the box is folded.
537export function legend(r: Reading, width: number) {
538 const lines: Slice[][] = [[]]
539 let used = 0
540 for (const s of r.slices) {
541 const size = 2 + s.name.length + 1 + tokens(s.tokens).length + (s.kind === 'used' ? 1 + share(s.tokens, r.window).length : 0)
542 const line = lines.at(-1)!
543 if (line.length > 0 && used + SPLIT.length + size > width) {
544 lines.push([s])
545 used = size
546 } else {
547 used += (line.length > 0 ? SPLIT.length : 0) + size
548 line.push(s)
549 }
550 }
551 return lines.filter(l => l.length > 0)
552}
553
554// How full the window is, for the badge: red where a compaction is close.
555export function levelColor(r: Reading) {
556 const level = r.compactsAt ? r.total / r.compactsAt : r.total / r.window
557 return level >= 0.9 ? 'red' : level >= 0.7 ? 'yellow' : 'green'
558}
559
560export function tokens(n: number) {
561 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
562 if (n >= 10_000) return `${Math.round(n / 1000)}k`
563 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
564 return String(n)
565}
566
567export function share(n: number, window: number) {
568 const p = (n / window) * 100
569 if (p > 0 && p < 0.1) return '<0.1%'
570 return `${p >= 10 ? Math.round(p) : +p.toFixed(1)}%`
571}
572
573// A path as it is read at a glance: `~` for the home it sits in.
574function shortPath(path: string) {
575 return path.replace(/^\/(?:Users|home)\/[^/]+/, '~')
576}
577
578// An MCP server's line: how many tools it brings, named where a few say enough.
579function mcpNote(tools: string[], onDemand: number) {
580 const parts: string[] = []
581 if (tools.length > 0) parts.push(tools.length <= 3 ? tools.join(', ') : `${tools.slice(0, 3).join(', ')}, +${tools.length - 3}`)
582 if (onDemand > 0) parts.push(`${onDemand} on demand`)
583 return parts.join(' · ') || 'no schemas loaded'
584}
585
586function cut(s: string, n: number) {
587 return s.length <= n ? s : `${s.slice(0, Math.max(1, n - 1))}…`
588}
589
590function pad(s: string, n: number) {
591 const text = cut(s, n)
592 return text + ' '.repeat(Math.max(0, n - text.length))
593}
594
595function lpad(s: string, n: number) {
596 const text = cut(s, n)
597 return ' '.repeat(Math.max(0, n - text.length)) + text
598}
599types/index.d.ts 75 lines1export type Slice = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
2
3/** One line under a row: a memory file, an MCP server, an agent, a skill or a tool. */
4export type Item = {
5 name: string
6 // Left out where there is no figure to draw: the engine counts the System tools row whole and
7 // hands over no per-tool count, so those rows carry a name and their own words and nothing else.
8 tokens?: number
9 note?: string
10 // The tokens are this mod's estimate off a text's length, not a count the engine made: drawn
11 // with a `~` so they are never read as one of the engine's figures.
12 estimated?: boolean
13}
14
15/** What sits under the rows: the lists `/context` carries beside them. */
16export type Drilldown = {
17 memoryFiles: Item[] // name: the path, note: where it was loaded from
18 mcpServers: Item[] // name: the server, note: the tools it brings
19 agents: Item[]
20 skills: Item[]
21 skillsTokens?: number // every listed skill together
22 skillsIncluded?: number // how many skills the window carries, listed or not
23 slashCommands?: { tokens: number; included: number; total: number }
24 model: string
25 autoCompactSource: string // how the window measured against was settled
26}
27
28export type Reading = {
29 slices: Slice[]
30 total: number // tokens in use
31 window: number // the window measured against
32 percent: number
33 compactsAt?: number // where auto-compaction runs, when it is on
34 drilldown: Drilldown
35}
36
37/**
38 * What a row holds in detail: the memory files, MCP servers, agents, skills or commands that
39 * come to the tokens the row is counted for. It opens under the row that names it.
40 */
41export type List = {
42 id: string // what a press on the row is remembered by
43 head: string // the row's own name, where the list has no row to open under
44 note?: string // what it holds and what it costs, read while it is shut
45 items: Item[]
46}
47
48/** One block of the opened box's body, in the order drawn. The bar is drawn above them. */
49export type Block =
50 | { kind: 'gap' }
51 | { kind: 'head'; text: string; note?: string }
52 // A row, its meter scaled to the largest of them, and the list that opens under it when it has one.
53 | { kind: 'row'; slice: Slice; max: number; list?: List; open: boolean }
54 | { kind: 'entry'; list: List; open: boolean } // a list with no row of its own to open under
55 | { kind: 'figure'; text: string; note: string } // a count that is all there is: nothing opens under it
56 | { kind: 'item'; item: Item }
57 | { kind: 'note'; text: string }
58
59declare module 'claude-code' {
60 interface PluginState {
61 // `openRow` names the one list that is open: opening another shuts the first.
62 // `promptSections` is the system prompt's own sections and `toolList` the built-in tools the
63 // model can call: the two rows the breakdown carries no list of their own for.
64 'context-bar': {
65 reading: Reading | null
66 isHidden: boolean
67 isOpen: boolean
68 openRow: string | null
69 promptSections: Item[]
70 toolList: Item[]
71 messageList: Item[]
72 }
73 }
74}
75