SLOPSHOPPER

Context Control

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…

newpanebandcommandtoast
v0.1.3MITupdated 2026-10-15AweGuider/Claude-Context-Control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-control
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ tab-overview tab-settings │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Context window · claude-opus-5-5 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 49% 97.4k of 200k tokens ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ███████████████████░░░░░░░░░░░░░░░░░░░░░ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ auto-compact off │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Last turn — Avg — Auto-compact in — │ ┃ › /context-control │ ┃ ⎿ context-control: Opened the Context Control pane. │ ┃ Compactions 0 this session │ ┃ │ ┃ None yet. │ ┃ │ ┃ │ ┃ [ Compact… ] │ ████░░░░░ toggle More ▴ [ Compact ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
████░░░░░ toggle More ▴ [ Compact ]
Pane · Context
tab-overview tab-settings Context window · claude-opus-5-5 49% 97.4k of 200k tokens ███████████████████░░░░░░░░░░░░░░░░░░░░░ auto-compact off Last turn — Avg — Auto-compact in — Compactions 0 this session None yet. [ Compact… ]
README

Context Control: what fills Claude's context window, turns left until auto-compact, and a focused Compact

Context Control: context window meter and Compact for Claude Code

<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.

What it does

  • Band above the prompt. Fill bar, percentage, tokens / window, growth last turn and turns left until auto-compact. Click it (or More) for the breakdown: the window with the auto-compact marker, what fills it by category, last turn, average per turn.
  • Compact with focus. Compact opens a field for what the summary must keep, then runs the same compaction /compact does. While Claude is working it waits for the turn to end.
  • Alerts. The band turns amber, then red, as you approach auto-compact (70% and 85% of the threshold by default), and one toast fires when you cross the alert line.
  • Side pane (/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.
  • Pre-compact snapshot (off by default): before any compaction, manual or auto, writes a Markdown handoff of recent prompts, files touched and open tasks.

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.

The band in six states: calm, warn, alert, Claude is working, compacting and compacted

<sub>Illustrative mockup of the band's states, with sample figures.</sub>

Comparison

What Claude Code shows on its own, and what Context Control adds.

Claude Code built inContext Control
Context usage always in viewDepends on the app version; some desktop builds show no context indicator, and the terminal needs a custom status lineA band above the prompt
What fills the context window/context, on demandLive, by category, plus what is loaded on every turn
Turns until auto-compactNot shownEstimated from recent growth
Warning before auto-compactA notice near the limitAmber, then red, and one toast
Compact with a focusType /compact <instructions>A Compact button with a focus field that waits for the turn to end
Snapshot before compactionWrite your own PreCompact hookBuilt in, optional
Context over the session and compaction historyNot shownSide pane

Install

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.

Settings

In the pane's Settings tab; saved across sessions.

SettingDefault
Band above the promptOn
Band startsCollapsed
Open the pane when a session startsOff
Warn at / Alert at70% / 85% of auto-compact
Toast once when crossing AlertOn
Turns-left estimate averages the last5 turns
Exact countsOff
Pre-compact snapshotOff
Keep snapshots out of gitOn

Snapshots

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.

What it reads, sends and writes

  • Reads the session's own usage figures (context fill, window size and the breakdown by category) and, only for a snapshot, the session's transcript.
  • Sends nothing by default: no network requests, no model calls, no telemetry. With Exact counts on (off by default), the breakdown uses Claude Code's own token-count requests, one per tool and memory file, as /context does, to the Anthropic API your session already uses.
  • Writes its settings through Claude Code's plugin storage and, only with snapshots on, two kinds of file in .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.
  • Hooks compaction (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.
  • Adds one command, /context-control, which opens the mod's pane and does nothing else.

The full policy is in PRIVACY.md.

FAQ

How do I see how much context Claude Code is using?

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.

Where did the context indicator and Compact button go in the Claude desktop app?

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.

How many turns do I have until Claude Code auto-compacts?

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.

What is taking up my context window, and how do I free some?

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.

How do I compact without losing important details?

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.

Does it work in the terminal, VS Code and the desktop app?

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.

Does it send my data anywhere or use API credits?

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.

Does it write anything to my disk?

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.

How do I update or uninstall it?

See Install. Updates arrive when the version changes; press Update on the plugin's page or run claude plugin update context-control@awedev.

Is it official?

No. Context Control is an unofficial community mod by AweDev, not affiliated with or endorsed by Anthropic.

Limitations

  • Where an organization manages Claude Code, hooks on compaction run only for plugins the organization lists, so snapshots and the compaction history record only the mod's own Compact button there.
  • Figures appear after the first reply of a session and again after the first reply following a compaction.

Development

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.

License

MIT © 2026 Alan Safonov (AweDev)

Source 5 files
hooks/register.tsx 691 lines
1import { 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}
691
hooks/model.ts 124 lines
1import 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}
124
hooks/snapshot.ts 94 lines
1import 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}
94
hooks/svg.ts 109 lines
1import 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}
109
types/index.d.ts 62 lines
1export 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