Retro prompt-cache thermometer above the prompt, a cold-cache gate that shows what the next prompt will cost, and a break mode that keeps the cache warm.

A Claude Code mod that makes the prompt cache visible, gates cold-cache runs, and keeps the cache warm while you are on a break. Retro look, pixel-art mascots (Ember the fire sprite, Frostbit the ice imp), 8-bit bleeps.
<img src="../../docs/images/cache-thermo.svg" alt="The thermometer band in hot, warm and cold, the cold-cache gate with Frostbit, and break mode with Ember at the campfire" width="880">
Spec and design: docs/cache-thermo-PRD.md.
API-equiv on a subscription, where the dollars are notional).go ahead anyway · $8.48 (c) or keep my prompt (x), which puts the text back in the composer.☕ break (or /cache-thermo break) turns the band into the break view (Ember at the campfire, next nudge countdown, nudges and spend) and refreshes the cache just before the TTL lapses with a tiny cache-read request. Come back with I'm back, /cache-thermo resume, or just type a prompt.<img src="../../docs/images/cache-thermo-cast.svg" alt="Ember when the cache is hot, while Claude works and as it cools down, Frostbit when the cache is cold, and Ember at the campfire on a break" width="880">
Ember changes mood with the cache: glowing while it is hot, blazing while a request refreshes the cache, fading as it cools. Frostbit takes over when the cache goes cold, and Ember sits by the campfire while you are on a break.
The terminal gets the same cast as one-cell ASCII, next to a block-character thermometer:
( * /\/\/\ . )
) ) , (。◕~◕。) (。ᵔ‿ᵔ。) ( (
(。◕‿◕。) (。-‿-。) zᶻ . u u * c[_] /\/\
hot cooling cold break
██████████████░░░░░░
At the prompt of a terminal session:
/plugin install cache-thermo --marketplace stilero/mods-marketplace
From a shell: claude plugin install cache-thermo --marketplace stilero/mods-marketplace. In Claude Code Desktop: + next to the prompt → Plugins → Add marketplace → Add plugin.
| Command | Effect |
|---|---|
/cache-thermo | One-line status: zone, time left, context, estimate, TTL source, threshold, price date |
/cache-thermo break | Start a break (keep the cache warm) |
/cache-thermo resume | End the break |
/cache-thermo hide / show | Hide or show the band |
/cache-thermo cold | Debug: mark the cache cold now, so the gate can be demoed |
/config rows)| Field | Default | Purpose |
|---|---|---|
| Cache TTL | auto | auto is 1h on a subscription account (rate limits reported), 5m on an API key |
| Gate threshold (USD) | 0.25 | Gate only when the estimated re-cache cost is at least this; 0 gates every cold prompt |
| Warn fraction | 0.2 | Band turns yellow at or below this fraction of the TTL |
| Keep-warm lead (seconds) | 90 | On a break, refresh when this many seconds remain |
| Max break (hours) | 4 | Stop keeping warm after this long |
| Input price override ($/MTok) | 0 | When above 0 replaces the shipped input price for the current model |
| Sounds | on | 8-bit bleeps (macOS) |
| Show cost on subscription | on | Subscription users see API-equivalent dollars; off shows tokens only |
pricing.json is the source of truth (list prices, asOf date, source URL). Cache writes cost 1.25× the input price on the 5-minute TTL and 2× on the 1-hour TTL; reads are 0.1× by default, 0.025× on Claude Fable 5.1 and 0.05× on Claude Opus 5.5. A hooks module cannot import JSON, so the table is also shipped as hooks/pricing-table.ts:
node scripts/sync-pricing.mjs
At session start the mod also reads pricing.json from its own folder, so editing the installed copy takes effect on the next session without a rebuild. Bump the plugin version when prices change.
turn.step records when each main-loop request starts and, from its stop chunk, the context size (input + cache_read + cache_creation + output) and model. warmUntil = requestStart + ttl.now into plugin state, which redraws the band; it also drives the zone toasts and the break loop.prompt.submit hook that drops the prompt, keeps its text in plugin state, and turns the band into the gate view. Continue resubmits the text as the user's own prompt; Cancel refills the composer. The hook fails open: if anything throws, the prompt goes through./clear or a resume into another conversation changes the cached prefix, so the band disappears until the next response starts a fresh clock (and any break ends): the next request writes a new cache however long you wait, so there is nothing to gate.auto TTL is decided at each response, because the rate limits that mark a subscription arrive with responses.$.model.fork, which re-sends the main thread's last request (same model, system prompt and messages) with a one-character ping, so the API serves the prefix from the prompt cache and refreshes its TTL. Each refresh is logged with its cache_read_input_tokens.Not gated: slash commands, hidden prompts, prompts delivered into a running turn, prompts with image attachments (a warning toast shows instead), and the mod's own resubmission.
claude plugin validate plugins/cache-thermo
claude plugin test plugins/cache-thermo
Type-check with the declarations the engine lays beside the mod once it has loaded it (tsc -p plugins/cache-thermo). Run it in a terminal with claude --plugin-dir plugins/cache-thermo, or in Claude Code Desktop by adding this repo folder as a marketplace. Art lives in hooks/art.ts (SVG for desktop, ASCII for the terminal); sounds are generated by scripts/gen-sounds.mjs.
hooks/register.tsx 698 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BreakState, CacheClock, CacheZone, PendingGate } from '../types'
5import { asciiWidth, bar, BUDDY_W, CAMPFIRE_ASCII, EMBER_ASCII, FROSTBIT_ASCII, PALETTE, campfireSvg, frostbitSvg, thermometerSvg } from './art'
6import {
7 clockFor,
8 displayKey,
9 formatAgo,
10 formatClock,
11 formatLeft,
12 formatTokens,
13 formatTokensLong,
14 isCold,
15 remainingFraction,
16 keepWarmLeadMs,
17 remainingMs,
18 resolveTtl,
19 zoneFor,
20} from './clock'
21import { readOptions } from './options'
22import { formatUsd, getPricingTable, keepWarmCost, setPricingTable } from './pricing'
23import { currentEstimate, moneyText, rt } from './runtime'
24import { THEME, ZONE_DOT, ZONE_LABEL, zoneColor } from './theme'
25import type { SoundName } from './runtime'
26const PLUGIN = 'cache-thermo'
27const BUILD = '0.2.2'
28
29const clockAtom = atom({ plugin: 'cache-thermo', key: 'clock' } as const, null)
30const contextAtom = atom({ plugin: 'cache-thermo', key: 'context' } as const, null)
31const nowAtom = atom({ plugin: 'cache-thermo', key: 'now' } as const, 0)
32const gateAtom = atom({ plugin: 'cache-thermo', key: 'gate' } as const, null)
33const brkAtom = atom({ plugin: 'cache-thermo', key: 'brk' } as const, null)
34const isBandHiddenAtom = atom({ plugin: 'cache-thermo', key: 'isBandHidden' } as const, false)
35const lastZoneAtom = atom({ plugin: 'cache-thermo', key: 'lastZone' } as const, null)
36
37/** Best effort: no sound on Linux/Windows, none when the file is missing. */
38function play($: EngineInterface, name: SoundName): void {
39 if (!rt.opts.sounds) return
40 $.audio.play({ asset: `sounds/${name}.wav` }).catch(() => undefined)
41}
42
43const WRITE_LABEL = { '5m': '1.25× write, 5m TTL', '1h': '2× write, 1h TTL' } as const
44
45function describePricing(g: PendingGate): string {
46 const e = g.estimate
47 switch (e.pricing) {
48 case 'override':
49 return `${WRITE_LABEL[e.ttl]}, your configured price`
50 case 'unknown-model':
51 return `${WRITE_LABEL[e.ttl]}, unknown model so Opus list price assumed`
52 default:
53 return `${WRITE_LABEL[e.ttl]}, ${e.modelName} list price`
54 }
55}
56
57async function resubmit($: EngineInterface, g: PendingGate): Promise<void> {
58 await update($, gateAtom, () => null)
59 void $.prompt.submit({ text: g.text, asUser: true })
60}
61
62async function giveBack($: EngineInterface, g: PendingGate): Promise<void> {
63 await update($, gateAtom, () => null)
64 await $.prompt.fill({ text: g.text })
65}
66
67export const KEEP_WARM_PROMPT =
68 'Cache keep-alive ping from the cache-thermo mod. Reply with a single period and nothing else.'
69
70const STORE_TOTALS = 'breakTotals'
71
72type Totals = { breaks: number; refreshes: number; spentUsd: number }
73
74async function readTotals($: EngineInterface): Promise<Totals> {
75 const v = (await $.store.get(STORE_TOTALS)) as Partial<Totals> | undefined
76 return {
77 breaks: v?.breaks ?? 0,
78 refreshes: v?.refreshes ?? 0,
79 spentUsd: v?.spentUsd ?? 0,
80 }
81}
82
83export async function startBreak($: EngineInterface): Promise<boolean> {
84 const clock = await read($, clockAtom)
85 if (clock === null) {
86 $.ui.toast('Nothing to keep warm yet: send a prompt first.')
87 return false
88 }
89 if ((await read($, brkAtom)) !== null) return true
90 const now = await $.clock.now()
91 const lead = keepWarmLeadMs(rt.opts.keepWarmLeadSeconds, clock.ttlMs)
92 const brk: BreakState = {
93 since: now,
94 refreshes: 0,
95 spentUsd: 0,
96 nextRefreshAt: Math.max(now, clock.warmUntil - lead),
97 inFlight: false,
98 lastReadTokens: 0,
99 }
100 await update($, brkAtom, () => brk)
101 await update($, isBandHiddenAtom, () => false)
102 $.ui.status('☕ on a break, keeping the cache warm')
103 return true
104}
105
106export async function endBreak(
107 $: EngineInterface,
108 reason: 'button' | 'command' | 'prompt' | 'timeout' | 'error' | 'session-end' | 'reset',
109): Promise<void> {
110 const brk = await read($, brkAtom)
111 if (brk === null) return
112 await update($, brkAtom, () => null)
113 $.ui.status(undefined)
114 const totals = await readTotals($)
115 await $.store.set(STORE_TOTALS, {
116 breaks: totals.breaks + 1,
117 refreshes: totals.refreshes + brk.refreshes,
118 spentUsd: totals.spentUsd + brk.spentUsd,
119 })
120 if (reason === 'timeout') {
121 $.ui.toast(`Break hit ${rt.opts.maxBreakHours}h: letting the cache cool now.`, { timeoutMs: 8000 })
122 } else if (reason !== 'session-end' && reason !== 'error' && reason !== 'reset') {
123 const ctx = await read($, contextAtom)
124 const money = moneyText(brk.spentUsd, ctx?.isSubscription ?? false)
125 $.ui.toast(
126 `Back from the break: ${brk.refreshes} keep-warm ${brk.refreshes === 1 ? 'refresh' : 'refreshes'}${money === null ? '' : `, ≈ ${money}`}`,
127 )
128 play($, 'resume')
129 }
130}
131
132/**
133 * The conversation's prefix changed under the clock (a compaction, a /clear, a resume
134 * into another session): the next request writes a new cache whatever the clock says,
135 * so drop the clock until that response arrives.
136 */
137async function forgetCache($: EngineInterface, why: string): Promise<void> {
138 if ((await read($, brkAtom)) !== null) {
139 await endBreak($, 'reset')
140 $.ui.toast(`${why}: the break ends, there is nothing cached to keep warm until the next response.`, {
141 timeoutMs: 8000,
142 })
143 }
144 await update($, clockAtom, () => null)
145 await update($, contextAtom, () => null)
146 await update($, lastZoneAtom, () => null)
147 lastDisplayKey = ''
148}
149
150/** The context window, and whether rate limits were reported (a subscription). */
151async function usageFacts($: EngineInterface): Promise<{ window: number; isSubscription: boolean }> {
152 try {
153 const u = await $.session.usage()
154 return { window: u.context.window, isSubscription: u.rateLimits.length > 0 }
155 } catch {
156 return { window: 0, isSubscription: false }
157 }
158}
159
160/** Runs once a second from the ticker while a break is on. */
161export async function breakTick($: EngineInterface, now: number, clock: CacheClock): Promise<void> {
162 const brk = await read($, brkAtom)
163 if (brk === null) return
164 if (now - brk.since > rt.opts.maxBreakHours * 3_600_000) {
165 await endBreak($, 'timeout')
166 return
167 }
168 if (rt.keepWarmInFlight || brk.inFlight) return
169 const lead = keepWarmLeadMs(rt.opts.keepWarmLeadSeconds, clock.ttlMs)
170 if (remainingMs(clock, now) > lead) return
171
172 rt.keepWarmInFlight = true
173 await update($, brkAtom, b => (b === null ? null : { ...b, inFlight: true }))
174 const t0 = await $.clock.now()
175 try {
176 const r = await $.model.fork({ prompt: KEEP_WARM_PROMPT })
177 if (!r.isAnswered && r.reason === 'nothing-to-fork') {
178 $.ui.toast('Nothing to keep warm: no response in this conversation yet.')
179 await endBreak($, 'error')
180 return
181 }
182 if (!r.isAnswered && r.reason === 'api-error') {
183 $.ui.toast(`Keep-warm request failed (HTTP ${r.status}); ending the break.`, { timeoutMs: 8000 })
184 await endBreak($, 'error')
185 return
186 }
187 const usage = r.usage
188 const ctx = await read($, contextAtom)
189 const model = ctx?.model ?? (await $.session.model())
190 const cost = keepWarmCost(usage, model, rt.opts.inputPriceOverrideUsdPerMTok, getPricingTable())
191 const prev = await read($, clockAtom)
192 const next = clockFor(t0, clock.ttl, prev?.ttlSource ?? clock.ttlSource)
193 await update($, clockAtom, () => next)
194 await update($, brkAtom, b =>
195 b === null
196 ? null
197 : {
198 ...b,
199 inFlight: false,
200 refreshes: b.refreshes + 1,
201 spentUsd: b.spentUsd + cost,
202 lastReadTokens: usage.cache_read_input_tokens,
203 nextRefreshAt: next.warmUntil - lead,
204 },
205 )
206 const hit = usage.cache_read_input_tokens > 0 ? 'cache hit' : 'CACHE MISS'
207 $.ui.log(
208 `keep-warm #${brk.refreshes + 1}: ${hit}, read ${usage.cache_read_input_tokens} / wrote ${usage.cache_creation_input_tokens} / out ${usage.output_tokens} tokens, ≈ ${formatUsd(cost)}`,
209 { to: 'debug' },
210 )
211 if (usage.cache_read_input_tokens === 0) {
212 $.ui.toast('Keep-warm request did not hit the cache (it re-cached instead).', { timeoutMs: 8000 })
213 }
214 } finally {
215 rt.keepWarmInFlight = false
216 await update($, brkAtom, b => (b === null ? null : { ...b, inFlight: false }))
217 }
218}
219
220const COMMAND = 'cache-thermo'
221
222const loadPricingOverride = async ($: EngineInterface): Promise<void> => {
223 try {
224 const text = await $.fs.read(`${$.plugin.root}/pricing.json`)
225 if (typeof text === 'string' && setPricingTable(JSON.parse(text))) {
226 $.ui.log(`pricing table from pricing.json (asOf ${getPricingTable().asOf})`, { to: 'debug' })
227 }
228 } catch {
229 // the shipped table stands
230 }
231}
232
233const announceZone = async ($: EngineInterface, zone: CacheZone, now: number): Promise<void> => {
234 const last = await read($, lastZoneAtom)
235 if (last === zone) return
236 await update($, lastZoneAtom, () => zone)
237 if (last === null || rt.isWorking) return
238 const clock = await read($, clockAtom)
239 const ctx = await read($, contextAtom)
240 if (clock === null || ctx === null) return
241 if (zone === 'warm' && last === 'hot') {
242 $.ui.toast(`Ember is dozing off: the cache goes cold in ${formatClock(remainingMs(clock, now))}`, { timeoutMs: 6000 })
243 play($, 'warm')
244 } else if (zone === 'cold' && last !== 'cold') {
245 const est = currentEstimate(ctx, clock)
246 const money = moneyText(est.usd, ctx.isSubscription)
247 $.ui.toast(
248 `Brrr, the cache is cold. The next prompt re-caches ${money === null ? `${formatTokens(ctx.tokens)} tokens` : `≈ ${money}`}`,
249 { timeoutMs: 10000 },
250 )
251 play($, 'cold')
252 }
253}
254
255let lastDisplayKey = ''
256
257const tick = async ($: EngineInterface): Promise<void> => {
258 const clock = await read($, clockAtom)
259 if (clock === null) return
260 const now = await $.clock.now()
261 const zone = zoneFor(clock, now, rt.opts.warnFraction, rt.isWorking)
262 if (zone !== null) await announceZone($, zone, now)
263 const hidden = await read($, isBandHiddenAtom)
264 const brk = await read($, brkAtom)
265 const gate = await read($, gateAtom)
266 // The band changes by the minute above two minutes left and by the second below;
267 // a break panel counts down to its next refresh the same way. Redraw only then.
268 let key = displayKey(remainingMs(clock, now), zone ?? 'hot')
269 if (brk !== null) key += `|b${displayKey(Math.max(0, brk.nextRefreshAt - now), 'r')}|${brk.inFlight ? 1 : 0}`
270 if (gate !== null) key += '|g'
271 if ((!hidden || brk !== null || gate !== null) && key !== lastDisplayKey) {
272 lastDisplayKey = key
273 await update($, nowAtom, () => now)
274 }
275 if (brk !== null) await breakTick($, now, clock)
276}
277
278export const register: Register = (on, options) => {
279 rt.opts = readOptions(options)
280
281 on('session.start', async ($, e, next) => {
282 await loadPricingOverride($)
283 await $.command.register({
284 name: COMMAND,
285 description: 'Prompt-cache thermometer: status, break, resume, hide, show, cold (debug)',
286 argumentHint: '[break|resume|hide|show|cold]',
287 })
288 $.clock.every(1000, () => {
289 void tick($).catch(err => $.ui.log(`tick failed: ${String(err)}`, { to: 'debug' }))
290 })
291 $.ui.log(`cache-thermo v${BUILD} ready: ttl ${rt.opts.cacheTtl}, prices asOf ${getPricingTable().asOf}`, {
292 to: 'debug',
293 })
294 return next(e)
295 })
296
297 on('turn.start', async ($, e, next) => {
298 if (e.agentId === undefined) rt.isWorking = true
299 return next(e)
300 })
301
302 // Every main-loop request refreshes the cache; the TTL counts from the request's start.
303 on('turn.step', async function* ($, e, next) {
304 if (e.agentId !== undefined) return yield* next(e)
305 const requestStartedAt = await $.clock.now()
306 for await (const chunk of next(e)) {
307 if (chunk.kind === 'stop' && chunk.usage !== null) {
308 const u = chunk.usage
309 const tokens = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
310 // Rate limits arrive with responses, so the auto TTL is decided here, not at session start.
311 const facts = await usageFacts($)
312 const { ttl, source } = resolveTtl(rt.opts.cacheTtl, facts.isSubscription)
313 await update($, clockAtom, () => clockFor(requestStartedAt, ttl, source))
314 await update($, contextAtom, () => ({ tokens, model: u.model || e.model, ...facts }))
315 }
316 yield chunk
317 }
318 })
319
320 on('turn.complete', async ($, e, next) => {
321 if (e.agentId !== undefined) return next(e)
322 rt.isWorking = false
323 const clock = await read($, clockAtom)
324 const now = await $.clock.now()
325 // Fallback when no stop chunk reached us (a made-up response, an interrupted stream).
326 if (e.usage !== undefined && (clock === null || clock.lastRequestStartedAt < now - e.durationMs - 1000)) {
327 const u = e.usage
328 const tokens = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
329 const facts = await usageFacts($)
330 const { ttl, source } = resolveTtl(rt.opts.cacheTtl, facts.isSubscription)
331 await update($, clockAtom, () => clockFor(now, ttl, source))
332 await update($, contextAtom, () => ({ tokens, model: u.model, ...facts }))
333 }
334 await update($, nowAtom, () => now)
335 return next(e)
336 })
337
338 on('command.run', async ($, e, next) => {
339 const name = String(e.command)
340 $.ui.log(`cache-thermo: command.run /${name} ${e.args}`, { to: 'debug' })
341 if (name !== COMMAND && !name.endsWith(`:${COMMAND}`)) return next(e)
342 const arg = String(e.args ?? '').trim().toLowerCase()
343 const now = await $.clock.now()
344 switch (arg) {
345 case 'break':
346 return { text: (await startBreak($)) ? 'On a break: keeping the cache warm.' : 'Nothing to keep warm yet.' }
347 case 'resume':
348 await endBreak($, 'command')
349 return { text: 'Back from the break.' }
350 case 'hide':
351 await update($, isBandHiddenAtom, () => true)
352 return { text: 'Thermometer hidden. /cache-thermo show brings it back.' }
353 case 'show':
354 await update($, isBandHiddenAtom, () => false)
355 return { text: 'Thermometer shown.' }
356 case 'cold': {
357 const clock = await read($, clockAtom)
358 if (clock === null) return { text: 'No cache clock yet: send a prompt first.' }
359 await update($, clockAtom, () => ({ ...clock, warmUntil: now }))
360 const ctx = await read($, contextAtom)
361 if (ctx === null) return { text: 'Debug: the cache is now marked cold.' }
362 // Say what the next prompt will do: a small context passes the gate silently.
363 const est = currentEstimate(ctx, clock)
364 const usd = formatUsd(est.usd)
365 const limit = formatUsd(rt.opts.gateThresholdUsd)
366 return {
367 text:
368 est.usd >= rt.opts.gateThresholdUsd
369 ? `Debug: the cache is now marked cold. Your next prompt re-caches ≈ ${usd} (${clock.ttl} TTL), at or over the ${limit} gate threshold, so the gate will hold it.`
370 : `Debug: the cache is now marked cold, but re-caching ${formatTokens(ctx.tokens)} tokens costs only ≈ ${usd} (${clock.ttl} TTL), under the ${limit} gate threshold, so your next prompt goes straight through. Set "Gate threshold (USD)" to 0 in the plugin's settings to gate every cold prompt.`,
371 }
372 }
373 default: {
374 const clock = await read($, clockAtom)
375 const ctx = await read($, contextAtom)
376 if (clock === null || ctx === null) return { text: 'cache-thermo: no response yet, nothing cached to watch.' }
377 const zone = zoneFor(clock, now, rt.opts.warnFraction, rt.isWorking) ?? 'hot'
378 const est = currentEstimate(ctx, clock)
379 const left = isCold(clock, now) ? 'cold' : `${formatClock(remainingMs(clock, now))} left`
380 const money = moneyText(est.usd, ctx.isSubscription)
381 const recache = money === null ? '' : ` · re-cache ≈ ${money}`
382 return {
383 text: `cache-thermo: ${ZONE_LABEL[zone]} · ${left} · ${formatTokens(ctx.tokens)} ctx on ${est.modelName}${recache} · TTL ${clock.ttl} (${clock.ttlSource}) · gate ≥ ${formatUsd(rt.opts.gateThresholdUsd)} · prices asOf ${getPricingTable().asOf}`,
384 }
385 }
386 }
387 }).catch(($, e, next) => (next.called ? next(e) : { text: `cache-thermo: the command failed: ${String(next.error)}` }))
388
389 on('session.end', async ($, e, next) => {
390 // A /clear or a resume goes on in this process under another conversation.
391 if (e.reason === 'clear' || e.reason === 'resume') await forgetCache($, 'New conversation')
392 else await endBreak($, 'session-end')
393 return next(e)
394 })
395
396 on('session.compact', async ($, e, next) => {
397 const r = await next(e)
398 if (e.agentId !== undefined || e.trigger === 'precompute' || r.skip !== undefined) return r
399 try {
400 await forgetCache($, 'Conversation compacted')
401 } catch (err) {
402 $.ui.log(`forgetting the cache after compaction failed: ${String(err)}`, { to: 'debug' })
403 }
404 return r
405 })
406
407
408 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
409 const hidden = await read($, isBandHiddenAtom)
410 const clock = await read($, clockAtom)
411 const ctx = await read($, contextAtom)
412 const gate = await read($, gateAtom)
413 if (e.props.hasSurvey || clock === null || ctx === null || (hidden && gate === null)) return next(e)
414
415 const tick = await read($, nowAtom)
416 const now = tick > 0 ? tick : await $.clock.now()
417 const zone = zoneFor(clock, now, rt.opts.warnFraction, e.props.isWorking) ?? 'hot'
418 const brk = await read($, brkAtom)
419 const fraction = e.props.isWorking ? 1 : remainingFraction(clock, now)
420 const est = currentEstimate(ctx, clock)
421 const color = zoneColor(zone)
422 const ttlNote = clock.ttlSource === 'heuristic' ? `${clock.ttl} ttl, assumed` : `${clock.ttl} ttl`
423 const unknown = est.pricing === 'unknown-model' ? ' · unknown model' : ''
424 const timeText = zone === 'working'
425 ? 'warming up'
426 : formatLeft(remainingMs(clock, now))
427 const status = `${ZONE_DOT[zone]} ${ZONE_LABEL[zone]} · ${timeText}`
428 const money = moneyText(est.usd, ctx.isSubscription)
429 const recache = money === null ? '' : ` · ~${money} to re-cache`
430 const stats = `${formatTokens(ctx.tokens)} ctx${recache} · ${ttlNote}${unknown}`
431 const breakLabel = '☕ break'
432 const onBreak = () => {
433 void startBreak($)
434 }
435 const onResume = () => {
436 void endBreak($, 'button')
437 }
438 const onHide = () => update($, isBandHiddenAtom, () => true)
439
440 // A held prompt takes the band over until the person picks: the gate is a band view, not a pane.
441 if (gate !== null) {
442 const g = gate
443 const gMoney = moneyText(g.estimate.usd, ctx.isSubscription)
444 const pct = ctx.window > 0 ? ` · ${Math.round((g.estimate.tokens / ctx.window) * 100)}% of window` : ''
445 const headline =
446 gMoney === null
447 ? `❄ brrr, the cache went cold · re-caches ${formatTokens(g.estimate.tokens)} tokens`
448 : `❄ brrr, the cache went cold · re-cache ≈ ${gMoney}`
449 const detail = `${formatTokensLong(g.estimate.tokens)} tokens${pct} · idle ${formatAgo(g.lastRequestAgoMs)}, ${g.estimate.ttl} TTL lapsed`
450 const pricing = gMoney === null ? 'your prompt waits here until you pick' : describePricing(g)
451 const continueLabel = gMoney === null ? 'go ahead anyway' : `go ahead anyway · ${formatUsd(g.estimate.usd)}`
452 const onContinue = () => {
453 void resubmit($, g)
454 }
455 const onCancel = () => {
456 void giveBack($, g)
457 }
458 const { Button } = $.ui.resolve(e)
459 const buttons = [
460 <Button key="continue" variant="primary" hotkey="c" onPress={onContinue}>
461 {continueLabel}
462 </Button>,
463 <Button key="cancel" hotkey="x" onPress={onCancel}>
464 keep my prompt
465 </Button>,
466 ]
467 if (e.surface === 'desktop') {
468 const { Box, Text, Svg } = $.ui.resolve(e)
469 return (
470 <Box
471 key="cache-thermo-band"
472 flexDirection="column"
473 borderStyle="round"
474 borderColor={THEME.cold}
475 backgroundColor={THEME.panel}
476 paddingX={1}
477 >
478 <Box gap={2} alignItems="center">
479 <Svg source={frostbitSvg(44)} alt="Frostbit, the ice imp, shivering" width={44} height={44} isInteractive />
480 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
481 <Text bold color={THEME.cold} wrap="truncate-end">
482 {headline}
483 </Text>
484 <Text color={THEME.ink} wrap="truncate-end">
485 {detail}
486 </Text>
487 <Text color={THEME.dim} wrap="truncate-end">
488 {pricing}
489 </Text>
490 </Box>
491 </Box>
492 <Box gap={1} justifyContent="flex-end">
493 {buttons}
494 </Box>
495 </Box>
496 )
497 }
498 const { Box, Text } = $.ui.resolve(e)
499 const sprite = FROSTBIT_ASCII[Math.floor(now / 1000) % 2] ?? FROSTBIT_ASCII[0] ?? []
500 return (
501 <Box borderStyle="round" borderColor={THEME.cold} paddingX={1} gap={1}>
502 <Box flexDirection="column" width={asciiWidth(FROSTBIT_ASCII)}>
503 {sprite.map(row => (
504 <Text color={THEME.frost}>{row}</Text>
505 ))}
506 </Box>
507 <Box flexDirection="column" flexGrow={1}>
508 <Text bold color={THEME.cold}>
509 {headline}
510 </Text>
511 <Text>{detail}</Text>
512 <Text color={THEME.dim}>{pricing}</Text>
513 <Box gap={2}>{buttons}</Box>
514 </Box>
515 </Box>
516 )
517 }
518
519 if (brk !== null) {
520 const untilRefresh = Math.max(0, brk.nextRefreshAt - now)
521 const line1 = brk.inFlight
522 ? '☕ on a break · stoking the fire now…'
523 : `☕ on a break · keeping the cache warm · next nudge in ${formatClock(untilRefresh)}`
524 const spent = moneyText(brk.spentUsd, ctx.isSubscription)
525 const line2 = `${brk.refreshes} ${brk.refreshes === 1 ? 'nudge' : 'nudges'}${spent === null ? '' : ` · ≈ ${spent} spent`} · away ${formatClock(now - brk.since)} · auto-stop after ${rt.opts.maxBreakHours}h`
526 if (e.surface === 'desktop') {
527 const { Box, Text, Button, Svg } = $.ui.resolve(e)
528 return (
529 <Box
530 key="cache-thermo-band"
531 borderStyle="round"
532 borderColor={THEME.warm}
533 borderDimColor
534 backgroundColor={THEME.panel}
535 paddingX={1}
536 gap={2}
537 alignItems="center"
538 >
539 <Svg source={campfireSvg(44)} alt="Ember tending a campfire" width={44} height={44} isInteractive />
540 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
541 <Text color={THEME.warm} wrap="truncate-end">
542 {line1}
543 </Text>
544 <Text color={THEME.dim} wrap="truncate-end">
545 {line2}
546 </Text>
547 </Box>
548 <Button key="resume" variant="primary" hotkey="r" onPress={onResume}>
549 I'm back
550 </Button>
551 </Box>
552 )
553 }
554 const { Box, Text, Button } = $.ui.resolve(e)
555 const frame = Math.floor(now / 1000) % 2
556 const sprite = CAMPFIRE_ASCII[frame] ?? CAMPFIRE_ASCII[0] ?? []
557 return (
558 <Box borderStyle="round" borderColor={THEME.warm} borderDimColor paddingX={1} gap={1}>
559 <Box flexDirection="column" width={asciiWidth(CAMPFIRE_ASCII)}>
560 {sprite.map(row => (
561 <Text color={THEME.ember}>{row}</Text>
562 ))}
563 </Box>
564 <Box flexDirection="column" flexGrow={1}>
565 <Text color={THEME.warm}>{line1}</Text>
566 <Text color={THEME.dim}>{line2}</Text>
567 <Box>
568 <Button key="resume" variant="primary" hotkey="r" onPress={onResume}>
569 I'm back
570 </Button>
571 </Box>
572 </Box>
573 </Box>
574 )
575 }
576
577 if (e.surface === 'desktop') {
578 const { Box, Text, Button, Svg } = $.ui.resolve(e)
579 // The band is narrow on the desktop (roughly 5 px per cell): keep the tube to about a third.
580 const widthPx = Math.round(Math.min(240, Math.max(140, e.props.bodyColumns * 1.8)))
581 return (
582 <Box
583 key="cache-thermo-band"
584 flexDirection="column"
585 borderStyle="round"
586 borderColor={color}
587 borderDimColor
588 backgroundColor={THEME.panel}
589 paddingX={1}
590 >
591 <Box gap={2} alignItems="center">
592 <Svg
593 source={thermometerSvg({ fraction, zone, widthPx, heightPx: 32 })}
594 alt={`Prompt cache thermometer: ${Math.round(fraction * 100)}% of the TTL left, ${ZONE_LABEL[zone]}`}
595 width={widthPx}
596 height={32}
597 isInteractive
598 />
599 <Box flexGrow={1} flexShrink={1}>
600 <Text color={color} wrap="truncate-end">
601 {status}
602 </Text>
603 </Box>
604 </Box>
605 <Box gap={2} alignItems="center" justifyContent="space-between">
606 <Box flexShrink={1}>
607 <Text color={THEME.dim} wrap="truncate-end">
608 {stats}
609 </Text>
610 </Box>
611 <Box gap={1}>
612 <Button key="break" hotkey="b" plain dimColor onPress={onBreak}>
613 {breakLabel}
614 </Button>
615 <Button key="hide" hotkey="x" plain dimColor onPress={onHide}>
616 hide
617 </Button>
618 </Box>
619 </Box>
620 </Box>
621 )
622 }
623
624 const { Box, Text, Button } = $.ui.resolve(e)
625 const frame = Math.floor(now / 1000) % 2
626 const buddy = zone === 'cold' ? FROSTBIT_ASCII : EMBER_ASCII[zone]
627 const sprite = buddy[frame] ?? buddy[0] ?? []
628 const spriteColor = zone === 'cold' ? PALETTE.frost : PALETTE.ember
629 const spriteW = BUDDY_W
630 const barW = Math.max(8, Math.min(32, e.props.bodyColumns - spriteW - 48))
631 const cells = bar(fraction * barW, barW)
632 return (
633 <Box borderStyle="round" borderColor={color} borderDimColor paddingX={1} gap={1}>
634 <Box flexDirection="column" width={spriteW}>
635 {sprite.map(row => (
636 <Text color={spriteColor}>{row}</Text>
637 ))}
638 </Box>
639 <Box flexDirection="column" flexGrow={1}>
640 <Box gap={1}>
641 <Text color={color}>{cells.fill}</Text>
642 <Text color={THEME.dim}>{cells.empty}</Text>
643 <Text color={color}>{status}</Text>
644 </Box>
645 <Text color={THEME.dim}>{stats}</Text>
646 <Box gap={2}>
647 <Button key="break" hotkey="b" plain dimColor onPress={onBreak}>
648 {breakLabel}
649 </Button>
650 <Button key="hide" hotkey="x" plain dimColor onPress={onHide}>
651 hide
652 </Button>
653 </Box>
654 </Box>
655 </Box>
656 )
657 })
658
659 on('prompt.submit', async ($, e, next) => {
660 if (e.origin.kind === 'plugin' && e.origin.name === PLUGIN) return next(e)
661
662 // Typing a prompt ends a break; the request itself refreshes the cache.
663 if ((await read($, brkAtom)) !== null) await endBreak($, 'prompt')
664
665 if (e.command !== undefined || e.isMeta || e.turnId !== undefined) return next(e)
666
667 const clock = await read($, clockAtom)
668 const ctx = await read($, contextAtom)
669 const now = await $.clock.now()
670 if (clock === null || ctx === null || !isCold(clock, now)) return next(e)
671
672 const estimate = currentEstimate(ctx, clock)
673 if (estimate.usd < rt.opts.gateThresholdUsd) return next(e)
674
675 const money = moneyText(estimate.usd, ctx.isSubscription)
676 const cost = money === null ? `${formatTokens(estimate.tokens)} tokens` : `≈ ${money}`
677 if (e.attachments !== undefined && e.attachments.length > 0) {
678 $.ui.toast(
679 `Cold cache: this prompt re-caches ${cost} (not gated, it carries attachments)`,
680 { timeoutMs: 8000 },
681 )
682 return next(e)
683 }
684
685 const pending: PendingGate = {
686 text: e.text,
687 estimate,
688 lastRequestAgoMs: now - clock.lastRequestStartedAt,
689 }
690 await update($, gateAtom, () => pending)
691 await update($, isBandHiddenAtom, () => false)
692 play($, 'gate')
693 return {
694 drop: `cache-thermo: the prompt cache is cold (re-cache ${cost}). Choose "go ahead anyway" or "keep my prompt" in the band above the prompt.`,
695 }
696 }).catch(($, e, next) => next(e))
697}
698hooks/art.ts 597 lines1// cache-thermo art: Ember (fire sprite), Frostbit (ice imp), the pixel thermometer, the campfire, ASCII frames.
2//
3// Dependency-free so it type-checks standalone. Every SVG string is a complete document; pixel art is `<rect>`
4// runs on an integer grid with crisp edges, animation is SMIL only (no scripts, no event attributes, no
5// external references). The desktop surface draws these through `Svg`; the terminal draws the ASCII frames.
6// The look is pastel and calm: soft colours, chubby rounded shapes, big friendly eyes, gentle 1–2 px motion.
7
8export type Zone = 'hot' | 'warm' | 'cold' | 'working'
9
10export const PALETTE = {
11 hot: '#6ee7b7',
12 warm: '#fcd34d',
13 cold: '#fb7185',
14 panel: '#0f1420',
15 ink: '#dfe7ef',
16 ember: '#ffb27a',
17 frost: '#93c5fd',
18}
19
20type Pal = Readonly<Record<string, string>>
21
22const XMLNS = 'xmlns="http://www.w3.org/2000/svg"'
23const CRISP = 'shape-rendering="crispEdges"'
24
25const clamp01 = (n: number): number => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0)
26
27const zoneColor = (zone: Zone): string =>
28 zone === 'cold' ? PALETTE.cold : zone === 'warm' ? PALETTE.warm : PALETTE.hot
29
30/** Pixel map → rect strings, one per horizontal run of a colour. '.' and ' ' are transparent. */
31function runs(rows: readonly string[], pal: Pal, ox: number, oy: number): Record<string, string[]> {
32 const out: Record<string, string[]> = {}
33 rows.forEach((row, y) => {
34 let x = 0
35 while (x < row.length) {
36 const ch = row.charAt(x)
37 let end = x + 1
38 while (end < row.length && row.charAt(end) === ch) end++
39 const color = pal[ch]
40 if (color !== undefined) {
41 ;(out[color] ??= []).push(`<rect x="${x + ox}" y="${y + oy}" width="${end - x}" height="1"/>`)
42 }
43 x = end
44 }
45 })
46 return out
47}
48
49/** Pixel map → rects grouped in a `<g fill>` per colour. */
50function pixels(rows: readonly string[], pal: Pal, ox = 0, oy = 0): string {
51 return Object.entries(runs(rows, pal, ox, oy))
52 .map(([color, rects]) => `<g fill="${color}">${rects.join('')}</g>`)
53 .join('')
54}
55
56/** Two drawings shown alternately (a hard 2-frame swap, like a sprite sheet). */
57function flicker(a: string, b: string, dur: string): string {
58 const swap = (values: string) =>
59 `<animate attributeName="opacity" values="${values}" keyTimes="0;0.5" calcMode="discrete" dur="${dur}" repeatCount="indefinite"/>`
60 return `<g>${a}${swap('1;0')}</g><g>${b}${swap('0;1')}</g>`
61}
62
63/** A pixel disc of diameter `d` as a map of '#'. */
64function disc(d: number): string[] {
65 const r = d / 2
66 const rows: string[] = []
67 for (let y = 0; y < d; y++) {
68 const dy = y + 0.5 - r
69 const half = Math.sqrt(Math.max(0, r * r - dy * dy))
70 let row = ''
71 for (let x = 0; x < d; x++) row += Math.abs(x + 0.5 - r) <= half ? '#' : '.'
72 rows.push(row)
73 }
74 return rows
75}
76
77/** A pixel pill (rounded ends) as rect strings, one per row. */
78function pillRects(x: number, y: number, w: number, h: number): string[] {
79 const r = Math.floor(h / 2)
80 const out: string[] = []
81 for (let row = 0; row < h; row++) {
82 const inset = Math.max(0, r - 1 - Math.min(row, h - 1 - row))
83 out.push(`<rect x="${x + inset}" y="${y + row}" width="${Math.max(1, w - 2 * inset)}" height="1"/>`)
84 }
85 return out
86}
87
88// ---------------------------------------------------------------------------------------------------------------
89// Ember — a 16×16 fire sprite. Rows 0–8 are the flame (two flicker frames), rows 9–15 the chubby body with the
90// face. R outer coral, O peach, Y butter, W cream core, K eyes/mouth, P cheeks, E dying-ember glow, S smoke.
91
92const EMBER_FLAME_A = [
93 '.......R........',
94 '......RO........',
95 '......ROR.......',
96 '.....ROYOR......',
97 '.....ROYOR..R...',
98 '....ROYYYOR.RO..',
99 '....ROYWYOR.ROR.',
100 '...ROYYWYYORROR.',
101 '...ROYYWWYYOYOR.',
102]
103
104const EMBER_FLAME_B = [
105 '................',
106 '........R.......',
107 '.......RO.......',
108 '......ROYR......',
109 '.....ROYYOR.....',
110 '....ROYYYOR.....',
111 '....ROYWYYOR.R..',
112 '...ROYYWYYOR.RO.',
113 '...ROYYWWYYORROR',
114]
115
116const EMBER_SMALL_FLAME_A = [
117 '................',
118 '................',
119 '................',
120 '................',
121 '.......R........',
122 '......RO........',
123 '......ROR...R...',
124 '.....ROYOR..RO..',
125 '....ROYYYORRROR.',
126]
127
128const EMBER_SMALL_FLAME_B = [
129 '................',
130 '................',
131 '................',
132 '................',
133 '................',
134 '.......R........',
135 '......ROR.......',
136 '......ROYR...R..',
137 '.....ROYYOR.RO..',
138]
139
140/** Body with big eyes, rosy cheeks and a smile (hot / working). */
141const EMBER_BODY_HAPPY = [
142 '..ROYYWWWWYYYOR.',
143 '..ROYWKWWWKWWOR.',
144 '..ROYWKWWWKWWOR.',
145 '..ROPWWWWWWWPOR.',
146 '..ROYWKWWWKWWOR.',
147 '...ROYYKKKYYOR..',
148 '....RROOOOORR...',
149]
150
151/** Body with a small worried mouth (warm). */
152const EMBER_BODY_WORRIED = [
153 '..ROYYWWWWYYYOR.',
154 '..ROYWKWWWKWWOR.',
155 '..ROYWKWWWKWWOR.',
156 '..ROPWWWWWWWPOR.',
157 '..ROYWWKKKWWWOR.',
158 '...ROYYWWWYYOR..',
159 '....RROOOOORR...',
160]
161
162/** Grey, frowning body with a dying glow (cold). */
163const EMBER_BODY_COLD = [
164 '..ROYYWWWWYYYOR.',
165 '..ROYWKWWWKWWOR.',
166 '..ROYWKWWWKWWOR.',
167 '..ROYWWWWWWWWOR.',
168 '..ROYWWKKKWWWOR.',
169 '...ROYKWWWKYOR..',
170 '....RREEEEERR...',
171]
172
173const EMBER_SMOKE = [
174 '................',
175 '........S.......',
176 '.......SS.......',
177 '........S.......',
178 '........S.......',
179 '.......S........',
180 '.......SS.......',
181 '........S.......',
182 '........S.......',
183]
184
185const EMBER_PAL: Record<'hot' | 'warm' | 'cold', Pal> = {
186 hot: { R: '#f08a7a', O: PALETTE.ember, Y: '#ffe3a3', W: '#fff8e6', K: '#5a3a3a', P: PALETTE.cold },
187 warm: { R: '#e39a86', O: '#f4b98e', Y: '#fde68a', W: '#fff3d6', K: '#5a3a3a', P: '#f3b8c0' },
188 cold: { R: '#4b5566', O: '#687486', Y: '#8a97aa', W: '#aab6c8', K: '#2a3240', P: '#8a97aa' },
189}
190
191const FLICKER_DUR: Record<Zone, string> = { hot: '0.6s', warm: '0.9s', cold: '1s', working: '0.3s' }
192
193/** Rising smoke wisps for the cold ember, two staggered copies. */
194function smoke(ox: number, oy: number): string {
195 const wisp = pixels(EMBER_SMOKE, { S: '#b7c2d1' }, ox, oy)
196 const copy = (begin: string) =>
197 `<g opacity="0"><animate attributeName="opacity" values="0;0.6;0.6;0" keyTimes="0;0.2;0.6;1" dur="2.8s" begin="${begin}" repeatCount="indefinite"/><animateTransform attributeName="transform" type="translate" from="0 4" to="0 -5" dur="2.8s" begin="${begin}" repeatCount="indefinite"/>${wisp}</g>`
198 return copy('0s') + copy('1.4s')
199}
200
201/** The dying-ember glow pixels (E) pulsing on the cold body. */
202function coldGlow(ox: number, oy: number): string {
203 return `<g><animate attributeName="opacity" values="0.3;0.85;0.3" dur="2.4s" repeatCount="indefinite"/>${pixels(EMBER_BODY_COLD, { E: PALETTE.ember }, ox, oy)}</g>`
204}
205
206/** Ember's body (rows 9–15) at an offset. */
207function emberBody(zone: Zone, ox: number, oy: number): string {
208 if (zone === 'cold') return pixels(EMBER_BODY_COLD, EMBER_PAL.cold, ox, oy + 9) + coldGlow(ox, oy + 9)
209 if (zone === 'warm') return pixels(EMBER_BODY_WORRIED, EMBER_PAL.warm, ox, oy + 9)
210 return pixels(EMBER_BODY_HAPPY, EMBER_PAL.hot, ox, oy + 9)
211}
212
213/** Ember's flame (rows 0–8) at an offset; `tall` forces the big flame shape (used by the thermometer). */
214function emberFlame(zone: Zone, ox: number, oy: number, tall = false): string {
215 if (zone === 'cold') return smoke(ox, oy)
216 const pal = zone === 'warm' ? EMBER_PAL.warm : EMBER_PAL.hot
217 const small = zone === 'warm' && !tall
218 const a = pixels(small ? EMBER_SMALL_FLAME_A : EMBER_FLAME_A, pal, ox, oy)
219 const b = pixels(small ? EMBER_SMALL_FLAME_B : EMBER_FLAME_B, pal, ox, oy)
220 return flicker(a, b, FLICKER_DUR[zone])
221}
222
223const EMBER_TITLE: Record<Zone, string> = {
224 hot: 'Ember, glowing: the cache is warm',
225 warm: 'Ember, fading: the cache is cooling',
226 cold: 'Ember, gone out: the cache is cold',
227 working: 'Ember, working: a request is refreshing the cache',
228}
229
230export function emberSvg(zone: Zone, sizePx = 48): string {
231 const s = Math.max(8, Math.round(sizePx))
232 return `<svg ${XMLNS} viewBox="0 0 16 16" width="${s}" height="${s}" ${CRISP}><title>${EMBER_TITLE[zone]}</title>${emberFlame(zone, 0, 0)}${emberBody(zone, 0, 0)}</svg>`
233}
234
235// ---------------------------------------------------------------------------------------------------------------
236// Frostbit — a 16×16 ice imp: little crystal ears, big eyes, rosy cheeks, a shy smile, stubby legs, a small shiver.
237// D soft-blue outline, F frost, L light frost, W white glints, K eyes and mouth, P cheeks.
238
239const FROSTBIT = [
240 '..D..........D..',
241 '..DL........LD..',
242 '..DFL......LFD..',
243 '..DFFLDDDDLFFD..',
244 '..DFFFFFFFFFFD..',
245 '.DFLFFFFFFFFLFD.',
246 '.DFLKKFFFFKKLFD.',
247 '.DFFKWFFFFKWFFD.',
248 '.DFFKKFFFFKKFFD.',
249 '.DFPFKFFFFKFPFD.',
250 '..DFFFKKKKFFFD..',
251 '..DFFFFFFFFFFD..',
252 '...DFFFFFFFFD...',
253 '...DFFDFFDFFD...',
254 '....DFD..DFD....',
255 '....DDD..DDD....',
256]
257
258/** Frostbit blinking: the eye rows swapped for closed lids. */
259const FROSTBIT_BLINK = [
260 '.DFLFFFFFFFFLFD.',
261 '.DFFDDFFFFDDFFD.',
262 '.DFFFFFFFFFFFFD.',
263]
264
265const FROSTBIT_PAL: Pal = { D: '#5b93d6', F: PALETTE.frost, L: '#d6e8ff', W: '#ffffff', K: '#2f4a63', P: '#fda4af' }
266
267/** Frostbit at an offset: a small shiver, an occasional blink, drifting snow pixels. */
268function frostbit(ox: number, oy: number): string {
269 const body = pixels(FROSTBIT, FROSTBIT_PAL, ox, oy)
270 const blink = `<g opacity="0"><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.9;0.9;0.97;1" calcMode="discrete" dur="3.4s" repeatCount="indefinite"/>${pixels(FROSTBIT_BLINK, FROSTBIT_PAL, ox, oy + 6)}</g>`
271 const shiver = `<animateTransform attributeName="transform" type="translate" values="0 0;1 0;0 0;0 0;-1 0;0 0;0 0;0 0" calcMode="discrete" dur="0.8s" repeatCount="indefinite"/>`
272 const flake = (x: number, y: number, dur: string, begin: string) =>
273 `<rect x="${x + ox}" y="${y + oy}" width="1" height="1" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.8;0.8;0" keyTimes="0;0.1;0.8;1" dur="${dur}" begin="${begin}" repeatCount="indefinite"/><animate attributeName="y" from="${y + oy}" to="${y + oy + 5}" dur="${dur}" begin="${begin}" repeatCount="indefinite"/></rect>`
274 return `<g>${shiver}${body}${blink}</g>${flake(0, 1, '3s', '0s')}${flake(15, 4, '2.6s', '1s')}${flake(7, 0, '3.3s', '1.9s')}`
275}
276
277export function frostbitSvg(sizePx = 48): string {
278 const s = Math.max(8, Math.round(sizePx))
279 return `<svg ${XMLNS} viewBox="0 0 16 16" width="${s}" height="${s}" ${CRISP}><title>Frostbit, the ice imp: the cache is cold</title>${frostbit(0, 0)}</svg>`
280}
281
282// ---------------------------------------------------------------------------------------------------------------
283// Campfire — 24×16: a fire on crossed logs, a small Ember sitting on a stone beside it, sparks drifting up.
284// B dark log, b light log, G stones, plus the Ember palette.
285
286const CAMPFIRE_FIRE_A = [
287 '........................',
288 '.........R..............',
289 '........RO..............',
290 '........ROR.............',
291 '.......ROYOR............',
292 '...R...ROYOR............',
293 '...RO.ROYYYOR...........',
294 '...RORROYWYYOR..........',
295 '....ROYYWWWYOR..........',
296 '....ROYWWWWWYOR.........',
297 '....ROYWWWWWYOR.........',
298 '.....ROYYWWYYOR.........',
299]
300
301const CAMPFIRE_FIRE_B = [
302 '........................',
303 '........................',
304 '..........R.............',
305 '.........RO.............',
306 '.........ROR............',
307 '........ROYOR...........',
308 '.......ROYYOR...........',
309 '..R....ROYWYOR..........',
310 '..RO..ROYYWWYOR.........',
311 '..RORROYWWWWYOR.........',
312 '...RROYWWWWWYOR.........',
313 '....ROYYWWWYYOR.........',
314]
315
316/** Ember sitting (rows 3–11 of the campfire, columns 16–23). */
317const CAMPFIRE_EMBER = [
318 '..................R.....',
319 '.................ROR....',
320 '.................ROYR...',
321 '................ROYYOR..',
322 '................ROWWWOR.',
323 '................ROKWWKOR',
324 '................ROKWWKOR',
325 '................ROWKKWOR',
326 '.................RROOOR.',
327]
328
329const CAMPFIRE_GROUND = [
330 '..bBBBBBBBBBBBBBBb.GGGGG',
331 '.bBbbbbbbbbbbbbbbBbGGGGG',
332 'GG.GGG.GG.GGG.GG.GGG.GG.',
333]
334
335const CAMPFIRE_PAL: Pal = { ...EMBER_PAL.hot, B: '#8a6a52', b: '#b89a80', G: '#4d566a' }
336
337export function campfireSvg(sizePx = 48): string {
338 const w = Math.max(12, Math.round(sizePx))
339 const h = Math.round((w * 16) / 24)
340 const spark = (x: number, y: number, dx: number, dur: string, begin: string, color: string) =>
341 `<rect x="${x}" y="${y}" width="1" height="1" fill="${color}" opacity="0"><animate attributeName="opacity" values="0;0.9;0.9;0" keyTimes="0;0.1;0.6;1" dur="${dur}" begin="${begin}" repeatCount="indefinite"/><animate attributeName="y" from="${y}" to="${y - 6}" dur="${dur}" begin="${begin}" repeatCount="indefinite"/><animate attributeName="x" values="${x};${x + dx};${x}" dur="${dur}" begin="${begin}" repeatCount="indefinite"/></rect>`
342 const sparks =
343 spark(8, 7, 1, '2.1s', '0s', '#ffe3a3') +
344 spark(11, 8, -1, '1.8s', '0.6s', PALETTE.ember) +
345 spark(6, 9, 1, '2.3s', '1.1s', '#fff8e6') +
346 spark(12, 6, 1, '1.9s', '1.6s', '#ffe3a3')
347 const fire = flicker(pixels(CAMPFIRE_FIRE_A, CAMPFIRE_PAL), pixels(CAMPFIRE_FIRE_B, CAMPFIRE_PAL), '0.55s')
348 const glow = `<rect x="3" y="11" width="13" height="1" fill="${PALETTE.ember}" opacity="0.45"><animate attributeName="opacity" values="0.45;0.2;0.45" dur="1.1s" repeatCount="indefinite"/></rect>`
349 return `<svg ${XMLNS} viewBox="0 0 24 16" width="${w}" height="${h}" ${CRISP}><title>Ember tending the campfire: keeping the cache warm</title>${pixels(CAMPFIRE_GROUND, CAMPFIRE_PAL, 0, 12)}${glow}${fire}${sparks}${pixels(CAMPFIRE_EMBER, CAMPFIRE_PAL, 0, 3)}</svg>`
350}
351
352// ---------------------------------------------------------------------------------------------------------------
353// Mini mascots for the thermometer — 12×12 so they fit a 32 px band at 2 px per art pixel.
354
355const MINI_FLAME_A = [
356 '.....R......',
357 '....RO......',
358 '....ROR..R..',
359 '...ROYOR.RO.',
360 '...ROYWORROR',
361 '..ROYWWYOYOR',
362]
363
364const MINI_FLAME_B = [
365 '............',
366 '......R.....',
367 '.....RO.....',
368 '....ROYR....',
369 '...ROYWOR.R.',
370 '..ROYWWYORRO',
371]
372
373const MINI_SMOKE = [
374 '............',
375 '......S.....',
376 '.....SS.....',
377 '......S.....',
378 '.....S......',
379 '.....SS.....',
380]
381
382const MINI_BODY_HAPPY = [
383 '.ROYWWWWYYOR',
384 '.ROYKWWWKWOR',
385 '.ROYKWWWKWOR',
386 '.RPWKWWWKWPR',
387 '..ROWKKKWOR.',
388 '...RROOORR..',
389]
390
391const MINI_BODY_WORRIED = [
392 '.ROYWWWWYYOR',
393 '.ROYKWWWKWOR',
394 '.ROYKWWWKWOR',
395 '.RPWWWWWWWPR',
396 '..ROWKKKWOR.',
397 '...RROOORR..',
398]
399
400const MINI_BODY_COLD = [
401 '.ROYWWWWYYOR',
402 '.ROYKWWWKWOR',
403 '.ROYKWWWKWOR',
404 '.RYWWKKKWWYR',
405 '..ROKWWWKOR.',
406 '...RREEERR..',
407]
408
409const MINI_FROSTBIT = [
410 '.D........D.',
411 '.DL......LD.',
412 '.DFLDDDDLFD.',
413 '.DFFFFFFFFD.',
414 'DFLKKFFFKKFD',
415 'DFFKWFFFKWFD',
416 'DFPFKFFFKFPD',
417 '.DFFFKKKFFD.',
418 '.DFFFFFFFFD.',
419 '..DFFFFFFD..',
420 '..DFDFFDFD..',
421 '..DDD..DDD..',
422]
423
424const MINI_FROSTBIT_BLINK = ['DFLFFFFFFFFD', 'DFFDDFFFDDFD']
425
426/** Mini Ember's body (rows 6–11) at an offset. */
427function miniEmberBody(zone: Zone, ox: number, oy: number): string {
428 if (zone === 'cold') {
429 return (
430 pixels(MINI_BODY_COLD, EMBER_PAL.cold, ox, oy + 6) +
431 `<g><animate attributeName="opacity" values="0.3;0.85;0.3" dur="2.4s" repeatCount="indefinite"/>${pixels(MINI_BODY_COLD, { E: PALETTE.ember }, ox, oy + 6)}</g>`
432 )
433 }
434 if (zone === 'warm') return pixels(MINI_BODY_WORRIED, EMBER_PAL.warm, ox, oy + 6)
435 return pixels(MINI_BODY_HAPPY, EMBER_PAL.hot, ox, oy + 6)
436}
437
438/** Mini Ember's flame (rows 0–5) at an offset; smoke when cold. */
439function miniEmberFlame(zone: Zone, ox: number, oy: number): string {
440 if (zone === 'cold') {
441 const wisp = pixels(MINI_SMOKE, { S: '#b7c2d1' }, ox, oy)
442 const copy = (begin: string) =>
443 `<g opacity="0"><animate attributeName="opacity" values="0;0.6;0.6;0" keyTimes="0;0.2;0.6;1" dur="2.8s" begin="${begin}" repeatCount="indefinite"/><animateTransform attributeName="transform" type="translate" from="0 3" to="0 -4" dur="2.8s" begin="${begin}" repeatCount="indefinite"/>${wisp}</g>`
444 return copy('0s') + copy('1.4s')
445 }
446 const pal = zone === 'warm' ? EMBER_PAL.warm : EMBER_PAL.hot
447 return flicker(pixels(MINI_FLAME_A, pal, ox, oy), pixels(MINI_FLAME_B, pal, ox, oy), FLICKER_DUR[zone])
448}
449
450/** Mini Frostbit at an offset: small shiver, occasional blink. */
451function miniFrostbit(ox: number, oy: number): string {
452 const blink = `<g opacity="0"><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.9;0.9;0.97;1" calcMode="discrete" dur="3.4s" repeatCount="indefinite"/>${pixels(MINI_FROSTBIT_BLINK, FROSTBIT_PAL, ox, oy + 4)}</g>`
453 const shiver = `<animateTransform attributeName="transform" type="translate" values="0 0;1 0;0 0;0 0;-1 0;0 0;0 0;0 0" calcMode="discrete" dur="0.8s" repeatCount="indefinite"/>`
454 return `<g>${shiver}${pixels(MINI_FROSTBIT, FROSTBIT_PAL, ox, oy)}${blink}</g>`
455}
456
457// ---------------------------------------------------------------------------------------------------------------
458// Thermometer — horizontal, 16 grid units tall (2 px per unit at the band's 32 px height). Mini Ember on the
459// left (its flame sinks into the body as the fraction drops), a round bulb, a slim pill-shaped tube filled in the
460// full zone colour with a soft highlight along the top, dim ticks at 25/50/75 %, mini Frostbit sliding in from
461// the right when the cache is cold.
462
463const GRID_H = 16
464
465export function thermometerSvg(opts: { fraction: number; zone: Zone; widthPx: number; heightPx?: number }): string {
466 const hPx = Math.max(16, Math.round(opts.heightPx ?? 32))
467 const unit = hPx / GRID_H
468 const wPx = Math.max(100, Math.round(opts.widthPx))
469 const W = Math.max(50, Math.round(wPx / unit))
470 const f = clamp01(opts.fraction)
471 const zone = opts.zone
472 const color = zoneColor(zone)
473 const fast = zone === 'working'
474 const cold = zone === 'cold'
475 const pct = Math.round(f * 100)
476 const uid = `ct${zone}${Math.round(f * 1000)}w${W}`
477 const glass = '#1a2233'
478
479 // Layout (grid units).
480 const emberY = 2 // mini sprite rows 0–11 → y 2..13; the eyes sit level with the tube
481 const bulbX = 11
482 const bulbY = 6 // 9-unit disc → y 6..14, centre y 10
483 const tubeX = 18
484 const tubeY = 7
485 const tubeH = 7 // outer pill, 14 px at 32 px height
486 const tubeW = Math.max(14, W - tubeX - 2)
487 const innerX = tubeX + 1
488 const innerY = tubeY + 1
489 const innerW = tubeW - 2
490 const innerH = tubeH - 2
491 const fillW = Math.round(innerW * f)
492
493 // Ember: the flame sinks behind the body as the fraction drops; only the tip shows near zero.
494 const flameRows = 6
495 const sink = cold ? 0 : Math.round((1 - f) * (flameRows - 1))
496 const clipId = `${uid}f`
497 const ember =
498 `<clipPath id="${clipId}"><rect x="0" y="${emberY}" width="12" height="${flameRows}"/></clipPath>` +
499 `<g clip-path="url(#${clipId})"><g transform="translate(0 ${sink})">${miniEmberFlame(zone, 0, emberY)}</g></g>` +
500 miniEmberBody(zone, 0, emberY)
501
502 // Bulb: ink ring, coloured core with a gentle breathing pulse, a small glint.
503 const pulseDur = fast ? '1.2s' : '2.6s'
504 const bulb =
505 pixels(disc(9), { '#': PALETTE.ink }, bulbX, bulbY) +
506 `<g>${pixels(disc(7), { '#': color }, bulbX + 1, bulbY + 1)}<animate attributeName="opacity" values="1;0.8;1" dur="${pulseDur}" repeatCount="indefinite"/></g>` +
507 `<rect x="${bulbX + 2}" y="${bulbY + 2}" width="2" height="1" fill="#ffffff" opacity="0.5"/>`
508
509 // Tube: a pill outline in ink with a darker glass interior; the fill is clipped to the inner pill.
510 const innerClip = `${uid}i`
511 const inner = pillRects(innerX, innerY, innerW, innerH)
512 const tube =
513 `<g fill="${PALETTE.ink}">${pillRects(tubeX, tubeY, tubeW, tubeH).join('')}</g>` +
514 `<g fill="${glass}">${inner.join('')}</g>` +
515 `<clipPath id="${innerClip}">${inner.join('')}</clipPath>`
516
517 // Fill: the full zone colour, a soft lighter line along the top, a faint shimmer drifting along it.
518 const shimmerDur = fast ? '1.6s' : '3.6s'
519 const fill =
520 fillW <= 0
521 ? ''
522 : `<g clip-path="url(#${innerClip})">` +
523 `<rect x="${innerX}" y="${innerY}" width="${fillW}" height="${innerH}" fill="${color}"/>` +
524 `<rect x="${innerX}" y="${innerY}" width="${fillW}" height="1" fill="#ffffff" opacity="0.4"/>` +
525 `<rect x="${innerX - 5}" y="${innerY}" width="4" height="${innerH}" fill="#ffffff" opacity="0.14"><animate attributeName="x" from="${innerX - 5}" to="${innerX + fillW + 2}" dur="${shimmerDur}" repeatCount="indefinite"/></rect>` +
526 `</g>`
527
528 // Dim ticks at 25/50/75 % through the glass.
529 const ticks = [0.25, 0.5, 0.75]
530 .map(t => {
531 const x = innerX + Math.round(innerW * t)
532 return `<rect x="${x}" y="${innerY}" width="1" height="${innerH}" fill="${PALETTE.ink}" opacity="0.22"/>`
533 })
534 .join('')
535
536 // Cold: a little rime along the empty tube and mini Frostbit sliding in from the right end.
537 const frostX = W - 14
538 const rime = cold
539 ? [0.5, 0.58, 0.66, 0.73, 0.8]
540 .map((t, i) => {
541 const x = innerX + Math.round(innerW * t)
542 const y = innerY + (i % 2 === 0 ? 0 : innerH - 1)
543 return `<rect x="${x}" y="${y}" width="${1 + (i % 2)}" height="1" fill="#d6e8ff" opacity="0.7"/>`
544 })
545 .join('')
546 : ''
547 const visitor = cold
548 ? `<g transform="translate(${W + 2} 2)"><animateTransform attributeName="transform" type="translate" from="${W + 2} 2" to="${frostX} 2" dur="0.8s" fill="freeze" calcMode="spline" keySplines="0.2 0.8 0.3 1"/>${miniFrostbit(0, 0)}</g>`
549 : ''
550
551 const title = `Prompt cache thermometer: ${pct}% of the TTL left (${zone})`
552 return `<svg ${XMLNS} viewBox="0 0 ${W} ${GRID_H}" width="${wPx}" height="${hPx}" ${CRISP}><title>${title}</title>${tube}${fill}${rime}${ticks}${bulb}${ember}${visitor}</svg>`
553}
554
555// ---------------------------------------------------------------------------------------------------------------
556// Terminal art: 3 rows, two frames each. Every glyph is one cell wide in every terminal: ASCII plus
557// 。 ◕ ‿ ᵔ ᶻ, never emoji (❄ ☕) or East Asian ambiguous-width marks (• ⌒ ˘ °), which some terminals draw
558// two cells wide and shear the sprite. Rows are padded to one width; `asciiWidth` sizes the column.
559
560const frames = (...f: string[][]): string[][] => {
561 const w = Math.max(...f.flat().map(r => r.length))
562 return f.map(rows => rows.map(r => r.padEnd(w)))
563}
564
565/** Ember: a flame tuft over a little face; big flames while working, a dozing ember when cooling off. */
566export const EMBER_ASCII: Record<Zone, string[][]> = {
567 hot: frames([' (', ' ) )', ' (。◕‿◕。)'], [' )', ' ( (', ' (。◕‿◕。)']),
568 warm: frames(['', ' ,', ' (。-‿-。) zᶻ'], ['', " '", ' (。-‿-。)ᶻ']),
569 cold: frames([' ~ ~', ' ~', ' (。;_;。)'], [' ~ ~', ' ~', ' (。;_;。)']),
570 working: frames([' \\ ) ( /', ' ) ( )', ' (。>‿<。)'], [' / ( ) \\', ' ( ) (', ' (。>‿<。)']),
571}
572
573/** Frostbit: an ice crown, a shiver (one cell side to side) and falling snow. */
574export const FROSTBIT_ASCII: string[][] = frames(
575 [' * /\\/\\/\\ .', ' (。◕~◕。)', ' . u u *'],
576 [' . /\\/\\/\\ *', ' (。◕~◕。)', ' * u u .'],
577)
578
579/** Ember on a break: a mug of cocoa beside a crackling campfire. */
580export const CAMPFIRE_ASCII: string[][] = frames(
581 [' )', ' (。ᵔ‿ᵔ。) ( (', ' c[_] /\\/\\'],
582 [' (', ' (。ᵔ‿ᵔ。) ) )', ' c[_] /\\/\\'],
583)
584
585/** The column a set of frames needs, one cell of air included. */
586export const asciiWidth = (f: string[][]): number => Math.max(...f.flat().map(r => r.length)) + 1
587
588/** One column for every Ember zone and Frostbit, so the band's bar does not jump when the zone changes. */
589export const BUDDY_W = Math.max(asciiWidth(FROSTBIT_ASCII), ...Object.values(EMBER_ASCII).map(asciiWidth))
590
591/** `█` × filled and `░` × (total − filled), both clamped to the bar. */
592export function bar(filledCells: number, totalCells: number): { fill: string; empty: string } {
593 const total = Math.max(0, Math.floor(Number.isFinite(totalCells) ? totalCells : 0))
594 const filled = Math.min(total, Math.max(0, Math.round(Number.isFinite(filledCells) ? filledCells : 0)))
595 return { fill: '█'.repeat(filled), empty: '░'.repeat(total - filled) }
596}
597hooks/clock.ts 132 lines1// Pure cache-clock math. No imports: this file is shared by the hooks module and the tests.
2
3import type { CacheClock, CacheTtl, CacheZone, TtlSource } from '../types'
4
5export const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
6
7export type ClockOptions = {
8 /** `auto`, `5m` or `1h` from userConfig. */
9 cacheTtl: string
10 /** Remaining fraction at or below which the band turns yellow. */
11 warnFraction: number
12}
13
14/** Picks the TTL: the configured one, else 1h on a subscription (rate limits reported) and 5m otherwise. */
15export function resolveTtl(
16 cacheTtl: string,
17 hasRateLimits: boolean,
18): { ttl: CacheTtl; source: TtlSource } {
19 if (cacheTtl === '5m' || cacheTtl === '1h') return { ttl: cacheTtl, source: 'config' }
20 return { ttl: hasRateLimits ? '1h' : '5m', source: 'heuristic' }
21}
22
23/** A clock started by a request at `requestStartedAt`. */
24export function clockFor(
25 requestStartedAt: number,
26 ttl: CacheTtl,
27 source: TtlSource,
28): CacheClock {
29 const ttlMs = TTL_MS[ttl]
30 return {
31 lastRequestStartedAt: requestStartedAt,
32 warmUntil: requestStartedAt + ttlMs,
33 ttlMs,
34 ttl,
35 ttlSource: source,
36 }
37}
38
39/** The same clock on another TTL (the config changed). */
40export function retime(clock: CacheClock, ttl: CacheTtl, source: TtlSource): CacheClock {
41 return clockFor(clock.lastRequestStartedAt, ttl, source)
42}
43
44export function remainingMs(clock: CacheClock, now: number): number {
45 return Math.max(0, clock.warmUntil - now)
46}
47
48/** Remaining TTL as a fraction in [0, 1]. */
49export function remainingFraction(clock: CacheClock, now: number): number {
50 if (clock.ttlMs <= 0) return 0
51 return Math.min(1, remainingMs(clock, now) / clock.ttlMs)
52}
53
54export function isCold(clock: CacheClock | null, now: number): boolean {
55 return clock !== null && remainingMs(clock, now) <= 0
56}
57
58export function zoneFor(
59 clock: CacheClock | null,
60 now: number,
61 warnFraction: number,
62 isWorking: boolean,
63): CacheZone | null {
64 if (clock === null) return null
65 if (isWorking) return 'working'
66 const fraction = remainingFraction(clock, now)
67 if (fraction <= 0) return 'cold'
68 if (fraction <= warnFraction) return 'warm'
69 return 'hot'
70}
71
72/** `mm:ss`, or `h:mm:ss` past an hour. */
73export function formatClock(ms: number): string {
74 const total = Math.max(0, Math.round(ms / 1000))
75 const h = Math.floor(total / 3600)
76 const m = Math.floor((total % 3600) / 60)
77 const s = total % 60
78 const mm = String(m).padStart(2, '0')
79 const ss = String(s).padStart(2, '0')
80 return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
81}
82
83/** `41 min left` above two minutes, `1:59 left` below; `nap time` when gone. */
84export function formatLeft(ms: number): string {
85 if (ms <= 0) return 'nap time'
86 if (ms > 120_000) {
87 const min = Math.ceil(ms / 60_000)
88 return `${min} min left`
89 }
90 return `${formatClock(ms)} left`
91}
92
93/**
94 * What the band shows changes only when this key changes: the zone, the
95 * minute above two minutes left, the second below. The ticker redraws on it,
96 * not on every second, so the desktop does not re-mount the band each tick.
97 */
98export function displayKey(remaining: number, zone: string): string {
99 if (remaining <= 0) return `${zone}:0`
100 if (remaining > 120_000) return `${zone}:m${Math.ceil(remaining / 60_000)}`
101 return `${zone}:s${Math.floor(remaining / 1000)}`
102}
103
104/** `1h 12m`, `12m`, `45s`: how long ago, for the dialog. */
105export function formatAgo(ms: number): string {
106 const total = Math.max(0, Math.round(ms / 1000))
107 if (total < 60) return `${total}s`
108 const h = Math.floor(total / 3600)
109 const m = Math.floor((total % 3600) / 60)
110 if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`
111 return `${m}m`
112}
113
114/** `212k`, `1.2M`, `980`. */
115export function formatTokens(n: number): string {
116 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
117 if (n >= 10_000) return `${Math.round(n / 1000)}k`
118 if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
119 return String(Math.round(n))
120}
121
122/** `212,480`. */
123export function formatTokensLong(n: number): string {
124 return Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
125}
126
127/** Keep-warm lead clamped to [30 s, ttl/2]. */
128export function keepWarmLeadMs(leadSeconds: number, ttlMs: number): number {
129 const lead = Math.max(30, Number.isFinite(leadSeconds) ? leadSeconds : 90) * 1000
130 return Math.min(lead, Math.floor(ttlMs / 2))
131}
132hooks/options.ts 32 lines1import type { PluginOptions } from 'claude-code'
2
3export type Opts = {
4 cacheTtl: string
5 gateThresholdUsd: number
6 warnFraction: number
7 keepWarmLeadSeconds: number
8 maxBreakHours: number
9 inputPriceOverrideUsdPerMTok: number
10 sounds: boolean
11 showCostOnSubscription: boolean
12}
13
14const num = (v: unknown, fallback: number): number => {
15 const n = typeof v === 'number' ? v : Number(v)
16 return Number.isFinite(n) ? n : fallback
17}
18
19export function readOptions(options: PluginOptions): Opts {
20 return {
21 cacheTtl: String(options.cacheTtl ?? 'auto'),
22 gateThresholdUsd: Math.max(0, num(options.gateThresholdUsd, 0.25)),
23 warnFraction: Math.min(0.95, Math.max(0.01, num(options.warnFraction, 0.2))),
24 keepWarmLeadSeconds: num(options.keepWarmLeadSeconds, 90),
25 maxBreakHours: Math.max(0.1, num(options.maxBreakHours, 4)),
26 inputPriceOverrideUsdPerMTok: Math.max(0, num(options.inputPriceOverrideUsdPerMTok, 0)),
27 sounds: options.sounds !== false && options.sounds !== 'false',
28 showCostOnSubscription:
29 options.showCostOnSubscription !== false && options.showCostOnSubscription !== 'false',
30 }
31}
32hooks/pricing.ts 136 lines1// Pure pricing math over the shipped table. No engine imports.
2
3import type { CacheTtl, Estimate } from '../types'
4import { PRICING } from './pricing-table'
5
6export type ModelPrice = {
7 match: string
8 name: string
9 inputUsdPerMTok: number
10 outputUsdPerMTok: number
11 cacheReadMultiplier?: number
12}
13
14export type PricingTable = {
15 asOf: string
16 source: string
17 cacheWriteMultiplier: Record<CacheTtl, number>
18 defaultCacheReadMultiplier: number
19 models: ModelPrice[]
20 fallback: Omit<ModelPrice, 'match'>
21}
22
23let activeTable: PricingTable = PRICING
24
25/** The table in force: the shipped one, or pricing.json read from the plugin folder at session start. */
26export function getPricingTable(): PricingTable {
27 return activeTable
28}
29
30/** Accepts a parsed pricing.json when it has the expected shape; returns whether it was taken. */
31export function setPricingTable(candidate: unknown): boolean {
32 if (!isPricingTable(candidate)) return false
33 activeTable = candidate
34 return true
35}
36
37export function isPricingTable(v: unknown): v is PricingTable {
38 if (typeof v !== 'object' || v === null) return false
39 const t = v as Record<string, unknown>
40 const mult = t.cacheWriteMultiplier as Record<string, unknown> | undefined
41 return (
42 typeof t.asOf === 'string' &&
43 typeof mult === 'object' && mult !== null &&
44 typeof mult['5m'] === 'number' && typeof mult['1h'] === 'number' &&
45 typeof t.defaultCacheReadMultiplier === 'number' &&
46 Array.isArray(t.models) &&
47 t.models.every(m => typeof m === 'object' && m !== null && typeof (m as ModelPrice).match === 'string' && typeof (m as ModelPrice).inputUsdPerMTok === 'number') &&
48 typeof t.fallback === 'object' && t.fallback !== null
49 )
50}
51
52export type ResolvedPrice = {
53 name: string
54 inputUsdPerMTok: number
55 outputUsdPerMTok: number
56 cacheReadMultiplier: number
57 isFallback: boolean
58}
59
60/** Strips a provider path (`us.anthropic.`), a `[1m]` tag and surrounding whitespace. */
61export function normalizeModelId(model: string): string {
62 let id = model.trim().toLowerCase()
63 id = id.replace(/\[.*?\]/g, '')
64 const slash = id.lastIndexOf('/')
65 if (slash >= 0) id = id.slice(slash + 1)
66 id = id.replace(/^(?:[a-z]{2}(?:-[a-z]+)?\.)?anthropic\./, '')
67 return id.trim()
68}
69
70export function priceFor(model: string, table: PricingTable = PRICING): ResolvedPrice {
71 const id = normalizeModelId(model)
72 for (const row of table.models) {
73 if (new RegExp(row.match).test(id)) {
74 return {
75 name: row.name,
76 inputUsdPerMTok: row.inputUsdPerMTok,
77 outputUsdPerMTok: row.outputUsdPerMTok,
78 cacheReadMultiplier: row.cacheReadMultiplier ?? table.defaultCacheReadMultiplier,
79 isFallback: false,
80 }
81 }
82 }
83 return {
84 name: table.fallback.name,
85 inputUsdPerMTok: table.fallback.inputUsdPerMTok,
86 outputUsdPerMTok: table.fallback.outputUsdPerMTok,
87 cacheReadMultiplier: table.fallback.cacheReadMultiplier ?? table.defaultCacheReadMultiplier,
88 isFallback: true,
89 }
90}
91
92/** What re-caching `tokens` of context costs on `model` at the `ttl` write rate. */
93export function estimateRecache(
94 tokens: number,
95 model: string,
96 ttl: CacheTtl,
97 inputPriceOverride = 0,
98 table: PricingTable = PRICING,
99): Estimate {
100 const price = priceFor(model, table)
101 const hasOverride = inputPriceOverride > 0
102 const inputPrice = hasOverride ? inputPriceOverride : price.inputUsdPerMTok
103 const usd = (tokens * inputPrice * table.cacheWriteMultiplier[ttl]) / 1_000_000
104 return {
105 usd,
106 tokens,
107 ttl,
108 model,
109 modelName: price.name,
110 pricing: hasOverride ? 'override' : price.isFallback ? 'unknown-model' : 'table',
111 }
112}
113
114/** What one keep-warm request cost: its cache read plus its (tiny) reply. */
115export function keepWarmCost(
116 usage: { cache_read_input_tokens: number; input_tokens: number; output_tokens: number },
117 model: string,
118 inputPriceOverride = 0,
119 table: PricingTable = PRICING,
120): number {
121 const price = priceFor(model, table)
122 const inputPrice = inputPriceOverride > 0 ? inputPriceOverride : price.inputUsdPerMTok
123 const read = (usage.cache_read_input_tokens * inputPrice * price.cacheReadMultiplier) / 1_000_000
124 const fresh = (usage.input_tokens * inputPrice) / 1_000_000
125 const out = (usage.output_tokens * price.outputUsdPerMTok) / 1_000_000
126 return read + fresh + out
127}
128
129export function formatUsd(usd: number): string {
130 if (usd >= 100) return `$${Math.round(usd)}`
131 if (usd >= 1) return `$${usd.toFixed(2)}`
132 if (usd >= 0.01) return `$${usd.toFixed(2)}`
133 if (usd > 0) return `<$0.01`
134 return '$0.00'
135}
136hooks/runtime.ts 41 lines1// Module-level runtime the hooks share. Nothing here must survive a hot reload:
2// everything a drawing reads lives in $.state (see types/index.d.ts).
3import type { EngineInterface } from 'claude-code'
4
5import type { CacheClock, CacheContext, Estimate } from '../types'
6import type { Opts } from './options'
7import { estimateRecache, formatUsd, getPricingTable } from './pricing'
8
9export const rt: {
10 opts: Opts
11 /** True while a main-loop turn runs (turn.start → turn.complete). */
12 isWorking: boolean
13 /** Synchronous guard for the keep-warm fork; mirrored in state for the drawing. */
14 keepWarmInFlight: boolean
15} = {
16 opts: {
17 cacheTtl: 'auto',
18 gateThresholdUsd: 0.25,
19 warnFraction: 0.2,
20 keepWarmLeadSeconds: 90,
21 maxBreakHours: 4,
22 inputPriceOverrideUsdPerMTok: 0,
23 sounds: true,
24 showCostOnSubscription: true,
25 },
26 isWorking: false,
27 keepWarmInFlight: false,
28}
29
30export function currentEstimate(ctx: CacheContext, clock: CacheClock): Estimate {
31 return estimateRecache(ctx.tokens, ctx.model, clock.ttl, rt.opts.inputPriceOverrideUsdPerMTok, getPricingTable())
32}
33
34/** A dollar figure as this person sees it: "$8.48", "$8.48 API-equiv" on a subscription, null when they turned dollars off. */
35export function moneyText(usd: number, isSubscription: boolean): string | null {
36 if (!isSubscription) return formatUsd(usd)
37 return rt.opts.showCostOnSubscription ? `${formatUsd(usd)} API-equiv` : null
38}
39
40export type SoundName = 'warm' | 'cold' | 'gate' | 'resume'
41hooks/theme.ts 32 lines1// The band's and panels' own chrome colors and copy: cute and calm, not loud.
2// Sprite and thermometer colors come from art.ts (PALETTE); these are for text and borders.
3import type { CacheZone } from '../types'
4
5export const THEME = {
6 hot: '#6ee7b7',
7 warm: '#fcd34d',
8 cold: '#fb7185',
9 ink: '#dfe7ef',
10 dim: '#8a93a6',
11 panel: '#0f1420',
12 ember: '#ffb27a',
13 frost: '#93c5fd',
14}
15
16export const ZONE_LABEL: Record<CacheZone, string> = {
17 hot: 'toasty',
18 warm: 'cooling off',
19 cold: 'brrr, cold',
20 working: 'warming up',
21}
22
23export const ZONE_DOT: Record<CacheZone, string> = {
24 hot: '●',
25 warm: '◐',
26 cold: '○',
27 working: '✦',
28}
29
30export const zoneColor = (zone: CacheZone): string =>
31 zone === 'cold' ? THEME.cold : zone === 'warm' ? THEME.warm : THEME.hot
32hooks/pricing-table.ts 84 lines1// GENERATED by scripts/sync-pricing.mjs from pricing.json — edit pricing.json, then run the script.
2import type { PricingTable } from './pricing'
3
4export const PRICING: PricingTable = {
5 "asOf": "2026-10-06",
6 "source": "https://platform.claude.com/docs/en/about-claude/pricing",
7 "cacheWriteMultiplier": {
8 "5m": 1.25,
9 "1h": 2
10 },
11 "defaultCacheReadMultiplier": 0.1,
12 "models": [
13 {
14 "match": "^claude-fable-5-1",
15 "name": "Claude Fable 5.1",
16 "inputUsdPerMTok": 10,
17 "outputUsdPerMTok": 50,
18 "cacheReadMultiplier": 0.025
19 },
20 {
21 "match": "^claude-mythos-5-1",
22 "name": "Claude Mythos 5.1",
23 "inputUsdPerMTok": 10,
24 "outputUsdPerMTok": 50,
25 "cacheReadMultiplier": 0.025
26 },
27 {
28 "match": "^claude-(fable|mythos)-5",
29 "name": "Claude Fable 5",
30 "inputUsdPerMTok": 10,
31 "outputUsdPerMTok": 50
32 },
33 {
34 "match": "^claude-opus-5-5",
35 "name": "Claude Opus 5.5",
36 "inputUsdPerMTok": 4,
37 "outputUsdPerMTok": 20,
38 "cacheReadMultiplier": 0.05
39 },
40 {
41 "match": "^claude-opus-(5|4-8|4-7|4-6)",
42 "name": "Claude Opus 5 / 4.x",
43 "inputUsdPerMTok": 5,
44 "outputUsdPerMTok": 25
45 },
46 {
47 "match": "^claude-sonnet-5-5",
48 "name": "Claude Sonnet 5.5",
49 "inputUsdPerMTok": 2,
50 "outputUsdPerMTok": 10,
51 "cacheReadMultiplier": 0.1
52 },
53 {
54 "match": "^claude-sonnet-5",
55 "name": "Claude Sonnet 5",
56 "inputUsdPerMTok": 2,
57 "outputUsdPerMTok": 10
58 },
59 {
60 "match": "^claude-sonnet-4-6",
61 "name": "Claude Sonnet 4.6",
62 "inputUsdPerMTok": 3,
63 "outputUsdPerMTok": 15
64 },
65 {
66 "match": "^claude-haiku-5-5",
67 "name": "Claude Haiku 5.5",
68 "inputUsdPerMTok": 0.1,
69 "outputUsdPerMTok": 0.5
70 },
71 {
72 "match": "^claude-haiku-4-5",
73 "name": "Claude Haiku 4.5",
74 "inputUsdPerMTok": 1,
75 "outputUsdPerMTok": 5
76 }
77 ],
78 "fallback": {
79 "name": "unknown model (Opus tier assumed)",
80 "inputUsdPerMTok": 5,
81 "outputUsdPerMTok": 25
82 }
83}
84types/index.d.ts 58 lines1export type CacheZone = 'hot' | 'warm' | 'cold' | 'working'
2export type CacheTtl = '5m' | '1h'
3export type TtlSource = 'config' | 'heuristic'
4
5export type CacheClock = {
6 lastRequestStartedAt: number
7 warmUntil: number
8 ttlMs: number
9 ttl: CacheTtl
10 ttlSource: TtlSource
11}
12
13export type CacheContext = {
14 tokens: number
15 window: number
16 model: string
17 /** Rate limits were reported, so the dollars are API-equivalent, not billed. */
18 isSubscription: boolean
19}
20
21export type Estimate = {
22 usd: number
23 tokens: number
24 ttl: CacheTtl
25 model: string
26 modelName: string
27 pricing: 'table' | 'override' | 'unknown-model'
28}
29
30export type PendingGate = {
31 text: string
32 estimate: Estimate
33 lastRequestAgoMs: number
34}
35
36export type BreakState = {
37 since: number
38 refreshes: number
39 spentUsd: number
40 nextRefreshAt: number
41 inFlight: boolean
42 lastReadTokens: number
43}
44
45declare module 'claude-code' {
46 interface PluginState {
47 'cache-thermo': {
48 clock: CacheClock | null
49 context: CacheContext | null
50 now: number
51 gate: PendingGate | null
52 brk: BreakState | null
53 isBandHidden: boolean
54 lastZone: CacheZone | null
55 }
56 }
57}
58