Detailed context window breakdown in a band above the prompt

A Claude Code mod that shows a detailed, live breakdown of your context window in a band directly above the prompt, so you can see what is filling the window, how fast it is growing, and how long until auto-compaction, without running /context.
It is built to sit next to a custom status line rather than repeat it: there is no model name, effort, rate-limit or git information here, only the context window.
60k / 200k · autocompact @167k (107k left) · ▂▃▃▄▅ +10k/turn · ~11 turns to compact p: pane c: more
~ means the figure is an estimate (before the first response, or right after a compaction).autocompact off or autocompact due when that applies).c: more)60k / 200k · autocompact @167k (107k left) · ▂▃▃▄▅ +10k/turn · ~11 turns to compact r: exact p: pane c: less
████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
█ System prompt 3k █ System tools 12k █ Memory files 4.2k █ Messages 41k ░ Free space 107k ▒ Autocompact buffer 33k estimated
Deferred MCP tools 45k · System tools 12k
Memory .claude/CLAUDE.md 1.2k (User) · memory/MEMORY.md 2.1k (AutoMem)
MCP linear 5.1k 12/40 loaded · neon 2.4k 3/70 loaded
Listings skills 2k 48/52 · agents 1.1k 6 · commands 400 30/31
Cache read 57k · wrote 2k · uncached 1k · output 2k → 95% hit
Session $1.50 · last turn +8.2k · 1 compaction, last 162k → 18k (auto)
░) and the compaction buffer (▒) in grey, and a legend with every category's tokens.p: pane)Everything, unabridged, in a pane that docks beside the conversation in fullscreen mode (or opens above the prompt otherwise) and scrolls:
A toast when the context window, the 5-hour limit or the 7-day limit crosses 80% and again at 90%, once each; it re-arms when the figure drops back below.
| Do | How |
|---|---|
| Show more / less | ctrl+x c from the prompt (after the one-time setup below), or ctrl+x tab then c, or click c: more, or /ctx |
| Open the side pane | ctrl+x tab then p, or click p: pane, or /ctx pane |
| Count tokens exactly | ctrl+x tab then r (expanded view or pane), or /ctx full |
| Collapse the band entirely | ctrl+x ctrl+a (Claude Code's own band toggle) |
| Close the pane | ctrl+x x |
| Leave the band or pane | esc |
ctrl+x tab focuses the band; while it has focus, its letter hotkeys work and the arrow keys scroll.
Counts are estimated by default, computed locally at no cost after every turn. Exact counting asks the token-count API once per tool and memory file, as /context does, so it is on demand only; the legend says which you are looking at.
ctrl+x cA mod cannot define a keyboard shortcut of its own, so the band's toggle borrows a built-in action, app:cycleDiffBase, which is otherwise only used while the diff sidebar is open. Add this line to the Chat context in ~/.claude/keybindings.json (create the file if you have none):
{
"$schema": "https://www.schemastore.org/claude-code-keybindings.json",
"bindings": [
{
"context": "Chat",
"bindings": {
"ctrl+x c": "app:cycleDiffBase"
}
}
]
}
While the diff sidebar is open, ctrl+x c goes to the sidebar instead of the band. ctrl+c cannot be used in the chord: Claude Code reserves it for interrupt and exit.
At the prompt of a Claude Code terminal session:
/plugin install context-band --marketplace 13zebras/claude-context-band-mod
Answer y to add the marketplace, then pick a scope (the user scope loads it in every session). The band appears above the prompt straight away.
From a shell:
claude plugin install context-band --marketplace 13zebras/claude-context-band-mod
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with 13zebras/claude-context-band-mod, then Add plugin.
To remove it, from a shell: claude plugin uninstall context-band.
main session)./clear.hooks/register.tsx state and hooks: measuring, alerts, compactions, /ctx, render hooks
hooks/draw.tsx the band and the pane
hooks/shared.ts formatting, colours, the snapshot taken from $.session.usage()
types/index.d.ts the $.state contract
tests/band.test.tsx tests
Run from the repository folder:
claude plugin validate .
claude plugin test .
To work on it live, start a session with claude --plugin-dir <this folder>: saving a file reloads the mod. Once Claude Code has loaded it, .claude-plugin/types/ holds the API's type declarations and tsc -p . type-checks the mod.
hooks/register.tsx 195 lines1import { atom, read, update } from 'claude-code'
2import type {
3 ContextBreakdownDetail,
4 EngineInterface,
5 Register,
6 SessionMeasureInput,
7} from 'claude-code'
8
9import { drawBand, drawPane } from './draw'
10import { toSnapshot } from './shared'
11
12// Change a tag when its value's fields change: it declines what an earlier
13// version stored, which outlives a reload
14const snapshot = atom({ plugin: 'context-band', key: 'snapshot' } as const, null, {
15 shape: 'snapshot-2',
16})
17const history = atom({ plugin: 'context-band', key: 'history' } as const, [], {
18 shape: 'history-1',
19})
20const compactions = atom({ plugin: 'context-band', key: 'compactions' } as const, [], {
21 shape: 'compactions-1',
22})
23const isExpanded = atom({ plugin: 'context-band', key: 'isExpanded' } as const, false)
24const isCounting = atom({ plugin: 'context-band', key: 'isCounting' } as const, false)
25const alerted = atom({ plugin: 'context-band', key: 'alerted' } as const, [])
26
27const PANE = 'context'
28const MAX_POINTS = 40
29const THRESHOLDS = [80, 90]
30const LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
31
32// `summary` estimates locally; `full` sends token-count requests as /context does
33async function refresh($: EngineInterface, detail: ContextBreakdownDetail) {
34 const usage = await $.session.usage({ breakdown: detail }).catch(() => null)
35 const measured = usage && toSnapshot(usage, detail === 'full')
36 if (measured) await update($, snapshot, () => measured)
37}
38
39async function recount($: EngineInterface) {
40 await update($, isCounting, () => true)
41 try {
42 await refresh($, 'full')
43 } finally {
44 await update($, isCounting, () => false)
45 }
46}
47
48async function toggle($: EngineInterface) {
49 const value = await update($, isExpanded, open => !open)
50 await $.store.set('isExpanded', value)
51}
52
53async function openPane($: EngineInterface) {
54 await $.ui.open({ id: PANE, title: 'Context' })
55}
56
57// Toasts once when context or a rate limit crosses 80% or 90%, and re-arms
58// once it drops back below
59async function alert($: EngineInterface, e: SessionMeasureInput) {
60 const readings = [
61 { name: 'Context', percent: e.context.percent },
62 ...e.rateLimits.map(limit => ({
63 name: `${LIMIT_NAMES[limit.kind] ?? limit.kind} limit`,
64 percent: limit.percentUsed,
65 })),
66 ]
67 const before = await read($, alerted)
68 const crossed: string[] = []
69
70 for (const { name, percent = 0 } of readings) {
71 const keys = THRESHOLDS.filter(t => percent >= t).map(t => `${name} ${t}`)
72 crossed.push(...keys)
73 if (keys.some(key => !before.includes(key))) {
74 $.ui.toast(`${name} at ${Math.round(percent)}%`)
75 }
76 }
77
78 await update($, alerted, () => crossed)
79}
80
81export const register: Register = on => {
82 on('session.start', async ($, e, next) => {
83 await $.command.register({
84 name: 'ctx',
85 description: 'Context band: show more or less; "full" counts exactly; "pane" opens the side pane',
86 argumentHint: '[full|pane]',
87 immediate: true,
88 })
89 const started = await next(e)
90
91 const stored = await $.store.get('isExpanded')
92 if (typeof stored === 'boolean') await update($, isExpanded, () => stored)
93 await refresh($, 'summary')
94
95 return started
96 })
97
98 on('session.measure', async ($, e, next) => {
99 await alert($, e)
100
101 const { tokens, window } = e.context
102 if (e.changed.includes('context') && tokens !== undefined) {
103 await update($, history, points => [...points, { tokens, window }].slice(-MAX_POINTS))
104 }
105 if (e.changed.includes('context') || e.changed.includes('cost')) {
106 await refresh($, 'summary')
107 }
108
109 return next(e)
110 })
111
112 on('session.compact', async ($, e, next) => {
113 const result = await next(e)
114 if (result.skip !== undefined || e.agentId !== undefined || e.trigger === 'precompute') {
115 return result
116 }
117
118 const compaction = {
119 trigger: e.trigger,
120 before: result.tokensBefore,
121 after: result.tokensAfter,
122 }
123 await update($, compactions, list => [...list, compaction].slice(-20))
124 await refresh($, 'summary')
125
126 return result
127 }).catch(($, e, next) => next(e)) // a failed record never blocks the compaction
128
129 on('session.end', async ($, e, next) => {
130 if (e.reason === 'clear') {
131 await update($, snapshot, () => null)
132 await update($, history, () => [])
133 await update($, compactions, () => [])
134 await update($, alerted, () => [])
135 }
136
137 return next(e)
138 })
139
140 on('command.run', { command: 'ctx' }, async ($, e) => {
141 const arg = e.args.trim()
142 if (arg === 'full') await recount($)
143 else if (arg === 'pane') await openPane($)
144 else await toggle($)
145
146 return {}
147 })
148
149 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
150 const snap = await read($, snapshot)
151 const points = await read($, history)
152 const compacted = await read($, compactions)
153 const expanded = await read($, isExpanded)
154 const counting = await read($, isCounting)
155
156 if (e.props.hasSurvey || snap === null) {
157 return next(e)
158 }
159
160 return drawBand($.ui.resolve(e), {
161 snap,
162 points,
163 compacted,
164 isCounting: counting,
165 isExpanded: expanded,
166 isAgentView: e.props.view.agentId !== undefined,
167 columns: e.props.bodyColumns,
168 recount: () => recount($),
169 toggle: () => toggle($),
170 openPane: () => openPane($),
171 })
172 })
173
174 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
175 const snap = await read($, snapshot)
176 const points = await read($, history)
177 const compacted = await read($, compactions)
178 const counting = await read($, isCounting)
179 const { Text } = $.ui.resolve(e)
180
181 if (snap === null) {
182 return <Text dimColor>No measurement yet: one comes after the next turn.</Text>
183 }
184
185 return drawPane($.ui.resolve(e), {
186 snap,
187 points,
188 compacted,
189 isCounting: counting,
190 columns: e.props.bodyColumns,
191 recount: () => recount($),
192 })
193 })
194}
195hooks/draw.tsx 435 lines1import type { ElementTable } from 'claude-code'
2
3import type { Compaction, Point, Snapshot } from '../types'
4import {
5 RED,
6 cells,
7 chunk,
8 fmt,
9 joinRuns,
10 outlook,
11 paintCategories,
12 pctColor,
13 shortPath,
14 signed,
15 sparkline,
16} from './shared'
17import type { Entry, Run } from './shared'
18
19type Ui = Pick<ElementTable, 'Box' | 'Button' | 'Text'>
20
21export type View = {
22 snap: Snapshot
23 points: Point[]
24 compacted: Compaction[]
25 isCounting: boolean
26 columns: number
27 recount: () => void
28}
29
30export type BandView = View & {
31 isExpanded: boolean
32 isAgentView: boolean
33 toggle: () => void
34 openPane: () => void
35}
36
37const SPARK_POINTS = 16
38const GRID = 10
39
40// The engine action the toggle borrows, so a chord bound to it in
41// keybindings.json presses the toggle from the prompt
42const TOGGLE_ACTION = 'app:cycleDiffBase'
43
44const paint = ({ Text }: Ui, runs: Run[]) =>
45 runs.map(run => <Text color={run.color}>{run.text}</Text>)
46
47const label = ({ Text }: Ui, text: string) => <Text dimColor>{text.padEnd(9)}</Text>
48
49const percent = (share: number) => `${(share * 100).toFixed(1)}%`
50
51function entries({ Text }: Ui, list: Entry[]) {
52 if (list.length === 0) return <Text dimColor>none</Text>
53
54 return list.map((entry, i) => (
55 <Text dimColor={entry.isDim}>
56 {i > 0 && <Text dimColor> · </Text>}
57 <Text dimColor>{entry.name} </Text>
58 {fmt(entry.tokens)}
59 {entry.note !== undefined && <Text dimColor> {entry.note}</Text>}
60 </Text>
61 ))
62}
63
64function fill({ Text }: Ui, snap: Snapshot, current: number) {
65 return (
66 <Text>
67 <Text color={pctColor((current / snap.window) * 100)}>
68 {snap.tokens === undefined ? '~' : ''}
69 {fmt(current)}
70 </Text>
71 <Text dimColor> / {fmt(snap.window)}</Text>
72 </Text>
73 )
74}
75
76function autocompact({ Text }: Ui, snap: Snapshot, left: number | undefined) {
77 if (snap.compactAt === undefined || left === undefined) {
78 return <Text dimColor> · autocompact off</Text>
79 }
80 if (left <= 0) return <Text color={RED}> · autocompact due</Text>
81
82 return (
83 <Text dimColor>
84 {' '}
85 · autocompact @{fmt(snap.compactAt)} ({fmt(left)} left)
86 </Text>
87 )
88}
89
90function cacheLine(ui: Ui, snap: Snapshot) {
91 const { Text } = ui
92 const { cache } = snap
93 if (cache === undefined) return <Text dimColor>no response yet</Text>
94
95 const hit = (cache.read / Math.max(1, cache.read + cache.written + cache.uncached)) * 100
96
97 return (
98 <Text>
99 {entries(ui, [
100 { name: 'read', tokens: cache.read },
101 { name: 'wrote', tokens: cache.written },
102 { name: 'uncached', tokens: cache.uncached },
103 { name: 'output', tokens: cache.output },
104 ])}
105 <Text dimColor> → </Text>
106 <Text color={pctColor(100 - hit)}>{Math.round(hit)}% hit</Text>
107 </Text>
108 )
109}
110
111function compaction({ Text }: Ui, item: Compaction) {
112 return (
113 <Text>
114 {item.before === undefined ? '?' : fmt(item.before)} →{' '}
115 {item.after === undefined ? '?' : fmt(item.after)}
116 <Text dimColor> ({item.trigger})</Text>
117 </Text>
118 )
119}
120
121function sessionLine(ui: Ui, v: View, last: number | undefined) {
122 const { Text } = ui
123 const latest = v.compacted.at(-1)
124
125 return (
126 <Text>
127 {v.snap.cost !== undefined && <Text>${v.snap.cost.toFixed(2)} · </Text>}
128 {last !== undefined && (
129 <Text>
130 <Text dimColor>last turn </Text>
131 {signed(last)}
132 <Text dimColor> · </Text>
133 </Text>
134 )}
135 {latest === undefined ? (
136 <Text dimColor>no compactions yet</Text>
137 ) : (
138 <Text>
139 {v.compacted.length}
140 <Text dimColor> {v.compacted.length === 1 ? 'compaction' : 'compactions'}, last </Text>
141 {compaction(ui, latest)}
142 </Text>
143 )}
144 </Text>
145 )
146}
147
148export function drawBand(ui: Ui, v: BandView) {
149 const { Box, Button, Text } = ui
150 const { snap, points } = v
151 const { current, left, last, average, turnsLeft } = outlook(snap, points)
152 const recent = points.slice(-SPARK_POINTS)
153
154 const summary = (
155 <Box flexDirection="row" justifyContent="space-between">
156 <Box flexShrink={1}>
157 <Text wrap="truncate-end">
158 {fill(ui, snap, current)}
159 {autocompact(ui, snap, left)}
160 {recent.length > 1 && <Text dimColor> · </Text>}
161 {recent.length > 1 && paint(ui, sparkline(recent))}
162 {average !== undefined && <Text dimColor> {signed(average)}/turn</Text>}
163 {turnsLeft !== undefined && <Text dimColor> · ~{turnsLeft} turns to compact</Text>}
164 {v.isAgentView && <Text dimColor> · main session</Text>}
165 </Text>
166 </Box>
167 <Box flexDirection="row" gap={2} paddingLeft={1}>
168 {v.isExpanded && (
169 <Button
170 key="recount"
171 label={v.isCounting ? 'counting…' : 'exact'}
172 hotkey="r"
173 plain
174 dimColor
175 onPress={v.recount}
176 />
177 )}
178 <Button key="pane" label="pane" hotkey="p" plain dimColor onPress={v.openPane} />
179 <Button
180 key="toggle"
181 label={v.isExpanded ? 'less' : 'more'}
182 hotkey="c"
183 action={TOGGLE_ACTION}
184 plain
185 dimColor
186 onPress={v.toggle}
187 />
188 </Box>
189 </Box>
190 )
191
192 // A blank row on top sets the band apart from the transcript above it
193 if (!v.isExpanded) {
194 return (
195 <Box flexDirection="column" paddingTop={1}>
196 {summary}
197 </Box>
198 )
199 }
200
201 const segments = paintCategories(snap.categories)
202 const shown = segments.filter(s => s.kind !== 'deferred')
203 const deferred = segments.filter(s => s.kind === 'deferred')
204 const { skills, commands, agents } = snap
205
206 return (
207 <Box flexDirection="column" paddingTop={1}>
208 {summary}
209 <Text>{paint(ui, joinRuns(cells(segments, Math.max(10, v.columns))))}</Text>
210 <Text>
211 {shown.map(segment => (
212 <Text>
213 <Text color={segment.color}>{segment.text}</Text>
214 <Text dimColor> {segment.name} </Text>
215 {fmt(segment.tokens)}
216 {' '}
217 </Text>
218 ))}
219 <Text dimColor>{snap.isExact ? 'exact counts' : 'estimated'}</Text>
220 </Text>
221 {deferred.length > 0 && (
222 <Text wrap="truncate-end">
223 {label(ui, 'Deferred')}
224 {entries(ui, deferred)}
225 </Text>
226 )}
227 <Text wrap="truncate-end">
228 {label(ui, 'Memory')}
229 {entries(
230 ui,
231 snap.memoryFiles.map(file => ({
232 name: shortPath(file.path),
233 tokens: file.tokens,
234 note: `(${file.type})`,
235 })),
236 )}
237 </Text>
238 <Text wrap="truncate-end">
239 {label(ui, 'MCP')}
240 {entries(
241 ui,
242 snap.mcpServers.map(server => ({
243 name: server.name,
244 tokens: server.tokens,
245 note: `${server.loaded}/${server.total} loaded`,
246 })),
247 )}
248 </Text>
249 <Text wrap="truncate-end">
250 {label(ui, 'Listings')}
251 {entries(ui, [
252 ...(skills
253 ? [{ name: 'skills', tokens: skills.tokens, note: `${skills.included}/${skills.total}` }]
254 : []),
255 { name: 'agents', tokens: agents.tokens, note: `${agents.total}` },
256 ...(commands
257 ? [
258 {
259 name: 'commands',
260 tokens: commands.tokens,
261 note: `${commands.included}/${commands.total}`,
262 },
263 ]
264 : []),
265 ])}
266 </Text>
267 <Text wrap="truncate-end">
268 {label(ui, 'Cache')}
269 {cacheLine(ui, snap)}
270 </Text>
271 <Text wrap="truncate-end">
272 {label(ui, 'Session')}
273 {sessionLine(ui, v, last)}
274 </Text>
275 </Box>
276 )
277}
278
279// The whole breakdown, unabridged: the pane scrolls where the band cannot
280export function drawPane(ui: Ui, v: View) {
281 const { Box, Button, Text } = ui
282 const { snap, points, compacted } = v
283 const { current, left, last, average, turnsLeft } = outlook(snap, points)
284 const segments = paintCategories(snap.categories)
285 const shown = segments.filter(s => s.kind !== 'deferred')
286 const deferred = segments.filter(s => s.kind === 'deferred')
287 const total = shown.reduce((n, s) => n + s.tokens, 0) || 1
288 const grid = chunk(cells(segments, GRID * GRID), GRID)
289 const nameWidth = Math.max(0, ...shown.map(s => s.name.length))
290 const isWide = v.columns >= GRID * 2 + nameWidth + 20
291 const loadedTools = snap.mcpServers.reduce((n, server) => n + server.loaded, 0)
292 const allTools = snap.mcpServers.reduce((n, server) => n + server.total, 0)
293 const { skills, commands, agents } = snap
294
295 const section = (title: string, note: string | undefined, body: JSX.Element) => (
296 <Box flexDirection="column">
297 <Text>
298 <Text weight="bold">{title}</Text>
299 {note !== undefined && <Text dimColor> {note}</Text>}
300 </Text>
301 {body}
302 </Box>
303 )
304
305 return (
306 <Box flexDirection="column" gap={1}>
307 <Box flexDirection="row" justifyContent="space-between">
308 <Box flexShrink={1}>
309 <Text>
310 {fill(ui, snap, current)}
311 <Text dimColor> ({Math.round((current / snap.window) * 100)}%)</Text>
312 {autocompact(ui, snap, left)}
313 <Text dimColor> · {snap.isExact ? 'exact counts' : 'estimated'}</Text>
314 </Text>
315 </Box>
316 <Box paddingLeft={1}>
317 <Button
318 key="recount"
319 label={v.isCounting ? 'counting…' : 'exact'}
320 hotkey="r"
321 plain
322 dimColor
323 onPress={v.recount}
324 />
325 </Box>
326 </Box>
327
328 <Box flexDirection={isWide ? 'row' : 'column'} gap={isWide ? 3 : 1}>
329 <Box flexDirection="column">
330 {grid.map(row => (
331 <Text>{paint(ui, joinRuns(row.map(cell => ({ ...cell, text: `${cell.text} ` }))))}</Text>
332 ))}
333 </Box>
334 <Box flexDirection="column">
335 {shown.map(segment => (
336 <Text>
337 <Text color={segment.color}>{segment.text}</Text> {segment.name.padEnd(nameWidth)}{' '}
338 {fmt(segment.tokens).padStart(6)}
339 <Text dimColor> {percent(segment.tokens / total).padStart(6)}</Text>
340 </Text>
341 ))}
342 </Box>
343 </Box>
344
345 {deferred.length > 0 &&
346 section('Deferred', 'tool schemas loaded on demand, outside the window', (
347 <Text>{entries(ui, deferred)}</Text>
348 ))}
349
350 {section('Trend', `${points.length} turns measured`, (
351 <Text>
352 {points.length > 1 ? paint(ui, sparkline(points)) : <Text dimColor>needs two turns</Text>}
353 {average !== undefined && <Text dimColor> {signed(average)}/turn</Text>}
354 {last !== undefined && <Text dimColor> · last turn {signed(last)}</Text>}
355 {turnsLeft !== undefined && <Text dimColor> · ~{turnsLeft} turns to compact</Text>}
356 </Text>
357 ))}
358
359 {section('Memory files', `${snap.memoryFiles.length}`, (
360 <Box flexDirection="column">
361 {snap.memoryFiles.map(file => (
362 <Box flexDirection="row" justifyContent="space-between">
363 <Box flexShrink={1}>
364 <Text wrap="truncate-start">
365 <Text dimColor>{file.type.padEnd(8)}</Text>
366 {file.path}
367 </Text>
368 </Box>
369 <Text> {fmt(file.tokens)}</Text>
370 </Box>
371 ))}
372 </Box>
373 ))}
374
375 {section('MCP servers', `${loadedTools}/${allTools} tools loaded`, (
376 <Box flexDirection="column">
377 {snap.mcpServers.map(server => (
378 <Box flexDirection="column">
379 <Text>
380 {server.name}
381 <Text dimColor>
382 {' '}
383 {fmt(server.tokens)} · {server.loaded}/{server.total} loaded
384 </Text>
385 </Text>
386 <Box paddingLeft={2}>
387 <Text>
388 {entries(
389 ui,
390 server.tools.map(tool => ({ ...tool, isDim: !tool.isLoaded })),
391 )}
392 </Text>
393 </Box>
394 </Box>
395 ))}
396 </Box>
397 ))}
398
399 {skills &&
400 section('Skills', `${fmt(skills.tokens)} · ${skills.included}/${skills.total} listed`, (
401 <Text>{entries(ui, skills.items)}</Text>
402 ))}
403
404 {section('Agents', `${fmt(agents.tokens)} · ${agents.total}`, (
405 <Text>{entries(ui, agents.items)}</Text>
406 ))}
407
408 {commands && (
409 <Text>
410 <Text weight="bold">Commands</Text>
411 <Text dimColor>
412 {' '}
413 {fmt(commands.tokens)} · {commands.included}/{commands.total} listed
414 </Text>
415 </Text>
416 )}
417
418 {section('Cache', undefined, cacheLine(ui, snap))}
419
420 {section('Session', undefined, (
421 <Box flexDirection="column">
422 {sessionLine(ui, v, last)}
423 {compacted.length > 1 &&
424 compacted.map((item, i) => (
425 <Text>
426 <Text dimColor>{`#${i + 1}`.padEnd(4)}</Text>
427 {compaction(ui, item)}
428 </Text>
429 ))}
430 </Box>
431 ))}
432 </Box>
433 )
434}
435hooks/shared.ts 209 lines1import type { SessionUsage } from 'claude-code'
2
3import type { Category, Item, McpServer, Point, Snapshot } from '../types'
4
5const SPARKS = '▁▂▃▄▅▆▇█'
6
7// The palette of statusline-command.sh, so the band matches the status line
8export const RED = '#ff0014'
9const ORANGE = '#ff6400'
10const YELLOW = 'yellow'
11const YELLOW_GREEN = '#78d200'
12const GREEN = '#28c800'
13const OLIVE = '#64aa32'
14const BLUE = '#3c82ff'
15const DARK_BLUE = '#3c5aff'
16const PURPLE = '#be28ff'
17const MAGENTA = '#ff00c8'
18const CYAN = '#00b4c8'
19const BROWN = '#a0785f'
20const GRAY = '#78787d'
21const DARK_GRAY = '#5a5a5a'
22
23const CATEGORY_COLORS = [BLUE, PURPLE, MAGENTA, ORANGE, YELLOW, OLIVE, CYAN, BROWN, DARK_BLUE]
24
25export type Run = { text: string; color: string }
26export type Segment = Category & Run
27export type Entry = Item & { note?: string; isDim?: boolean }
28
29// Same thresholds as pct_color in the status line
30export function pctColor(percent: number): string {
31 if (percent >= 90) return RED
32 if (percent >= 70) return ORANGE
33 if (percent >= 50) return YELLOW
34 if (percent >= 40) return YELLOW_GREEN
35 return GREEN
36}
37
38export function fmt(tokens: number): string {
39 const n = Math.abs(tokens)
40 const sign = tokens < 0 ? '-' : ''
41 if (n < 1000) return `${sign}${Math.round(n)}`
42 if (n < 100_000) return `${sign}${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
43 if (n < 1_000_000) return `${sign}${Math.round(n / 1000)}k`
44 return `${sign}${(n / 1_000_000).toFixed(2).replace(/\.?0+$/, '')}M`
45}
46
47export const signed = (tokens: number) => `${tokens >= 0 ? '+' : ''}${fmt(tokens)}`
48export const shortPath = (path: string) => path.split('/').slice(-2).join('/')
49const byTokens = (a: { tokens: number }, b: { tokens: number }) => b.tokens - a.tokens
50
51// Joins neighbouring cells of one colour into a single Text run
52export function joinRuns(cells: Run[]): Run[] {
53 const out: Run[] = []
54 for (const cell of cells) {
55 const last = out.at(-1)
56 if (last?.color === cell.color) last.text += cell.text
57 else out.push({ ...cell })
58 }
59 return out
60}
61
62export function chunk<T>(list: T[], size: number): T[][] {
63 return Array.from({ length: Math.ceil(list.length / size) }, (_, i) =>
64 list.slice(i * size, (i + 1) * size),
65 )
66}
67
68export function toSnapshot(usage: SessionUsage, isExact: boolean): Snapshot | null {
69 const { context, cost } = usage
70 const breakdown = context.breakdown
71 if (!breakdown) return null
72
73 const servers = new Map<string, McpServer>()
74 for (const tool of breakdown.mcpTools) {
75 const server = servers.get(tool.serverName) ?? {
76 name: tool.serverName,
77 tokens: 0,
78 loaded: 0,
79 total: 0,
80 tools: [],
81 }
82 server.total += 1
83 if (tool.isLoaded) {
84 server.loaded += 1
85 server.tokens += tool.tokens
86 }
87 server.tools.push({
88 name: tool.name.replace(/^mcp__.+?__/, ''),
89 tokens: tool.tokens,
90 isLoaded: tool.isLoaded,
91 })
92 servers.set(tool.serverName, server)
93 }
94 for (const server of servers.values()) server.tools.sort(byTokens)
95
96 const { skills, slashCommands, apiUsage, agents } = breakdown
97
98 return {
99 tokens: context.tokens,
100 window: context.window,
101 estimated: breakdown.totalTokens,
102 compactAt: breakdown.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined,
103 categories: breakdown.categories.map(({ name, tokens, kind }) => ({ name, tokens, kind })),
104 memoryFiles: breakdown.memoryFiles
105 .map(({ path, type, tokens }) => ({ path, type, tokens }))
106 .sort(byTokens),
107 mcpServers: [...servers.values()].sort(byTokens),
108 agents: {
109 tokens: agents.reduce((n, agent) => n + agent.tokens, 0),
110 included: agents.length,
111 total: agents.length,
112 items: agents.map(agent => ({ name: agent.agentType, tokens: agent.tokens })).sort(byTokens),
113 },
114 skills: skills && {
115 tokens: skills.tokens,
116 included: skills.includedSkills,
117 total: skills.totalSkills,
118 items: skills.skillFrontmatter
119 .map(skill => ({ name: skill.name, tokens: skill.tokens }))
120 .sort(byTokens),
121 },
122 commands: slashCommands && {
123 tokens: slashCommands.tokens,
124 included: slashCommands.includedCommands,
125 total: slashCommands.totalCommands,
126 items: [],
127 },
128 cache: apiUsage
129 ? {
130 read: apiUsage.cache_read_input_tokens,
131 written: apiUsage.cache_creation_input_tokens,
132 uncached: apiUsage.input_tokens,
133 output: apiUsage.output_tokens,
134 }
135 : undefined,
136 cost: cost?.usd,
137 isExact,
138 }
139}
140
141// Growth per turn since the last compaction: the last turn's, and the
142// average of the last five
143export function trend(points: Point[]) {
144 const deltas = points.slice(1).map((point, i) => point.tokens - (points[i]?.tokens ?? 0))
145 const sinceCompaction = deltas.slice(deltas.findLastIndex(d => d < 0) + 1).slice(-5)
146 const average = sinceCompaction.length
147 ? sinceCompaction.reduce((n, d) => n + d, 0) / sinceCompaction.length
148 : undefined
149
150 return { last: deltas.at(-1), average }
151}
152
153// The summary's figures: the live fill, and how far auto-compaction is
154export function outlook(snap: Snapshot, points: Point[]) {
155 const current = snap.tokens ?? snap.estimated
156 const left = snap.compactAt === undefined ? undefined : snap.compactAt - current
157 const { last, average } = trend(points)
158 const turnsLeft =
159 left !== undefined && left > 0 && average !== undefined && average > 0
160 ? Math.ceil(left / average)
161 : undefined
162
163 return { current, left, last, average, turnsLeft }
164}
165
166export function sparkline(points: Point[]): Run[] {
167 return joinRuns(
168 points.map(({ tokens, window }) => {
169 const percent = (tokens / window) * 100
170 const level = Math.min(SPARKS.length - 1, Math.floor((percent / 100) * SPARKS.length))
171
172 return { text: SPARKS.charAt(level), color: pctColor(percent) }
173 }),
174 )
175}
176
177export function paintCategories(categories: Category[]): Segment[] {
178 let used = 0
179
180 return categories.map(category => {
181 if (category.kind === 'used') {
182 const color = CATEGORY_COLORS[used++ % CATEGORY_COLORS.length] ?? BLUE
183 return { ...category, text: '█', color }
184 }
185 if (category.kind === 'free') return { ...category, text: '░', color: DARK_GRAY }
186 if (category.kind === 'buffer') return { ...category, text: '▒', color: GRAY }
187 return { ...category, text: '·', color: DARK_GRAY }
188 })
189}
190
191// One cell per `count`th of the window; deferred tool schemas sit outside it
192export function cells(segments: Segment[], count: number): Run[] {
193 const shown = segments.filter(s => s.kind !== 'deferred')
194 const total = shown.reduce((n, s) => n + s.tokens, 0) || 1
195 let tokens = 0
196 let filled = 0
197
198 return shown.flatMap(segment => {
199 tokens += segment.tokens
200 const end = Math.round((tokens / total) * count)
201 const run = Array.from({ length: end - filled }, () => ({
202 text: segment.text,
203 color: segment.color,
204 }))
205 filled = end
206 return run
207 })
208}
209types/index.d.ts 44 lines1export type CategoryKind = 'used' | 'free' | 'buffer' | 'deferred'
2export type Category = { name: string; tokens: number; kind: CategoryKind }
3export type Item = { name: string; tokens: number }
4export type MemoryFile = { path: string; type: string; tokens: number }
5export type McpTool = { name: string; tokens: number; isLoaded: boolean }
6export type McpServer = { name: string; tokens: number; loaded: number; total: number; tools: McpTool[] }
7export type Listing = { tokens: number; included: number; total: number; items: Item[] }
8export type Cache = { read: number; written: number; uncached: number; output: number }
9
10// One measurement of the context window, distilled from $.session.usage()
11export type Snapshot = {
12 tokens?: number
13 window: number
14 estimated: number
15 compactAt?: number
16 categories: Category[]
17 memoryFiles: MemoryFile[]
18 mcpServers: McpServer[]
19 agents: Listing
20 skills?: Listing
21 commands?: Listing
22 cache?: Cache
23 cost?: number
24 isExact: boolean
25}
26
27// The context fill after one main-thread turn
28export type Point = { tokens: number; window: number }
29
30export type Compaction = { trigger: string; before?: number; after?: number }
31
32declare module 'claude-code' {
33 interface PluginState {
34 'context-band': {
35 snapshot: Shaped<Snapshot | null>
36 history: Shaped<Point[]>
37 compactions: Shaped<Compaction[]>
38 isExpanded: boolean
39 isCounting: boolean
40 alerted: string[]
41 }
42 }
43}
44