SLOPSHOPPER

cache-thermo

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.

newbandcommandtoaststatusprompt
★ 2v0.2.2MITupdated 2026-10-25stilero/mods-marketplace/plugins/cache-thermo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-thermo
› fix the failing auth test and add an audit log call ● cache-thermo: cache-thermo v0.2.2 ready: ttl auto, prices asOf 2026-10-06 ● cache-thermo: cache-thermo: command.run /cache-thermo ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cache-thermo ⎿ cache-thermo: cache-thermo: toasty · 1:00:00 left · 99k ctx on Claude Opus 5.5 · re-cache ≈ $0.79 API-equiv · TTL 1h (heuris ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ( ████████████████████████████████ ● toasty · 60 min left │ │ ) ) 99k ctx · ~$0.79 API-equiv to re-cache · 1h ttl, assumed │ │ (。◕‿◕。) b: ☕ break x: hide │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ( ████████████████████████████████ ● toasty · 60 min left │ │ ) ) 99k ctx · ~$0.79 API-equiv to re-cache · 1h ttl, assumed │ │ (。◕‿◕。) b: ☕ break x: hide │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

cache-thermo

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.

What you get

  • Thermometer band above the prompt. Mint while the cache is hot, honey when it is close to cold, rose when cold. Counts down the cache TTL from the start of the last request. Shows context size and the estimated re-cache cost (marked API-equiv on a subscription, where the dollars are notional).
  • Cold-cache gate. Submit a prompt while the cache is cold and the estimated re-cache cost clears the threshold, and the band turns into Frostbit's cold-cache view and holds the prompt: go ahead anyway · $8.48 (c) or keep my prompt (x), which puts the text back in the composer.
  • Break mode. ☕ 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.
  • Transition toasts when the cache turns cooling and cold, with sounds on macOS.

The cast

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

In the terminal

The terminal gets the same cast as one-cell ASCII, next to a block-character thermometer:

    (                       * /\/\/\ .                )
   ) )          ,            (。◕~◕。)       (。ᵔ‿ᵔ。)   ( (
 (。◕‿◕。)     (。-‿-。) zᶻ     .  u  u  *      c[_]    /\/\
   hot         cooling          cold            break

██████████████░░░░░░

Install

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.

Commands

CommandEffect
/cache-thermoOne-line status: zone, time left, context, estimate, TTL source, threshold, price date
/cache-thermo breakStart a break (keep the cache warm)
/cache-thermo resumeEnd the break
/cache-thermo hide / showHide or show the band
/cache-thermo coldDebug: mark the cache cold now, so the gate can be demoed

Configuration (/config rows)

FieldDefaultPurpose
Cache TTLautoauto is 1h on a subscription account (rate limits reported), 5m on an API key
Gate threshold (USD)0.25Gate only when the estimated re-cache cost is at least this; 0 gates every cold prompt
Warn fraction0.2Band turns yellow at or below this fraction of the TTL
Keep-warm lead (seconds)90On a break, refresh when this many seconds remain
Max break (hours)4Stop keeping warm after this long
Input price override ($/MTok)0When above 0 replaces the shipped input price for the current model
Soundson8-bit bleeps (macOS)
Show cost on subscriptiononSubscription users see API-equivalent dollars; off shows tokens only

Pricing

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.

How it works

  • 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.
  • A 1 Hz ticker writes now into plugin state, which redraws the band; it also drives the zone toasts and the break loop.
  • The gate is a 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.
  • A compaction, a /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.
  • Break mode uses $.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.

Development

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.

Source 9 files
hooks/register.tsx 698 lines
1import { 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}
698
hooks/art.ts 597 lines
1// 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}
597
hooks/clock.ts 132 lines
1// 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}
132
hooks/options.ts 32 lines
1import 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}
32
hooks/pricing.ts 136 lines
1// 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}
136
hooks/runtime.ts 41 lines
1// 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'
41
hooks/theme.ts 32 lines
1// 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
32
hooks/pricing-table.ts 84 lines
1// 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}
84
types/index.d.ts 58 lines
1export 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