See what fills your context window, what the last prompt added, and where tokens are wasted

A Claude Code plugin that shows what's in your context window and which tokens are going to waste.
<img src="docs/blocks.png" alt="Blocks tab: likely waste with Drop buttons" width="520">
/plugin install context-lens --marketplace Gautamstar/context-lens
In the desktop app, click + next to the prompt box, then Plugins, and add the marketplace Gautamstar/context-lens.
Needs Claude Code 2.1.299 or later. The plugin API is still early access, so a Claude Code update can break it.
Overview shows how full the window is, token use by category, fill over time, and what the last prompt cost.
Blocks lists likely waste: files read more than once, reads of files that changed afterwards, tool outputs over 5k tokens, and MCP tools that were never called. Drop removes one of these from the conversation without summarizing anything else. It resets the prompt cache, so the next prompt costs more.
Ablation replays your last question with each big block removed and shows which ones changed the answer. It shows an estimated cost first and waits for you to press Run. The replays run on Haiku over a flattened transcript, so treat the results as hints.
Agents compares how much each subagent read with how much it sent back.
Per-block token counts are estimates.
Only the ablation calls a model, and only after you press Run or type /ctx ablate run yourself. Nothing else in Context Lens spends tokens.
When you run /compact or auto-compact kicks in, Context Lens tells the summarizer which files were edited and which reads are stale. This and Drop don't run where an organization manages Claude Code.
| Command | |
|---|---|
/ctx | Open the pane |
/ctx full | Recount with the token-counting API |
/ctx ablate [n] | Estimate the cost of testing the n biggest blocks (default 5) |
/ctx ablate run | Run it |
/ctx ablate cancel | Cancel it |
Set these in /config.
| Setting | Default | |
|---|---|---|
autoOpen | in-app | Open the pane at startup in the desktop app and VS Code. Also always or never. |
ablationModel | haiku | Model for ablation replays. sonnet costs about 20x more. |
largeOutputTokens | 5000 | Size at which a tool output counts as waste. |
compactionHints | on | Add the compaction notes. |
claude --plugin-dir ./context-lens
claude plugin test ./context-lens
MIT
hooks/register.tsx 493 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, PluginOptions, Register } from 'claude-code'
3
4import type { Ablation, AblationPlan, AblationResult, AgentRow, DropRequest, History, PaneTab, Snapshot } from '../types'
5import { cacheMarks, estimateAblation, judgeText, JUDGE_LABELS, pickCandidates, promptTokens, replayPrompt, toReplay, verdictOf } from './ablation'
6import type { Replay, Segment, TextBlock } from './ablation'
7import { autoCompactPoint, calibrate, diff, estimateTokens, estimateTranscript, findWaste, formatTokens, LARGE_OUTPUT_TOKENS, mergeCalls, parse, readsInContext, stubResults } from './analyze'
8import type { Message, Parsed } from './analyze'
9import { compactionHints, editedFiles, withHints } from './hints'
10import { drawPane } from './pane'
11
12const PANE = 'context-lens'
13const snapshot = atom({ plugin: 'context-lens', key: 'snapshot' } as const, null)
14const ablation = atom({ plugin: 'context-lens', key: 'ablation' } as const, null)
15const ablationPlan = atom({ plugin: 'context-lens', key: 'ablationPlan' } as const, null)
16const history = atom({ plugin: 'context-lens', key: 'history' } as const, { points: [], turns: [], compactions: [] } as History)
17const readTimes = atom({ plugin: 'context-lens', key: 'readTimes' } as const, {})
18const dropRequest = atom({ plugin: 'context-lens', key: 'dropRequest' } as const, null)
19const agentStats = atom({ plugin: 'context-lens', key: 'agentStats' } as const, {})
20const paneTab = atom({ plugin: 'context-lens', key: 'tab' } as const, 'overview' as PaneTab)
21
22const MAX_POINTS = 120
23const MAX_TURNS = 50
24const MAX_READ_TIMES = 500
25// A file's modified time within this long of its read is the same moment.
26const MTIME_SLACK_MS = 1000
27
28// A pending Drop answers only the compaction it queued, which runs within this.
29const DROP_WINDOW_MS = 120_000
30// Read by the session.compact hook's .catch, which cannot reach $.state.
31let isDropPending = false
32
33// Where a command typed by the person comes from: the prompt, a phone or web
34// client, or the desktop app and other SDK hosts.
35const PERSON_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
36
37const DROP_STUB =
38 '[Context Lens removed this tool result to save context. Run the tool again if you need what it said.]'
39
40const DEFAULT_CANDIDATES = 5
41const MAX_CANDIDATES = 8
42// Each replay resends the conversation, so a long one gets expensive fast.
43// This also keeps Haiku under its 100k-token price step.
44const MAX_REPLAY_TOKENS = 80_000
45
46// What the person set in the plugin's options, read once per load.
47type Settings = {
48 autoOpen: 'in-app' | 'always' | 'never'
49 // Replays go to a small model by default: each one resends most of the
50 // conversation, and which blocks matter is a question a small model answers well enough.
51 ablationModel: string
52 largeOutputTokens: number
53 compactionHints: boolean
54}
55
56const readSettings = (options: PluginOptions): Settings => {
57 const autoOpen = options.autoOpen
58 const threshold = Number(options.largeOutputTokens)
59 return {
60 autoOpen: autoOpen === 'always' || autoOpen === 'never' ? autoOpen : 'in-app',
61 ablationModel: typeof options.ablationModel === 'string' && options.ablationModel !== '' ? options.ablationModel : 'haiku',
62 largeOutputTokens: Number.isFinite(threshold) && threshold > 0 ? threshold : LARGE_OUTPUT_TOKENS,
63 compactionHints: options.compactionHints !== false,
64 }
65}
66
67let settings: Settings = readSettings({})
68// Set once the pane has opened by itself, so a surface attaching later does
69// not reopen a pane the person closed.
70let hasAutoOpened = false
71
72const byTokens = (a: { tokens: number }, b: { tokens: number }) => b.tokens - a.tokens
73
74// Reads the window and the conversation, and stores what the pane draws.
75// `summary` estimates locally; `full` asks the token-count API, as /context does.
76const refresh = async ($: EngineInterface, detail: 'summary' | 'full' = 'summary'): Promise<Snapshot | null> => {
77 const [usage, messages, rows, stats, compactPercent] = await Promise.all([
78 $.session.usage({ breakdown: detail }),
79 $.session.messages({ as: 'api' }),
80 $.session.messages(),
81 read($, agentStats),
82 $.env.get('CLAUDE_AUTOCOMPACT_PCT_OVERRIDE').catch(() => undefined),
83 ])
84 // The same call can appear in more than one row; one entry per call.
85 const agentUses = [...new Map(rows.flatMap(row => row.toolUses ?? [])
86 .filter(use => use.tool === 'Agent')
87 .map(use => [use.tool_use_id, use] as const)).values()]
88 const agents: AgentRow[] = agentUses.map(use => {
89 const known = use.agentId === undefined ? undefined : stats[use.agentId]
90 return {
91 input: 0, cacheRead: 0, output: 0, runs: 0, durationMs: 0, returnedTokens: 0,
92 ...known,
93 agentId: use.agentId,
94 toolUseId: use.tool_use_id,
95 description: typeof use.input.description === 'string' ? use.input.description : 'Agent',
96 agentType: typeof use.input.subagent_type === 'string' ? use.input.subagent_type : undefined,
97 isRunning: known === undefined,
98 }
99 })
100 const breakdown = usage.context.breakdown
101 const raw = parse(messages as unknown as Message[])
102 const exactMessages = breakdown?.categories.find(row => row.kind === 'used' && row.name === 'Messages')?.tokens
103 const { blocks, factor } = calibrate(raw.blocks, exactMessages)
104 const parsed: Parsed = { ...raw, blocks }
105 const byId = new Map(blocks.map(block => [block.id, block]))
106 const waste = findWaste(parsed, breakdown?.mcpTools ?? [], await changedOnDisk($, parsed), settings.largeOutputTokens)
107 // Turns are diffed on the raw estimates, so a calibration that moves between
108 // refreshes does not read as blocks added or dropped.
109 const messageTokens = raw.blocks.reduce((sum, block) => sum + block.tokens, 0)
110 const at = await $.clock.now()
111
112 let stored: Snapshot | null = null
113 await update($, snapshot, previous => {
114 const change = diff(previous?.blockIds, previous?.messageTokens ?? 0, raw.blocks)
115 stored = {
116 at,
117 percent: usage.context.percent ?? breakdown?.percentage,
118 tokens: usage.context.tokens ?? breakdown?.totalTokens,
119 window: breakdown?.rawMaxTokens ?? usage.context.window,
120 categories: (breakdown?.categories ?? [])
121 .filter(row => row.kind === 'used' && row.tokens > 0)
122 .map(row => ({ name: row.name, tokens: row.tokens, color: row.color }))
123 .sort(byTokens),
124 messageTokens,
125 biggest: mergeCalls(parsed.blocks).sort(byTokens).slice(0, 8),
126 added: mergeCalls(change.added.map(block => byId.get(block.id) ?? block)).sort(byTokens).slice(0, 5),
127 addedTokens: Math.round(change.addedTokens * factor),
128 droppedTokens: Math.round(change.droppedTokens * factor),
129 waste: waste.slice(0, 8),
130 wasteTokens: waste.reduce((sum, item) => sum + item.tokens, 0),
131 calibration: factor,
132 agents: agents.slice(-8),
133 autoCompactAt: breakdown?.isAutoCompactEnabled
134 ? breakdown.autoCompactThreshold === undefined ? undefined : autoCompactPoint(breakdown.autoCompactThreshold, compactPercent)
135 : previous?.autoCompactAt,
136 editedFiles: editedFiles(rows.flatMap(row => row.toolUses ?? [])),
137 blockIds: parsed.blocks.map(block => block.id),
138 }
139 return stored
140 })
141
142 const current = stored as Snapshot | null
143 if (current) {
144 const fill = current.percent === undefined ? '' : `${current.percent}% · `
145 $.ui.status(`ctx ${fill}waste ${formatTokens(current.wasteTokens)}`)
146 }
147 return current
148}
149
150// The reads still in context whose file changed after the read finished, by
151// whatever means. Reads from before the mod loaded have no time and are skipped.
152const changedOnDisk = async ($: EngineInterface, parsed: Parsed): Promise<Set<string>> => {
153 const times = await read($, readTimes)
154 const byPath = new Map<string, string[]>()
155 for (const [id, path] of readsInContext(parsed)) {
156 if (times[id] !== undefined) byPath.set(path, [...(byPath.get(path) ?? []), id])
157 }
158 const changed = new Set<string>()
159 await Promise.all([...byPath].map(async ([path, ids]) => {
160 try {
161 const { mtimeMs } = await $.fs.stat(path)
162 for (const id of ids) if (mtimeMs > (times[id] ?? Infinity) + MTIME_SLACK_MS) changed.add(id)
163 } catch {
164 // Gone or unreadable: nothing to compare against.
165 }
166 }))
167 return changed
168}
169
170// Stubs out the given tool results through a compaction this mod answers
171// itself: every other message is kept exactly as the engine has it, so nothing
172// is summarized. The prompt cache restarts after it, as after any compaction.
173//
174// It queues /compact as if the person typed it, the one way to compact that
175// works in a terminal and in a headless session (the desktop app, the SDK),
176// where `$.session.compact` is not available.
177const dropBlocks = async ($: EngineInterface, request: Omit<DropRequest, 'at'>): Promise<string> => {
178 if (isDropPending) return 'A Drop is already running.'
179 isDropPending = true
180 const at = await $.clock.now()
181 await update($, dropRequest, () => ({ ...request, at }))
182 try {
183 await $.command.run({ command: 'compact' })
184 const applied = (await read($, dropRequest))?.isApplied === true
185 return applied
186 ? `Dropped ${request.label}, about ${formatTokens(request.tokens)} tokens.`
187 : 'The Drop did not run; the conversation is unchanged.'
188 } catch (error) {
189 return `Could not drop it now (${String(error)}). Try again once Claude is idle.`
190 } finally {
191 isDropPending = false
192 await update($, dropRequest, () => null)
193 await refresh($).catch(() => null)
194 }
195}
196
197type Tally = { input: number; cached: number; output: number }
198
199const addUsage = (tally: Tally, usage: ModelUsage | undefined) => {
200 if (!usage) return
201 tally.input += usage.input_tokens + usage.cache_creation_input_tokens
202 tally.cached += usage.cache_read_input_tokens
203 tally.output += usage.output_tokens
204}
205
206const runAblation = async ($: EngineInterface, replay: Replay, candidates: Segment[], marks: number[], model: string) => {
207 const tally: Tally = { input: 0, cached: 0, output: 0 }
208 const answer = async (prompt: TextBlock[]) => {
209 const reply = await $.model.complete({ model, prompt, maxTokens: 400, effort: 'low' })
210 addUsage(tally, reply.usage)
211 return reply.isAnswered ? reply.text : undefined
212 }
213 const judge = async (baseline: string, other: string | undefined) =>
214 other === undefined
215 ? undefined
216 : $.model.classify(judgeText(replay.question, baseline, other), JUDGE_LABELS).catch(() => undefined)
217 const save = (change: (current: Ablation) => Ablation) =>
218 update($, ablation, current =>
219 current ? change({ ...current, inputTokens: tally.input, cachedTokens: tally.cached, outputTokens: tally.output }) : current)
220
221 // The first baseline runs alone so it writes the cache every other replay reads.
222 const baseline = await answer(replayPrompt(replay, marks))
223 if (baseline === undefined) {
224 await save(current => ({ ...current, status: 'failed', message: 'The baseline replay got no answer.' }))
225 return
226 }
227
228 const noise = answer(replayPrompt(replay, marks)).then(second => judge(baseline, second))
229 await Promise.all(candidates.map(async candidate => {
230 const label = await judge(baseline, await answer(replayPrompt(replay, marks, candidate.id)))
231 await save(current => ({
232 ...current,
233 results: current.results.map(result => (result.id === candidate.id ? { ...result, verdict: verdictOf(label) } : result)),
234 }))
235 }))
236 const noiseLabel = await noise
237
238 await save(current => ({ ...current, status: 'done', isNoisy: noiseLabel !== undefined && noiseLabel !== 'same' }))
239 const needed = (await read($, ablation))?.results.filter(result => result.verdict === 'needed').length ?? 0
240 $.ui.toast(`Context Lens: the last answer needed ${needed} of ${candidates.length} blocks tested.`)
241}
242
243type Prepared = { replay: Replay; candidates: Segment[]; marks: number[]; size: number }
244
245// Checks there is something to test and that it is small enough to replay.
246const prepareAblation = async ($: EngineInterface, count: number): Promise<Prepared | string> => {
247 if ((await read($, ablation))?.status === 'running') return 'An ablation is already running.'
248 const replay = toReplay((await $.session.messages({ as: 'api' })) as unknown as Message[])
249 if (!replay) return 'Nothing to test yet: ask Claude something and let it answer first.'
250 const candidates = pickCandidates(replay, count)
251 if (candidates.length === 0) return 'No block before the last question is big enough to be worth testing.'
252 const marks = cacheMarks(replay, candidates)
253 const size = promptTokens(replayPrompt(replay, marks))
254 if (size > MAX_REPLAY_TOKENS) {
255 return `The conversation is about ${formatTokens(size)} tokens, more than the ${formatTokens(MAX_REPLAY_TOKENS)} ` +
256 'a replay is allowed. Try again after /compact.'
257 }
258 return { replay, candidates, marks, size }
259}
260
261const describePlan = (plan: AblationPlan): string => {
262 const cost = plan.usd === undefined ? '' : `, ${plan.usd < 0.01 ? 'under $0.01' : `up to $${plan.usd.toFixed(2)}`} at list price`
263 return `${plan.replays} replays with ${plan.model} of about ${formatTokens(plan.promptTokens)} tokens each ` +
264 `(${formatTokens(plan.totalTokens)} in all${cost}).`
265}
266
267// The first step of a run: works out what it would cost and waits for a yes,
268// from the pane's Run button or /ctx ablate run.
269const planAblation = async ($: EngineInterface, count: number): Promise<string> => {
270 const prepared = await prepareAblation($, count)
271 if (typeof prepared === 'string') return prepared
272 const model = settings.ablationModel
273 const plan: AblationPlan = { count, model, ...estimateAblation(prepared.replay, prepared.candidates, prepared.marks, model) }
274 await update($, ablationPlan, () => plan)
275 return `Testing ${prepared.candidates.length} blocks takes ${describePlan(plan)} Press Run in the Context Lens pane, or /ctx ablate run.`
276}
277
278const cancelAblation = async ($: EngineInterface): Promise<string> => {
279 const had = (await read($, ablationPlan)) !== null
280 await update($, ablationPlan, () => null)
281 return had ? 'Ablation cancelled.' : 'No ablation is waiting to run.'
282}
283
284// Records the run and starts it on a timer, so the command returns at once and
285// the pane fills in as replays finish. The conversation is read again here,
286// so the run tests what is there now.
287const startAblation = async ($: EngineInterface): Promise<string> => {
288 const plan = await read($, ablationPlan)
289 if (!plan) return 'Nothing to run: /ctx ablate works out the cost first.'
290 await update($, ablationPlan, () => null)
291 const prepared = await prepareAblation($, plan.count)
292 if (typeof prepared === 'string') return prepared
293 const { replay, candidates, marks } = prepared
294
295 const results: AblationResult[] = candidates.map(({ id, label, tokens }) => ({ id, label, tokens, verdict: 'pending' }))
296 const startedAt = await $.clock.now()
297 await update($, ablation, () => ({
298 status: 'running', startedAt, model: plan.model, question: replay.question,
299 results, inputTokens: 0, cachedTokens: 0, outputTokens: 0,
300 }))
301 $.clock.after(0, () => {
302 runAblation($, replay, candidates, marks, plan.model).catch(error =>
303 update($, ablation, current => current && { ...current, status: 'failed' as const, message: String(error) }))
304 })
305 return `Testing ${candidates.length} blocks with ${plan.model}. Results fill in the Context Lens pane.`
306}
307
308// The notes a normal compaction of the main conversation gets: the edits from
309// the transcript being compacted, the rest from the last refresh and ablation.
310const hintsFor = async (
311 $: EngineInterface,
312 messages: readonly { toolUses?: readonly { tool: string; input: Record<string, unknown> }[] }[],
313) => {
314 const [current, tested] = await Promise.all([read($, snapshot), read($, ablation)])
315 return compactionHints({
316 edited: editedFiles(messages.flatMap(message => message.toolUses ?? [])),
317 ablation: tested,
318 waste: current?.waste ?? [],
319 })
320}
321
322const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Context Lens' })
323
324export const register: Register = (on, options) => {
325 settings = readSettings(options)
326
327 on('session.start', async ($, e, next) => {
328 // Turn costs are differences of the session's total; start from where it is.
329 const usd = (await $.session.usage()).cost?.usd
330 await update($, history, current => ({ ...current, lastUsd: current.lastUsd ?? usd }))
331 await $.command.register({
332 name: 'ctx',
333 description: 'Open Context Lens. "/ctx full" counts exactly, "/ctx ablate [n]" tests which blocks the last answer needed',
334 })
335 if (settings.autoOpen === 'always') void openPane($)
336 if (settings.autoOpen === 'in-app' && (await $.session.surfaces()).some(surface => surface !== 'terminal')) {
337 hasAutoOpened = true
338 void openPane($)
339 }
340 return next(e)
341 })
342
343 // The desktop app or VS Code can attach after the session started.
344 on('session.attach', async ($, e, next) => {
345 if (settings.autoOpen === 'in-app' && e.surface !== 'terminal' && !hasAutoOpened) {
346 hasAutoOpened = true
347 void openPane($)
348 }
349 return next(e)
350 })
351
352 on('command.run', { command: 'ctx' }, async ($, e) => {
353 await openPane($)
354 const [verb, amount] = e.args.trim().split(/\s+/)
355 if (verb === 'ablate') {
356 if (amount === 'run') {
357 // A run spends model tokens, so only the person may start it.
358 if (!PERSON_ORIGINS.has(e.origin.kind)) {
359 return { text: `Context Lens runs an ablation only when you start it (this came from ${e.origin.kind}).` }
360 }
361 return { text: await startAblation($) }
362 }
363 if (amount === 'cancel') return { text: await cancelAblation($) }
364 const count = Math.min(MAX_CANDIDATES, Math.max(1, Number(amount) || DEFAULT_CANDIDATES))
365 await update($, paneTab, () => 'ablation' as const)
366 return { text: await planAblation($, count) }
367 }
368 const current = await refresh($, verb === 'full' ? 'full' : 'summary')
369 if (!current) return { text: 'Context Lens opened.' }
370 const fill = current.percent === undefined ? 'unknown' : `${current.percent}%`
371 return { text: `Context ${fill} full, about ${formatTokens(current.wasteTokens)} tokens look wasted.` }
372 })
373
374 // When each Read finished, so a later change to the file shows the copy is old.
375 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
376 const ran = await next(e)
377 if (ran.deny === undefined && ran.isError !== true) {
378 const at = await $.clock.now()
379 await update($, readTimes, times =>
380 Object.fromEntries(Object.entries({ ...times, [e.tool_use_id]: at }).slice(-MAX_READ_TIMES)))
381 }
382 return ran
383 }).catch(($, e, next) => next(e))
384
385 // A Drop answers its own compaction; every other one runs as usual and is
386 // marked on the timeline.
387 on('session.compact', async ($, e, next) => {
388 const pending = e.agentId === undefined && (e.trigger === 'manual' || e.trigger === 'plugin')
389 ? await read($, dropRequest)
390 : null
391 const isFresh = pending !== null && (await $.clock.now()) - pending.at < DROP_WINDOW_MS
392 // While this module waits on the /compact it queued, the compaction is the
393 // Drop's however late it runs; one that came from anywhere else is used
394 // only within the window.
395 const request = pending && !pending.isApplied && (isDropPending || isFresh) ? pending : null
396 if (!request && isDropPending && e.agentId === undefined && e.trigger !== 'precompute') {
397 // Never let a Drop turn into a summarizing compaction: that costs tokens
398 // and rewrites far more than the person asked to remove.
399 return { skip: 'Context Lens could not apply the drop, so nothing was compacted.' }
400 }
401 if (!request) {
402 const hints = settings.compactionHints && e.agentId === undefined ? await hintsFor($, e.messages) : undefined
403 const result = await next(hints === undefined ? e : { ...e, instructions: withHints(e.instructions, hints) })
404 if (result.skip === undefined && e.trigger !== 'precompute' && e.agentId === undefined) {
405 const at = await $.clock.now()
406 await update($, history, current => ({ ...current, compactions: [...current.compactions, at].slice(-MAX_POINTS) }))
407 }
408 return result
409 }
410 const messages = stubResults(e.messages, new Set(request.blockIds), DROP_STUB)
411 await update($, dropRequest, current => current && { ...current, isApplied: true })
412 return { messages }
413 }).catch(($, e, next) =>
414 // Failing here must never turn a Drop into a full summarizing compaction.
415 next.called || !isDropPending ? next(e) : { skip: 'Context Lens could not apply the drop.' })
416
417 on('turn.complete', async ($, e, next) => {
418 const result = await next(e)
419 const used = e.usage
420 const agentId = e.agentId
421 if (agentId !== undefined) {
422 const transcript = await $.session.messages({ agentId })
423 const workTokens = 'deny' in transcript ? undefined : estimateTranscript(transcript)
424 await update($, agentStats, all => {
425 const before = all[agentId]
426 return Object.fromEntries(Object.entries({
427 ...all,
428 [agentId]: {
429 input: (before?.input ?? 0) + (used ? used.input_tokens + used.cache_creation_input_tokens : 0),
430 cacheRead: (before?.cacheRead ?? 0) + (used?.cache_read_input_tokens ?? 0),
431 output: (before?.output ?? 0) + (used?.output_tokens ?? 0),
432 model: used?.model ?? before?.model,
433 runs: (before?.runs ?? 0) + 1,
434 durationMs: (before?.durationMs ?? 0) + e.durationMs,
435 returnedTokens: (before?.returnedTokens ?? 0) + estimateTokens(e.answer),
436 workTokens: workTokens ?? before?.workTokens,
437 },
438 }).slice(-50))
439 })
440 await refresh($).catch(() => null)
441 return result
442 }
443 if (e.agentId === undefined && used) {
444 const [at, usage] = await Promise.all([$.clock.now(), $.session.usage()])
445 const usd = usage.cost?.usd
446 await update($, history, current => ({
447 ...current,
448 turns: [...current.turns, {
449 at,
450 input: used.input_tokens,
451 cacheRead: used.cache_read_input_tokens,
452 cacheWrite: used.cache_creation_input_tokens,
453 output: used.output_tokens,
454 usd: usd !== undefined && current.lastUsd !== undefined ? Math.max(0, usd - current.lastUsd) : undefined,
455 }].slice(-MAX_TURNS),
456 lastUsd: usd ?? current.lastUsd,
457 }))
458 }
459 return result
460 })
461
462 // After each main-thread turn the engine re-measures; redraw when the fill moved.
463 on('session.measure', async ($, e, next) => {
464 if (e.changed.includes('context')) {
465 try {
466 const current = await refresh($)
467 if (current?.percent !== undefined) {
468 const point = { at: current.at, percent: current.percent }
469 await update($, history, past => ({ ...past, points: [...past.points, point].slice(-MAX_POINTS) }))
470 }
471 } catch (error) {
472 $.ui.log(`context-lens: refresh failed: ${String(error)}`, { to: 'debug' })
473 }
474 }
475 return next(e)
476 })
477
478 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
479 const [current, tested, plan, past, tab] = await Promise.all([
480 read($, snapshot), read($, ablation), read($, ablationPlan), read($, history), read($, paneTab),
481 ])
482 const view = { current, tested, plan, past, tab, hasHints: settings.compactionHints }
483 return drawPane($.ui.resolve(e), e.surface, Math.max(24, e.props.bodyColumns), view, {
484 setTab: next => void update($, paneTab, () => next),
485 refresh: () => void refresh($, 'full'),
486 drop: item => void dropBlocks($, { blockIds: item.blockIds, label: item.label, tokens: item.tokens }).then(text => $.ui.toast(text)),
487 plan: () => void planAblation($, DEFAULT_CANDIDATES).then(text => $.ui.toast(text)),
488 run: () => void startAblation($).then(text => $.ui.toast(text)),
489 cancel: () => void cancelAblation($),
490 })
491 })
492}
493hooks/ablation.ts 203 lines1// Context ablation: replay the last question with one block of context taken
2// out, and see whether the answer changes. A block whose removal changes the
3// answer is one the answer relied on; one whose removal changes nothing was
4// along for the ride.
5//
6// Everything here is pure; register.tsx makes the model calls.
7
8import type { ContentBlock, Message } from './analyze'
9import { estimateTokens, parse } from './analyze'
10
11// One block of the conversation as the replay shows it to the model.
12export type Segment = { id: string; label: string; tokens: number; text: string }
13
14export type Replay = {
15 // Everything before the question, in order.
16 segments: Segment[]
17 question: string
18 // What the assistant actually said; shown in the pane, never sent.
19 answer: string
20}
21
22export type TextBlock = { text: string; cache?: true }
23
24const MIN_CANDIDATE_TOKENS = 200
25
26// The API allows four cache marks per request; the replays use three, so a
27// fourth stays free for whatever a provider adds.
28const CACHE_MARKS = 3
29
30const HEADER =
31 'Below is the transcript of a coding session between a user and an AI assistant, ' +
32 'including the tool calls the assistant made and what they returned.\n\n<transcript>\n'
33
34const str = (value: unknown): string => (typeof value === 'string' ? value : '')
35
36// Slash commands and their output come back as user text; they are no question.
37const isCommandRecord = (text: string): boolean => /<command-name>|<local-command-/.test(text)
38
39const resultBody = (content: unknown): string => {
40 if (typeof content === 'string') return content
41 if (!Array.isArray(content)) return ''
42 return (content as ContentBlock[])
43 .map(part => (part.type === 'text' ? str(part.text) : `[${part.type}]`))
44 .join('\n')
45}
46
47const bodyOf = (part: ContentBlock): string => {
48 if (part.type === 'text') return str(part.text)
49 if (part.type === 'tool_use') return JSON.stringify(part.input ?? {})
50 if (part.type === 'tool_result') return resultBody(part.content)
51 return ''
52}
53
54// Splits the conversation at the last question that got an answer. Thinking,
55// images and documents are left out of the replay: a text-only model call
56// cannot carry them as the session did.
57export const toReplay = (messages: readonly Message[]): Replay | undefined => {
58 const { blocks } = parse(messages)
59 const flat: { block: (typeof blocks)[number]; part: ContentBlock }[] = []
60 let i = 0
61 for (const message of messages) {
62 for (const part of message.content) {
63 if (!['text', 'thinking', 'tool_use', 'tool_result', 'image', 'document'].includes(part.type)) continue
64 const block = blocks[i++]
65 if (block) flat.push({ block, part })
66 }
67 }
68
69 let questionAt = -1
70 for (let k = flat.length - 1; k >= 0; k--) {
71 const entry = flat[k]!
72 if (entry.block.kind !== 'prompt' || isCommandRecord(bodyOf(entry.part))) continue
73 const hasAnswer = flat.slice(k + 1).some(later => later.block.kind === 'reply')
74 if (hasAnswer) {
75 questionAt = k
76 break
77 }
78 }
79 if (questionAt < 0) return undefined
80
81 const answer = flat
82 .slice(questionAt + 1)
83 .filter(entry => entry.block.kind === 'reply')
84 .map(entry => bodyOf(entry.part))
85 .join('\n')
86
87 const segments = flat
88 .slice(0, questionAt)
89 .filter(entry => entry.block.kind !== 'thinking' && entry.block.kind !== 'media')
90 .map(({ block, part }) => ({
91 id: block.id,
92 label: block.label,
93 tokens: block.tokens,
94 text: `[${block.label}]\n${bodyOf(part)}\n\n`,
95 }))
96
97 return { segments, question: bodyOf(flat[questionAt]!.part), answer }
98}
99
100// The blocks worth testing: the biggest ones, since they cost the most to keep.
101export const pickCandidates = (replay: Replay, count: number): Segment[] =>
102 [...replay.segments]
103 .filter(segment => segment.tokens >= MIN_CANDIDATE_TOKENS)
104 .sort((a, b) => b.tokens - a.tokens)
105 .slice(0, count)
106
107// Where the replays put their cache marks: just before the earliest candidates.
108// Every replay sends the same text up to the block it removes, so a mark there
109// lets the API serve that prefix from cache instead of billing it again.
110export const cacheMarks = (replay: Replay, candidates: readonly Segment[]): number[] => {
111 const position = new Map(replay.segments.map((segment, at) => [segment.id, at]))
112 return candidates
113 .map(candidate => position.get(candidate.id) ?? 0)
114 .filter(at => at > 0)
115 .sort((a, b) => a - b)
116 .slice(0, CACHE_MARKS)
117}
118
119// The prompt for one replay, `without` naming the block to remove (none for
120// the baseline). The removed block is left out entirely, so nothing hints at
121// what is missing.
122export const replayPrompt = (replay: Replay, marks: readonly number[], without?: string): TextBlock[] => {
123 const skipAt = without === undefined ? -1 : replay.segments.findIndex(segment => segment.id === without)
124 // Marks past the removed block would sit on text no other replay sends.
125 const usable = skipAt < 0 ? marks : marks.filter(at => at <= skipAt)
126 const prompt: TextBlock[] = []
127 let from = 0
128 let head = HEADER
129 for (const at of usable) {
130 const text = head + replay.segments.slice(from, at).map(segment => segment.text).join('')
131 prompt.push({ text, cache: true })
132 head = ''
133 from = at
134 }
135 const rest = replay.segments
136 .slice(from)
137 .filter((_, k) => from + k !== skipAt)
138 .map(segment => segment.text)
139 .join('')
140 prompt.push({
141 text:
142 head + rest + '</transcript>\n\n' +
143 `The user's next message:\n<question>\n${replay.question}\n</question>\n\n` +
144 'Reply as the assistant would at this point in the session. Give its answer and ' +
145 'the actions it would take, in at most 150 words, with no preamble.',
146 })
147 return prompt
148}
149
150export const promptTokens = (prompt: readonly TextBlock[]): number =>
151 prompt.reduce((sum, block) => sum + estimateTokens(block.text), 0)
152
153// What the judge sees: two answers to the same question, labelled neutrally.
154export const judgeText = (question: string, a: string, b: string): string =>
155 `Question:\n${question}\n\nAnswer A:\n${a}\n\nAnswer B:\n${b}\n\n` +
156 'Do A and B reach the same conclusions and propose the same actions? ' +
157 'Wording does not matter, substance does.'
158
159export const JUDGE_LABELS = ['same', 'partly different', 'different'] as const
160export type JudgeLabel = (typeof JUDGE_LABELS)[number]
161
162export type Verdict = 'needed' | 'partly' | 'unused' | 'failed'
163
164export const verdictOf = (label: string | undefined): Verdict =>
165 label === 'different' ? 'needed' : label === 'partly different' ? 'partly' : label === 'same' ? 'unused' : 'failed'
166
167// List prices in dollars per million tokens, for the estimate shown before a
168// run. Haiku's are for prompts under 100k tokens, which the size cap keeps to.
169export const PRICES: Record<string, { input: number; output: number }> = {
170 haiku: { input: 0.1, output: 0.5 },
171 sonnet: { input: 2, output: 10 },
172}
173
174// A replay's answer is capped at 150 words; this is a little over that.
175const ANSWER_TOKENS = 300
176// Each judge call reads the question and two answers and names one label.
177const JUDGE_INPUT_TOKENS = 1000
178const JUDGE_OUTPUT_TOKENS = 10
179
180export type AblationEstimate = { replays: number; promptTokens: number; totalTokens: number; usd?: number }
181
182// What a run would send: the baseline twice, then one replay per candidate.
183// Priced as if nothing came from the prompt cache: the replays do mark a
184// shared prefix, but how much of it the API serves from cache varies (one live
185// run read only a fifth of its input from cache), so this is a ceiling.
186export const estimateAblation = (
187 replay: Replay,
188 candidates: readonly Segment[],
189 marks: readonly number[],
190 model: string,
191): AblationEstimate => {
192 const promptSize = promptTokens(replayPrompt(replay, marks))
193 const replays = candidates.length + 2
194 const price = PRICES[model]
195 // One judge call per replay but the first baseline, priced at the replay model's rate.
196 const judges = replays - 1
197 const usd = price && (
198 replays * (promptSize * price.input + ANSWER_TOKENS * price.output) +
199 judges * (JUDGE_INPUT_TOKENS * price.input + JUDGE_OUTPUT_TOKENS * price.output)
200 ) / 1_000_000
201 return { replays, promptTokens: promptSize, totalTokens: replays * promptSize, usd }
202}
203hooks/analyze.ts 306 lines1// Pure analysis of the conversation: no `$`, so the tests can feed it
2// hand-built messages.
3
4import type { Block, Waste } from '../types'
5
6export type ContentBlock = { type: string; [field: string]: unknown }
7export type Message = { role: 'user' | 'assistant'; content: ContentBlock[] }
8export type McpTool = { name: string; serverName: string; tokens: number; isLoaded: boolean }
9
10// Roughly four characters per token for English and code. Good enough to rank
11// blocks against each other; /context's own figures stay the authority.
12export const estimateTokens = (text: string): number => Math.ceil(text.length / 4)
13
14// What the API charges for an image or a PDF page is size dependent; this is a
15// typical figure for a screenshot.
16const MEDIA_TOKENS = 1600
17
18export const LARGE_OUTPUT_TOKENS = 5000
19
20const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
21const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
22
23type ToolUse = { name: string; input: Record<string, unknown>; index: number }
24
25const str = (value: unknown): string => (typeof value === 'string' ? value : '')
26
27const clip = (text: string, max: number): string => {
28 const flat = text.replace(/\s+/g, ' ').trim()
29 return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
30}
31
32const normalizePath = (path: string): string => path.replace(/\\/g, '/').toLowerCase()
33
34const shortPath = (path: string): string => {
35 const parts = path.replace(/\\/g, '/').split('/').filter(Boolean)
36 return parts.slice(-2).join('/')
37}
38
39// A one-line name for a tool call, so a list of blocks reads like a log.
40export const describeCall = (name: string, input: Record<string, unknown>): string => {
41 if (FILE_TOOLS.has(name)) return `${name} ${shortPath(str(input.file_path) || str(input.notebook_path))}`
42 if (name === 'Bash' || name === 'PowerShell') return `${name} ${clip(str(input.command), 40)}`
43 if (name === 'Grep' || name === 'Glob') return `${name} ${clip(str(input.pattern), 40)}`
44 if (name === 'WebFetch') return `WebFetch ${clip(str(input.url), 40)}`
45 if (name === 'Agent') return `Agent ${clip(str(input.description), 40)}`
46 return name
47}
48
49const resultText = (content: unknown): { text: string; media: number } => {
50 if (typeof content === 'string') return { text: content, media: 0 }
51 if (!Array.isArray(content)) return { text: '', media: 0 }
52 let text = ''
53 let media = 0
54 for (const part of content as ContentBlock[]) {
55 if (part.type === 'text') text += str(part.text)
56 else if (part.type === 'image' || part.type === 'document') media += 1
57 }
58 return { text, media }
59}
60
61export type Parsed = {
62 blocks: Block[]
63 // Which call each tool result answers, by tool_use_id.
64 calls: Map<string, ToolUse>
65 // Where each block sits in the conversation, for "did X happen after Y".
66 order: Map<string, number>
67}
68
69export const parse = (messages: readonly Message[]): Parsed => {
70 const blocks: Block[] = []
71 const calls = new Map<string, ToolUse>()
72 const order = new Map<string, number>()
73 let index = 0
74
75 const push = (block: Block) => {
76 blocks.push(block)
77 order.set(block.id, index++)
78 }
79
80 messages.forEach((message, m) => {
81 message.content.forEach((part, b) => {
82 const id = `m${m}.${b}`
83 if (part.type === 'text') {
84 const text = str(part.text)
85 const isReminder = message.role === 'user' && text.trimStart().startsWith('<system-reminder>')
86 const kind = isReminder ? 'reminder' : message.role === 'user' ? 'prompt' : 'reply'
87 const label = isReminder ? 'System reminder' : `${kind === 'prompt' ? 'You' : 'Claude'}: ${clip(text, 50)}`
88 push({ id, kind, label, tokens: estimateTokens(text) })
89 } else if (part.type === 'thinking') {
90 push({ id, kind: 'thinking', label: 'Thinking', tokens: estimateTokens(str(part.thinking)) })
91 } else if (part.type === 'tool_use') {
92 const name = str(part.name)
93 const input = (part.input ?? {}) as Record<string, unknown>
94 const useId = str(part.id) || id
95 calls.set(useId, { name, input, index })
96 push({ id: `${useId}:call`, kind: 'tool-call', label: describeCall(name, input), tokens: estimateTokens(JSON.stringify(input)) })
97 } else if (part.type === 'tool_result') {
98 const useId = str(part.tool_use_id)
99 const call = calls.get(useId)
100 const { text, media } = resultText(part.content)
101 push({
102 id: useId || id,
103 kind: 'tool-result',
104 label: call ? `${describeCall(call.name, call.input)} result` : 'Tool result',
105 tokens: estimateTokens(text) + media * MEDIA_TOKENS,
106 })
107 } else if (part.type === 'image' || part.type === 'document') {
108 push({ id, kind: 'media', label: part.type === 'image' ? 'Image' : 'Document', tokens: MEDIA_TOKENS })
109 }
110 })
111 })
112
113 return { blocks, calls, order }
114}
115
116// The Read calls whose results are still in the conversation, by tool_use_id,
117// with the file each one read: what the disk check stats.
118export const readsInContext = (parsed: Parsed): Map<string, string> => {
119 const reads = new Map<string, string>()
120 for (const block of parsed.blocks) {
121 const call = block.kind === 'tool-result' ? parsed.calls.get(block.id) : undefined
122 if (call?.name === 'Read' && str(call.input.file_path)) reads.set(block.id, str(call.input.file_path))
123 }
124 return reads
125}
126
127// Tokens the next request pays for that probably do nothing for the model.
128// Each block is counted at most once, under the first rule that claims it.
129// `changedOnDisk` names reads whose file changed after they ran, by any means
130// (a Bash command, a formatter, an editor), which the transcript cannot show.
131export const findWaste = (
132 parsed: Parsed,
133 mcpTools: readonly McpTool[] = [],
134 changedOnDisk: ReadonlySet<string> = new Set(),
135 largeOutputTokens = LARGE_OUTPUT_TOKENS,
136): Waste[] => {
137 const { blocks, calls, order } = parsed
138 const waste: Waste[] = []
139 const claimed = new Set<string>()
140
141 // `path` compares (case and slashes folded); `shown` is how the call spelled it.
142 const reads: { block: Block; key: string; path: string; shown: string; at: number }[] = []
143 const editedAt = new Map<string, number[]>()
144 for (const [, call] of calls) {
145 if (!EDIT_TOOLS.has(call.name)) continue
146 const path = normalizePath(str(call.input.file_path) || str(call.input.notebook_path))
147 editedAt.set(path, [...(editedAt.get(path) ?? []), call.index])
148 }
149 for (const block of blocks) {
150 if (block.kind !== 'tool-result') continue
151 const call = calls.get(block.id)
152 if (call?.name !== 'Read') continue
153 const path = normalizePath(str(call.input.file_path))
154 // The same range of the same file; a different offset is a different read.
155 const key = `${path}|${String(call.input.offset ?? '')}|${String(call.input.limit ?? '')}`
156 reads.push({ block, key, path, shown: str(call.input.file_path), at: order.get(block.id) ?? 0 })
157 }
158
159 // The same file range read more than once: every copy but the newest.
160 const byKey = new Map<string, typeof reads>()
161 for (const read of reads) byKey.set(read.key, [...(byKey.get(read.key) ?? []), read])
162 for (const group of byKey.values()) {
163 const first = group[0]
164 if (first === undefined || group.length < 2) continue
165 const older = group.slice(0, -1)
166 older.forEach(read => claimed.add(read.block.id))
167 waste.push({
168 kind: 'duplicate-read',
169 label: `${shortPath(first.shown)} read ${group.length} times`,
170 tokens: older.reduce((sum, read) => sum + read.block.tokens, 0),
171 blockIds: older.map(read => read.block.id),
172 })
173 }
174
175 // A read the file has since changed past: the model is holding an old copy.
176 for (const read of reads) {
177 if (claimed.has(read.block.id)) continue
178 const isEdited = (editedAt.get(read.path) ?? []).some(at => at > read.at)
179 if (!isEdited && !changedOnDisk.has(read.block.id)) continue
180 claimed.add(read.block.id)
181 waste.push({
182 kind: 'stale-read',
183 label: `${shortPath(read.shown)} ${isEdited ? 'edited' : 'changed on disk'} after it was read`,
184 tokens: read.block.tokens,
185 blockIds: [read.block.id],
186 })
187 }
188
189 // One tool result big enough to matter on its own.
190 for (const block of blocks) {
191 if (block.kind !== 'tool-result' || claimed.has(block.id)) continue
192 if (calls.get(block.id)?.name === 'Read' || block.tokens < largeOutputTokens) continue
193 claimed.add(block.id)
194 waste.push({ kind: 'large-output', label: block.label.replace(/ result$/, ''), tokens: block.tokens, blockIds: [block.id] })
195 }
196
197 // MCP tool schemas sent every turn for tools this conversation never called.
198 const called = new Set([...calls.values()].map(call => call.name))
199 const unusedByServer = new Map<string, { tokens: number; count: number }>()
200 for (const tool of mcpTools) {
201 if (!tool.isLoaded || called.has(tool.name)) continue
202 const entry = unusedByServer.get(tool.serverName) ?? { tokens: 0, count: 0 }
203 unusedByServer.set(tool.serverName, { tokens: entry.tokens + tool.tokens, count: entry.count + 1 })
204 }
205 for (const [server, { tokens, count }] of unusedByServer) {
206 // Connectors are often named by an id; its first group is enough to tell them apart.
207 const name = /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(server) ? `${server.slice(0, 8)}…` : server
208 waste.push({ kind: 'unused-mcp', label: `${name} · ${count} unused tool${count === 1 ? '' : 's'}`, tokens, blockIds: [] })
209 }
210
211 return waste.sort((a, b) => b.tokens - a.tokens)
212}
213
214// Scales the per-block estimates so they add up to Claude Code's own count of
215// the messages, keeping their ranking. Clamped, so one bad reading cannot
216// stretch every block tenfold.
217export const calibrate = (blocks: readonly Block[], exactTokens: number | undefined): { blocks: Block[]; factor: number } => {
218 const estimated = blocks.reduce((sum, block) => sum + block.tokens, 0)
219 if (!exactTokens || estimated === 0) return { blocks: [...blocks], factor: 1 }
220 const factor = Math.min(4, Math.max(0.25, exactTokens / estimated))
221 return { blocks: blocks.map(block => ({ ...block, tokens: Math.round(block.tokens * factor) })), factor }
222}
223
224// A tool call and its result as one row: what a person thinks of as "the Read
225// of app.ts", instead of a tiny call block beside a big result block.
226export const mergeCalls = (blocks: readonly Block[]): Block[] => {
227 const results = new Set(blocks.filter(block => block.kind === 'tool-result').map(block => block.id))
228 const callTokens = new Map<string, number>()
229 for (const block of blocks) {
230 if (block.kind === 'tool-call' && results.has(block.id.replace(/:call$/, ''))) {
231 callTokens.set(block.id.replace(/:call$/, ''), block.tokens)
232 }
233 }
234 return blocks
235 .filter(block => !(block.kind === 'tool-call' && callTokens.has(block.id.replace(/:call$/, ''))))
236 .map(block =>
237 block.kind === 'tool-result' && callTokens.has(block.id)
238 ? { ...block, label: block.label.replace(/ result$/, ''), tokens: block.tokens + (callTokens.get(block.id) ?? 0) }
239 : block)
240}
241
242// A transcript's size as the rows `$.session.messages()` returns: text, tool
243// inputs and tool results, estimated the same way as the main conversation.
244export type TranscriptRow = {
245 text: string
246 toolUses: readonly { input: Record<string, unknown>; text?: string }[]
247 toolResults?: readonly { text: string }[]
248}
249
250export const estimateTranscript = (rows: readonly TranscriptRow[]): number =>
251 rows.reduce((sum, row) =>
252 sum + estimateTokens(row.text) +
253 row.toolUses.reduce((uses, use) => uses + estimateTokens(JSON.stringify(use.input)) + estimateTokens(use.text ?? ''), 0) +
254 (row.toolResults ?? []).reduce((results, result) => results + estimateTokens(result.text), 0), 0)
255
256// The rows a compaction hands a hook, as far as Drop needs them.
257export type CompactRow = {
258 toolResults?: readonly { tool_use_id: string; text: string; isError: boolean }[]
259 handle?: string
260}
261
262// Replaces the named tool results with `stub` and leaves every other message
263// exactly as it was, handle and all. A message that changed loses its handle,
264// which tells the engine to rebuild it from these fields.
265export const stubResults = <R extends CompactRow>(rows: readonly R[], ids: ReadonlySet<string>, stub: string): R[] =>
266 rows.map(row => {
267 if (!row.toolResults?.some(result => ids.has(result.tool_use_id))) return row
268 const { handle: _handle, ...rest } = row
269 return {
270 ...rest,
271 toolResults: row.toolResults.map(result =>
272 ids.has(result.tool_use_id) ? { tool_use_id: result.tool_use_id, text: stub, isError: false } : result),
273 } as unknown as R
274 })
275
276export type Diff = { added: Block[]; addedTokens: number; droppedTokens: number }
277
278// What changed since the last refresh. Blocks keep their ids from one turn to
279// the next, so anything new is what this turn added; a compaction shows up as
280// a large drop.
281export const diff = (previousIds: readonly string[] | undefined, previousTokens: number, blocks: readonly Block[]): Diff => {
282 if (previousIds === undefined) return { added: [], addedTokens: 0, droppedTokens: 0 }
283 const seen = new Set(previousIds)
284 const added = blocks.filter(block => !seen.has(block.id))
285 const addedTokens = added.reduce((sum, block) => sum + block.tokens, 0)
286 const kept = blocks.reduce((sum, block) => sum + block.tokens, 0) - addedTokens
287 return { added, addedTokens, droppedTokens: Math.max(0, previousTokens - kept) }
288}
289
290// The breakdown reports auto-compact at the window less a 13k reserve and
291// ignores CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, which the compaction check itself
292// applies: the lower of that percentage of the window and the usual point.
293const AUTOCOMPACT_RESERVE = 13_000
294
295export const autoCompactPoint = (reported: number, overridePercent: string | undefined): number => {
296 const percent = Number(overridePercent)
297 if (!overridePercent || !Number.isFinite(percent) || percent <= 0 || percent > 100) return reported
298 return Math.min(Math.floor((reported + AUTOCOMPACT_RESERVE) * (percent / 100)), reported)
299}
300
301export const formatTokens = (tokens: number): string =>
302 tokens >= 1_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M`
303 : tokens >= 10_000 ? `${Math.round(tokens / 1000)}k`
304 : tokens >= 1000 ? `${(tokens / 1000).toFixed(1).replace(/\.0$/, '')}k`
305 : String(tokens)
306hooks/hints.ts 86 lines1// Notes for the summarizer when a normal compaction runs. The summary is
2// written by a model that sees the transcript but not what this mod measured:
3// which files changed, which blocks the last answer leaned on, and which ones
4// are old or repeated copies. Telling it keeps the right things in the summary.
5//
6// Everything here is pure; register.tsx hands the text to session.compact.
7
8import type { Ablation, Waste } from '../types'
9
10const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
11
12// How many names each note lists, so the instructions stay short.
13const MAX_NAMED = 12
14
15type ToolUse = { tool: string; input: Record<string, unknown> }
16
17// The files the conversation edited, in the order first edited, each once.
18export const editedFiles = (uses: readonly ToolUse[]): string[] => {
19 const seen = new Map<string, string>()
20 for (const use of uses) {
21 if (!EDIT_TOOLS.has(use.tool)) continue
22 const path = use.input.file_path ?? use.input.notebook_path
23 if (typeof path !== 'string' || path === '') continue
24 const key = path.replace(/\\/g, '/').toLowerCase()
25 if (!seen.has(key)) seen.set(key, path)
26 }
27 return [...seen.values()]
28}
29
30export type HintSources = {
31 edited: readonly string[]
32 ablation: Ablation | null
33 waste: readonly Waste[]
34}
35
36const list = (names: readonly string[]) => {
37 const shown = names.slice(0, MAX_NAMED).join('; ')
38 return names.length > MAX_NAMED ? `${shown}; and ${names.length - MAX_NAMED} more` : shown
39}
40
41// What each note covers, for the pane to say before a compaction runs.
42export const hintCounts = ({ edited, ablation, waste }: HintSources) => ({
43 edited: edited.length,
44 needed: ablation?.status === 'done'
45 ? ablation.results.filter(result => result.verdict === 'needed' || result.verdict === 'partly').length
46 : 0,
47 shorten: waste.filter(item => item.blockIds.length > 0).length,
48})
49
50// The notes, or undefined when there is nothing worth saying.
51export const compactionHints = ({ edited, ablation, waste }: HintSources): string | undefined => {
52 const notes: string[] = []
53 if (edited.length > 0) {
54 notes.push(
55 'These files were edited in this session. Keep each one\'s path, what changed in it and why, ' +
56 `and any edit still unfinished: ${list(edited)}.`,
57 )
58 }
59 const needed = ablation?.status === 'done'
60 ? ablation.results.filter(result => result.verdict === 'needed' || result.verdict === 'partly').map(result => result.label)
61 : []
62 if (needed.length > 0) {
63 notes.push(
64 'A replay test found the latest answer depended on these blocks. Keep the facts they ' +
65 `contributed, specific values included: ${list(needed)}.`,
66 )
67 }
68 const stale = waste.filter(item => item.kind === 'duplicate-read' || item.kind === 'stale-read').map(item => item.label)
69 if (stale.length > 0) {
70 notes.push(
71 'These file reads are repeated or out of date, since the file changed later. Do not carry ' +
72 `their old contents into the summary; at most note that the file was read: ${list(stale)}.`,
73 )
74 }
75 const large = waste.filter(item => item.kind === 'large-output').map(item => item.label)
76 if (large.length > 0) {
77 notes.push(`These tool outputs were long. Keep one line each with the outcome (passed, failed, the error): ${list(large)}.`)
78 }
79 if (notes.length === 0) return undefined
80 return ['Notes from Context Lens on what this session holds:', ...notes.map(note => `- ${note}`)].join('\n')
81}
82
83// The person's own /compact text comes first; the notes follow it.
84export const withHints = (instructions: string | undefined, hints: string | undefined): string | undefined =>
85 [instructions?.trim(), hints].filter(part => part !== undefined && part !== '').join('\n\n') || undefined
86hooks/pane.tsx 421 lines1// The pane's drawing, apart from the hooks that feed it. Everything here turns
2// a snapshot into elements; nothing reads or writes state.
3
4import type { ElementTable, RenderChildren, RenderSurface } from 'claude-code'
5
6import type { Ablation, AblationPlan, AblationResult, AgentRow, Block, History, PaneTab, Snapshot, Waste } from '../types'
7import { formatTokens } from './analyze'
8import { hintCounts } from './hints'
9
10export type PaneActions = {
11 refresh: () => void
12 // An ablation is planned first, shown with its cost, then run or cancelled.
13 plan: () => void
14 run: () => void
15 cancel: () => void
16 drop: (item: Waste) => void
17 setTab: (tab: PaneTab) => void
18}
19
20const MUTED = '#8A8F98'
21const TEAL = '#3FB6A8'
22const AMBER = '#E6A23C'
23const ROSE = '#E06C8C'
24const BLUE = '#5B9BF0'
25
26const VERDICT_STYLE: Record<AblationResult['verdict'], { text: string; color: string }> = {
27 needed: { text: 'needed', color: TEAL },
28 partly: { text: 'partly', color: AMBER },
29 unused: { text: 'unused', color: MUTED },
30 failed: { text: 'failed', color: ROSE },
31 pending: { text: '···', color: MUTED },
32}
33
34const WASTE_HINT: Record<Waste['kind'], string> = {
35 'duplicate-read': 'Re-read',
36 'stale-read': 'Stale',
37 'large-output': 'Large',
38 'unused-mcp': 'Idle MCP',
39}
40
41const TABS: { id: PaneTab; label: string; hotkey: string }[] = [
42 { id: 'overview', label: 'Overview', hotkey: '1' },
43 { id: 'blocks', label: 'Blocks', hotkey: '2' },
44 { id: 'ablation', label: 'Ablation', hotkey: '3' },
45 { id: 'agents', label: 'Agents', hotkey: '4' },
46]
47
48const KNOWN_TOOLS = /^(Read|Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell|Grep|Glob|WebFetch|Agent) (.+)$/
49const MCP_TOOL = /^mcp__(.+?)__(.+)$/
50
51// Fixed columns, in cells, so the numbers and the Drop buttons line up.
52const VALUE_COLUMN = 7
53const ACTION_COLUMN = 6
54const VERDICT_COLUMN = 8
55
56const SPARK = '▁▂▃▄▅▆▇█'
57const PARTIAL = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
58
59const sparkline = (percents: readonly number[]) =>
60 percents.map(percent => SPARK[Math.min(SPARK.length - 1, Math.floor((percent / 100) * SPARK.length))]).join('')
61
62// A text bar with eighth-cell precision, for the terminal.
63const textBar = (fraction: number, cells: number) => {
64 const eighths = Math.max(1, Math.round(Math.min(1, fraction) * cells * 8))
65 return '█'.repeat(Math.floor(eighths / 8)) + PARTIAL[eighths % 8]
66}
67
68// The fill over time as a small area chart, on an absolute 0-100 scale so a
69// tall chart always means a full window.
70const timelineSvg = (percents: readonly number[], color: string, threshold?: number): string => {
71 const W = 300
72 const H = 40
73 const step = percents.length > 1 ? W / (percents.length - 1) : W
74 const y = (percent: number) => (H - 2 - (Math.min(100, percent) / 100) * (H - 4)).toFixed(1)
75 const line = percents.map((percent, i) => `${(i * step).toFixed(1)},${y(percent)}`).join(' ')
76 const limit = threshold === undefined ? ''
77 : `<line x1="0" x2="${W}" y1="${y(threshold)}" y2="${y(threshold)}" stroke="${ROSE}" stroke-width="1" stroke-dasharray="3 3" opacity="0.7"/>`
78 return (
79 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="100%" height="${H}" preserveAspectRatio="none">` +
80 `<polygon points="0,${H} ${line} ${W},${H}" fill="${color}" fill-opacity="0.18"/>` +
81 `<polyline points="${line}" fill="none" stroke="${color}" stroke-width="2" vector-effect="non-scaling-stroke"/>` +
82 `${limit}</svg>`
83 )
84}
85
86const formatUsd = (usd: number) => (usd >= 0.01 ? `$${usd.toFixed(2)}` : '<$0.01')
87
88const percentOf = (part: number, whole: number) => (whole > 0 ? Math.round((part / whole) * 100) : 0)
89
90// Everything the pane draws from, read by the render hook.
91export type PaneView = {
92 current: Snapshot | null
93 tested: Ablation | null
94 plan: AblationPlan | null
95 past: History
96 tab: PaneTab
97 // Whether compactions get Context Lens's notes (the compactionHints option).
98 hasHints: boolean
99}
100
101export const drawPane = (
102 els: ElementTable,
103 surface: RenderSurface,
104 width: number,
105 view: PaneView,
106 actions: PaneActions,
107) => {
108 const { current, tested, plan, past, tab, hasHints } = view
109 const { Box, Text, Button } = els
110 const isTerminal = surface === 'terminal'
111
112 // A label that takes whatever room its row leaves and truncates at the end.
113 const fill = (children: RenderChildren) => (
114 <Box flexGrow={1} flexShrink={1} overflow="hidden">
115 <Text wrap="truncate-end">{children}</Text>
116 </Box>
117 )
118
119 // "Read src/app.ts" and "wrap_up · ccd_turn" read best with the tool quiet.
120 const label = (raw: string) => {
121 const text = raw.replace(/ result$/, '')
122 const mcp = MCP_TOOL.exec(text.split(' ')[0] ?? '')
123 if (mcp) {
124 const [, server = '', tool = ''] = mcp
125 return <Text>{tool}<Text dimColor>{` · ${server}`}</Text></Text>
126 }
127 const known = KNOWN_TOOLS.exec(text)
128 if (!known) return <Text>{text}</Text>
129 const [, tool = '', target = ''] = known
130 return <Text><Text dimColor>{tool} </Text>{target}</Text>
131 }
132
133 const value = (text: string, color?: string) => (
134 <Box width={VALUE_COLUMN} justifyContent="flex-end" flexShrink={0}>
135 <Text tabular dimColor={color === undefined} color={color}>{text}</Text>
136 </Box>
137 )
138
139 const row = (key: string, left: RenderChildren, right: RenderChildren) => (
140 <Box key={key} flexDirection="row" gap={1}>
141 {fill(left)}
142 {right}
143 </Box>
144 )
145
146 const section = (key: string, title: string, aside: RenderChildren, body: RenderChildren) => (
147 <Box key={key} flexDirection="column" marginTop={1}>
148 <Box flexDirection="row" justifyContent="space-between">
149 <Text weight="semibold" size="footnote">{title}</Text>
150 {aside}
151 </Box>
152 {body}
153 </Box>
154 )
155
156 const blockRow = (prefix: string, block: Block) =>
157 row(`${prefix}-${block.id}`, label(block.label), value(formatTokens(block.tokens)))
158
159 const quiet = (text: string) => <Text dimColor>{text}</Text>
160
161 const tabBar = (
162 <Box key="tabs" flexDirection="row" justifyContent="space-between">
163 <Box flexDirection="row" gap={1}>
164 {TABS.map(({ id, label: name, hotkey }) => {
165 const count = id === 'agents' ? current?.agents.length ?? 0 : id === 'blocks' ? current?.waste.length ?? 0 : 0
166 return (
167 <Button
168 key={`tab-${id}`}
169 plain
170 hotkey={hotkey}
171 variant={tab === id ? 'primary' : undefined}
172 label={count > 0 ? `${name} ${count}` : name}
173 onPress={() => actions.setTab(id)}
174 />
175 )
176 })}
177 </Box>
178 <Button key="refresh" plain label="↻" onPress={actions.refresh} />
179 </Box>
180 )
181
182 if (!current) {
183 return (
184 <Box flexDirection="column">
185 {tabBar}
186 <Box marginTop={1}>{quiet('Waiting for the first prompt to measure the window.')}</Box>
187 </Box>
188 )
189 }
190
191 const window = current.window || 1
192 const used = current.tokens ?? current.categories.reduce((sum, category) => sum + category.tokens, 0)
193 const percent = current.percent ?? percentOf(used, window)
194 const fillColor = percent >= 85 ? ROSE : percent >= 65 ? AMBER : undefined
195
196 // Overview: the fill, a bar per category, the fill over time, the last turn.
197 const compactAt = current.autoCompactAt
198 const compactLine = compactAt === undefined ? 'Auto-compact is off'
199 : used >= compactAt ? 'Past the auto-compact point'
200 : `${formatTokens(compactAt - used)} until auto-compact at ${formatTokens(compactAt)}`
201
202 const biggestCategory = current.categories[0]?.tokens ?? 1
203 const barCells = Math.max(10, width - 2)
204 const bar = (key: string, fraction: number, color: string) => isTerminal
205 ? <Text key={key} color={color}>{textBar(fraction, barCells)}</Text>
206 : (
207 <Box key={key} width="100%" height={1} backgroundColor="#8A8F981F">
208 <Box width={`${Math.max(1, Math.round(Math.min(1, fraction) * 100))}%`} height={1} backgroundColor={color} />
209 </Box>
210 )
211
212 const header = (
213 <Box key="header" flexDirection="column" marginTop={1}>
214 <Text>
215 <Text size="title" weight="bold" color={fillColor}>{`${percent}%`}</Text>
216 <Text dimColor>{` ${formatTokens(used)} of ${formatTokens(window)}`}</Text>
217 </Text>
218 <Text dimColor>{compactLine}</Text>
219 </Box>
220 )
221
222 const bars = (
223 <Box key="bars" flexDirection="column">
224 {current.categories.map(category => (
225 <Box key={`cat-${category.name}`} flexDirection="column" marginTop={1}>
226 {row(`cat-row-${category.name}`,
227 <Text>{category.name}</Text>,
228 <Box flexDirection="row" flexShrink={0}>
229 <Text tabular>{formatTokens(category.tokens)}</Text>
230 <Box width={5} justifyContent="flex-end"><Text dimColor tabular>{`${percentOf(category.tokens, window)}%`}</Text></Box>
231 </Box>)}
232 {bar(`cat-bar-${category.name}`, category.tokens / biggestCategory, category.color)}
233 </Box>
234 ))}
235 </Box>
236 )
237
238 const lastCost = past.turns.at(-1)
239 const sessionUsd = past.turns.reduce((sum, turn) => sum + (turn.usd ?? 0), 0)
240 const costLine = lastCost && (() => {
241 const sent = lastCost.input + lastCost.cacheRead + lastCost.cacheWrite
242 const parts = [`${formatTokens(sent)} in`, `${percentOf(lastCost.cacheRead, sent)}% cached`, `${formatTokens(lastCost.output)} out`]
243 if (lastCost.usd !== undefined) parts.push(formatUsd(lastCost.usd))
244 return parts.join(' · ')
245 })()
246 const prompts = `${past.points.length} prompt${past.points.length === 1 ? '' : 's'}`
247 const percents = past.points.slice(-Math.max(10, width)).map(point => point.percent)
248 const thresholdPercent = compactAt === undefined ? undefined : percentOf(compactAt, window)
249 const timeline = percents.length < 2 && !lastCost ? undefined : section(
250 'timeline',
251 'Over time',
252 <Text dimColor tabular>
253 {past.compactions.length === 0 ? prompts
254 : `${prompts} · ${past.compactions.length} compacted`}
255 </Text>,
256 <Box flexDirection="column">
257 {percents.length >= 2 && (isTerminal || !('Svg' in els)
258 ? <Text color={fillColor ?? BLUE}>{sparkline(percents)}</Text>
259 : <els.Svg source={timelineSvg(percents, fillColor ?? BLUE, thresholdPercent)} alt={`Window fill over the last ${percents.length} prompts, now ${percent}%`} />)}
260 {costLine && row('cost', <Text><Text dimColor>Last prompt </Text>{costLine}</Text>,
261 sessionUsd > 0 ? <Text dimColor tabular>{`${formatUsd(sessionUsd)} total`}</Text> : <Text>{''}</Text>)}
262 </Box>,
263 )
264
265 const added = current.added.filter(block => block.tokens > 0)
266 const turnAside = (
267 <Text tabular>
268 <Text color={TEAL}>{`+${formatTokens(current.addedTokens)}`}</Text>
269 {current.droppedTokens > 0 ? <Text color={MUTED}>{` −${formatTokens(current.droppedTokens)}`}</Text> : ''}
270 </Text>
271 )
272 const lastTurn = section('turn', 'Added by the last prompt', turnAside,
273 added.length === 0
274 ? quiet('Nothing new since the last refresh.')
275 : added.slice(0, 4).map(block => blockRow('added', block)))
276
277 // Blocks: what looks wasted, with Drop, then the biggest blocks.
278 const waste = section('waste', 'Likely waste',
279 current.wasteTokens > 0 ? <Text tabular color={AMBER}>{formatTokens(current.wasteTokens)}</Text> : quiet('none'),
280 current.waste.length === 0
281 ? quiet('Nothing flagged.')
282 : current.waste.map((item, i) =>
283 row(`waste-${i}`,
284 <Text><Text color={AMBER}>{`${WASTE_HINT[item.kind]} `}</Text>{item.label}</Text>,
285 <Box flexDirection="row" flexShrink={0}>
286 {value(formatTokens(item.tokens))}
287 <Box width={ACTION_COLUMN} justifyContent="flex-end">
288 {item.blockIds.length > 0
289 ? <Button key={`drop-${i}`} plain label="Drop" onPress={() => actions.drop(item)} />
290 : <Text>{' '}</Text>}
291 </Box>
292 </Box>)))
293
294 // What a /compact will be told, so the notes are no surprise.
295 const hinted = hintCounts({ edited: current.editedFiles, ablation: tested, waste: current.waste })
296 const noteParts = [
297 hinted.edited > 0 && `keep ${hinted.edited} edited file${hinted.edited === 1 ? '' : 's'}`,
298 hinted.needed > 0 && `keep ${hinted.needed} block${hinted.needed === 1 ? '' : 's'} the last answer needed`,
299 hinted.shorten > 0 && `drop or shorten ${hinted.shorten} flagged`,
300 ].filter((part): part is string => typeof part === 'string')
301 const compactNotes = !hasHints ? undefined : section('notes', 'On /compact', undefined,
302 quiet(noteParts.length === 0
303 ? 'Nothing to add to the summary yet.'
304 : `The summary is told to ${noteParts.join(', ')}.`))
305
306 const biggest = section('big', 'Biggest blocks', quiet(`top ${Math.min(8, current.biggest.length)}`),
307 current.biggest.length === 0
308 ? quiet('The conversation is empty.')
309 : current.biggest.filter(block => block.tokens > 0).slice(0, 8).map(block => blockRow('big', block)))
310
311 // Ablation: which blocks the last answer needed.
312 const ablateButton = (
313 <Button key="ablate" plain label={tested ? '▶ Run again' : '▶ Test blocks'} onPress={actions.plan} />
314 )
315 const usd = plan?.usd === undefined ? '' : plan.usd < 0.01 ? ', under $0.01' : `, up to $${plan.usd.toFixed(2)}`
316 const confirm = plan && (
317 <Box key="confirm" flexDirection="column" marginTop={1}>
318 <Text>
319 {`${plan.replays} replays with ${plan.model}, ${formatTokens(plan.promptTokens)} tokens each`}
320 <Text dimColor>{` (${formatTokens(plan.totalTokens)} in all${usd})`}</Text>
321 </Text>
322 <Box flexDirection="row" gap={2}>
323 <Button key="ablate-run" plain variant="primary" label="Run" onPress={actions.run} />
324 <Button key="ablate-cancel" plain label="Cancel" onPress={actions.cancel} />
325 </Box>
326 </Box>
327 )
328 const counts = (tested?.results ?? []).reduce<Record<string, number>>(
329 (tally, result) => ({ ...tally, [result.verdict]: (tally[result.verdict] ?? 0) + 1 }), {})
330 const summary = !tested ? undefined
331 : tested.status === 'running'
332 ? quiet(`Replaying with ${tested.model}: ${tested.results.length - (counts.pending ?? 0)} of ${tested.results.length} done`)
333 : tested.status === 'failed'
334 ? <Text color={ROSE}>{`Failed: ${tested.message ?? 'unknown error'}`}</Text>
335 : (
336 <Text>
337 {(['needed', 'partly', 'unused'] as const).filter(verdict => counts[verdict]).map((verdict, i) => (
338 <Text>
339 {i > 0 ? <Text dimColor>{' · '}</Text> : ''}
340 <Text color={VERDICT_STYLE[verdict].color}>{`${counts[verdict]} ${verdict}`}</Text>
341 </Text>
342 ))}
343 <Text dimColor>{` of ${tested.results.length} tested with ${tested.model}`}</Text>
344 </Text>
345 )
346 const ablation = section('relied', 'What the last answer needed',
347 tested && tested.status !== 'running' && !plan ? ablateButton : undefined,
348 !tested ? (
349 <Box flexDirection="column">
350 {quiet('Replays your last question with each big block removed, to see which ones the answer needed.')}
351 {confirm ?? ablateButton}
352 </Box>
353 ) : (
354 <Box flexDirection="column">
355 {confirm}
356 {summary}
357 <Box flexDirection="column" marginTop={1}>
358 {tested.results.map(result =>
359 row(`ablate-${result.id}`,
360 <Text>{label(result.label)}</Text>,
361 <Box flexDirection="row" flexShrink={0}>
362 {value(formatTokens(result.tokens))}
363 <Box width={VERDICT_COLUMN} justifyContent="flex-end">
364 <Text color={VERDICT_STYLE[result.verdict].color}>{VERDICT_STYLE[result.verdict].text}</Text>
365 </Box>
366 </Box>))}
367 </Box>
368 {tested.isNoisy && (
369 <Box marginTop={1}>
370 <Text color={AMBER}>Two baseline replays disagreed, so the answer varies on its own. Treat these as weak signals.</Text>
371 </Box>
372 )}
373 <Box marginTop={1}>
374 {quiet(`Cost ${formatTokens(tested.inputTokens)} in, ${formatTokens(tested.cachedTokens)} cached, ${formatTokens(tested.outputTokens)} out`)}
375 </Box>
376 </Box>
377 ))
378
379 // Agents: what each subagent worked through in its own context, against the
380 // report it handed back to this one, and what that work cost.
381 const agents = current.agents
382 const kept = agents.reduce((sum, agent) => sum + Math.max(0, (agent.workTokens ?? 0) - agent.returnedTokens), 0)
383 const processed = agents.reduce((sum, agent) => sum + agent.input + agent.cacheRead, 0)
384 const agentRow = (agent: AgentRow) => {
385 const model = agent.model ? ` · ${agent.model.replace(/^claude-/, '')}` : ''
386 const detail = agent.isRunning
387 ? `${agent.agentType ?? 'agent'} · running`
388 : `${agent.agentType ?? 'agent'}${model} · processed ${formatTokens(agent.input + agent.cacheRead)} · ${Math.round(agent.durationMs / 1000)}s`
389 return (
390 <Box key={`agent-${agent.toolUseId}`} flexDirection="column" marginTop={1}>
391 {row(`agent-row-${agent.toolUseId}`, <Text>{agent.description}</Text>,
392 agent.isRunning ? value('…')
393 : <Text tabular>{formatTokens(agent.workTokens ?? 0)}<Text dimColor> → </Text>{formatTokens(agent.returnedTokens)}</Text>)}
394 <Text dimColor wrap="truncate-end">{detail}</Text>
395 </Box>
396 )
397 }
398 const delegation = section('agents', 'Subagents',
399 agents.length > 0 ? <Text tabular color={TEAL}>{`${formatTokens(kept)} kept out`}</Text> : undefined,
400 agents.length === 0
401 ? quiet('No subagents in this conversation yet.')
402 : (
403 <Box flexDirection="column">
404 {quiet(`Work each did in its own context → what it handed back. ${formatTokens(processed)} processed in all.`)}
405 {agents.map(agentRow)}
406 </Box>
407 ))
408
409 const body = tab === 'blocks' ? [waste, compactNotes, biggest]
410 : tab === 'ablation' ? [ablation]
411 : tab === 'agents' ? [delegation]
412 : [header, bars, timeline, lastTurn]
413
414 return (
415 <Box flexDirection="column">
416 {tabBar}
417 {body}
418 </Box>
419 )
420}
421types/index.d.ts 154 lines1// One piece of the conversation the next request carries: a prompt, a reply,
2// a tool call, a tool result, a reminder.
3export type Block = {
4 id: string
5 kind: 'prompt' | 'reply' | 'reminder' | 'tool-call' | 'tool-result' | 'thinking' | 'media'
6 label: string
7 tokens: number
8}
9
10export type WasteKind = 'duplicate-read' | 'stale-read' | 'large-output' | 'unused-mcp'
11
12export type Waste = {
13 kind: WasteKind
14 label: string
15 tokens: number
16 // The tool results Drop would stub out; empty for what lives outside the
17 // conversation (MCP schemas).
18 blockIds: string[]
19}
20
21export type Category = {
22 name: string
23 tokens: number
24 color: string
25}
26
27export type Snapshot = {
28 at: number
29 percent?: number
30 tokens?: number
31 window: number
32 categories: Category[]
33 messageTokens: number
34 biggest: Block[]
35 added: Block[]
36 addedTokens: number
37 droppedTokens: number
38 waste: Waste[]
39 wasteTokens: number
40 // Exact message tokens over the estimate; block counts are scaled by it.
41 calibration: number
42 // Each Agent call still in the conversation, with what it cost and returned.
43 agents: AgentRow[]
44 // Where auto-compaction kicks in, in tokens; absent when it is off.
45 autoCompactAt?: number
46 // Files this conversation edited, for the notes a compaction gets.
47 editedFiles: string[]
48 // What the last refresh saw, so the next one can diff against it.
49 blockIds: string[]
50}
51
52// One block the ablation took out, and what that did to the answer.
53export type AblationResult = {
54 id: string
55 label: string
56 tokens: number
57 verdict: 'needed' | 'partly' | 'unused' | 'failed' | 'pending'
58}
59
60export type Ablation = {
61 status: 'running' | 'done' | 'failed'
62 startedAt: number
63 model: string
64 question: string
65 results: AblationResult[]
66 // Two baseline runs that disagree mean the answer varies on its own, and a
67 // changed answer says little about the block removed.
68 isNoisy?: boolean
69 // Tokens the ablation's own calls used, cache reads counted apart.
70 inputTokens: number
71 cachedTokens: number
72 outputTokens: number
73 message?: string
74}
75
76// An ablation worked out but not started: what it would cost, waiting for a yes.
77export type AblationPlan = {
78 count: number
79 model: string
80 replays: number
81 // The size of one replay's prompt, and what all of them send in total.
82 promptTokens: number
83 totalTokens: number
84 // An estimate from list prices; undefined for a model without a known price.
85 usd?: number
86}
87
88// What one subagent's runs added up to, from its turn.complete events.
89export type AgentStats = {
90 // Input the agent's requests sent, uncached and cache-written.
91 input: number
92 cacheRead: number
93 output: number
94 model?: string
95 runs: number
96 durationMs: number
97 // The report it handed back: what the main conversation had to hold.
98 returnedTokens: number
99 // How big its own conversation grew: what the main one would have held had
100 // it done the work itself.
101 workTokens?: number
102}
103
104export type AgentRow = AgentStats & {
105 agentId?: string
106 toolUseId: string
107 description: string
108 agentType?: string
109 isRunning: boolean
110}
111
112export type PaneTab = 'overview' | 'blocks' | 'ablation' | 'agents'
113
114// The window's fill at one measurement, for the timeline.
115export type FillPoint = { at: number; percent: number }
116
117// What one main-loop turn cost.
118export type TurnCost = {
119 at: number
120 input: number
121 cacheRead: number
122 cacheWrite: number
123 output: number
124 usd?: number
125}
126
127export type History = {
128 points: FillPoint[]
129 turns: TurnCost[]
130 compactions: number[]
131 // The session's cost total at the last turn, to work out the next turn's.
132 lastUsd?: number
133}
134
135// What Drop is removing, and what it saved, for the toast afterwards. The
136// compaction that applies it marks it applied.
137export type DropRequest = { blockIds: string[]; label: string; tokens: number; at: number; isApplied?: boolean }
138
139declare module 'claude-code' {
140 interface PluginState {
141 'context-lens': {
142 snapshot: Snapshot | null
143 ablation: Ablation | null
144 ablationPlan: AblationPlan | null
145 history: History
146 // When each Read in this session finished, by tool_use_id.
147 readTimes: Record<string, number>
148 dropRequest: DropRequest | null
149 agentStats: Record<string, AgentStats>
150 tab: PaneTab
151 }
152 }
153}
154