See what fills Claude Code's context window, how many turns until auto-compact, and compact with a focus. Live band above the prompt, breakdown pane, optional…


<a href="LICENSE"><img src="https://img.shields.io/github/license/AweGuider/Claude-Context-Control" alt="License"></a> <img src="https://img.shields.io/badge/Claude%20Code-mod-4f7cc4" alt="Claude Code mod"> <img src="https://img.shields.io/badge/mod%20API-early%20access-c98c2e" alt="Mod API: early access">
<a href="#install">Install</a> · <a href="#what-it-does">What it does</a> · <a href="#comparison">Comparison</a> · <a href="#settings">Settings</a> · <a href="#what-it-reads-sends-and-writes">Privacy</a> · <a href="#faq">FAQ</a> · <a href="CHANGELOG.md">Changelog</a>
Context Control is a Claude Code mod that keeps your context window in view. A band above the prompt shows your context usage as you work: how full the window is, how many tokens each turn adds, what fills it, and how many turns are left until auto-compact. When you want to compact, a Compact button takes a focus, so the summary keeps what matters. It works in the Claude desktop app's Code tab and in the terminal.
Unofficial community mod. Not affiliated with or endorsed by Anthropic.
/compact does. While Claude is working it waits for the turn to end./context-control, or Open pane ↗ in the expanded band): context over the session with compaction markers, what fills it, what is loaded on every turn (MCP servers, memory files, skill listing, agents) and compaction history.The bars animate as the numbers change: fill grows, the session chart draws in, the band breathes at the alert level and shimmers while compacting.

