SLOPSHOPPER

context-lens

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

newpaneguardcommandtoaststatus
v0.1.3MITupdated 2026-10-19Gautamstar/context-lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-lens
│ ┃ Context Lens ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Overview 2: Blocks 3: Ablation 4: Agents↻ │ ┃ ● context-lens: context-lens: refresh failed: TypeError: undefined is │ ┃ Waiting for the first prompt to measure the ⏺ Read(src/auth.ts) │ ┃ window. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ctx │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context Lens
1: Overview 2: Blocks 3: Ablation 4: Agents ↻ Waiting for the first prompt to measure the window.
README

Context Lens

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

Install

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

Tabs

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.

Compaction notes

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.

Commands

Command
/ctxOpen the pane
/ctx fullRecount with the token-counting API
/ctx ablate [n]Estimate the cost of testing the n biggest blocks (default 5)
/ctx ablate runRun it
/ctx ablate cancelCancel it

Settings

Set these in /config.

SettingDefault
autoOpenin-appOpen the pane at startup in the desktop app and VS Code. Also always or never.
ablationModelhaikuModel for ablation replays. sonnet costs about 20x more.
largeOutputTokens5000Size at which a tool output counts as waste.
compactionHintsonAdd the compaction notes.

Development

claude --plugin-dir ./context-lens
claude plugin test ./context-lens

License

MIT

Source 6 files
hooks/register.tsx 493 lines
1import { 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}
493
hooks/ablation.ts 203 lines
1// 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}
203
hooks/analyze.ts 306 lines
1// 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)
306
hooks/hints.ts 86 lines
1// 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
86
hooks/pane.tsx 421 lines
1// 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}
421
types/index.d.ts 154 lines
1// 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