Compact smartly. Cost & cache panel for Claude Code: cache countdown, cost per turn, ranked cost hints, savings from compacting, and compaction at natural…

Compact smartly.
A Claude Code plugin that shows what your session costs and compacts it at task boundaries, with a summary written as a handoff for the next task.
/cost-panel): what each turn costs, when the prompt cache expires, and ranked hints: task boundary, cache expiring or cold, unexpected cache rebuild, large context, a cheaper model, large tool output. From top to bottom it has:⚙ Settings turns each section on or off and picks the footer line's fields (presets minimal, cost, standard, or custom); choices are kept across sessions.
A one-line footer line beside the prompt keeps the cache state in view when the panel is closed (● warm 42m · $0.36 if cold · ▲ 1 tip). Press it to open the panel.
The terminal draws text bars; the desktop app draws SVG. Clicking the buttons in the CLI needs /tui fullscreen; the hotkeys work without it. Where no window can be drawn, the command replies with the same report as text.
auto mode): when you send a prompt and the context is at least minTokens, a judge model (Claude Sonnet by default) reads a short digest of the session and the new prompt. If it sees a natural break with enough confidence, the plugin holds the prompt back, compacts with a handoff summary that also names the new prompt, and then sends it. The prompt you see in the transcript is then sent by the plugin. A prompt with an image or an @-mention is never held back, because a plugin can't send those again. Neither is a prompt from an SDK host (claude -p or another stream-json host), where a held-back prompt would be lost. There the git signals decide, as with judge=off.auto mode with a judge): after a turn with a git commit, push or PR on a large context, if no prompt follows, the plugin compacts 5 minutes before the 1-hour cache expires (1 minute before the 5-minute one). The summarizer still reads the context from cache, and when you come back your next message re-writes the short summary instead of the whole context. A prompt sent before then cancels it, and the judge decides as usual. This only runs while Claude Code stays open./compactly if it is safe. The judge still decides at your next prompt./compactly-rate up|down). The judge learns from the answers:Ratings are kept in the plugin's store under ~/.claude, with the digest the judge read (the start of your prompts and of the agent's answer, and file names) and the project's root. Ratings from every project move the confidence bar, but a digest is only shown to the judge in the project it was rated in, so prompts from one project never reach another's.
suggest mode): after a successful git commit, git push or gh pr create/merge command (more categories via signals), once the turn ends and the context is at least minTokens, it suggests /compactly. With judge=off, auto mode compacts on these signals instead./compactly: compact now, with instructions to keep open work, paused work and why it stopped, decisions with their reasons, and file paths./cost-panel's text. Each turn after a compaction is credited with the context it no longer re-reads; the compaction's own cost (the summarizer, and writing the new context to the cache) is taken off first, so a fresh compaction shows a small loss until it pays back. A compaction after the cache expired is priced against the full re-write the next turn would have needed anyway: it isn't charged for writing the new context, and the next turn is credited with the re-write it avoided. Your /compact counts; autocompact doesn't, since it would have run anyway. An all-time total across sessions earns levels, from Cache Rookie ($0) to Compaction Legend ($500), with a toast when you reach one. The total is kept in the plugin's own store under ~/.claude: dollar amounts and counts only, one entry per session.See the decision flow.
Requires Claude Code 2.1.295 or later. Install and settings are covered in the top-level README, installation and configuration.
claude plugin validate plugin/compactly
claude plugin test plugin/compactly
.claude-plugin/types/ is generated by the Claude Code plugin tooling and is not committed; tsconfig.json extends it.
hooks/register.tsx 777 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { PanelState, PrefsState, Rating, Savings, Unrated } from '../types'
5import { chartAlt, costChart, meter, SERIES, sparkline } from './chart'
6import { footerLine } from './footer'
7import { cacheState, minutes, SNOOZE_MS } from './hints'
8import type { Hint } from './hints'
9import { digest, examples, isOnHold, JUDGES, prompt, SYSTEM, threshold, verdict } from './judge'
10import type { Judge } from './judge'
11import { panelModel, ttlMsOf, windowMsOf } from './model'
12import { clockView, hintView, tilesView } from './panel-view'
13import { costSplit, kTokens, priceOf, usd, writePrice } from './pricing'
14import { report } from './report'
15import { DEFAULT_PREFS, normalize, PRESETS } from './settings'
16import type { FieldId, ViewId } from './settings'
17import { settingsView } from './settings-view'
18import { carried, compacted, level, lifetime, net, NO_SAVINGS, payback, signed } from './savings'
19
20type Boundary = 'pr' | 'push' | 'deploy' | 'commit' | 'tests' | 'validate' | 'build' | 'todos'
21
22// git's global options may sit before the subcommand: `git -C <dir> commit`.
23const GIT = String.raw`\bgit(?:\s+(?:-[Cc]\s+\S+|--\S+))*\s+`
24
25// Strongest first: a turn that commits and opens a PR reports `pr`.
26const SIGNALS: readonly (readonly [Boundary, RegExp])[] = [
27 ['pr', /\bgh\s+pr\s+(create|merge)\b/],
28 ['push', new RegExp(`${GIT}push\\b`)],
29 ['commit', new RegExp(`${GIT}commit\\b`)],
30 ['deploy', /\bhelm\s+(upgrade|install)\b|\bkubectl\s+(apply|rollout\s+restart)\b(?!.*--dry-run)|\bterraform\s+apply\b|\bargocd\s+app\s+sync\b/],
31 [
32 'tests',
33 /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bpytest\b|\bgo\s+test\b|\bcargo\s+test\b|\b(jest|vitest)\b|\bmake\s+test\b|\bgradlew?\b.*\btest\b|\bmvn\b.*\b(test|verify)\b/,
34 ],
35 ['validate', /\bhelm\s+(lint|template)\b|\bterraform\s+(validate|plan)\b|\bkubectl\b.*--dry-run|\bkubeconform\b|\bkustomize\s+build\b/],
36 [
37 'build',
38 /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(build|lint|typecheck)\b|\btsc\b|\bgo\s+(build|vet)\b|\bcargo\s+(build|check|clippy)\b|\bruff\b|\beslint\b/,
39 ],
40]
41
42const RANK: Record<Boundary, number> = { pr: 0, push: 1, deploy: 2, commit: 3, tests: 4, validate: 5, build: 6, todos: 7 }
43
44// Which signals count, by the `signals` setting.
45const SIGNAL_SETS: Record<string, readonly Boundary[]> = {
46 git: ['pr', 'push', 'commit'],
47 'git+deploy': ['pr', 'push', 'commit', 'deploy'],
48 all: ['pr', 'push', 'deploy', 'commit', 'tests', 'validate', 'build', 'todos'],
49}
50
51const COMMAND = 'compactly'
52const PANEL_COMMAND = 'cost-panel'
53const RATE_COMMAND = 'compactly-rate'
54const PANE = 'cost-panel'
55const DEFER_MS = 1500
56const COMMAND_DEFER_MS = 100
57const TICK_MS = 30_000
58// Room the footer line may take beside the mode labels; fields drop from the right past it.
59const FOOTER_COLS = 50
60const MAX_TURNS = 60
61// A tool result at least this large (in tokens, ~4 characters each) is worth a hint.
62const BIG_OUTPUT = 8_000
63// The judge runs before the prompt enters; past this it lets the prompt through unjudged.
64const JUDGE_TIMEOUT_MS = 15_000
65// With the judge on, a boundary no prompt followed compacts this long after the turn: 55 minutes
66// on the 1-hour cache, 4 on the 5-minute one, so the summarizer still reads it warm.
67const beforeExpiryMs = (ttl: string) => ttlMsOf(ttl) - windowMsOf(ttl)
68// Prompts the person typed, here or through the Remote Control bridge. An SDK host's prompt
69// (-p, or another SDK host) is never held back: a -p run ends with it, and the prompt is lost.
70const PERSON = new Set(['composer', 'bridge'])
71const MENTION = /(^|\s)@\S/
72
73const PREFS = 'prefs'
74
75const prefs = atom({ plugin: 'compactly', key: 'prefs' } as const, { ...DEFAULT_PREFS, isSettingsOpen: false } as PrefsState)
76
77/** Applies a change to the preferences and keeps it for later sessions. */
78const setPrefs = async ($: EngineInterface, change: (p: PrefsState) => PrefsState) => {
79 const next = await update($, prefs, change)
80 const { isSettingsOpen: _, ...keep } = next
81 await $.store.set(PREFS, keep).catch(() => undefined)
82}
83
84const panel = atom({ plugin: 'compactly', key: 'panel' } as const, {
85 turns: [],
86 dismissed: [],
87 tick: 0,
88 tokens: 0,
89 window: 0,
90 sessionUsd: 0,
91 model: '',
92 saved: NO_SAVINGS,
93} as PanelState)
94
95// Each run of a session keeps its own entry in the store, so parallel sessions never race.
96const RUN = 'run:'
97
98/** Net savings and compactions of every earlier run, from the plugin's store. */
99const pastRuns = async ($: EngineInterface) => {
100 const keys = (await $.store.keys()).filter(k => k.startsWith(RUN))
101 const runs = (await Promise.all(keys.map(k => $.store.get(k)))) as ({ usd?: number; compactions?: number } | undefined)[]
102 const total = { usd: 0, compactions: 0 }
103 for (const r of runs) {
104 total.usd += r?.usd ?? 0
105 total.compactions += r?.compactions ?? 0
106 }
107 return total
108}
109
110// Each rating of an automatic compaction is its own store entry, for the same reason.
111const RATING = 'rating:'
112
113const ratings = async ($: EngineInterface) => {
114 const keys = (await $.store.keys()).filter(k => k.startsWith(RATING))
115 return ((await Promise.all(keys.map(k => $.store.get(k)))) as (Rating | undefined)[]).filter(r => r !== undefined)
116}
117
118const persist = ($: EngineInterface, v: Savings, now: number) =>
119 v.runKey === undefined || v.compactions === 0
120 ? Promise.resolve()
121 : $.store.set(v.runKey, { usd: net(v), compactions: v.compactions, at: now }).catch(() => undefined)
122
123type Reason = Boundary | 'manual' | 'natural break'
124
125export const handoff = (boundary: Reason, next?: string) =>
126 [
127 `This compaction runs at a task boundary (${boundary}). Write the summary as a handoff for the NEXT task, not a history of this one.`,
128 ...(next === undefined ? [] : [`The next task starts with this prompt; keep what it will need: ${JSON.stringify(next.slice(0, 500))}`]),
129 'Keep, in this order:',
130 '1. What was just completed, in two or three sentences, with commit hashes, branch and PR links.',
131 '2. Open work and follow-ups the user asked for or agreed to, verbatim where the user wrote them.',
132 '3. Work that is paused, blocked or waiting: what it is, why it stopped, and what must be true before it resumes. Never describe paused work as in progress or as the next step.',
133 '4. Decisions, constraints and preferences that still apply, each with its reason, whether the user stated it or the work found it.',
134 '5. Files and paths that matter for the follow-up work.',
135 '6. Anything still failing, skipped or unverified.',
136 'Drop: dead ends, superseded plans, raw tool and test output, and file contents that can be read again.',
137 ].join('\n')
138
139export const detect = (command: string, allowed: readonly Boundary[] = SIGNAL_SETS.all!): Boundary | undefined =>
140 SIGNALS.find(([b, re]) => allowed.includes(b) && re.test(command))?.[0]
141
142// A todo list whose every item is done marks the end of the work it planned.
143const isTodoDone = (input: unknown) => {
144 const todos = (input as { todos?: { status?: string }[] } | undefined)?.todos ?? []
145 return todos.length > 0 && todos.every(t => t.status === 'completed')
146}
147
148const stronger = (a: Boundary | undefined, b: Boundary) =>
149 a === undefined || RANK[b] < RANK[a] ? b : a
150
151// Per register call, so a reload or a second session has its own.
152type Idle = {
153 /** Cancels the armed idle compaction's timer; a compaction that runs first, or a prompt, calls it. */
154 cancel?: () => void
155 isCompacting: boolean
156}
157
158// One compaction at a time: false, without running, when another is in flight.
159const oneAtATime = async (idle: Idle, run: () => Promise<void>) => {
160 if (idle.isCompacting) return false
161 idle.isCompacting = true
162 try {
163 await run()
164 return true
165 } finally {
166 idle.isCompacting = false
167 }
168}
169
170// Right after a compaction `context.tokens` is empty until the next response; /context's estimate is not.
171const sizeNow = async ($: EngineInterface) => {
172 const { context } = await $.session.usage({ breakdown: 'summary' }).catch(() => ({ context: undefined }))
173 return context?.tokens ?? context?.breakdown?.totalTokens
174}
175
176/**
177 * Settles a main-conversation compaction: the boundary and the big output are gone with the
178 * context, and one the person or a plugin chose counts toward the savings.
179 */
180const settle = async (
181 $: EngineInterface,
182 idle: Idle,
183 r: { tokensBefore?: number; tokensAfter?: number; usage?: ModelUsage },
184 counts: boolean,
185 ttl: string,
186) => {
187 idle.cancel?.()
188 idle.cancel = undefined
189 const now = await $.clock.now()
190 const tokensAfter = r.tokensAfter ?? (await sizeNow($))
191 return update($, panel, s => {
192 const p = priceOf(s.model)
193 const tokensBefore = r.tokensBefore ?? s.tokens
194 // After the cache expired, as when the judge compacts at the first prompt back from a break.
195 const isCold = cacheState(s, now, ttlMsOf(ttl))?.isWarm === false
196 return {
197 ...s,
198 // The context shrank now; the next measure or turn would only catch up.
199 tokens: tokensAfter ?? s.tokens,
200 boundary: undefined,
201 bigOutput: undefined,
202 compactAt: undefined,
203 saved: counts && p !== undefined && tokensBefore > 0 && tokensAfter !== undefined
204 ? compacted(s.saved, { tokensBefore, tokensAfter, usage: r.usage }, p, ttl, isCold)
205 : s.saved,
206 }
207 })
208}
209
210// The panel announces the armed idle compaction; a cancelled or run one drops the announcement.
211const clearCompactAt = ($: EngineInterface) =>
212 update($, panel, s => (s.compactAt === undefined ? s : { ...s, compactAt: undefined }))
213
214// The plugin's own compaction does not pass through its own session.compact hook: it settles here.
215const compact = async ($: EngineInterface, idle: Idle, boundary: Reason, ttl: string, next?: string) => {
216 let r
217 try {
218 r = await $.session.compact({ instructions: handoff(boundary, next) })
219 } catch (err) {
220 if (!/headless/.test(String(err))) throw err
221 // SDK hosts (-p, stream-json) compact only as a command: run `/compact` itself.
222 const tokensBefore = (await read($, panel)).tokens
223 await $.command.run({ command: 'compact', args: handoff(boundary, next) })
224 await settle($, idle, { tokensBefore }, true, ttl)
225 return { text: `compactly: ${boundary} → ran /compact (SDK session)`, isCompacted: true }
226 }
227 if (r.skip !== undefined) return { text: `compactly: skipped (${r.skip})`, isCompacted: false }
228 await settle($, idle, r, true, ttl)
229 const before = r.tokensBefore === undefined ? '?' : kTokens(r.tokensBefore)
230 const after = r.tokensAfter === undefined ? '?' : kTokens(r.tokensAfter)
231 const cost = r.usage
232 ? `, summarizer read ${kTokens(r.usage.cache_read_input_tokens ?? 0)} from cache`
233 : ''
234 const p = priceOf((await read($, panel)).model)
235 const removed = (r.tokensBefore ?? 0) - (r.tokensAfter ?? r.tokensBefore ?? 0)
236 const saves = p !== undefined && removed > 0 ? ` · saves ~${usd((removed * p.read) / 1e6)} per turn` : ''
237 return { text: `compactly: ${boundary} → compacted ${before} → ${after}${cost}${saves}`, isCompacted: true }
238}
239
240const compactAndLog = async ($: EngineInterface, idle: Idle, ttl: string) => {
241 const ran = await oneAtATime(idle, async () => {
242 try {
243 $.ui.log((await compact($, idle, 'manual', ttl)).text)
244 } catch (err) {
245 $.ui.toast(`compactly: could not compact (${String(err)})`)
246 }
247 })
248 if (!ran) $.ui.toast('compactly: already compacting')
249}
250
251/**
252 * Compacts at a natural break the judge found, then sends the prompt it held back: the host
253 * refuses a compaction under a prompt's own submission, so the prompt waits for it.
254 */
255const compactThenSend = async ($: EngineInterface, idle: Idle, text: string, ttl: string, unrated: Unrated, why: string) => {
256 try {
257 await oneAtATime(idle, async () => {
258 const done = await compact($, idle, 'natural break', ttl, text)
259 $.ui.log(done.isCompacted ? `${done.text} · ${why} · rate it: /${RATE_COMMAND} up|down` : done.text)
260 if (done.isCompacted) await update($, panel, s => ({ ...s, unrated }))
261 })
262 } catch (err) {
263 $.ui.log(`compactly: could not compact (${String(err)})`)
264 } finally {
265 await $.prompt.submit({ text, asUser: true })
266 }
267}
268
269/** Stores the person's rating of the last automatic compaction; the judge learns from it. */
270const rate = async ($: EngineInterface, isGood: boolean, start: number | undefined) => {
271 const { unrated } = await read($, panel)
272 if (unrated === undefined) return 'compactly: no automatic compaction to rate.'
273 const rating: Rating = { at: unrated.at, isGood, confidence: unrated.confidence, digest: unrated.digest, project: unrated.project }
274 await $.store.set(`${RATING}${await $.session.id()}:${unrated.at}`, rating)
275 await update($, panel, s => ({ ...s, unrated: undefined }))
276 const bar = threshold(start ?? unrated.threshold, await ratings($))
277 return `compactly: ${isGood ? '👍' : '👎'} saved. The judge sees this moment as an example from now on, and compacts at confidence ≥ ${bar.toFixed(2)}.`
278}
279
280// Drops the snoozes that have run out, so the map keeps only the hints still hidden.
281const liveSnoozes = (snoozed: Record<string, number> | undefined, now: number) =>
282 Object.fromEntries(Object.entries(snoozed ?? {}).filter(([, until]) => until > now))
283
284/** The one-line summary for the status line, where no pane is drawn. */
285const statusLine = (s: PanelState, now: number, ttl: string) => {
286 const p = priceOf(s.model)
287 if (p === undefined || s.tokens === 0) return undefined
288 const cache = cacheState(s, now, ttlMsOf(ttl))
289 const state = cache === undefined ? '' : cache.isWarm ? `cache warm ${minutes(cache.remainingMs)} · ` : 'cache cold · '
290 return `${state}${kTokens(s.tokens)} ctx · ${usd((s.tokens * p.read) / 1e6)}/turn · /${PANEL_COMMAND}`
291}
292
293export const register: Register = (on, options) => {
294 const mode = String(options.mode ?? 'auto')
295 const minTokens = Number(options.minTokens ?? 150000)
296 const ttl = String(options.cacheTtl ?? '1h')
297 const openPanel = options.openPanel !== false
298 const allowed = SIGNAL_SETS[String(options.signals ?? 'git')] ?? SIGNAL_SETS.git!
299 const judgeName = String(options.judge ?? 'sonnet')
300 // In auto mode a judge, when one is set, decides at prompt time in place of the signals.
301 const judge = judgeName in JUDGES ? JUDGES[judgeName as Judge] : undefined
302
303 let pending: Boundary | undefined
304 let isTurnRunning = false
305 const idle: Idle = { isCompacting: false }
306 const cancel = () => {
307 idle.cancel?.()
308 idle.cancel = undefined
309 }
310 let isPanePlaced = false
311 // An SDK host sends its prompts as `sdk`; there the signals decide, as with judge=off.
312 let isSdk = false
313
314 on('session.start', async ($, e, next) => {
315 // A reload keeps the panel state but not the timer that would run the idle compaction.
316 await clearCompactAt($)
317 $.ui.log(`compactly: loaded (mode=${mode}, judge=${judge === undefined ? 'off' : judgeName}, minTokens=${minTokens}, ttl=${ttl})`, { to: 'debug' })
318 await $.command.register({
319 name: COMMAND,
320 description: 'Compact now with a task-handoff summary (keeps open work, decisions, files).',
321 })
322 await $.command.register({
323 name: RATE_COMMAND,
324 description: 'Rate the last automatic compaction: up (a natural break) or down (the work was still going).',
325 argumentHint: 'up|down',
326 })
327 await $.command.register({
328 name: PANEL_COMMAND,
329 description: 'Open the cost & cache panel: cache countdown, cost per turn, and hints.',
330 })
331 if (openPanel) {
332 const opened = await $.ui.open({ id: PANE, title: 'Cost & cache' })
333 isPanePlaced = opened.isPlaced
334 $.ui.log(`compactly: panel ${opened.isPlaced ? 'placed' : `waiting (${opened.reason})`}`, { to: 'debug' })
335 }
336 try {
337 const saved = normalize(await $.store.get(PREFS))
338 await update($, prefs, p => ({ ...p, ...saved }))
339 } catch (err) {
340 $.ui.log(`compactly: no saved settings (${String(err)})`, { to: 'debug' })
341 }
342 // Savings from earlier runs, for the all-time total and the level.
343 try {
344 const [id, now, past] = await Promise.all([$.session.id(), $.clock.now(), pastRuns($)])
345 await update($, panel, s => ({
346 ...s,
347 saved: { ...s.saved, pastUsd: past.usd, pastCompactions: past.compactions, runKey: s.saved.runKey ?? `${RUN}${id}:${now}` },
348 }))
349 } catch (err) {
350 $.ui.log(`compactly: no saved totals (${String(err)})`, { to: 'debug' })
351 }
352 // Redraws the countdown; the status line carries it where no pane is drawn.
353 $.clock.every(TICK_MS, () => {
354 void update($, panel, s => ({ ...s, tick: s.tick + 1 }))
355 if (isPanePlaced) return
356 void Promise.all([read($, panel), $.clock.now()]).then(([s, now]) => {
357 const line = statusLine(s, now, ttl)
358 if (line !== undefined) $.ui.status(line)
359 })
360 })
361 return next(e)
362 })
363
364 // The host refuses a compaction under the command's own turn: run it once the command returns.
365 on('command.run', { command: COMMAND }, async $ => {
366 $.clock.after(COMMAND_DEFER_MS, () => void compactAndLog($, idle, ttl))
367 return { text: 'compactly: compacting with a handoff summary…' }
368 })
369
370 on('command.run', { command: RATE_COMMAND }, async ($, e) => {
371 const arg = e.args.trim().toLowerCase()
372 if (!/^(up|down|good|bad)$/.test(arg)) return { text: `Usage: /${RATE_COMMAND} up|down` }
373 return { text: await rate($, arg === 'up' || arg === 'good', judge?.confidence) }
374 })
375
376 // Prompt time: the judge reads the new prompt. At a natural break the plugin holds the prompt
377 // back, compacts, and sends it again, so it starts on the handoff summary.
378 on('prompt.submit', async ($, e, next) => {
379 if (e.origin.kind === 'sdk') isSdk = true
380 // A prompt for the model is back before the cache expired: the judge decides, not the timer.
381 if (!e.text.trimStart().startsWith('/')) {
382 cancel()
383 await clearCompactAt($)
384 }
385 if (judge === undefined || mode !== 'auto' || isTurnRunning || e.turnId !== undefined) return next(e)
386 if (!PERSON.has(e.origin.kind) || e.text.trimStart().startsWith('/')) return next(e)
387 // A prompt sent again by a plugin loses its images and @-mentions: never hold one back.
388 if (e.attachments?.length || MENTION.test(e.text)) return next(e)
389 if (((await $.session.usage()).context.tokens ?? 0) < minTokens) return next(e)
390 const d = digest(await $.session.messages(), e.text)
391 if (d === undefined) return next(e)
392 const [rated, project] = await Promise.all([ratings($), $.session.root()])
393 // Every rating moves the bar, but only this project's digests are shown to the judge:
394 // prompts from one project never reach another project's judge.
395 const bar = threshold(judge.confidence, rated)
396 const own = rated.filter(r => r.project === project)
397 $.ui.status('compactly: checking for a natural break…')
398 try {
399 const r = await $.model.complete({
400 model: judge.model,
401 system: SYSTEM,
402 prompt: prompt(d, examples(own)),
403 maxTokens: 100,
404 effort: 'low',
405 timeoutMs: JUDGE_TIMEOUT_MS,
406 })
407 // The judge's cost counts against what compacting saves.
408 const p = priceOf(judge.priceAs)
409 if (p !== undefined) {
410 const u = r.usage
411 const spent = (u.input_tokens * p.input + u.output_tokens * p.output + u.cache_read_input_tokens * p.read
412 + u.cache_creation_input_tokens * p.write5m) / 1e6
413 await update($, panel, s => ({ ...s, saved: { ...s.saved, spent: s.saved.spent + spent } }))
414 }
415 const v = r.isAnswered ? verdict(r.text) : undefined
416 const said = v === undefined ? `no verdict (${r.isAnswered ? 'unreadable reply' : r.reason})` : `break=${v.isBreak} confidence=${v.confidence}`
417 $.ui.log(`compactly: ${judgeName} judge ${said}, needs ${bar}`, { to: 'debug' })
418 if (v?.isBreak && v.confidence >= bar) {
419 const unrated: Unrated = { at: await $.clock.now(), judge: judgeName, confidence: v.confidence, threshold: bar, digest: d, project }
420 const why = `${judgeName} ${v.confidence.toFixed(2)} ≥ ${bar.toFixed(2)}`
421 $.clock.after(COMMAND_DEFER_MS, () => void compactThenSend($, idle, e.text, ttl, unrated, why))
422 return { drop: `compactly: a natural break (${why}). Compacting first, then sending your prompt.` }
423 }
424 } finally {
425 $.ui.status(undefined)
426 }
427 return next(e)
428 }).catch(($, e, next) => next(e)) // a failed judge never holds the prompt back
429
430 on('command.run', { command: PANEL_COMMAND }, async $ => {
431 const [surfaces, s, now] = await Promise.all([$.session.surfaces(), read($, panel), $.clock.now()])
432 // A surface that draws no plugin pane answers with the report as text.
433 if (!surfaces.some(surface => surface === 'terminal' || surface === 'desktop')) return { text: report(s, now, ttl, COMMAND) }
434 const opened = await $.ui.open({ id: PANE, title: 'Cost & cache' })
435 isPanePlaced = opened.isPlaced
436 return { text: opened.isPlaced ? 'Cost & cache panel opened.' : `Cost & cache panel is waiting: ${opened.reason}` }
437 })
438
439 on('session.measure', async ($, e, next) => {
440 await update($, panel, s => ({
441 ...s,
442 tokens: e.context.tokens ?? s.tokens,
443 window: e.context.window,
444 sessionUsd: e.cost?.usd ?? s.sessionUsd,
445 }))
446 return next(e)
447 })
448
449 // Compactions the host runs: the person's /compact and autocompact, which would have run anyway.
450 on('session.compact', async ($, e, next) => {
451 const r = await next(e)
452 if (e.agentId === undefined && r.skip === undefined && e.trigger !== 'precompute') {
453 await settle($, idle, r, e.trigger !== 'auto', ttl)
454 }
455 return r
456 }).catch(($, e, next) => next(e))
457
458 // Only the main loop raises turn.start; a subagent's run raises none.
459 on('turn.start', async ($, e, next) => {
460 isTurnRunning = true
461 cancel()
462 await clearCompactAt($)
463 return next(e)
464 })
465
466 on('tool.call', async ($, e, next) => {
467 const ran = await next(e)
468 if (e.agentId !== undefined) return ran
469 const tokens = Math.round((ran.text?.length ?? 0) / 4)
470 if (tokens >= BIG_OUTPUT) {
471 await update($, panel, s => ({ ...s, bigOutput: { tool: String(e.tool), tokens } }))
472 }
473 if (mode === 'off') return ran
474 const isOk = ran.deny === undefined && ran.isError !== true
475 if (e.tool === 'TodoWrite' && isOk && allowed.includes('todos') && isTodoDone(e)) {
476 pending = stronger(pending, 'todos')
477 return ran
478 }
479 if (e.tool !== 'Bash') return ran
480 const boundary = isOk ? detect(e.command, allowed) : undefined
481 if (boundary !== undefined) pending = stronger(pending, boundary)
482 $.ui.log(`compactly: bash ok=${isOk} boundary=${boundary ?? 'none'}: ${e.command.slice(0, 60)}`, { to: 'debug' })
483 return ran
484 }).catch(($, e, next) => next(e)) // observe only: never block a tool
485
486 on('turn.complete', async ($, e, next) => {
487 const result = await next(e)
488 if (e.agentId !== undefined) return result
489 isTurnRunning = false
490
491 const boundary = pending
492 pending = undefined
493 const [usage, now, model] = await Promise.all([$.session.usage(), $.clock.now(), $.session.model()])
494 const tokens = usage.context.tokens ?? 0
495 const turnModel = e.usage?.model ?? model
496 const p = priceOf(turnModel)
497
498 let was = NO_SAVINGS
499 const after = await update($, panel, s => {
500 const turn = e.usage && p
501 ? [{
502 at: now,
503 model: turnModel,
504 ctx: tokens,
505 readTokens: e.usage.cache_read_input_tokens,
506 writeTokens: e.usage.cache_creation_input_tokens,
507 ...costSplit(e.usage, p, ttl),
508 }]
509 : []
510 was = s.saved
511 return {
512 ...s,
513 saved: turn[0] && p ? carried(s.saved, turn[0], usage.context.window, p, ttl) : s.saved,
514 turns: [...s.turns, ...turn].slice(-MAX_TURNS),
515 lastActivityAt: e.usage ? now : s.lastActivityAt,
516 model: turnModel,
517 tokens: tokens || s.tokens,
518 window: usage.context.window,
519 sessionUsd: usage.cost?.usd ?? s.sessionUsd,
520 boundary: mode === 'suggest' && boundary !== undefined && tokens >= minTokens ? boundary : s.boundary,
521 }
522 })
523
524 const saved = after.saved
525 if (saved.gross !== was.gross) {
526 await persist($, saved, now)
527 const [from, to] = [level(lifetime(was)), level(lifetime(saved))]
528 if (to.rank > from.rank) {
529 $.ui.toast(`★ Level ${to.rank}: ${to.name}. Compacting has saved you ${usd(lifetime(saved))} so far.`, { timeoutMs: 8000 })
530 } else if (net(was) < 0 && net(saved) >= 0) {
531 $.ui.toast(`Compaction paid for itself: ${usd(net(saved))} saved this session.`, { timeoutMs: 6000 })
532 }
533 }
534
535 $.ui.log(`compactly: turn.complete reason=${e.reason} boundary=${boundary ?? 'none'}`, { to: 'debug' })
536 if (boundary === undefined || e.reason !== 'answer' || mode === 'off') return result
537
538 $.ui.log(`compactly: context ${tokens} tokens (min ${minTokens})`, { to: 'debug' })
539 if (tokens < minTokens) return result
540
541 if (mode === 'auto') {
542 // A commit can land with work paused on purpose: leave that compaction to the person.
543 if (isOnHold(await $.session.messages())) {
544 $.ui.log(`compactly: ${boundary}, but recent turns mention paused or blocked work; not compacting automatically. Run /${COMMAND} if it is safe.`)
545 return result
546 }
547 // With a judge, the signals no longer compact now: the judge decides at the next prompt.
548 // If none comes before the cache expires, compact just before it does, while it is warm.
549 if (judge !== undefined && !isSdk) {
550 cancel()
551 await clearCompactAt($)
552 const timer = $.clock.after(beforeExpiryMs(ttl), () => {
553 idle.cancel = undefined
554 void update($, panel, st => ({ ...st, compactAt: undefined })).catch(() => undefined)
555 if (isTurnRunning) return
556 // A /compactly since the boundary may have made the context small already.
557 void $.session.usage()
558 .then(u => {
559 if ((u.context.tokens ?? 0) < minTokens) return undefined
560 return oneAtATime(idle, async () => {
561 const r = await compact($, idle, boundary, ttl)
562 $.ui.log(r.isCompacted ? `${r.text} · idle, before the cache expired` : r.text)
563 })
564 })
565 .catch(err => $.ui.log(`compactly: could not compact (${String(err)})`))
566 })
567 const armed = () => timer.cancel()
568 idle.cancel = armed
569 await update($, panel, st => ({ ...st, compactAt: now + beforeExpiryMs(ttl) }))
570 // A compaction or a prompt during that write cancelled the timer; the write must not leave its announcement behind.
571 if (idle.cancel !== armed) await clearCompactAt($)
572 return result
573 }
574 // Compaction rejects while a turn runs; wait until this one has fully settled.
575 const timer = $.clock.after(DEFER_MS, () => {
576 idle.cancel = undefined
577 if (isTurnRunning) return
578 void oneAtATime(idle, async () => {
579 $.ui.log((await compact($, idle, boundary, ttl)).text)
580 }).catch(err => $.ui.log(`compactly: could not compact (${String(err)})`))
581 })
582 idle.cancel = () => timer.cancel()
583 return result
584 }
585
586 const carry = p === undefined ? '' : ` (~${usd((tokens * p.read) / 1e6)} per turn to carry)`
587 $.ui.status(`task boundary (${boundary}) · ${kTokens(tokens)} ctx${carry} · /${COMMAND}`)
588 // Hosts without toasts still show the prompt box's suggestion: Tab takes it.
589 $.prompt.suggest({ text: `/${COMMAND}` }).catch(() => undefined)
590 $.ui.toast(
591 `Task boundary (${boundary}) with ${kTokens(tokens)} of context${carry}. Run /${COMMAND} now, while the cache is warm.`,
592 { timeoutMs: 8000 },
593 )
594 return result
595 })
596
597 // Above the prompt after an automatic compaction: was it a natural break? The answer teaches the judge.
598 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
599 const { unrated } = await read($, panel)
600 if (unrated === undefined || e.props.hasSurvey) return next(e)
601 const { Box, Text, Button } = $.ui.resolve(e)
602 const press = (isGood: boolean) => () => void rate($, isGood, judge?.confidence).then(text => $.ui.toast(text, { timeoutMs: 6000 }))
603 return (
604 <Box flexDirection="row">
605 <Text dimColor wrap="truncate-end">
606 Compacted at a natural break ({unrated.judge}, {unrated.confidence.toFixed(2)}). Right call?{' '}
607 </Text>
608 <Button key="rate-good" label="👍 Yes" onPress={press(true)} />
609 <Button key="rate-bad" label="👎 No, I was mid-task" onPress={press(false)} />
610 <Button key="rate-dismiss" label="Dismiss" onPress={() => void update($, panel, s => ({ ...s, unrated: undefined }))} />
611 </Box>
612 )
613 })
614
615 // The footer line stays whether the pane is open or closed; pressing it opens the pane.
616 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
617 const [s, now, pf] = await Promise.all([read($, panel), $.clock.now(), read($, prefs)])
618 const f = footerLine(s, now, ttl, FOOTER_COLS, pf.fields)
619 if (f === undefined) return next(e)
620 const { Box, Text, Button } = $.ui.resolve(e)
621 return (
622 <Box flexDirection="row" gap={1}>
623 {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' · ')}</Text>}
624 <Button
625 key="footer-open"
626 label={f.text}
627 onPress={() =>
628 void $.ui.open({ id: PANE, title: 'Cost & cache' }).then(opened => {
629 isPanePlaced = opened.isPlaced
630 })
631 }
632 />
633 </Box>
634 )
635 })
636
637 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
638 const els = $.ui.resolve(e)
639 const { Box, Text, Button } = els
640 // The terminal draws the bars and sparklines as text even where the kit offers an Svg element.
641 const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
642 const [s, now] = await Promise.all([read($, panel), $.clock.now()])
643 const m = panelModel(s, now, ttl)
644 const p = priceOf(s.model)
645 const cols = e.props.bodyColumns
646
647 if (m === undefined || p === undefined) {
648 return (
649 <Box flexDirection="column" paddingX={1}>
650 <Text bold>Cost & cache</Text>
651 <Text dimColor>Send a message: the panel fills in after the first reply.</Text>
652 </Box>
653 )
654 }
655
656 const pf = await read($, prefs)
657 if (pf.isSettingsOpen) {
658 const f = footerLine(s, now, ttl, FOOTER_COLS, pf.fields)
659 return settingsView(els, pf, f?.text, {
660 toggleView: (id: ViewId) => void setPrefs($, p => ({ ...p, views: { ...p.views, [id]: !p.views[id] } })),
661 toggleField: (id: FieldId) => void setPrefs($, p => ({ ...p, fields: { ...p.fields, [id]: !p.fields[id] } })),
662 setPreset: name => void setPrefs($, p => ({ ...p, fields: { ...PRESETS[name]! } })),
663 close: () => void update($, prefs, p => ({ ...p, isSettingsOpen: false })),
664 })
665 }
666
667 const share = s.window > 0 ? s.tokens / s.window : 0
668 const fill = meter(share, Math.max(10, Math.min(30, cols - 30)))
669 const fillColor = share >= 0.8 ? 'error' : share >= 0.5 ? 'warning' : 'claude'
670 const rebuild = (s.tokens * writePrice(p, ttl)) / 1e6
671 const last = s.turns.at(-1)
672 const v = s.saved
673 const perTurn = (v.removed * p.read) / 1e6
674 const turnsLeft = payback(v, perTurn)
675 const total = lifetime(v)
676 const lv = level(total)
677 const progress = meter(lv.next === undefined ? 1 : (total - lv.at) / (lv.next.usd - lv.at), Math.max(10, Math.min(30, cols - 30)))
678
679 const actions = (hint: Hint) => ({
680 compact: () => void compactAndLog($, idle, ttl),
681 snooze: () =>
682 void $.clock.now().then(at =>
683 update($, panel, st => ({ ...st, snoozed: { ...liveSnoozes(st.snoozed, at), [hint.id]: at + SNOOZE_MS } })),
684 ),
685 dismiss: () => void update($, panel, st => ({ ...st, dismissed: [...st.dismissed, hint.id] })),
686 })
687
688 return (
689 <Box flexDirection="column" paddingX={1}>
690 <Box flexDirection="row" justifyContent="space-between">
691 <Text>
692 <Text bold>Cost & cache</Text>
693 <Text dimColor> {s.model} · {ttl} cache</Text>
694 </Text>
695 <Box flexDirection="row" gap={1}>
696 <Button key="settings-open" label="⚙ Settings" onPress={() => void update($, prefs, p => ({ ...p, isSettingsOpen: true }))} />
697 <Button key="compact-header" label="Compact" onPress={() => void compactAndLog($, idle, ttl)} />
698 </Box>
699 </Box>
700
701 {pf.views.tiles && tilesView(els, m.tiles, Svg)}
702 {pf.views.clock &&
703 clockView(els, m.clock, { tokens: kTokens(s.tokens), rebuild: usd(rebuild), auto: mode === 'auto' && judge !== undefined && !isSdk }, cols, Svg)}
704
705 {pf.views.context && (
706 <Text>
707 <Text dimColor>Context </Text>
708 <Text color={fillColor}>{fill.filled}</Text>
709 <Text dimColor>{fill.track}</Text>
710 <Text> {kTokens(s.tokens)} / {kTokens(s.window)} ({Math.round(share * 100)}%)</Text>
711 </Text>
712 )}
713
714 {pf.views.hints && (
715 <Box flexDirection="column" marginTop={1}>
716 <Text bold>Hints</Text>
717 {m.hints.length === 0 ? (
718 <Text>
719 <Text color="success">✓ Nothing to act on.</Text>
720 <Text dimColor> The cache is doing its job.</Text>
721 </Text>
722 ) : (
723 m.hints.map((hint, i) => hintView(els, hint, actions(hint), i === 0))
724 )}
725 </Box>
726 )}
727
728 {pf.views.saved && (
729 <Box flexDirection="column" marginTop={1}>
730 <Text bold>Saved by compacting</Text>
731 {v.compactions === 0 ? (
732 <Text dimColor>Nothing yet this session. Compact at a task boundary to start saving.</Text>
733 ) : (
734 <Text>
735 <Text bold color={net(v) >= 0 ? 'success' : 'warning'}>{signed(net(v))}</Text>
736 <Text> this session</Text>
737 <Text dimColor>
738 {' '}· {v.compactions} compaction{v.compactions === 1 ? '' : 's'} · {kTokens(v.removed)} less to re-read, ~{usd(perTurn)} per turn
739 </Text>
740 </Text>
741 )}
742 {turnsLeft > 0 && <Text dimColor> The compaction pays for itself in {turnsLeft} more turn{turnsLeft === 1 ? '' : 's'}.</Text>}
743 <Text>
744 <Text color="claude">★ Level {lv.rank}: {lv.name}</Text>
745 <Text dimColor> all time {signed(total)}</Text>
746 </Text>
747 {lv.next !== undefined && (
748 <Text>
749 <Text dimColor>{' '}</Text>
750 <Text color="claude">{progress.filled}</Text>
751 <Text dimColor>{progress.track}</Text>
752 <Text dimColor> {usd(lv.next.usd - total)} to {lv.next.name}</Text>
753 </Text>
754 )}
755 </Box>
756 )}
757
758 {pf.views.chart && s.turns.length > 0 && (
759 <Box flexDirection="column" marginTop={1}>
760 <Text bold>Cost per turn</Text>
761 {Svg !== undefined ? (
762 <Svg source={costChart(s.turns)} alt={chartAlt(s.turns)} isInteractive />
763 ) : (
764 <Text color="claude">{sparkline(s.turns, Math.max(10, cols - 4))}</Text>
765 )}
766 {last !== undefined && (
767 <Text dimColor wrap="wrap">
768 Last turn: {SERIES.map(x => `${x.label.toLowerCase()} ${usd(last[x.key])}`).join(' · ')}
769 </Text>
770 )}
771 </Box>
772 )}
773 </Box>
774 )
775 })
776}
777hooks/chart.ts 134 lines1import type { TurnRow } from '../types'
2import { kTokens, usd } from './pricing'
3
4// Categorical slots 1-4 of the reference palette, validated as adjacent stack pairs in both
5// modes. Light aqua and yellow sit under 3:1 on the surface, so the panel adds a text
6// breakdown of the last turn beside the chart (the relief rule).
7export const SERIES = [
8 { key: 'read', label: 'Cache read', light: '#2a78d6', dark: '#3987e5' },
9 { key: 'write', label: 'Cache write', light: '#eb6834', dark: '#d95926' },
10 { key: 'output', label: 'Output', light: '#1baf7a', dark: '#199e70' },
11 { key: 'input', label: 'Uncached input', light: '#eda100', dark: '#c98500' },
12] as const
13
14const SLOTS = 30
15const W = 560
16const H = 180
17const M = { top: 30, right: 8, bottom: 20, left: 44 }
18const GAP = 2
19
20const total = (t: TurnRow) => t.read + t.write + t.output + t.input
21
22const niceMax = (n: number) => {
23 if (n <= 0) return 0.01
24 const step = 10 ** Math.floor(Math.log10(n))
25 return [1, 2, 2.5, 5, 10].map(m => m * step).find(v => v >= n) ?? n
26}
27
28const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
29
30// A column segment whose top corners are rounded (the data end), square at the bottom.
31const topRounded = (x: number, y: number, w: number, h: number, cls: string) => {
32 const r = Math.min(4, h, w / 2)
33 return `<path class="${cls}" d="M${x},${y + h}V${y + r}Q${x},${y} ${x + r},${y}H${x + w - r}Q${x + w},${y} ${x + w},${y + r}V${y + h}Z"/>`
34}
35
36/** Stacked cost per turn, newest on the right, as one SVG document. */
37export const costChart = (turns: readonly TurnRow[]) => {
38 const shown = turns.slice(-SLOTS)
39 const max = niceMax(Math.max(...shown.map(total)))
40 const plotW = W - M.left - M.right
41 const plotH = H - M.top - M.bottom
42 const slot = plotW / SLOTS
43 const barW = Math.min(24, Math.max(4, slot * 0.6))
44 const base = M.top + plotH
45 const y = (v: number) => (v / max) * plotH
46
47 const grid = [0, max / 2, max]
48 .map(v => {
49 const gy = base - y(v)
50 return `<line class="${v === 0 ? 'b' : 'g'}" x1="${M.left}" x2="${W - M.right}" y1="${gy}" y2="${gy}"/>` +
51 `<text class="mu num" x="${M.left - 6}" y="${gy + 4}" text-anchor="end">${v === 0 ? '$0' : usd(v)}</text>`
52 })
53 .join('')
54
55 const bars = shown
56 .map((t, i) => {
57 const x = M.left + (SLOTS - shown.length + i) * slot + (slot - barW) / 2
58 const parts = SERIES.map((s, k) => ({ cls: `c${k + 1}`, h: y(t[s.key]) })).filter(p => p.h >= 0.5)
59 let top = base
60 const marks = parts
61 .map((p, k) => {
62 const h = k === 0 ? p.h : p.h - GAP
63 top -= p.h
64 if (h < 0.5) return ''
65 const yTop = top
66 return k === parts.length - 1
67 ? topRounded(x, yTop, barW, h, p.cls)
68 : `<rect class="${p.cls}" x="${x}" y="${yTop}" width="${barW}" height="${h}"/>`
69 })
70 .join('')
71 const tip = [
72 `Turn ${turns.length - shown.length + i + 1}: ${usd(total(t))}, ${kTokens(t.ctx)} context`,
73 ...SERIES.map(s => `${s.label}: ${usd(t[s.key])}`),
74 ].join('\n')
75 const hitX = M.left + (SLOTS - shown.length + i) * slot
76 return `<g class="bar"><title>${esc(tip)}</title><rect class="hit" x="${hitX}" y="${M.top}" width="${slot}" height="${plotH}"/>${marks}</g>`
77 })
78 .join('')
79
80 // One direct label, on the most expensive turn: the story is usually a cache rebuild.
81 const peakIndex = shown.reduce((best, t, i) => (total(t) > total(shown[best]!) ? i : best), 0)
82 const peak = shown[peakIndex]
83 const peakLabel = peak === undefined ? '' : (() => {
84 const cx = M.left + (SLOTS - shown.length + peakIndex) * slot + slot / 2
85 const what = peak.write > total(peak) / 2 ? ' cache rebuild' : ''
86 const anchor = cx > W - 90 ? 'end' : cx < M.left + 60 ? 'start' : 'middle'
87 return `<text class="t2 num" x="${cx}" y="${base - y(total(peak)) - 6}" text-anchor="${anchor}">${usd(total(peak))}${what}</text>`
88 })()
89
90 let lx = M.left
91 const legend = SERIES.map((s, k) => {
92 const item = `<rect class="c${k + 1}" x="${lx}" y="8" width="10" height="10" rx="2"/><text class="t2" x="${lx + 14}" y="17">${s.label}</text>`
93 lx += 14 + s.label.length * 5.9 + 18
94 return item
95 }).join('')
96
97 const light = SERIES.map((s, k) => `.c${k + 1}{fill:${s.light}}`).join('')
98 const dark = SERIES.map((s, k) => `.c${k + 1}{fill:${s.dark}}`).join('')
99
100 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
101 `<style>text{font:11px system-ui,-apple-system,"Segoe UI",sans-serif}.num{font-variant-numeric:tabular-nums}` +
102 `.s{fill:#fcfcfb}.t2{fill:#52514e}.mu{fill:#898781}.g{stroke:#e1e0d9;stroke-width:1}.b{stroke:#c3c2b7;stroke-width:1}` +
103 `.hit{fill:transparent}g.bar:hover .hit{fill:rgba(137,135,129,.14)}${light}` +
104 `@media (prefers-color-scheme: dark){.s{fill:#1a1a19}.t2{fill:#c3c2b7}.g{stroke:#2c2c2a}.b{stroke:#383835}${dark}}</style>` +
105 `<rect class="s" width="${W}" height="${H}" rx="6"/>${legend}${grid}${bars}${peakLabel}` +
106 `<text class="mu" x="${W - M.right}" y="${H - 5}" text-anchor="end">last ${shown.length} turn${shown.length === 1 ? '' : 's'}, newest right</text>` +
107 `</svg>`
108}
109
110export const chartAlt = (turns: readonly TurnRow[]) => {
111 const shown = turns.slice(-SLOTS)
112 const sum = shown.reduce((n, t) => n + total(t), 0)
113 const peak = shown.reduce((a, t) => (total(t) > total(a) ? t : a), shown[0] ?? turns[0]!)
114 return `Cost per turn for the last ${shown.length} turns: ${usd(sum)} in all, the most expensive ${usd(total(peak))}.`
115}
116
117const BLOCKS = '▁▂▃▄▅▆▇█'
118
119/** One block per value, tallest at the series' own maximum. */
120export const blocks = (series: readonly number[], width: number) => {
121 const shown = series.slice(-Math.max(1, width))
122 const max = Math.max(...shown, 1e-9)
123 return shown.map(v => BLOCKS[Math.min(7, Math.round((v / max) * 7))]).join('')
124}
125
126/** The terminal's stand-in for the chart: one block per turn, height by cost. */
127export const sparkline = (turns: readonly TurnRow[], width: number) => blocks(turns.map(total), width)
128
129/** A text meter: filled cells for the share used, light cells for the rest. */
130export const meter = (share: number, cells: number) => {
131 const filled = Math.round(Math.min(1, Math.max(0, share)) * cells)
132 return { filled: '█'.repeat(filled), track: '░'.repeat(cells - filled) }
133}
134hooks/footer.ts 44 lines1import type { PanelState, Prefs } from '../types'
2import { panelModel } from './model'
3import { DEFAULT_PREFS } from './settings'
4
5/** Context share at which the footer starts to show it, the same point the pane's bar turns red. */
6const CTX_WARN = 0.8
7
8export type Footer = { text: string; color: 'success' | 'warning' | 'error' | undefined }
9
10const short = (ms: number) => (ms < 60_000 ? '<1m' : `${Math.floor(ms / 60_000)}m`)
11
12/**
13 * The one-line footer: the fields `fields` turns on, in order, dropping from the right until the line fits
14 * `width`. Undefined while there is nothing priced to show, or no field is on.
15 */
16export const footerLine = (
17 s: PanelState,
18 now: number,
19 ttl: string,
20 width = Infinity,
21 fields: Prefs['fields'] = DEFAULT_PREFS.fields,
22): Footer | undefined => {
23 const m = panelModel(s, now, ttl)
24 if (m === undefined) return undefined
25 const rebuild = m.tiles.find(t => t.id === 'rebuild')!.value
26 const { zone, remainingMs } = m.clock
27 const tips = m.hints.length
28 const share = s.window > 0 ? s.tokens / s.window : 0
29
30 const head = zone === 'unknown' ? '○ cache ?' : zone === 'cold' ? '○ cold' : `● warm ${short(remainingMs)}`
31 const color = zone === 'cold' || zone === 'window' ? 'warning' : zone === 'warm' ? 'success' : undefined
32 const parts = [
33 fields.cache ? head : '',
34 fields.rebuild ? (zone === 'cold' ? `next msg re-writes ${rebuild}` : zone === 'unknown' ? '' : `${rebuild} if cold`) : '',
35 fields.tips && tips > 0 ? `▲ ${tips} tip${tips === 1 ? '' : 's'}` : '',
36 fields.ctx && share >= CTX_WARN ? `ctx ${Math.round(share * 100)}%` : '',
37 ].filter(p => p !== '')
38 if (parts.length === 0) return undefined
39
40 let line = parts
41 while (line.length > 1 && line.join(' · ').length > width) line = line.slice(0, -1)
42 return { text: line.join(' · '), color: share >= CTX_WARN ? 'error' : color }
43}
44hooks/hints.ts 143 lines1import type { PanelState } from '../types'
2import { cheaperModel, kTokens, priceOf, usd, writePrice } from './pricing'
3import type { Price } from './pricing'
4
5export type Level = 'critical' | 'warning' | 'info'
6
7export type Hint = {
8 id: string
9 level: Level
10 title: string
11 detail: string
12 /** Estimated USD at stake, used to rank the hints. */
13 stake: number
14 action?: 'compact'
15}
16
17export type CacheState = { isWarm: boolean; remainingMs: number }
18
19// A handoff summary is a few thousand output tokens.
20const SUMMARY_TOKENS = 3000
21// How many more turns a session is assumed to run, for "per turn" stakes.
22const HORIZON = 10
23
24/** How long "Snooze" hides a hint. */
25export const SNOOZE_MS = 10 * 60_000
26
27/** What a handoff compaction costs now: the summarizer reads the context from cache and writes a short summary. */
28export const compactCost = (tokens: number, p: Price) => (tokens * p.read) / 1e6 + (SUMMARY_TOKENS * p.output) / 1e6
29
30export const cacheState = (s: PanelState, now: number, ttlMs: number): CacheState | undefined => {
31 if (s.lastActivityAt === undefined) return undefined
32 const remainingMs = s.lastActivityAt + ttlMs - now
33 return { isWarm: remainingMs > 0, remainingMs: Math.max(0, remainingMs) }
34}
35
36export const hitRatio = (s: PanelState) => {
37 const read = s.turns.reduce((n, t) => n + t.readTokens, 0)
38 const write = s.turns.reduce((n, t) => n + t.writeTokens, 0)
39 return read + write === 0 ? undefined : read / (read + write)
40}
41
42export const minutes = (ms: number) => (ms < 60_000 ? '<1 min' : `${Math.floor(ms / 60_000)} min`)
43
44/** Every hint that applies now, most money at stake first, dismissed ones left out. */
45export const hints = (s: PanelState, now: number, ttl: string): Hint[] => {
46 const p = priceOf(s.model)
47 if (p === undefined || s.tokens === 0) return []
48 const ttlMs = ttl === '5m' ? 5 * 60_000 : 60 * 60_000
49 const ctx = kTokens(s.tokens)
50 const carry = (s.tokens * p.read) / 1e6
51 const rebuild = (s.tokens * writePrice(p, ttl)) / 1e6
52 const compactNow = compactCost(s.tokens, p)
53 const cache = cacheState(s, now, ttlMs)
54 const out: Hint[] = []
55
56 if (s.boundary !== undefined && s.tokens >= 50_000) {
57 const turns = Math.max(1, Math.ceil(compactNow / carry))
58 out.push({
59 id: 'boundary',
60 level: 'warning',
61 title: `Task finished (${s.boundary}): compact while the cache is warm`,
62 detail: `Carrying ${ctx} costs ${usd(carry)} per turn. A handoff compaction costs about ${usd(compactNow)} now and pays for itself in ${turns} turn${turns === 1 ? '' : 's'}.`,
63 stake: carry * HORIZON,
64 action: 'compact',
65 })
66 }
67
68 if (cache?.isWarm && cache.remainingMs < 10 * 60_000 && s.tokens >= 100_000) {
69 out.push({
70 id: 'expiring',
71 level: 'critical',
72 title: `Cache expires in ${minutes(cache.remainingMs)}`,
73 detail: `After that, your next message re-writes ${ctx} (${usd(rebuild)}). Reply now, compact (about ${usd(compactNow)}), or /clear before you step away.`,
74 stake: rebuild,
75 action: 'compact',
76 })
77 }
78
79 if (cache !== undefined && !cache.isWarm && s.tokens >= 100_000) {
80 out.push({
81 id: 'cold',
82 level: 'warning',
83 title: 'Cache is cold',
84 detail: `Your next message re-writes ${ctx} (${usd(rebuild)}). Starting something new? /clear and restate the task instead.`,
85 stake: rebuild,
86 })
87 }
88
89 const [prev, last] = s.turns.slice(-2)
90 if (last !== undefined && last.ctx >= 50_000 && last.writeTokens >= 0.6 * last.ctx) {
91 const wasIdle = prev === undefined || last.at - prev.at >= ttlMs
92 const switched = prev !== undefined && prev.model !== last.model
93 if (!wasIdle) {
94 out.push({
95 id: `rebuild-${last.at}`,
96 level: 'warning',
97 title: `Unexpected cache rebuild: ${usd(last.write)}`,
98 detail: switched
99 ? `The model changed (${prev?.model} → ${last.model}), and each model has its own cache. Pick the model at the start of a session.`
100 : 'The cache was warm, but the prefix was re-written. Usual causes: an effort or fast-mode change, or an MCP server or plugin change mid-session.',
101 stake: last.write,
102 })
103 }
104 }
105
106 if (s.tokens >= 300_000) {
107 out.push({
108 id: 'large',
109 level: 'warning',
110 title: `Large context: ${ctx} is re-read on every turn`,
111 detail: `That's ${usd(carry)} per turn before Claude writes anything. /compact at the next break, or /clear between tasks.`,
112 stake: carry * HORIZON,
113 action: 'compact',
114 })
115 }
116
117 const cheaper = cheaperModel(s.model)
118 const cheaperPrice = cheaper === undefined ? undefined : priceOf(cheaper)
119 if (cheaper !== undefined && cheaperPrice !== undefined && cheaperPrice.read < p.read) {
120 const saved = Math.round((1 - cheaperPrice.read / p.read) * 100)
121 out.push({
122 id: 'model',
123 level: 'info',
124 title: `${cheaper} reads the cache ${saved}% cheaper`,
125 detail: `Start your next session on ${cheaper}. Switching mid-session re-writes ${ctx} (${usd(rebuild)}), so switch at a /clear, not now.`,
126 stake: ((s.tokens * (p.read - cheaperPrice.read)) / 1e6) * HORIZON,
127 })
128 }
129
130 if (s.bigOutput !== undefined && s.bigOutput.tokens >= 8_000) {
131 const perTurn = (s.bigOutput.tokens * p.read) / 1e6
132 out.push({
133 id: 'output',
134 level: 'info',
135 title: `${s.bigOutput.tool} output added about ${kTokens(s.bigOutput.tokens)} tokens`,
136 detail: `It stays in context and is re-read on every turn (${usd(perTurn)} per turn). Pipe long output through tail or grep, or ask for a summary.`,
137 stake: perTurn * HORIZON,
138 })
139 }
140
141 return out.filter(h => !s.dismissed.includes(h.id) && (s.snoozed?.[h.id] ?? 0) <= now).sort((a, b) => b.stake - a.stake)
142}
143hooks/judge.ts 146 lines1import type { SessionMessage } from 'claude-code'
2
3import type { Digest, Rating } from '../types'
4
5export const SYSTEM = `You label moments in a developer's session with an AI coding agent.
6You see what the session has been about and the developer's NEW prompt.
7Decide whether this is a NATURAL BREAK: the previous piece of work is finished, and the new
8prompt could be handled well if the earlier conversation were replaced by a short handoff
9summary (what was done, decisions, open items, file paths). Answer false when the new prompt
10depends on exact details from the recent conversation (error output, a specific line just
11discussed, an answer it is replying to, an in-progress debugging thread).
12Judge only from what is shown. Reply with one JSON object only:
13{"break": true|false, "confidence": <0.0-1.0>}`
14
15export type Judge = 'sonnet' | 'haiku'
16
17export const JUDGES: Record<Judge, { model: string; priceAs: string; confidence: number }> = {
18 // Sonnet starts at confidence 0.5; Haiku at 0.8.
19 sonnet: { model: 'sonnet', priceAs: 'claude-sonnet-5-5', confidence: 0.5 },
20 haiku: { model: 'haiku', priceAs: 'claude-haiku-5-5', confidence: 0.8 },
21}
22
23// Learning: each 👎 in the last RECENT ratings raises the bar, each 👍 takes some of that back.
24const RECENT = 20
25const BAD_RAISES = 0.05
26const GOOD_LOWERS = 0.02
27const CEILING = 0.9
28const EXAMPLES_EACH = 3
29
30const TAG = /<(ide_[a-z_]+|system-reminder)>[\s\S]*?<\/\1>/g
31const SUMMARY = /^This session is being continued from a previous conversation/
32const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
33
34const clean = (text: string) => text.replace(TAG, '').trim()
35
36/** The digest of the session at a new prompt, or undefined before the session's second prompt. */
37export const digest = (messages: readonly SessionMessage[], newPrompt: string): Digest | undefined => {
38 const prompts: string[] = []
39 let taskStart: string | undefined
40 let files: string[] = []
41 let lastAnswer = ''
42 for (const m of messages) {
43 if (m.role === 'assistant') {
44 for (const t of m.toolUses) {
45 const path = t.input.file_path ?? t.input.notebook_path ?? t.input.path
46 if (EDITS.has(t.tool) && typeof path === 'string') files.push(path.split('/').at(-1) ?? path)
47 }
48 if (m.text.trim() !== '') lastAnswer = m.text.trim()
49 continue
50 }
51 if (m.toolResults?.length) continue
52 const text = clean(m.text)
53 // Command rows and notices are tagged; a compaction's summary starts the task over.
54 if (text === '' || text.startsWith('<')) continue
55 if (SUMMARY.test(text)) {
56 taskStart = undefined
57 continue
58 }
59 prompts.push(text)
60 taskStart ??= text
61 files = []
62 lastAnswer = ''
63 }
64 if (prompts.length === 0) return undefined
65 return {
66 taskStart: (taskStart ?? prompts[0]!).slice(0, 400),
67 previous: prompts.slice(-2).map(p => p.slice(0, 300)),
68 files: [...new Set(files)].slice(-8),
69 lastAnswer: lastAnswer.slice(0, 300),
70 newPrompt: clean(newPrompt).slice(0, 500),
71 }
72}
73
74// Words for work stopped on purpose: a paused migration, a rollout waiting on someone.
75const HOLD = /\b(paus(e|ed|es|ing)|on hold|hold off|halt(ed)?|blocked (on|by)|waiting (for|on)|wait (for|until)|(don'?t|do not) (continue|proceed|resume|run)|resume (once|when|after|later)|stopp?(ed)? (here|before|partway|halfway))\b/i
76const HOLD_PROMPTS = 3
77
78/**
79 * Whether the last few prompts, or the answers since, say work is paused or blocked. A
80 * summary could drop why it stopped, so a compaction there is the person's call.
81 */
82export const isOnHold = (messages: readonly SessionMessage[]) => {
83 let prompts = 0
84 for (let i = messages.length - 1; i >= 0 && prompts < HOLD_PROMPTS; i--) {
85 const m = messages[i]!
86 if (m.role === 'user' && m.toolResults?.length) continue
87 const text = clean(m.text)
88 if (m.role === 'user') {
89 if (text === '' || text.startsWith('<')) continue
90 if (SUMMARY.test(text)) break
91 prompts++
92 }
93 if (HOLD.test(text)) return true
94 }
95 return false
96}
97
98const itemText = (d: Digest) =>
99 [
100 `Session started with: ${d.taskStart}`,
101 'Recent prompts:',
102 ...d.previous.map(p => `- ${p}`),
103 `Files edited since the last prompt: ${d.files.join(', ') || 'none'}`,
104 `Agent's last answer began: ${d.lastAnswer}`,
105 `NEW prompt: ${d.newPrompt}`,
106 ].join('\n')
107
108/** The newest ratings of each kind, oldest first: what the judge learns this developer's breaks from. */
109export const examples = (ratings: readonly Rating[]) => {
110 const newest = [...ratings].sort((a, b) => b.at - a.at)
111 const pick = (isGood: boolean) => newest.filter(r => r.isGood === isGood).slice(0, EXAMPLES_EACH)
112 return [...pick(true), ...pick(false)].sort((a, b) => a.at - b.at)
113}
114
115export const prompt = (d: Digest, rated: readonly Rating[]) => {
116 const past = rated.map(r =>
117 `<rated verdict="${r.isGood ? 'right: a natural break' : 'wrong: the work was still going'}">\n${itemText(r.digest)}\n</rated>`,
118 )
119 const lead = past.length === 0
120 ? []
121 : ['This developer rated earlier moments where a compaction ran. Learn from them where their work really ends:', ...past, '']
122 return [...lead, `<item>\n${itemText(d)}\n</item>`].join('\n')
123}
124
125/** The judge's reply as a verdict, or undefined when it is not the JSON asked for. */
126export const verdict = (text: string): { isBreak: boolean; confidence: number } | undefined => {
127 const json = /\{[\s\S]*?\}/.exec(text)?.[0]
128 if (json === undefined) return undefined
129 try {
130 const v = JSON.parse(json) as { break?: unknown; confidence?: unknown }
131 if (typeof v.break !== 'boolean' || typeof v.confidence !== 'number') return undefined
132 return { isBreak: v.break, confidence: v.confidence }
133 } catch {
134 return undefined
135 }
136}
137
138/** The confidence a break needs, from where the judge starts and how its compactions were rated. */
139export const threshold = (start: number, ratings: readonly Rating[]) => {
140 const recent = [...ratings].sort((a, b) => b.at - a.at).slice(0, RECENT)
141 const bad = recent.filter(r => !r.isGood).length
142 const good = recent.length - bad
143 const t = Math.min(CEILING, start + Math.max(0, BAD_RAISES * bad - GOOD_LOWERS * good))
144 return Math.round(t * 100) / 100
145}
146hooks/model.ts 72 lines1import type { PanelState, TurnRow } from '../types'
2import { cacheState, compactCost, hints, hitRatio } from './hints'
3import type { Hint } from './hints'
4import { priceOf, usd, writePrice } from './pricing'
5
6export const ttlMsOf = (ttl: string) => (ttl === '5m' ? 5 * 60_000 : 60 * 60_000)
7
8/** The last stretch of the cache's life, when the plugin compacts if no prompt came: 5 minutes, or a fifth of the 5-minute TTL. */
9export const windowMsOf = (ttl: string) => Math.min(5 * 60_000, ttlMsOf(ttl) / 5)
10
11export type Tile = { id: 'session' | 'hit' | 'carry' | 'rebuild'; label: string; value: string; series: number[] }
12export type Zone = 'unknown' | 'warm' | 'window' | 'cold'
13export type Clock = {
14 zone: Zone
15 remainingMs: number
16 ttlMs: number
17 windowMs: number
18 /** Set while the pre-expiry compaction is armed. */
19 compactInMs?: number
20 /** What that compaction saves against the cache rebuild, in USD. */
21 saves?: number
22}
23export type PanelModel = { tiles: Tile[]; clock: Clock; hints: Hint[] }
24
25const cost = (t: TurnRow) => t.read + t.write + t.output + t.input
26
27const running = (turns: readonly TurnRow[]) => {
28 let sum = 0
29 return turns.map(t => (sum += cost(t)))
30}
31
32const hitOf = (t: TurnRow) => t.readTokens / (t.readTokens + t.writeTokens)
33
34/** What the panel shows, or undefined while there is nothing priced to show. */
35export const panelModel = (s: PanelState, now: number, ttl: string): PanelModel | undefined => {
36 const p = priceOf(s.model)
37 if (p === undefined || s.tokens === 0) return undefined
38 const ttlMs = ttlMsOf(ttl)
39 const windowMs = windowMsOf(ttl)
40 const carry = (s.tokens * p.read) / 1e6
41 const rebuild = (s.tokens * writePrice(p, ttl)) / 1e6
42 const ratio = hitRatio(s)
43 const priceFor = (t: TurnRow) => priceOf(t.model) ?? p
44 const cache = cacheState(s, now, ttlMs)
45 const zone: Zone = cache === undefined ? 'unknown' : !cache.isWarm ? 'cold' : cache.remainingMs <= windowMs ? 'window' : 'warm'
46 return {
47 tiles: [
48 { id: 'session', label: 'Session cost', value: usd(s.sessionUsd), series: running(s.turns) },
49 { id: 'hit', label: 'Cache hit', value: ratio === undefined ? '–' : `${(ratio * 100).toFixed(1)}%`, series: s.turns.filter(t => t.readTokens + t.writeTokens > 0).map(hitOf) },
50 { id: 'carry', label: 'Re-read per turn', value: usd(carry), series: s.turns.map(t => (t.ctx * priceFor(t).read) / 1e6) },
51 { id: 'rebuild', label: 'Rebuild if cold', value: usd(rebuild), series: s.turns.map(t => (t.ctx * writePrice(priceFor(t), ttl)) / 1e6) },
52 ],
53 clock: {
54 zone,
55 remainingMs: cache?.remainingMs ?? 0,
56 ttlMs,
57 windowMs,
58 ...(s.compactAt === undefined
59 ? {}
60 : { compactInMs: Math.max(0, s.compactAt - now), saves: Math.max(0, rebuild - compactCost(s.tokens, p)) }),
61 },
62 hints: hints(s, now, ttl).slice(0, 4),
63 }
64}
65
66/** The bar left to right: the compact window, the rest of the warm time, then what has drained. Shares of the TTL. */
67export const clockShares = (c: Clock) => {
68 const filled = Math.min(1, c.remainingMs / c.ttlMs)
69 const window = Math.min(filled, c.windowMs / c.ttlMs)
70 return { window, warm: filled - window, track: 1 - filled }
71}
72hooks/panel-view.tsx 116 lines1import type { ElementTable } from 'claude-code'
2
3import { blocks } from './chart'
4import { minutes } from './hints'
5import type { Hint } from './hints'
6import { clockShares } from './model'
7import type { Clock, Tile } from './model'
8import { usd } from './pricing'
9import { clockAlt, clockSvg, sparkAlt, sparkSvg } from './svg'
10
11export const LEVEL = {
12 critical: { icon: '▲', color: 'error', word: 'Act now' },
13 warning: { icon: '◆', color: 'warning', word: 'Worth it' },
14 info: { icon: '●', color: 'suggestion', word: 'Tip' },
15} as const
16
17/** Bordered stat tiles that wrap with the pane's width; a sparkline under each value once there are two turns. */
18export const tilesView = (els: ElementTable, tiles: readonly Tile[], Svg: ElementTable['Svg'] | undefined) => {
19 const { Box, Text } = els
20 const spark = (t: Tile) =>
21 t.series.length < 2 ? (
22 <Text dimColor>–</Text>
23 ) : Svg !== undefined ? (
24 <Svg source={sparkSvg(t.series)} alt={sparkAlt(t)} />
25 ) : (
26 <Text color="claude">{blocks(t.series, 14)}</Text>
27 )
28 return (
29 <Box flexDirection="row" flexWrap="wrap" gap={1} marginTop={1}>
30 {tiles.map(t => (
31 <Box key={`tile-${t.id}`} flexDirection="column" borderStyle="round" paddingX={1}>
32 <Text dimColor>{t.label}</Text>
33 <Text bold>{t.value}</Text>
34 {spark(t)}
35 </Box>
36 ))}
37 </Box>
38 )
39}
40
41export type ClockText = { tokens: string; rebuild: string; auto: boolean }
42
43/** The cache clock: state line, the draining bar by zone, a legend, and the armed idle compaction when there is one. */
44export const clockView = (els: ElementTable, c: Clock, t: ClockText, cols: number, Svg: ElementTable['Svg'] | undefined) => {
45 const { Box, Text } = els
46 const sh = clockShares(c)
47 const cells = Math.max(10, Math.min(40, cols - 24))
48 const win = Math.round(sh.window * cells)
49 const warm = Math.round((sh.window + sh.warm) * cells) - win
50 const status =
51 c.zone === 'unknown' ? (
52 <Text dimColor>○ Cache state unknown until the next reply</Text>
53 ) : c.zone === 'cold' ? (
54 <Text>
55 <Text color="warning">○ Cold</Text>
56 <Text> the next message re-writes {t.tokens}</Text>
57 <Text dimColor> · {t.rebuild}</Text>
58 </Text>
59 ) : (
60 <Text>
61 <Text color={c.zone === 'window' ? 'warning' : 'success'}>● Warm</Text>
62 <Text> expires in {minutes(c.remainingMs)}</Text>
63 <Text dimColor> · rebuilding later costs {t.rebuild}</Text>
64 </Text>
65 )
66 const next =
67 c.compactInMs === undefined
68 ? undefined
69 : `Next: auto-compact in ${minutes(c.compactInMs)}${c.saves !== undefined && c.saves > 0 ? ` (saves ≈ ${usd(c.saves)})` : ''}`
70 return (
71 <Box flexDirection="column" marginTop={1}>
72 {status}
73 {c.zone !== 'unknown' &&
74 (Svg !== undefined ? (
75 <Svg source={clockSvg(c)} alt={clockAlt(c)} height={36} isInteractive />
76 ) : (
77 <Text>
78 <Text color="warning">{'█'.repeat(win)}</Text>
79 <Text color="success">{'█'.repeat(warm)}</Text>
80 <Text dimColor>{'░'.repeat(cells - win - warm)}</Text>
81 </Text>
82 ))}
83 <Text dimColor>
84 <Text color="success">█</Text> warm <Text color="warning">█</Text> last {minutes(c.windowMs)} before expiry{t.auto ? ': auto-compact' : ''} <Text dimColor>░</Text> cold
85 </Text>
86 {next !== undefined && <Text>{next}</Text>}
87 </Box>
88 )
89}
90
91export type HintActions = { compact: () => void; snooze: () => void; dismiss: () => void }
92
93/** One hint with its three actions. Only the top hint takes the hotkeys 1, 2, 3: two buttons on a hotkey, the later wins. */
94export const hintView = (els: ElementTable, hint: Hint, act: HintActions, hasKeys: boolean) => {
95 const { Box, Text, Button } = els
96 const lv = LEVEL[hint.level]
97 const key = (digit: string) => (hasKeys ? { hotkey: digit } : {})
98 return (
99 <Box key={`hint-${hint.id}`} flexDirection="column" marginTop={1}>
100 <Text>
101 <Text color={lv.color}>{lv.icon} {lv.word}</Text>
102 <Text bold> {hint.title}</Text>
103 <Text dimColor> ~{usd(hint.stake)} at stake</Text>
104 </Text>
105 <Text dimColor wrap="wrap"> {hint.detail}</Text>
106 <Box flexDirection="row" marginLeft={2} gap={1}>
107 {hint.action === 'compact' && (
108 <Button key={`compact-${hint.id}`} variant="primary" label="Compact now" {...key('1')} onPress={act.compact} />
109 )}
110 <Button key={`snooze-${hint.id}`} label="Snooze 10 min" {...key('2')} onPress={act.snooze} />
111 <Button key={`dismiss-${hint.id}`} label="Not this session" {...key('3')} onPress={act.dismiss} />
112 </Box>
113 </Box>
114 )
115}
116hooks/pricing.ts 45 lines1// List prices, USD per million tokens (platform.claude.com/docs/en/about-claude/pricing).
2// Matched in order, so the 5.5 rows sit before the family fallbacks.
3export type Price = { input: number; write5m: number; write1h: number; read: number; output: number }
4
5const PRICES: readonly (readonly [RegExp, Price])[] = [
6 [/opus-5-5/, { input: 4, write5m: 5, write1h: 8, read: 0.2, output: 20 }],
7 [/opus/, { input: 5, write5m: 6.25, write1h: 10, read: 0.5, output: 25 }],
8 [/sonnet-5-5/, { input: 2, write5m: 2.5, write1h: 4, read: 0.1, output: 10 }],
9 [/sonnet/, { input: 2, write5m: 2.5, write1h: 4, read: 0.2, output: 10 }],
10 [/haiku/, { input: 0.1, write5m: 0.125, write1h: 0.2, read: 0.01, output: 0.5 }],
11 [/fable/, { input: 10, write5m: 12.5, write1h: 20, read: 0.25, output: 50 }],
12]
13
14// The model whose cache reads cost less, for the "move to 5.5" hint.
15const CHEAPER: readonly (readonly [RegExp, string])[] = [
16 [/opus-5(?!-5)|opus-4/, 'claude-opus-5-5'],
17 [/sonnet-5(?!-5)|sonnet-4/, 'claude-sonnet-5-5'],
18]
19
20export const priceOf = (model: string): Price | undefined =>
21 PRICES.find(([re]) => re.test(model))?.[1]
22
23export const cheaperModel = (model: string): string | undefined =>
24 CHEAPER.find(([re]) => re.test(model))?.[1]
25
26export const writePrice = (p: Price, ttl: string) => (ttl === '5m' ? p.write5m : p.write1h)
27
28export type Split = { read: number; write: number; output: number; input: number }
29
30/** One turn's cost in USD, split the way the chart stacks it. */
31export const costSplit = (
32 usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number },
33 p: Price,
34 ttl: string,
35): Split => ({
36 read: (usage.cache_read_input_tokens * p.read) / 1e6,
37 write: (usage.cache_creation_input_tokens * writePrice(p, ttl)) / 1e6,
38 output: (usage.output_tokens * p.output) / 1e6,
39 input: (usage.input_tokens * p.input) / 1e6,
40})
41
42export const usd = (n: number) => (n >= 10 ? `$${n.toFixed(1)}` : n >= 0.1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`)
43
44export const kTokens = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1000)}K`)
45hooks/report.ts 56 lines1import type { PanelState } from '../types'
2import { sparkline } from './chart'
3import { cacheState, hints, hitRatio, minutes } from './hints'
4import { kTokens, priceOf, usd, writePrice } from './pricing'
5import { level, lifetime, net, signed } from './savings'
6
7const ICON = { critical: '▲', warning: '◆', info: '●' } as const
8
9/**
10 * The panel as plain text, for surfaces that draw no plugin pane:
11 * `/cost-panel` answers with it, so it shows in the transcript. The model reads it too, so it
12 * stays short.
13 */
14export const report = (s: PanelState, now: number, ttl: string, compactCommand: string) => {
15 const p = priceOf(s.model)
16 if (p === undefined || s.tokens === 0) return 'Cost & cache: send a message first; the report fills in after the first reply.'
17
18 const cache = cacheState(s, now, ttl === '5m' ? 5 * 60_000 : 60 * 60_000)
19 const rebuild = usd((s.tokens * writePrice(p, ttl)) / 1e6)
20 const ratio = hitRatio(s)
21 const share = s.window > 0 ? Math.round((s.tokens / s.window) * 100) : 0
22 const recent = s.turns.slice(-20)
23 const peak = recent.reduce<(typeof recent)[number] | undefined>(
24 (a, t) => (a === undefined || t.read + t.write + t.output + t.input > a.read + a.write + a.output + a.input ? t : a),
25 undefined,
26 )
27
28 const lines = [
29 `Cost & cache · ${s.model} · ${ttl} cache`,
30 cache === undefined
31 ? '○ Cache state unknown until the next reply'
32 : cache.isWarm
33 ? `● Warm, expires in ${minutes(cache.remainingMs)} (rebuilding later costs ${rebuild})`
34 : `○ Cold: the next message re-writes ${kTokens(s.tokens)} (${rebuild})`,
35 `Context ${kTokens(s.tokens)} / ${kTokens(s.window)} (${share}%) · session ${usd(s.sessionUsd)} · cache hit ${ratio === undefined ? '–' : `${(ratio * 100).toFixed(1)}%`} · re-read ${usd((s.tokens * p.read) / 1e6)}/turn`,
36 ]
37 if (recent.length > 1 && peak !== undefined) {
38 const peakUsd = peak.read + peak.write + peak.output + peak.input
39 const what = peak.write > peakUsd / 2 ? ' cache rebuild' : ''
40 lines.push(`Last ${recent.length} turns: ${sparkline(recent, 20)} (peak ${usd(peakUsd)}${what})`)
41 }
42
43 const v = s.saved
44 const lv = level(lifetime(v))
45 lines.push(
46 `Saved by compacting: ${v.compactions === 0 ? 'nothing yet this session' : `${signed(net(v))} this session (${v.compactions} compaction${v.compactions === 1 ? '' : 's'}, ${kTokens(v.removed)} less to re-read per turn)`} · ★ Level ${lv.rank} ${lv.name}, all time ${signed(lifetime(v))}`,
47 )
48
49 const list = hints(s, now, ttl).slice(0, 3)
50 if (list.length === 0) lines.push('✓ Nothing to act on: the cache is doing its job.')
51 for (const h of list) {
52 lines.push(`${ICON[h.level]} ${h.title} (~${usd(h.stake)} at stake). ${h.detail}${h.action === 'compact' ? ` → /${compactCommand}` : ''}`)
53 }
54 return lines.join('\n')
55}
56hooks/settings.ts 45 lines1import type { Prefs } from '../types'
2
3export const VIEWS = [
4 { id: 'tiles', label: 'Stat tiles' },
5 { id: 'clock', label: 'Cache clock' },
6 { id: 'context', label: 'Context bar' },
7 { id: 'hints', label: 'Hints' },
8 { id: 'saved', label: 'Saved by compacting' },
9 { id: 'chart', label: 'Cost per turn' },
10] as const
11
12export const FIELDS = [
13 { id: 'cache', label: 'Cache state' },
14 { id: 'rebuild', label: 'Rebuild cost' },
15 { id: 'tips', label: 'Tip count' },
16 { id: 'ctx', label: 'Context % (from 80%)' },
17] as const
18
19export type ViewId = (typeof VIEWS)[number]['id']
20export type FieldId = (typeof FIELDS)[number]['id']
21
22/** `standard` is what the footer showed before it was configurable. */
23export const PRESETS: Record<string, Record<FieldId, boolean>> = {
24 minimal: { cache: true, rebuild: false, tips: false, ctx: false },
25 cost: { cache: true, rebuild: true, tips: false, ctx: false },
26 standard: { cache: true, rebuild: true, tips: true, ctx: true },
27}
28
29export const DEFAULT_PREFS: Prefs = {
30 views: { tiles: true, clock: true, context: true, hints: true, saved: true, chart: true },
31 fields: PRESETS.standard!,
32}
33
34/** The preset whose fields match exactly, else `custom`. */
35export const presetOf = (fields: Prefs['fields']): string =>
36 Object.keys(PRESETS).find(name => FIELDS.every(f => PRESETS[name]![f.id] === fields[f.id])) ?? 'custom'
37
38/** A stored value from an older or hand-edited store: keep what is a boolean, default the rest. */
39export const normalize = (raw: unknown): Prefs => {
40 const r = (raw ?? {}) as Partial<Record<'views' | 'fields', Record<string, unknown>>>
41 const pick = <K extends string>(ids: readonly { id: K }[], from: Record<string, unknown> | undefined, base: Record<K, boolean>) =>
42 Object.fromEntries(ids.map(({ id }) => [id, typeof from?.[id] === 'boolean' ? from[id] : base[id]])) as Record<K, boolean>
43 return { views: pick(VIEWS, r.views, DEFAULT_PREFS.views), fields: pick(FIELDS, r.fields, DEFAULT_PREFS.fields) }
44}
45hooks/settings-view.tsx 57 lines1import type { ElementTable } from 'claude-code'
2
3import type { Prefs } from '../types'
4import { FIELDS, PRESETS, presetOf, VIEWS } from './settings'
5import type { FieldId, ViewId } from './settings'
6
7export type SettingsActions = {
8 toggleView: (id: ViewId) => void
9 toggleField: (id: FieldId) => void
10 setPreset: (name: string) => void
11 close: () => void
12}
13
14const box = (on: boolean) => (on ? '[x]' : '[ ]')
15
16/** The panel's settings page: which sections to draw, and which fields the footer line carries. */
17export const settingsView = (els: ElementTable, p: Prefs, footer: string | undefined, act: SettingsActions) => {
18 const { Box, Text, Button } = els
19 const preset = presetOf(p.fields)
20 return (
21 <Box flexDirection="column" paddingX={1}>
22 <Box flexDirection="row" justifyContent="space-between">
23 <Text bold>Cost & cache settings</Text>
24 <Button key="settings-close" label="← Back" onPress={act.close} />
25 </Box>
26
27 <Box flexDirection="column" marginTop={1}>
28 <Text bold>Panel sections</Text>
29 <Box flexDirection="row" flexWrap="wrap" gap={1}>
30 {VIEWS.map(v => (
31 <Button key={`view-${v.id}`} label={`${box(p.views[v.id])} ${v.label}`} onPress={() => act.toggleView(v.id)} />
32 ))}
33 </Box>
34 </Box>
35
36 <Box flexDirection="column" marginTop={1}>
37 <Text bold>Footer line</Text>
38 <Box flexDirection="row" flexWrap="wrap" gap={1}>
39 {[...Object.keys(PRESETS), ...(preset === 'custom' ? ['custom'] : [])].map(name => (
40 <Button
41 key={`preset-${name}`}
42 label={`${name === preset ? '(•)' : '( )'} ${name}`}
43 onPress={() => name in PRESETS && act.setPreset(name)}
44 />
45 ))}
46 </Box>
47 <Box flexDirection="row" flexWrap="wrap" gap={1}>
48 {FIELDS.map(f => (
49 <Button key={`field-${f.id}`} label={`${box(p.fields[f.id])} ${f.label}`} onPress={() => act.toggleField(f.id)} />
50 ))}
51 </Box>
52 <Text dimColor>Now: {footer ?? '(nothing to show: the footer line is hidden)'}</Text>
53 </Box>
54 </Box>
55 )
56}
57hooks/savings.ts 84 lines1import type { Savings, TurnRow } from '../types'
2import type { Price } from './pricing'
3import { usd, writePrice } from './pricing'
4
5// A handoff summary is a few thousand output tokens, when the compaction reports no usage.
6const SUMMARY_TOKENS = 3000
7
8export const NO_SAVINGS: Savings = {
9 gross: 0,
10 spent: 0,
11 removed: 0,
12 compactions: 0,
13 isFresh: false,
14 pastUsd: 0,
15 pastCompactions: 0,
16}
17
18export const LEVELS = [
19 { usd: 0, name: 'Cache Rookie' },
20 { usd: 1, name: 'Context Trimmer' },
21 { usd: 5, name: 'Token Saver' },
22 { usd: 25, name: 'Cache Keeper' },
23 { usd: 100, name: 'Context Master' },
24 { usd: 500, name: 'Compaction Legend' },
25] as const
26
27export const net = (v: Savings) => v.gross - v.spent
28export const lifetime = (v: Savings) => v.pastUsd + net(v)
29
30/** The level a lifetime total has reached, and the next one up. */
31export const level = (total: number) => {
32 const i = LEVELS.findLastIndex(l => total >= l.usd)
33 const at = Math.max(0, i)
34 return { rank: at + 1, name: LEVELS[at]!.name, at: LEVELS[at]!.usd, next: LEVELS[at + 1] }
35}
36
37/** USD with a sign: a compaction costs before it pays back. */
38export const signed = (n: number) => (n < 0 ? `−${usd(-n)}` : usd(n))
39
40/**
41 * One compaction: the tokens it took out, and its cost. That is the summarizer's request,
42 * plus writing the new context to the cache on the next turn, where it would have been read.
43 * After the cache expired the old context would have been re-written too: there the next
44 * turn's write costs nothing extra, and the rebuild it avoids is credited by `carried`.
45 */
46export const compacted = (
47 v: Savings,
48 r: { tokensBefore: number; tokensAfter: number; usage?: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } },
49 p: Price,
50 ttl: string,
51 isCold = false,
52): Savings => {
53 const summarizer = r.usage
54 ? (r.usage.input_tokens * p.input +
55 r.usage.output_tokens * p.output +
56 r.usage.cache_read_input_tokens * p.read +
57 r.usage.cache_creation_input_tokens * writePrice(p, ttl)) / 1e6
58 : // Cold, the summarizer reads the context uncached: priced as a cache write, the dearer case.
59 (r.tokensBefore * (isCold ? writePrice(p, ttl) : p.read) + SUMMARY_TOKENS * p.output) / 1e6
60 const rewrite = isCold ? 0 : (r.tokensAfter * (writePrice(p, ttl) - p.read)) / 1e6
61 return {
62 ...v,
63 spent: v.spent + summarizer + rewrite,
64 removed: v.removed + Math.max(0, r.tokensBefore - r.tokensAfter),
65 compactions: v.compactions + 1,
66 isFresh: !isCold,
67 }
68}
69
70/**
71 * One turn since a compaction: what it would also have carried, priced as a cache read, or
72 * as a write when the turn rebuilt a cold cache. Never more than the window could hold.
73 */
74export const carried = (v: Savings, turn: TurnRow, window: number, p: Price, ttl: string): Savings => {
75 if (v.removed === 0) return v
76 const extra = window > 0 ? Math.min(v.removed, Math.max(0, window - turn.ctx)) : v.removed
77 const isRebuild = !v.isFresh && turn.ctx > 0 && turn.writeTokens >= 0.6 * turn.ctx
78 return { ...v, gross: v.gross + (extra * (isRebuild ? writePrice(p, ttl) : p.read)) / 1e6, isFresh: false }
79}
80
81/** Turns until a compaction's cost is paid back, at the current saving per turn. */
82export const payback = (v: Savings, perTurn: number) =>
83 net(v) >= 0 || perTurn <= 0 ? 0 : Math.ceil(-net(v) / perTurn)
84