<sub>Illustrative mockup of the band's states, with sample figures.</sub>
What Claude Code shows on its own, and what Context Control adds.
| Claude Code built in | Context Control | |
|---|---|---|
| Context usage always in view | Depends on the app version; some desktop builds show no context indicator, and the terminal needs a custom status line | A band above the prompt |
| What fills the context window | /context, on demand | Live, by category, plus what is loaded on every turn |
| Turns until auto-compact | Not shown | Estimated from recent growth |
| Warning before auto-compact | A notice near the limit | Amber, then red, and one toast |
| Compact with a focus | Type /compact <instructions> | A Compact button with a focus field that waits for the turn to end |
| Snapshot before compaction | Write your own PreCompact hook | Built in, optional |
| Context over the session and compaction history | Not shown | Side pane |
In a terminal Claude Code session:
/plugin install context-control --marketplace AweGuider/Claude-Context-Control
Answer y to add the marketplace, then pick the user scope. Or, from your shell:
claude plugin install context-control --marketplace AweGuider/Claude-Context-Control
Installed at user scope, it loads in every new session, including the Claude desktop app's Code tab. In a session that is already running, type /reload-plugins.
Update: claude plugin update context-control@awedev, then /reload-plugins, or press Update on the plugin's page in the desktop app. Uninstall: claude plugin uninstall context-control@awedev.
Built against Claude Code 2.1.295. The mod API is early access and can change between releases.
In the pane's Settings tab; saved across sessions.
| Setting | Default |
|---|---|
| Band above the prompt | On |
| Band starts | Collapsed |
| Open the pane when a session starts | Off |
| Warn at / Alert at | 70% / 85% of auto-compact |
| Toast once when crossing Alert | On |
| Turns-left estimate averages the last | 5 turns |
| Exact counts | Off |
| Pre-compact snapshot | Off |
| Keep snapshots out of git | On |
When on, each compaction first writes .claude/context-snapshots/<date>_<time>.md in the session's working directory (normally the project root) with the trigger, model, context size, the focus you gave, your last 10 prompts, files edited and read, and open tasks. The folder is fixed. With Keep snapshots out of git on, the mod also writes .claude/context-snapshots/.gitignore containing *, a file git reads to ignore the snapshots; your project's own .gitignore is not touched. Old snapshots are not deleted.
/context does, to the Anthropic API your session already uses..claude/context-snapshots/ under the working directory: the snapshot Markdown files and, with Keep snapshots out of git on, a .gitignore that tells git to ignore that folder. It writes no other file.session.compact) only to save the snapshot first and to record the compaction in its history. It passes every compaction on unchanged and never delays, edits or blocks it./context-control, which opens the mod's pane and does nothing else.The full policy is in PRIVACY.md.
Install Context Control. The band above the prompt shows the context window's fill as a bar and a percentage, the tokens used out of the window, and how much the last turn added. Click it for the breakdown, or run /context-control for the pane.
Some versions of the desktop app don't show a context indicator or a Compact button above the prompt. Context Control adds both back as a mod, with more detail: what fills the context and how many turns are left until auto-compact.
The band shows it, estimated from the average growth of your last five turns (adjustable in Settings) against the auto-compact threshold. The expanded band and the pane mark that threshold on the bar.
The breakdown splits the context into messages, system prompt, system tools, MCP tools, skills, agents and memory files. The pane's Always loaded list ranks what you pay for on every turn, such as MCP servers and CLAUDE.md files, so you can see what to disconnect or trim.
Press Compact and write what the summary must keep, such as "the plan and the open bugs". It runs the same compaction as /compact <instructions>. Turn on the pre-compact snapshot to also keep a Markdown handoff on disk.
The band appears in the Claude desktop app's Code tab and in the terminal. The pane opens on every surface Claude Code draws on; VS Code is untested.
No. It makes no network requests and no model calls. The optional Exact counts setting uses the same token-count requests as /context; it's off by default.
Only its settings, and snapshots if you turn them on. Snapshots go to .claude/context-snapshots/ in your project, with a .gitignore there that keeps them out of git.
See Install. Updates arrive when the version changes; press Update on the plugin's page or run claude plugin update context-control@awedev.
No. Context Control is an unofficial community mod by AweDev, not affiliated with or endorsed by Anthropic.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
hooks/register.tsx holds the hooks and drawings, hooks/model.ts the figures, hooks/svg.ts the bars and chart, hooks/snapshot.ts the snapshot writer, types/index.d.ts the state contract and tests/control.test.ts the tests.
MIT © 2026 Alan Safonov (AweDev)
hooks/register.tsx 691 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, SessionCompactResult, SessionContextUsage, SessionMessage } from 'claude-code'
3
4import { DEFAULTS, TEXT_TONE, TONE, clampPercent, fmt, fmtDelta, fmtWindow, growth, levelOf, limitOf, toBreakdown, turnsLeft } from './model'
5import type { Level } from './model'
6import { snapshotStamp, snapshotText } from './snapshot'
7import { compositionBar, rankBar, sparkline, windowBar } from './svg'
8import type { Breakdown, Compaction, Point, Reading, ControlSettings } from '../types'
9
10const PANE = 'context-control'
11
12const reading = atom({ plugin: 'context-control', key: 'reading' } as const, null)
13const history = atom({ plugin: 'context-control', key: 'history' } as const, [])
14const breakdown = atom({ plugin: 'context-control', key: 'breakdown' } as const, null)
15const compactions = atom({ plugin: 'context-control', key: 'compactions' } as const, [])
16const phase = atom({ plugin: 'context-control', key: 'phase' } as const, 'idle')
17const expanded = atom({ plugin: 'context-control', key: 'expanded' } as const, false)
18const composing = atom({ plugin: 'context-control', key: 'composing' } as const, false)
19const draft = atom({ plugin: 'context-control', key: 'draft' } as const, '')
20const tab = atom({ plugin: 'context-control', key: 'tab' } as const, 'overview')
21const settings = atom({ plugin: 'context-control', key: 'settings' } as const, DEFAULTS)
22const shownFill = atom({ plugin: 'context-control', key: 'shownFill' } as const, 0)
23const isAlerted = atom({ plugin: 'context-control', key: 'isAlerted' } as const, false)
24const error = atom({ plugin: 'context-control', key: 'error' } as const, '')
25
26// Not `$`: the engine and the directory read that name as the capability object.
27type Engine = EngineInterface
28type Els = Elements[keyof Elements]
29
30const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
31
32/** Everything a drawing needs, derived once per draw. */
33type View = {
34 r: Reading
35 tokens?: number
36 pct?: number
37 limit: number
38 limitPct?: number
39 level: Level
40 last?: number
41 avg?: number
42 turns?: number
43 b: Breakdown | null
44 s: ControlSettings
45 from: number
46}
47
48async function viewOf($: Engine): Promise<View | null> {
49 const r = await read($, reading)
50 if (r === null) return null
51 const [b, s, points, from] = await Promise.all([read($, breakdown), read($, settings), read($, history), read($, shownFill)])
52 const limit = limitOf(r, b)
53 const tokens = r.tokens
54 const pct = tokens === undefined ? undefined : (r.percent ?? Math.round((tokens / r.window) * 100))
55 const { last, avg } = growth(points, s.averageOver)
56 return {
57 r,
58 tokens,
59 pct,
60 limit,
61 limitPct: b?.threshold !== undefined ? (b.threshold / r.window) * 100 : undefined,
62 level: tokens === undefined ? 'calm' : levelOf(tokens, limit, s),
63 last,
64 avg,
65 turns: tokens === undefined ? undefined : turnsLeft(tokens, limit, avg),
66 b,
67 s,
68 from,
69 }
70}
71
72async function ingest($: Engine, ctx: SessionContextUsage) {
73 const before = await read($, reading)
74 const next: Reading = { tokens: ctx.tokens, window: ctx.window, percent: ctx.percent }
75 await update($, shownFill, () => (before?.tokens !== undefined ? (before.tokens / before.window) * 100 : 0))
76 await update($, reading, () => next)
77 if (next.tokens === undefined) return
78 const tokens = next.tokens
79
80 const wasCompacted = (await read($, phase)) === 'compacted'
81 if (wasCompacted) await update($, phase, () => 'idle')
82 await update($, history, list => {
83 const lastPoint = list.at(-1)
84 if (!wasCompacted && lastPoint?.tokens === tokens) return list
85 const point: Point = wasCompacted ? { tokens, isCompaction: true } : { tokens }
86 return [...list, point].slice(-300)
87 })
88
89 const [s, b, points] = await Promise.all([read($, settings), read($, breakdown), read($, history)])
90 const limit = limitOf(next, b)
91 const level = levelOf(tokens, limit, s)
92 if (level === 'alert' && !(await read($, isAlerted))) {
93 await update($, isAlerted, () => true)
94 if (s.toastOnAlert) {
95 const turns = turnsLeft(tokens, limit, growth(points, s.averageOver).avg)
96 const pct = next.percent ?? Math.round((tokens / next.window) * 100)
97 $.ui.toast(`Context at ${pct}%${turns !== undefined ? ` — auto-compact in ~${turns} ${turns === 1 ? 'turn' : 'turns'}` : ''}`, { timeoutMs: 6000 })
98 }
99 } else if (level === 'calm') {
100 await update($, isAlerted, () => false)
101 }
102}
103
104async function refresh($: Engine, ctx?: SessionContextUsage) {
105 try {
106 const s = await read($, settings)
107 const usage = await $.session.usage({ breakdown: s.exactCounts ? 'full' : 'summary' })
108 const found = usage.context.breakdown
109 if (found) await update($, breakdown, () => toBreakdown(found, s.exactCounts))
110 await ingest($, ctx ?? usage.context)
111 await update($, error, () => '')
112 } catch (err) {
113 await update($, error, () => message(err))
114 }
115}
116
117async function saveSettings($: Engine, patch: Partial<ControlSettings>) {
118 const next = await update($, settings, s => ({ ...s, ...patch }))
119 await $.store.set('settings', next)
120 if (patch.exactCounts !== undefined || patch.warnAt !== undefined || patch.alertAt !== undefined) void refresh($)
121}
122
123/**
124 * Writes the snapshot; the engine follows `$` only into functions of this file,
125 * so the I/O lives here and snapshot.ts only builds the text.
126 */
127async function writeSnapshot(
128 $: Engine,
129 o: { messages: readonly SessionMessage[]; trigger: string; focus?: string; reading: Reading | null; settings: ControlSettings },
130): Promise<string> {
131 const stamp = snapshotStamp(await $.clock.now())
132 const sessionId = await $.session.id()
133 const model = await $.session.model()
134 const text = snapshotText({ stamp, trigger: o.trigger, sessionId, model, reading: o.reading, focus: o.focus, messages: o.messages })
135 // Fixed paths, relative to the working directory, so the plugin directory can read what is written.
136 await $.fs.write(`.claude/context-snapshots/${stamp}.md`, text)
137 if (o.settings.snapshotGitignore && !(await $.fs.exists('.claude/context-snapshots/.gitignore'))) {
138 await $.fs.write('.claude/context-snapshots/.gitignore', '*\n')
139 }
140 return `.claude/context-snapshots/${stamp}.md`
141}
142
143/**
144 * Snapshot first (when on), run the compaction, record it. Shared by the
145 * session.compact hook and the mod's own button: the engine runs a plugin's
146 * `$.session.compact` past that plugin's own hook, so the button does this itself.
147 */
148async function aroundCompact(
149 $: Engine,
150 o: { trigger: string; focus?: string; messages: readonly SessionMessage[] },
151 run: () => Promise<SessionCompactResult>,
152): Promise<SessionCompactResult> {
153 const s = await read($, settings)
154 const before = await read($, reading)
155 await update($, phase, () => 'compacting')
156 try {
157 let snapshot: string | undefined
158 if (s.snapshot) {
159 try {
160 snapshot = await writeSnapshot($, { messages: o.messages, trigger: o.trigger, focus: o.focus, reading: before, settings: s })
161 } catch (err) {
162 $.ui.toast(`Snapshot not saved: ${message(err)}`)
163 }
164 }
165 const result = await run()
166 if (result.skip !== undefined) {
167 await update($, phase, () => 'idle')
168 return result
169 }
170 const record: Compaction = {
171 at: await $.clock.now(),
172 trigger: o.trigger,
173 before: result.tokensBefore ?? before?.tokens,
174 after: result.tokensAfter,
175 focus: o.focus,
176 snapshot,
177 }
178 await update($, compactions, list => [...list, record].slice(-50))
179 await update($, phase, () => 'compacted')
180 await update($, isAlerted, () => false)
181 return result
182 } catch (err) {
183 await update($, phase, () => 'idle')
184 throw err
185 }
186}
187
188const asMessages = (found: unknown): readonly SessionMessage[] => (Array.isArray(found) ? found : [])
189
190async function compactNow($: Engine, focus: string) {
191 await update($, composing, () => false)
192 await update($, draft, () => '')
193 const text = focus.trim()
194 try {
195 let messages: readonly SessionMessage[] = []
196 try {
197 messages = asMessages(await $.session.messages())
198 } catch {
199 messages = []
200 }
201 const result = await aroundCompact($, { trigger: 'manual', focus: text || undefined, messages }, () =>
202 $.session.compact(text ? { instructions: text } : {}),
203 )
204 if (result.skip !== undefined) $.ui.toast(`Compact skipped: ${result.skip}`)
205 } catch (err) {
206 $.ui.toast(`Couldn't compact: ${message(err)}`)
207 }
208}
209
210function openPane($: Engine) {
211 return $.ui.open({ id: PANE, title: 'Context' })
212}
213
214const textBar = (pct: number, cells: number) => {
215 const filled = Math.round((clampPercent(pct) / 100) * cells)
216 return '█'.repeat(filled) + '░'.repeat(cells - filled)
217}
218
219const share = (part: number, whole: number) => {
220 const p = whole > 0 ? (part / whole) * 100 : 0
221 return p > 0 && p < 1 ? '<1%' : `${Math.round(p)}%`
222}
223
224function windowSvg(els: Els, v: View, o: { width?: number; height: number; compacting: boolean }) {
225 const to = v.tokens === undefined ? 0 : (v.tokens / v.r.window) * 100
226 if (!('Svg' in els)) {
227 const { Text } = els
228 return <Text color={TEXT_TONE[v.level]}>{textBar(to, o.width ? Math.max(6, Math.round(o.width / 8)) : 40)}</Text>
229 }
230 const { Svg } = els
231 const source = windowBar({
232 from: v.from,
233 to,
234 tick: v.limitPct,
235 tone: TONE[v.level],
236 pulse: v.level === 'alert' && !o.compacting,
237 shimmer: o.compacting,
238 width: o.width,
239 height: o.height,
240 })
241 const alt = `${v.pct ?? 0}% of the context window used${v.b?.threshold !== undefined ? `; auto-compact at ${fmt(v.b.threshold)}` : ''}`
242 return o.width !== undefined ? <Svg source={source} alt={alt} width={o.width} height={o.height} /> : <Svg source={source} alt={alt} height={o.height} />
243}
244
245function compositionSvg(els: Els, b: Breakdown) {
246 const total = b.used.reduce((a, c) => a + c.tokens, 0)
247 if (!('Svg' in els)) {
248 const { Box, Text } = els
249 return (
250 <Box flexDirection="row">
251 {b.used.map(c => (
252 <Text color={c.color}>{'█'.repeat(Math.max(1, Math.round((c.tokens / total) * 40)))}</Text>
253 ))}
254 </Box>
255 )
256 }
257 const { Svg } = els
258 return <Svg source={compositionBar(b.used)} alt={`Used context by category: ${b.used.map(c => `${c.name} ${fmt(c.tokens)}`).join(', ')}`} height={8} />
259}
260
261function legend(els: Els, b: Breakdown, columns: 1 | 2) {
262 const { Box, Text } = els
263 const total = b.used.reduce((a, c) => a + c.tokens, 0)
264 return (
265 <Box flexDirection="row" flexWrap="wrap">
266 {b.used.map(c => (
267 <Box flexDirection="row" width={columns === 2 ? '50%' : '100%'} paddingRight={columns === 2 ? 3 : 0}>
268 <Text color={c.color}>■ </Text>
269 <Box flexGrow={1}>
270 <Text wrap="truncate-end">{c.name}</Text>
271 </Box>
272 <Text>{fmt(c.tokens)}</Text>
273 <Box width={6} justifyContent="flex-end">
274 <Text dimColor>{share(c.tokens, total)}</Text>
275 </Box>
276 </Box>
277 ))}
278 </Box>
279 )
280}
281
282export const register: Register = on => {
283 on('session.start', async ($, e, next) => {
284 const stored = (await $.store.get('settings')) as Partial<ControlSettings> | undefined
285 const s: ControlSettings = { ...DEFAULTS, ...(stored ?? {}) }
286 await update($, settings, () => s)
287 await update($, expanded, () => s.startExpanded)
288 await $.command.register({
289 name: 'context-control',
290 description: 'Open the Context Control pane: breakdown, history, compactions and settings',
291 })
292 void refresh($)
293 if (s.openPaneOnStart) void openPane($)
294 return next(e)
295 })
296
297 on('session.end', async ($, e, next) => {
298 if (e.reason === 'clear') {
299 await update($, reading, () => null)
300 await update($, history, () => [])
301 await update($, compactions, () => [])
302 await update($, phase, () => 'idle')
303 await update($, isAlerted, () => false)
304 }
305 return next(e)
306 })
307
308 on('session.measure', async ($, e, next) => {
309 const out = await next(e)
310 if (e.changed.includes('context')) await refresh($, e.context)
311 return out
312 })
313
314 on('session.compact', ($, e, next) => {
315 if (e.agentId !== undefined || e.trigger === 'precompute') return next(e)
316 return aroundCompact($, { trigger: e.trigger, focus: e.instructions, messages: e.messages }, () => next(e))
317 }).catch(($, e, next) => next(e))
318
319 on('command.run', { command: 'context-control' }, async $ => {
320 await openPane($)
321 return { text: 'Opened the Context Control pane.' }
322 })
323
324 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
325 const s = await read($, settings)
326 if (!s.showBand || e.props.hasSurvey) return next(e)
327 const v = await viewOf($)
328 if (v === null) return next(e)
329 const [ph, isOpen, isComposing, text, compacts] = await Promise.all([
330 read($, phase),
331 read($, expanded),
332 read($, composing),
333 read($, draft),
334 read($, compactions),
335 ])
336 const els = $.ui.resolve(e)
337 const { Box, Text, Button } = els
338 const tone = TEXT_TONE[v.level]
339 const win = fmtWindow(v.r.window)
340 const toggle = () => update($, expanded, x => !x)
341 const lastCompaction = compacts.at(-1)
342
343 let summary
344 if (ph === 'compacting') {
345 summary = (
346 <Text>
347 <Text bold color="claude">Compacting…</Text>
348 <Text dimColor>{v.tokens !== undefined ? ` summarising ${fmt(v.tokens)}` : ''}{s.snapshot ? ' · snapshot first' : ''}</Text>
349 </Text>
350 )
351 } else if (ph === 'compacted' || v.tokens === undefined) {
352 const wasCompacted = ph === 'compacted' && lastCompaction !== undefined
353 summary = wasCompacted ? (
354 <Text>
355 <Text bold color="success">Compacted</Text>
356 <Text dimColor>
357 {` ${lastCompaction.before !== undefined ? fmt(lastCompaction.before) : '?'} → ${lastCompaction.after !== undefined ? fmt(lastCompaction.after) : 'summary'}`}
358 {lastCompaction.snapshot ? ' · snapshot saved' : ''}
359 {' · new size after the next reply'}
360 </Text>
361 </Text>
362 ) : (
363 <Text>
364 <Text bold dimColor>Context</Text>
365 <Text dimColor> measuring — figures appear after the next reply</Text>
366 </Text>
367 )
368 } else {
369 summary = (
370 <Button key="toggle" plain onPress={toggle}>
371 <Text bold color={tone}>{`${v.pct}%`}</Text>
372 <Text dimColor>{` ${fmt(v.tokens)} / ${win}`}</Text>
373 <Text dimColor>{v.last !== undefined ? ` · ${fmtDelta(v.last)} last turn` : ''}</Text>
374 </Button>
375 )
376 }
377
378 const right =
379 v.tokens === undefined || ph !== 'idle'
380 ? ''
381 : v.b?.threshold === undefined
382 ? v.b && !v.b.isAutoCompactEnabled
383 ? 'auto-compact off'
384 : ''
385 : v.turns !== undefined
386 ? `auto-compact in ~${v.turns} ${v.turns === 1 ? 'turn' : 'turns'}`
387 : `auto-compact at ${fmt(v.b.threshold)}`
388
389 let compactControl
390 if (ph === 'compacting') compactControl = null
391 else if (e.props.isWorking) compactControl = <Text dimColor>Compact after this turn</Text>
392 else if (isComposing) compactControl = null
393 else
394 compactControl = (
395 <Button key="compact" label="Compact" variant={v.level === 'alert' ? 'primary' : undefined} onPress={() => update($, composing, () => true)} />
396 )
397
398 const strip = (
399 <Box flexDirection="row" alignItems="center" columnGap={2}>
400 {windowSvg(els, v, { width: 72, height: 10, compacting: ph === 'compacting' })}
401 {summary}
402 <Box flexGrow={1} />
403 {right ? <Text color={v.level === 'calm' ? undefined : tone} dimColor={v.level === 'calm'}>{right}</Text> : null}
404 {v.tokens !== undefined && ph === 'idle' ? <Button key="more" plain dimColor label={isOpen ? 'Less ▾' : 'More ▴'} onPress={toggle} /> : null}
405 {compactControl}
406 </Box>
407 )
408
409 const details =
410 isOpen && v.tokens !== undefined && ph === 'idle' ? (
411 <Box flexDirection="column" rowGap={1} paddingBottom={1}>
412 <Box flexDirection="row" justifyContent="space-between">
413 <Text>
414 <Text bold color={tone}>{`${v.pct}%`}</Text>
415 <Text dimColor>{` of ${win} · ${fmt(v.tokens)} tokens`}</Text>
416 </Text>
417 <Text dimColor>{v.b?.threshold !== undefined ? `auto-compact at ${fmt(v.b.threshold)}` : v.b && !v.b.isAutoCompactEnabled ? 'auto-compact off' : ''}</Text>
418 </Box>
419 {windowSvg(els, v, { height: 12, compacting: false })}
420 {v.b && v.b.used.length > 0 ? (
421 <Box flexDirection="column" rowGap={1}>
422 <Box flexDirection="row" justifyContent="space-between">
423 <Text dimColor>What fills it</Text>
424 <Text dimColor>{v.b.isExact ? 'exact counts' : 'local estimate'}</Text>
425 </Box>
426 {compositionSvg(els, v.b)}
427 {legend(els, v.b, e.props.bodyColumns >= 70 ? 2 : 1)}
428 </Box>
429 ) : null}
430 <Box flexDirection="row" columnGap={4} flexWrap="wrap">
431 <Text dimColor>
432 Last turn <Text bold>{fmtDelta(v.last)}</Text>
433 </Text>
434 <Text dimColor>
435 Avg / turn <Text bold>{v.avg !== undefined ? fmt(v.avg) : '—'}</Text>
436 </Text>
437 <Text dimColor>
438 Auto-compact in <Text bold color={tone}>{v.turns !== undefined ? `~${v.turns} ${v.turns === 1 ? 'turn' : 'turns'}` : '—'}</Text>
439 </Text>
440 <Box flexGrow={1} />
441 <Button key="pane" plain dimColor label="Open pane ↗" onPress={() => openPane($)} />
442 </Box>
443 </Box>
444 ) : null
445
446 const compose =
447 isComposing && ph === 'idle' && !e.props.isWorking ? (
448 <Box flexDirection="column" rowGap={1} paddingBottom={1}>
449 <Text>
450 <Text bold>Compact conversation</Text>
451 <Text dimColor>{v.tokens !== undefined ? ` · ${fmt(v.tokens)} now; older messages become a summary, recent ones stay` : ''}</Text>
452 </Text>
453 {'Input' in els ? (
454 <els.Input
455 key="focus"
456 label="Focus (optional)"
457 placeholder="What the summary must keep"
458 value={text}
459 autoFocus
460 submitLabel="Compact"
461 onInput={value => void update($, draft, () => value)}
462 onSubmit={value => void compactNow($, value)}
463 />
464 ) : null}
465 <Box flexDirection="row" columnGap={2} alignItems="center">
466 <Text dimColor>{s.snapshot ? 'Snapshot first → .claude/context-snapshots/' : 'No snapshot · turn it on in the pane’s Settings'}</Text>
467 <Box flexGrow={1} />
468 <Button key="cancel" label="Cancel" onPress={() => update($, composing, () => false)} />
469 <Button key="go" label="Compact" variant="primary" onPress={async () => compactNow($, await read($, draft))} />
470 </Box>
471 </Box>
472 ) : null
473
474 return (
475 <Box flexDirection="column">
476 {compose}
477 {details}
478 {strip}
479 </Box>
480 )
481 })
482
483 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
484 const els = $.ui.resolve(e)
485 const { Box, Text, Button } = els
486 const [current, v, points, compacts, err] = await Promise.all([read($, tab), viewOf($), read($, history), read($, compactions), read($, error)])
487 const tabs = (
488 <Box flexDirection="row" columnGap={2} paddingBottom={1}>
489 <Button key="tab-overview" plain onPress={() => update($, tab, () => 'overview')}>
490 <Text bold={current === 'overview'} dimColor={current !== 'overview'}>Overview</Text>
491 </Button>
492 <Button key="tab-settings" plain onPress={() => update($, tab, () => 'settings')}>
493 <Text bold={current === 'settings'} dimColor={current !== 'settings'}>Settings</Text>
494 </Button>
495 </Box>
496 )
497
498 if (current === 'settings') {
499 const s = await read($, settings)
500 const cwd = await $.session.cwd()
501 const toggleRow = (key: keyof ControlSettings & string, label: string, value: boolean, hint?: string) => (
502 <Box flexDirection="column">
503 <Box flexDirection="row" alignItems="center" columnGap={2}>
504 <Box flexGrow={1}>
505 <Text>{label}</Text>
506 </Box>
507 <Button key={`set-${key}`} label={value ? 'On' : 'Off'} variant={value ? 'primary' : undefined} onPress={() => saveSettings($, { [key]: !value })} />
508 </Box>
509 {hint ? <Text dimColor>{hint}</Text> : null}
510 </Box>
511 )
512 const numberRow = (key: 'warnAt' | 'alertAt' | 'averageOver', label: string, suffix: string, min: number, max: number) =>
513 'Input' in els ? (
514 <Box flexDirection="row" alignItems="center" columnGap={2}>
515 <Box flexGrow={1}>
516 <Text>{label}</Text>
517 </Box>
518 <els.Input
519 key={`set-${key}`}
520 label={label}
521 value={String(s[key])}
522 submitLabel="Set"
523 onSubmit={value => {
524 const n = Math.round(Number(value))
525 if (Number.isFinite(n) && n >= min && n <= max) void saveSettings($, { [key]: n })
526 else $.ui.toast(`${label}: enter ${min}–${max}`)
527 }}
528 />
529 <Text dimColor>{suffix}</Text>
530 </Box>
531 ) : (
532 <Text>{`${label}: ${s[key]} ${suffix}`}</Text>
533 )
534 const heading = (t: string) => <Text bold dimColor>{t.toUpperCase()}</Text>
535
536 return (
537 <Box flexDirection="column" rowGap={1}>
538 {tabs}
539 {heading('Display')}
540 {toggleRow('showBand', 'Band above the prompt', s.showBand)}
541 {'Select' in els ? (
542 <Box flexDirection="row" alignItems="center" columnGap={2}>
543 <Box flexGrow={1}>
544 <Text>Band starts</Text>
545 </Box>
546 <els.Select
547 key="set-startExpanded"
548 options={[{ value: 'collapsed', label: 'Collapsed' }, { value: 'expanded', label: 'Expanded' }]}
549 value={s.startExpanded ? 'expanded' : 'collapsed'}
550 onSelect={value => void saveSettings($, { startExpanded: value === 'expanded' })}
551 />
552 </Box>
553 ) : null}
554 {toggleRow('openPaneOnStart', 'Open this pane when a session starts', s.openPaneOnStart)}
555 {heading('Alerts')}
556 {numberRow('warnAt', 'Warn at', '% of auto-compact', 1, 100)}
557 {numberRow('alertAt', 'Alert at', '% of auto-compact', 1, 100)}
558 {toggleRow('toastOnAlert', 'Toast once when crossing Alert', s.toastOnAlert)}
559 {numberRow('averageOver', 'Turns-left estimate averages', 'turns', 1, 50)}
560 {heading('Breakdown accuracy')}
561 {toggleRow(
562 'exactCounts',
563 'Exact counts',
564 s.exactCounts,
565 s.exactCounts
566 ? 'Calls the token-count API per category after each turn, with your account. Not a model call, but not free of requests.'
567 : 'Off: local estimate from the last reply’s usage. No API calls.',
568 )}
569 {heading('Pre-compact snapshot')}
570 {toggleRow('snapshot', 'Save a snapshot before each compaction', s.snapshot, 'A Markdown handoff built from the transcript (recent prompts, files touched, open tasks). No model calls.')}
571 <Text dimColor>{`Folder: .claude/context-snapshots/ in ${cwd}`}</Text>
572 {toggleRow('snapshotGitignore', 'Keep snapshots out of git', s.snapshotGitignore, 'Writes a one-line .gitignore inside the snapshot folder; the project’s own .gitignore is untouched.')}
573 </Box>
574 )
575 }
576
577 if (v === null || v.tokens === undefined) {
578 return (
579 <Box flexDirection="column" rowGap={1}>
580 {tabs}
581 <Text dimColor>{err ? `Couldn’t read usage: ${err}` : 'Measuring — figures appear after the next reply.'}</Text>
582 </Box>
583 )
584 }
585
586 const tone = TEXT_TONE[v.level]
587 const used = v.b?.used ?? []
588 const usedTotal = used.reduce((a, c) => a + c.tokens, 0)
589 const fixed = v.b?.fixed ?? []
590 const maxFixed = fixed[0]?.tokens ?? 1
591 const hasSvg = 'Svg' in els
592 const section = (title: string, aside?: string) => (
593 <Box flexDirection="row" justifyContent="space-between">
594 <Text bold>{title}</Text>
595 {aside ? <Text dimColor>{aside}</Text> : null}
596 </Box>
597 )
598
599 return (
600 <Box flexDirection="column" rowGap={1}>
601 {tabs}
602 <Text dimColor>{`Context window · ${await $.session.model()}`}</Text>
603 <Text>
604 <Text bold color={tone}>{`${v.pct}%`}</Text>
605 <Text dimColor>{` ${fmt(v.tokens)} of ${fmtWindow(v.r.window)} tokens`}</Text>
606 </Text>
607 {windowSvg(els, v, { height: 12, compacting: false })}
608 <Text dimColor>{v.b?.threshold !== undefined ? `auto-compact at ${fmt(v.b.threshold)}` : 'auto-compact off'}</Text>
609 <Box flexDirection="row" columnGap={3} flexWrap="wrap">
610 <Text dimColor>
611 Last turn <Text bold>{fmtDelta(v.last)}</Text>
612 </Text>
613 <Text dimColor>
614 Avg <Text bold>{v.avg !== undefined ? fmt(v.avg) : '—'}</Text>
615 </Text>
616 <Text dimColor>
617 Auto-compact in <Text bold color={tone}>{v.turns !== undefined ? `~${v.turns} ${v.turns === 1 ? 'turn' : 'turns'}` : '—'}</Text>
618 </Text>
619 </Box>
620
621 {points.length >= 2 && hasSvg && 'Svg' in els ? (
622 <Box flexDirection="column" rowGap={1} paddingTop={1}>
623 {section('This session', `${points.length} readings`)}
624 <els.Svg
625 source={sparkline(points, v.r.window, v.b?.threshold)}
626 alt={`Context over the session, now ${fmt(v.tokens)}${compacts.length ? `, ${compacts.length} compaction${compacts.length === 1 ? '' : 's'}` : ''}`}
627 />
628 <Text dimColor>- - auto-compact ● compaction ○ now</Text>
629 </Box>
630 ) : null}
631
632 {used.length > 0 && v.b ? (
633 <Box flexDirection="column" rowGap={1} paddingTop={1}>
634 {section('What fills it', v.b.isExact ? 'exact counts' : 'local estimate')}
635 {compositionSvg(els, v.b)}
636 {legend(els, v.b, 1)}
637 <Text dimColor>{`${fmt(usedTotal)} counted by category`}</Text>
638 </Box>
639 ) : null}
640
641 {fixed.length > 0 ? (
642 <Box flexDirection="column" rowGap={1} paddingTop={1}>
643 {section('Always loaded')}
644 <Text dimColor>Paid on every turn. Trim these to buy headroom.</Text>
645 {fixed.map(f => (
646 <Box flexDirection="column">
647 <Box flexDirection="row" columnGap={1}>
648 <Box width={9}>
649 <Text dimColor>{f.kind}</Text>
650 </Box>
651 <Box flexGrow={1}>
652 <Text wrap="truncate-end">{f.name}</Text>
653 </Box>
654 <Text>{fmt(f.tokens)}</Text>
655 </Box>
656 {'Svg' in els ? <els.Svg source={rankBar(f.tokens / maxFixed, f.color)} alt={`${f.name}: ${fmt(f.tokens)}`} height={4} /> : null}
657 </Box>
658 ))}
659 </Box>
660 ) : null}
661
662 <Box flexDirection="column" rowGap={1} paddingTop={1}>
663 {section('Compactions', `${compacts.length} this session`)}
664 {compacts.length === 0 ? <Text dimColor>None yet.</Text> : null}
665 {[...compacts]
666 .reverse()
667 .slice(0, 6)
668 .map(c => {
669 const at = new Date(c.at)
670 const time = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
671 return (
672 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
673 <Box flexDirection="row" justifyContent="space-between">
674 <Text bold>{`${time} · ${c.trigger}`}</Text>
675 <Text>{`${c.before !== undefined ? fmt(c.before) : '?'} → ${c.after !== undefined ? fmt(c.after) : 'summary'}`}</Text>
676 </Box>
677 {c.focus ? <Text dimColor>{`Focus: “${c.focus}”`}</Text> : null}
678 {c.snapshot ? <Text dimColor wrap="truncate-start">{c.snapshot}</Text> : null}
679 </Box>
680 )
681 })}
682 </Box>
683
684 <Box paddingTop={1}>
685 <Button key="pane-compact" label="Compact…" onPress={() => update($, composing, () => true)} />
686 </Box>
687 </Box>
688 )
689 })
690}
691hooks/model.ts 124 lines1import type { SessionContextBreakdown } from 'claude-code'
2
3import type { Breakdown, Category, FixedCost, Point, Reading, ControlSettings } from '../types'
4
5export const DEFAULTS: ControlSettings = {
6 showBand: true,
7 startExpanded: false,
8 openPaneOnStart: false,
9 warnAt: 70,
10 alertAt: 85,
11 toastOnAlert: true,
12 averageOver: 5,
13 exactCounts: false,
14 snapshot: false,
15 snapshotGitignore: true,
16}
17
18export type Level = 'calm' | 'warn' | 'alert'
19
20export const TONE: Record<Level, string> = { calm: '#9a968c', warn: '#d99a3a', alert: '#e0624a' }
21
22export const TEXT_TONE: Record<Level, string | undefined> = { calm: undefined, warn: 'warning', alert: 'error' }
23
24const CATEGORY_COLORS: Record<string, string> = {
25 Messages: '#6f9be0',
26 'MCP tools': '#3f9a96',
27 'System tools': '#8a99a8',
28 'System prompt': '#77736b',
29 Skills: '#9a86c9',
30 'Custom agents': '#b48ad6',
31 'Memory files': '#c9a24f',
32}
33
34const FALLBACK_COLORS = ['#c77b9b', '#7fb37a', '#d08a5a', '#5aa9c9']
35
36const baseName = (path: string) => path.split(/[\\/]/).pop() ?? path
37
38export function toBreakdown(b: SessionContextBreakdown, isExact: boolean): Breakdown {
39 const used: Category[] = b.categories
40 .filter(c => c.kind === 'used' && c.tokens > 0)
41 .map((c, i) => ({
42 name: c.name,
43 tokens: c.tokens,
44 color: CATEGORY_COLORS[c.name] ?? FALLBACK_COLORS[i % FALLBACK_COLORS.length] ?? '#9a968c',
45 }))
46 .sort((x, y) => y.tokens - x.tokens)
47
48 const fixed: FixedCost[] = []
49 const servers = new Map<string, number>()
50 for (const tool of b.mcpTools) {
51 if (tool.isLoaded) servers.set(tool.serverName, (servers.get(tool.serverName) ?? 0) + tool.tokens)
52 }
53 for (const [name, tokens] of servers) fixed.push({ kind: 'MCP', name, tokens, color: CATEGORY_COLORS['MCP tools']! })
54 for (const file of b.memoryFiles) {
55 fixed.push({ kind: 'Memory', name: `${baseName(file.path)} · ${file.type}`, tokens: file.tokens, color: CATEGORY_COLORS['Memory files']! })
56 }
57 if (b.skills && b.skills.tokens > 0) {
58 fixed.push({ kind: 'Skills', name: `Skill listing · ${b.skills.includedSkills} skills`, tokens: b.skills.tokens, color: CATEGORY_COLORS.Skills! })
59 }
60 const agentTokens = b.agents.reduce((sum, a) => sum + a.tokens, 0)
61 if (agentTokens > 0) {
62 fixed.push({ kind: 'Agents', name: `Custom agents · ${b.agents.length}`, tokens: agentTokens, color: CATEGORY_COLORS['Custom agents']! })
63 }
64 if (b.slashCommands && b.slashCommands.tokens > 0) {
65 fixed.push({ kind: 'Commands', name: `Command listing · ${b.slashCommands.includedCommands}`, tokens: b.slashCommands.tokens, color: '#8a99a8' })
66 }
67 fixed.sort((x, y) => y.tokens - x.tokens)
68
69 return {
70 used,
71 threshold: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
72 isAutoCompactEnabled: b.isAutoCompactEnabled,
73 fixed: fixed.slice(0, 8),
74 isExact,
75 }
76}
77
78export function fmt(n: number): string {
79 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
80 if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
81 return String(Math.round(n))
82}
83
84export function fmtWindow(n: number): string {
85 if (n % 1_000_000 === 0) return `${n / 1_000_000}M`
86 if (n % 1000 === 0) return `${n / 1000}k`
87 return fmt(n)
88}
89
90export function fmtDelta(n: number | undefined): string {
91 if (n === undefined) return '—'
92 return `${n >= 0 ? '+' : '−'}${fmt(Math.abs(n))}`
93}
94
95export const limitOf = (reading: Reading, breakdown: Breakdown | null) => breakdown?.threshold ?? reading.window
96
97export function levelOf(tokens: number, limit: number, s: ControlSettings): Level {
98 const ratio = (tokens / limit) * 100
99 if (ratio >= s.alertAt) return 'alert'
100 if (ratio >= s.warnAt) return 'warn'
101 return 'calm'
102}
103
104/** Growth per turn since the last compaction: the newest delta and the mean of the last `over`. */
105export function growth(history: readonly Point[], over: number): { last?: number; avg?: number } {
106 const start = history.findLastIndex(p => p.isCompaction === true)
107 const run = history.slice(Math.max(0, start))
108 const deltas: number[] = []
109 for (let i = 1; i < run.length; i++) deltas.push(run[i]!.tokens - run[i - 1]!.tokens)
110 if (deltas.length === 0) return {}
111 const recent = deltas.slice(-Math.max(1, over)).filter(d => d > 0)
112 const avg = recent.length > 0 ? recent.reduce((a, b) => a + b, 0) / recent.length : undefined
113 return { last: deltas.at(-1), avg }
114}
115
116export function turnsLeft(tokens: number, limit: number, avg: number | undefined): number | undefined {
117 if (avg === undefined || avg <= 0) return undefined
118 return Math.max(0, Math.ceil((limit - tokens) / avg))
119}
120
121export function clampPercent(n: number): number {
122 return Math.max(0, Math.min(100, n))
123}
124hooks/snapshot.ts 94 lines1import type { SessionMessage } from 'claude-code'
2
3import { fmt } from './model'
4import type { Reading } from '../types'
5
6const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
7const TASK_TOOLS = new Set(['TodoWrite', 'mcp__ccd_turn__update_plan'])
8
9const pad = (n: number) => String(n).padStart(2, '0')
10
11const clean = (text: string) =>
12 text
13 .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
14 .replace(/<[a-z-]+-context[^>]*>[\s\S]*?<\/[a-z-]+-context>/g, '')
15 .trim()
16
17function promptsOf(messages: readonly SessionMessage[], count: number): string[] {
18 return messages
19 .filter(m => m.role === 'user' && (m.toolResults?.length ?? 0) === 0)
20 .map(m => clean(m.text))
21 .filter(t => t.length > 0)
22 .slice(-count)
23}
24
25function filesOf(messages: readonly SessionMessage[]): { edited: string[]; read: string[] } {
26 const edited = new Set<string>()
27 const read = new Set<string>()
28 for (const m of messages) {
29 for (const use of m.toolUses) {
30 const path = use.input.file_path ?? use.input.notebook_path
31 if (typeof path !== 'string') continue
32 if (EDITS.has(use.tool)) edited.add(path)
33 else if (use.tool === 'Read') read.add(path)
34 }
35 }
36 for (const p of edited) read.delete(p)
37 return { edited: [...edited], read: [...read] }
38}
39
40function tasksOf(messages: readonly SessionMessage[]): string[] {
41 for (let i = messages.length - 1; i >= 0; i--) {
42 const use = [...messages[i]!.toolUses].reverse().find(u => TASK_TOOLS.has(u.tool) && Array.isArray(u.input.todos ?? u.input.items))
43 if (!use) continue
44 const list = (use.input.todos ?? use.input.items) as { content?: unknown; status?: unknown }[]
45 return list
46 .filter(t => t.status !== 'completed')
47 .map(t => `- [${t.status === 'in_progress' ? '~' : ' '}] ${String(t.content ?? '')}`)
48 }
49 return []
50}
51
52/** The snapshot's file name stamp for a time: `2026-10-15_164705`. */
53export function snapshotStamp(nowMs: number): string {
54 const now = new Date(nowMs)
55 return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
56}
57
58/** The Markdown handoff of the conversation about to be compacted. */
59export function snapshotText(o: {
60 stamp: string
61 trigger: string
62 sessionId: string
63 model: string
64 reading: Reading | null
65 focus?: string
66 messages: readonly SessionMessage[]
67}): string {
68 const r = o.reading
69 const size = r?.tokens !== undefined ? `${fmt(r.tokens)} of ${fmt(r.window)} (${r.percent ?? Math.round((r.tokens / r.window) * 100)}%)` : 'unknown'
70 const files = filesOf(o.messages)
71 const tasks = tasksOf(o.messages)
72 const prompts = promptsOf(o.messages, 10)
73
74 return [
75 `# Context snapshot — ${o.stamp.replace('_', ' ')}`,
76 '',
77 `- Trigger: ${o.trigger}`,
78 `- Session: ${o.sessionId}`,
79 `- Model: ${o.model}`,
80 `- Context before compaction: ${size}`,
81 ...(o.focus ? [`- Focus: ${o.focus}`] : []),
82 '',
83 `## Recent prompts (${prompts.length})`,
84 '',
85 ...prompts.flatMap(p => [p.slice(0, 1200).replace(/^/gm, '> '), '']),
86 '## Files touched',
87 '',
88 ...(files.edited.length ? ['Edited:', ...files.edited.map(f => `- ${f}`), ''] : []),
89 ...(files.read.length ? ['Read:', ...files.read.slice(-40).map(f => `- ${f}`), ''] : []),
90 ...(files.edited.length + files.read.length === 0 ? ['None recorded.', ''] : []),
91 ...(tasks.length ? ['## Open tasks', '', ...tasks, ''] : []),
92 ].join('\n')
93}
94hooks/svg.ts 109 lines1import type { Category, Point } from '../types'
2
3const TRACK = 'rgba(128,128,128,0.22)'
4const TICK = '#b8b4aa'
5const EASE = 'calcMode="spline" keyTimes="0;1" keySplines="0.2 0.8 0.2 1"'
6
7const svg = (w: number, h: number, body: string) =>
8 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">${body}</svg>`
9
10/**
11 * The window as one bar: fill grows from `from` to `to` (percent of the window),
12 * the hatch past `tick` is the auto-compact reserve, `pulse` breathes the fill,
13 * `shimmer` sweeps it while compacting.
14 */
15export function windowBar(o: {
16 from: number
17 to: number
18 tick?: number
19 tone: string
20 pulse?: boolean
21 shimmer?: boolean
22 width?: number
23 height?: number
24}): string {
25 const W = o.width ?? 1600
26 const H = o.height ?? 12
27 const bh = Math.min(6, H - 4)
28 const y = (H - bh) / 2
29 const x = (p: number) => (Math.max(0, Math.min(100, p)) / 100) * W
30 const to = x(o.to)
31 const from = x(o.from)
32 const parts: string[] = [
33 `<defs><pattern id="h" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="2" height="5" fill="rgba(128,128,128,0.5)"/></pattern>`,
34 `<clipPath id="c"><rect x="0" y="${y}" width="${to}" height="${bh}" rx="${bh / 2}"><animate attributeName="width" from="${from}" to="${to}" dur="0.7s" fill="freeze" ${EASE}/></rect></clipPath>`,
35 `<linearGradient id="s" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs>`,
36 `<rect x="0" y="${y}" width="${W}" height="${bh}" rx="${bh / 2}" fill="${TRACK}"/>`,
37 ]
38 if (o.tick !== undefined && o.tick < 100) {
39 parts.push(`<rect x="${x(o.tick)}" y="${y}" width="${W - x(o.tick)}" height="${bh}" fill="url(#h)"/>`)
40 }
41 const pulse = o.pulse ? `<animate attributeName="opacity" values="1;0.55;1" dur="2.4s" repeatCount="indefinite"/>` : ''
42 parts.push(`<rect x="0" y="${y}" width="${W}" height="${bh}" fill="${o.tone}" clip-path="url(#c)">${pulse}</rect>`)
43 if (o.shimmer) {
44 const sw = Math.max(40, W * 0.15)
45 parts.push(
46 `<rect x="${-sw}" y="${y}" width="${sw}" height="${bh}" fill="url(#s)" clip-path="url(#c)"><animate attributeName="x" from="${-sw}" to="${Math.max(to, sw)}" dur="1.3s" repeatCount="indefinite"/></rect>`,
47 )
48 }
49 if (o.tick !== undefined && o.tick < 100) {
50 parts.push(`<rect x="${x(o.tick) - 1}" y="0" width="2" height="${H}" rx="1" fill="${TICK}"/>`)
51 }
52 return svg(W, H, parts.join(''))
53}
54
55/** What fills the used part, as one stacked bar; segments fade in left to right. */
56export function compositionBar(cats: readonly Category[], width = 1600, height = 8): string {
57 const total = cats.reduce((a, c) => a + c.tokens, 0)
58 if (total <= 0) return svg(width, height, '')
59 const gap = 6
60 const minW = 8
61 const room = width - gap * Math.max(0, cats.length - 1)
62 let x = 0
63 const parts = cats.map((c, i) => {
64 const w = Math.max(minW, (c.tokens / total) * room)
65 const at = Math.min(0.9, i * 0.08).toFixed(2)
66 const rect = `<rect x="${x.toFixed(1)}" y="0" width="${w.toFixed(1)}" height="${height}" rx="2" fill="${c.color}"><animate attributeName="opacity" values="0;0;1" keyTimes="0;${at};1" dur="${(0.5 + Number(at)).toFixed(2)}s" fill="freeze"/></rect>`
67 x += w + gap
68 return rect
69 })
70 return svg(Math.max(width, Math.ceil(x - gap)), height, parts.join(''))
71}
72
73/** Context over the session: area, line drawn in, compaction markers, the auto-compact line. */
74export function sparkline(points: readonly Point[], window: number, limit: number | undefined, width = 360, height = 96): string {
75 if (points.length < 2) return svg(width, height, '')
76 const pad = 4
77 const x = (i: number) => (i / (points.length - 1)) * (width - pad * 2) + pad
78 const y = (t: number) => height - pad - (Math.min(t, window) / window) * (height - pad * 2)
79 const line = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(p.tokens).toFixed(1)}`).join(' ')
80 const area = `${line} L${x(points.length - 1).toFixed(1)},${height - pad} L${pad},${height - pad} Z`
81 const parts: string[] = []
82 if (limit !== undefined) {
83 parts.push(`<line x1="0" x2="${width}" y1="${y(limit).toFixed(1)}" y2="${y(limit).toFixed(1)}" stroke="#a3a097" stroke-width="1" stroke-dasharray="3 3"/>`)
84 }
85 parts.push(`<path d="${area}" fill="#6f9be0" fill-opacity="0.14"><animate attributeName="fill-opacity" values="0;0.14" dur="0.9s" fill="freeze"/></path>`)
86 parts.push(
87 `<path d="${line}" fill="none" stroke="#6f9be0" stroke-width="1.8" stroke-linejoin="round" pathLength="1" stroke-dasharray="1" stroke-dashoffset="0"><animate attributeName="stroke-dashoffset" from="1" to="0" dur="0.9s" fill="freeze" ${EASE}/></path>`,
88 )
89 points.forEach((p, i) => {
90 if (!p.isCompaction || i === 0) return
91 const prev = points[i - 1]!
92 parts.push(`<line x1="${x(i).toFixed(1)}" x2="${x(i).toFixed(1)}" y1="${y(prev.tokens).toFixed(1)}" y2="${height - pad}" stroke="#d97757" stroke-width="1" stroke-dasharray="2 3"/>`)
93 parts.push(`<circle cx="${x(i - 1).toFixed(1)}" cy="${y(prev.tokens).toFixed(1)}" r="3.5" fill="#d97757"/>`)
94 })
95 const last = points[points.length - 1]!
96 parts.push(`<circle cx="${x(points.length - 1).toFixed(1)}" cy="${y(last.tokens).toFixed(1)}" r="3.5" fill="#e8e6dc" stroke="#6f9be0" stroke-width="1.5"/>`)
97 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${parts.join('')}</svg>`
98}
99
100/** A small filled bar for a ranked row: `share` of the row's width. */
101export function rankBar(share: number, color: string, width = 600, height = 4): string {
102 const w = Math.max(4, Math.min(1, share) * width)
103 return svg(
104 width,
105 height,
106 `<rect x="0" y="0" width="${width}" height="${height}" rx="2" fill="${TRACK}"/><rect x="0" y="0" width="${w.toFixed(1)}" height="${height}" rx="2" fill="${color}"><animate attributeName="width" from="0" to="${w.toFixed(1)}" dur="0.6s" fill="freeze" ${EASE}/></rect>`,
107 )
108}
109types/index.d.ts 62 lines1export type Reading = { tokens?: number; window: number; percent?: number }
2
3export type Point = { tokens: number; isCompaction?: true }
4
5export type Category = { name: string; tokens: number; color: string }
6
7export type FixedCost = { kind: string; name: string; tokens: number; color: string }
8
9export type Breakdown = {
10 used: Category[]
11 threshold?: number
12 isAutoCompactEnabled: boolean
13 fixed: FixedCost[]
14 isExact: boolean
15}
16
17export type Compaction = {
18 at: number
19 trigger: string
20 before?: number
21 after?: number
22 focus?: string
23 snapshot?: string
24}
25
26export type Phase = 'idle' | 'compacting' | 'compacted'
27
28export type Tab = 'overview' | 'settings'
29
30export type ControlSettings = {
31 showBand: boolean
32 startExpanded: boolean
33 openPaneOnStart: boolean
34 warnAt: number
35 alertAt: number
36 toastOnAlert: boolean
37 averageOver: number
38 exactCounts: boolean
39 snapshot: boolean
40 snapshotGitignore: boolean
41}
42
43declare module 'claude-code' {
44 interface PluginState {
45 'context-control': {
46 reading: Reading | null
47 history: Point[]
48 breakdown: Breakdown | null
49 compactions: Compaction[]
50 phase: Phase
51 expanded: boolean
52 composing: boolean
53 draft: string
54 tab: Tab
55 settings: ControlSettings
56 shownFill: number
57 isAlerted: boolean
58 error: string
59 }
60 }
61}
62