One line above the prompt: how long the prompt cache stays warm, what letting it go cold costs, and how close the context is to compaction

One line above the Claude Code prompt that answers the two questions worth acting on: should I reply before the cache goes cold? and how close am I to compaction?
● warm 4:40 · next msg $0.06 (cold $1.56) · ctx 250k/367k ██████▊░░░ 68%
○ cold · next msg $1.56 · ctx 250k/367k ██████▊░░░ 68%
| Part | Shows | Colour |
|---|---|---|
● warm 4:40 | Time until the prompt cache goes cold | green; yellow in the last 20% of the lifetime (the last minute on a 5m cache) |
○ cold | The cache has expired | red |
◌ waiting for a reply | Right after a start or reload: the context is known, the cache's state is not until a reply finishes | dim |
next msg $0.06 | What the next message pays to re-send the context: a cache read while warm, a full cache write once cold. Your new text and the reply are not included | |
(cold $1.56) | While warm: what that same message would pay if you let the cache lapse | |
ctx 250k/367k | Context of the last reply against where auto-compaction runs (set with /autocompact); against the model's window when auto-compaction is off | bar green < 50%, yellow < 80%, red above |
A ~ before the price means it is still the list price: no reply has given a usable calibration sample yet.
The line refreshes after every model request, so a long turn of tool calls keeps the cache warm rather than draining it from the previous reply. While Claude is working it shows just ● warm; the countdown starts once the turn ends and nothing is being sent. Price calibration still happens once per reply. With cache-keepalive armed, its line sits right under this one. The countdown ticks every second while the cache is warm and stops once it has drawn it cold, and it catches up correctly after the machine sleeps through the expiry.
| Command | Does |
|---|---|
/cache-gauge | Toggle the line |
/cache-gauge show / hide | Show or hide it explicitly |
/cache-gauge debug | Print the raw context figures the line is drawn from, for diagnosing a number that looks wrong |
| Option | Default | Meaning |
|---|---|---|
cacheTtl | 5m | How long the cache stays warm after a reply: 5m or 1h. Match it to the TTL your Claude Code session actually requests |
The estimate starts from list prices per model family (Opus, Sonnet, Haiku, Fable), then calibrates against what the session really pays: after each reply it divides the session's cost increase by that reply's tokens, weighted by kind (cache read, cache write, uncached input, output), and keeps a moving average. Until the first usable sample the price carries a ~. This makes the figure right on Bedrock or enterprise pricing, where list prices do not apply. Replies that ran a subagent are skipped as samples, since the subagent's cost lands in the session total but not in the reply's own tokens.
ctx counts the input side of the last reply; the reply's own output, which the next request also sends, is not included.ctx drops to the compacted size and the cache shows ○ cold, since nothing of the new conversation is cached until the next reply writes it. Changing the threshold with /autocompact shows on the next reply./model (or /fast) switches to another model, the price is that model's (with ~ until one of its replies calibrates it) and the cache shows ○ cold: the prompt cache is per model, so the context is written again on the first message. Switching back within the lifetime shows the original cache warm again.hooks/register.tsx 283 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import {
6 bar,
7 calibrate,
8 countdown,
9 isSameModel,
10 level,
11 nextCost,
12 ratesFor,
13 tokens,
14 ttlMs,
15 usd,
16 weightedUnits,
17} from './pricing'
18import type { Ttl } from './pricing'
19
20const PLUGIN = 'cache-gauge'
21const snapshot = atom({ plugin: 'cache-gauge', key: 'snapshot' } as const, null)
22const isHidden = atom({ plugin: 'cache-gauge', key: 'isHidden' } as const, false)
23const now = atom({ plugin: 'cache-gauge', key: 'now' } as const, 0)
24
25export const register: Register = (on, options) => {
26 const ttl: Ttl = options.cacheTtl === '1h' ? '1h' : '5m'
27 let lastUsd = 0
28 // Calibrated per model: a /model switch must not price one model at another's rate.
29 const bases = new Map<string, number>()
30 const baseFor = (model: string) => [...bases].find(([m]) => isSameModel(m, model))?.[1] ?? null
31 let sawSubagent = false
32 let warmUntil = 0
33 let drawnAt = 0
34
35 on('session.start', async ($, e, next) => {
36 await $.command.register({ name: PLUGIN, description: 'Show or hide the cache gauges above the prompt' })
37 const usage = await $.session.usage({ breakdown: 'summary' })
38 lastUsd = usage.cost?.usd ?? 0
39 // Draw the context at once (a reload, a resumed session) rather than after the
40 // next reply; the cache's state is unknown until a reply says when it was written.
41 const held = await read($, snapshot)
42 if (held === null && usage.context.tokens !== undefined) {
43 const breakdown = usage.context.breakdown
44 const model = breakdown?.model ?? ''
45 await update($, snapshot, () => ({
46 ctxTokens: usage.context.tokens ?? 0,
47 window: usage.context.window,
48 compactAt: breakdown?.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null,
49 respondedAt: null,
50 isCompacted: false,
51 model,
52 currentModel: model,
53 baseUsd: ratesFor(model).input,
54 isCalibrated: false,
55 }))
56 }
57 // A reload starts the module over: pick the countdown up from the held reply.
58 if (held !== null && held.respondedAt !== null && !held.isCompacted) {
59 warmUntil = held.respondedAt + ttlMs(ttl)
60 }
61 // Ticks until the band has drawn the cache cold. Not a time window: a machine
62 // that sleeps through the expiry wakes to a first tick long past it.
63 $.clock.every(1000, () => {
64 if (drawnAt > warmUntil) {
65 return
66 }
67 void $.clock.now().then(t => {
68 drawnAt = t
69
70 return update($, now, () => t)
71 })
72 })
73
74 return next(e)
75 })
76
77 on('command.run', { command: PLUGIN }, async ($, e) => {
78 if (e.args.trim() === 'debug') {
79 const u = await $.session.usage({ breakdown: 'summary' })
80 const b = u.context.breakdown
81 const raw = {
82 context: { tokens: u.context.tokens, window: u.context.window, percent: u.context.percent },
83 breakdown: b && {
84 totalTokens: b.totalTokens,
85 rawMaxTokens: b.rawMaxTokens,
86 autocompactSource: b.autocompactSource,
87 percentage: b.percentage,
88 autoCompactThreshold: b.autoCompactThreshold,
89 isAutoCompactEnabled: b.isAutoCompactEnabled,
90 model: b.model,
91 categories: b.categories.map(c => `${c.kind}:${c.name}=${c.tokens}`),
92 },
93 snapshot: await read($, snapshot),
94 }
95
96 return { text: JSON.stringify(raw, null, 2) }
97 }
98 const arg = e.args.trim().toLowerCase()
99 const hidden = await update($, isHidden, was => (arg === 'show' ? false : arg === 'hide' ? true : !was))
100
101 return { text: hidden ? 'Cache gauges hidden.' : 'Cache gauges shown.' }
102 })
103
104 on('turn.complete', async ($, e, next) => {
105 const result = await next(e)
106 if (e.agentId !== undefined) {
107 sawSubagent = true
108
109 return result
110 }
111 if (!e.usage) {
112 return result
113 }
114
115 const usage = await $.session.usage({ breakdown: 'summary' })
116 const sessionUsd = usage.cost?.usd ?? lastUsd
117 // A subagent's cost is in Δcost but not in e.usage, so that sample would overprice.
118 const model = e.usage.model
119 const sample = sawSubagent ? null : calibrate(baseFor(model), sessionUsd - lastUsd, weightedUnits(e.usage, model, ttl))
120 if (sample !== null) {
121 bases.set(model, sample)
122 }
123 const baseUsd = baseFor(model)
124 lastUsd = sessionUsd
125 sawSubagent = false
126
127 const respondedAt = await $.clock.now()
128 warmUntil = respondedAt + ttlMs(ttl)
129 const breakdown = usage.context.breakdown
130 const fresh: Snapshot = {
131 ctxTokens: usage.context.tokens ?? 0,
132 window: usage.context.window,
133 compactAt: breakdown?.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null,
134 respondedAt,
135 isCompacted: false,
136 model,
137 currentModel: model,
138 baseUsd: baseUsd ?? ratesFor(model).input,
139 isCalibrated: baseUsd !== null,
140 }
141 await update($, snapshot, () => fresh)
142 drawnAt = respondedAt
143 await update($, now, () => respondedAt)
144
145 return result
146 })
147
148 // Every main-loop request reads the cache and restarts its lifetime, so a long
149 // turn of tool calls keeps it warm; the turn's end alone would draw it cold.
150 on('turn.step', async function* ($, e, next) {
151 const result = yield* next(e)
152 if (e.agentId !== undefined || result.usage === null) {
153 return result
154 }
155 const held = await read($, snapshot)
156 if (held !== null) {
157 const usage = await $.session.usage({ breakdown: 'summary' })
158 const respondedAt = await $.clock.now()
159 warmUntil = respondedAt + ttlMs(ttl)
160 const model = result.usage.model
161 const base = baseFor(model)
162 await update($, snapshot, () => ({
163 ...held,
164 ctxTokens: usage.context.tokens ?? held.ctxTokens,
165 respondedAt,
166 isCompacted: false,
167 model,
168 currentModel: model,
169 baseUsd: base ?? ratesFor(model).input,
170 isCalibrated: base !== null,
171 }))
172 drawnAt = respondedAt
173 await update($, now, () => respondedAt)
174 }
175
176 return result
177 })
178
179 on('session.compact', async ($, e, next) => {
180 const result = await next(e)
181 if (e.trigger === 'precompute' || e.agentId !== undefined || result.skip !== undefined) {
182 return result
183 }
184 const held = await read($, snapshot)
185 const usage = await $.session.usage({ breakdown: 'summary' })
186 // The summarizer's cost is in the session total but in no reply's tokens.
187 lastUsd = usage.cost?.usd ?? lastUsd
188 if (held !== null) {
189 // The compacted conversation is a new prefix: nothing of it is cached yet.
190 warmUntil = 0
191 await update($, snapshot, () => ({
192 ...held,
193 ctxTokens: result.tokensAfter ?? usage.context.tokens ?? held.ctxTokens,
194 isCompacted: true,
195 }))
196 }
197
198 return result
199 })
200
201 // `/model` (and `/fast`, when it promotes the model) points the next message at
202 // another model: its own price, and none of the context in its cache.
203 on('command.run', async ($, e, next) => {
204 const result = await next(e)
205 if (e.command !== 'model' && e.command !== 'fast') {
206 return result
207 }
208 const held = await read($, snapshot)
209 const current = await $.session.model()
210 if (held !== null && typeof current === 'string' && !isSameModel(current, held.currentModel ?? held.model)) {
211 const base = baseFor(current)
212 await update($, snapshot, () => ({
213 ...held,
214 currentModel: current,
215 baseUsd: base ?? ratesFor(current).input,
216 isCalibrated: base !== null,
217 }))
218 }
219
220 return result
221 })
222
223 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
224 const snap = await read($, snapshot)
225 if (e.props.hasSurvey || snap === null || (await read($, isHidden))) {
226 return next(e)
227 }
228
229 const { Box, Text } = $.ui.resolve(e)
230
231 // Fill toward where compaction runs (/autocompact moves it), else the model's window.
232 const limit = snap.compactAt ?? snap.window
233 const ctx = limit > 0 ? snap.ctxTokens / limit : 0
234 const ctxPart = ` · ctx ${tokens(snap.ctxTokens)}/${tokens(limit)} `
235
236 let state
237 let consequence
238 if (snap.respondedAt === null) {
239 state = <Text dimColor>◌ waiting for a reply</Text>
240 consequence = ''
241 } else {
242 const t = Math.max(await read($, now), snap.respondedAt)
243 const leftMs = snap.respondedAt + ttlMs(ttl) - t
244 const current = snap.currentModel ?? snap.model
245 const isWarm = !snap.isCompacted && isSameModel(current, snap.model) && leftMs > 0
246 const cost = nextCost(snap.ctxTokens, snap.baseUsd, current, ttl)
247 const price = (n: number) => `${snap.isCalibrated ? '' : '~'}${usd(n)}`
248 const cold = price(cost.cold)
249 state = isWarm ? (
250 // Mid-turn every request restarts the lifetime: count down only once idle.
251 e.props.isWorking ? (
252 <Text color="success">● warm</Text>
253 ) : (
254 <Text color={leftMs < ttlMs(ttl) * 0.2 ? 'warning' : 'success'}>{`● warm ${countdown(leftMs)}`}</Text>
255 )
256 ) : (
257 <Text color="error">○ cold</Text>
258 )
259 consequence = isWarm ? ` · next msg ${price(cost.warm)} (cold ${cold})` : ` · next msg ${cold}`
260 }
261
262 const mine = (
263 <Box>
264 {state}
265 <Text dimColor>{`${consequence}${ctxPart}`}</Text>
266 <Text color={level(ctx, 0.5, 0.8)}>{bar(ctx)}</Text>
267 <Text dimColor>{` ${Math.round(ctx * 100)}%`}</Text>
268 </Box>
269 )
270 // Wrap, not replace: another mod's line (cache-keepalive) stays in the band.
271 const below = await next(e)
272
273 return below ? (
274 <Box flexDirection="column">
275 {mine}
276 {below}
277 </Box>
278 ) : (
279 mine
280 )
281 })
282}
283hooks/pricing.ts 98 lines1// Rates per model family, from the claude-api skill's pricing (cached 2026-10-06).
2// `input` is the list price per uncached input token; the calibration replaces it
3// with what this session actually pays (Bedrock, enterprise pricing).
4type Rates = { input: number; read: number }
5
6const PER_MTOK = 1e-6
7const FAMILIES: ReadonlyArray<readonly [RegExp, Rates]> = [
8 [/(fable|mythos)-5-1/, { input: 10 * PER_MTOK, read: 0.025 }],
9 [/(fable|mythos)-5/, { input: 10 * PER_MTOK, read: 0.1 }],
10 [/opus-5-5/, { input: 4 * PER_MTOK, read: 0.05 }],
11 [/opus/, { input: 5 * PER_MTOK, read: 0.1 }],
12 [/sonnet-(5|4-6)/, { input: 2 * PER_MTOK, read: 0.1 }],
13 [/sonnet/, { input: 3 * PER_MTOK, read: 0.1 }],
14 [/haiku-5-5/, { input: 0.1 * PER_MTOK, read: 0.1 }],
15 [/haiku/, { input: 1 * PER_MTOK, read: 0.1 }],
16]
17const DEFAULT_RATES: Rates = { input: 4 * PER_MTOK, read: 0.05 }
18
19export const OUTPUT_RATIO = 5
20export const EMA_ALPHA = 0.3
21
22export type Ttl = '5m' | '1h'
23export type Usage = {
24 input_tokens: number
25 output_tokens: number
26 cache_read_input_tokens: number
27 cache_creation_input_tokens: number
28}
29
30export const ratesFor = (model: string): Rates =>
31 FAMILIES.find(([pattern]) => pattern.test(model))?.[1] ?? DEFAULT_RATES
32
33/** One model under two spellings: a reply's id may carry a provider prefix (`us.anthropic.`) that `/model`'s does not. */
34export const isSameModel = (a: string | undefined, b: string | undefined): boolean =>
35 a === b ||
36 (typeof a === 'string' &&
37 typeof b === 'string' &&
38 a !== '' &&
39 b !== '' &&
40 (a.endsWith(`.${b}`) || b.endsWith(`.${a}`)))
41
42export const writeRatio = (ttl: Ttl): number => (ttl === '1h' ? 2 : 1.25)
43
44export const ttlMs = (ttl: Ttl): number => (ttl === '1h' ? 3_600_000 : 300_000)
45
46/** The turn's tokens in units of one uncached input token. */
47export const weightedUnits = (u: Usage, model: string, ttl: Ttl): number =>
48 u.input_tokens +
49 ratesFor(model).read * u.cache_read_input_tokens +
50 writeRatio(ttl) * u.cache_creation_input_tokens +
51 OUTPUT_RATIO * u.output_tokens
52
53/** Next base rate: an EMA of Δcost / weighted units, or `prev` when the sample is unusable. */
54export const calibrate = (prev: number | null, deltaUsd: number, units: number): number | null => {
55 if (!(deltaUsd > 0) || !(units > 0)) {
56 return prev
57 }
58 const sample = deltaUsd / units
59
60 return prev === null ? sample : prev + EMA_ALPHA * (sample - prev)
61}
62
63/** Cost of re-sending `ctxTokens` on the next request, excluding its new input and response. */
64export const nextCost = (ctxTokens: number, baseUsd: number, model: string, ttl: Ttl) => ({
65 warm: ctxTokens * ratesFor(model).read * baseUsd,
66 cold: ctxTokens * writeRatio(ttl) * baseUsd,
67})
68
69export const BAR_WIDTH = 10
70
71// Eighth blocks, so a short bar still moves visibly with each step.
72const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
73
74export const bar = (fraction: number, width = BAR_WIDTH): string => {
75 const units = Math.round(Math.min(1, Math.max(0, fraction)) * width * 8)
76 const full = Math.floor(units / 8)
77 const part = EIGHTHS[units % 8]
78
79 return '█'.repeat(full) + part + '░'.repeat(width - full - (part ? 1 : 0))
80}
81
82export type Level = 'success' | 'warning' | 'error'
83
84/** Green below `warnAt`, yellow below `errorAt`, red from there. */
85export const level = (fraction: number, warnAt: number, errorAt: number): Level =>
86 fraction >= errorAt ? 'error' : fraction >= warnAt ? 'warning' : 'success'
87
88export const tokens = (n: number): string =>
89 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
90
91export const usd = (n: number): string => (n < 0.01 ? `$${n.toFixed(3)}` : `$${n.toFixed(2)}`)
92
93export const countdown = (ms: number): string => {
94 const s = Math.max(0, Math.ceil(ms / 1000))
95
96 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
97}
98types/index.d.ts 26 lines1export type Snapshot = {
2 /** Input tokens of the last main-loop response (what the next request re-sends). */
3 ctxTokens: number
4 /** The model's context window. */
5 window: number
6 /** Auto-compact threshold in tokens, when auto-compaction is on. */
7 compactAt: number | null
8 /** $.clock.now() when the last main-loop response finished; null before one did (seeded at start). */
9 respondedAt: number | null
10 /** A compaction ran since that response: its new prefix is not cached yet. */
11 isCompacted: boolean
12 /** The model that answered last: the one whose cache holds the context. */
13 model: string
14 /** The model the next message goes to; differs from `model` after `/model`, whose switch leaves the cache behind. */
15 currentModel: string
16 /** USD per uncached input token on `currentModel`, calibrated from /cost or the list-price fallback. */
17 baseUsd: number
18 isCalibrated: boolean
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'cache-gauge': { snapshot: Snapshot | null; isHidden: boolean; now: number }
24 }
25}
26