SLOPSHOPPER

Cache Countdown

Prompt-cache meter above the input box: hit %, read/wrote/new tokens, a TTL countdown and advice; cache-expiry toasts and context-window alerts. /cache opens a…

newpanebandcommandtoaststatus
v0.1.5MITupdated 2026-10-06jmac122/cache-countdown
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-countdown
│ ┃ cache ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ PROMPT CACHE │ cache-countdown │ │ ┃ 1h lifetime · Claude subscription default ● cache-countdown: cac│ cache-countdown is on: /cache setup walks │ │ ┃ ⏺ Read(src/auth.ts) │ through its settings │ │ ┃ cold: no request yet ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ┃ no request yet ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ close ] Esc or /cache stop closes ⏺ 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 │ ⎿ cache-countdown: 1h cache (Claude subscription default) · cold: │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cache
PROMPT CACHE 1h lifetime · Claude subscription default cold: no request yet no request yet [ close ] Esc or /cache stop closes
Pane · cache-setup
cache-countdown setup · step 1 of 11 Start from a preset cache-countdown shows how much of each request Claude served from its prompt cache, and counts down to when that cache expires. Pick a starting point. The next steps explain each setting so you can adjust it, or skip straight to the review. [ ● Recommended ] minute steps, 4 cache toasts, context aler [ ○ Quiet ] the meter only: no toasts, no alerts [ ○ Live ] per-second countdown, footer line, more toasts an [ Next ] [ Skip to review ] [ Cancel ]
README

cache-countdown

A prompt-cache meter for Claude Code, written as a mod (function hooks).

● cache ██████████ 86% read 317k wrote 52.3k new 2 ⏱ 59m 1h · warm: keep going
  • Meter row above the input box: hit %, read / wrote / new tokens, the time left on the cache, and advice: warm, soon, expired (suggests /compact on big contexts), miss (names the cause: model changed, cache lapsed, prefix changed), off.
  • /cache: a pane with the countdown bar, the last request as a stacked read/wrote/new bar, and a per-turn table with a totals row. /cache stop, Esc or [ close ] closes it.
  • /cache setup: a step-by-step walkthrough. Start from a preset (Recommended, Quiet, Live), then one step per setting that explains what it is for, with a hint on each choice and a custom… field where a list makes sense. A review page shows everything; Save writes your Claude Code settings the same way /config does, and the mod reloads with them.
  • Cache expiry toasts before the cache expires (default at 1 min, 10 s, 5 s and 1 s left; set any times you like, e.g. 30m, 15m, 5m, 1m).
  • Context window alerts, separate from the cache: a toast as the conversation crosses fill levels of the model's context window (default 50%, 25% and 10% remaining), for anyone whose status line doesn't show context usage. They re-arm after /compact or /clear.
  • Toasts are drawn inside Claude Code (top-right corner, about 4 s), not in the Windows or macOS notification center.
  • Footer line (optional): cache 86% · 59m on its own line under the input box, beside your statusline.

Install

Requires Claude Code 2.1.287 or later (mods).

From the marketplace (loads in every session):

/plugin marketplace add jmac122/cache-countdown
/plugin install cache-countdown@cache-countdown

For one session only: clone it and point --plugin-dir at the folder:

git clone https://github.com/jmac122/cache-countdown
claude --plugin-dir ./cache-countdown

It writes nothing to your settings until you press Save in /cache setup. On first load it shows one toast pointing at /cache setup, once ever.

How often it wakes

There is no fixed interval. One timer sleeps until the next moment something changes on screen:

Time leftCountdown showsWakes
more than warnSeconds59mevery tickSeconds (default 60)
the last warnSeconds (default 60)0:42every finalTickSeconds (default 1)
a toast markonce, at the mark
expired, nothing cached, caching off0:00never, until your next request
meter row, footer line and toasts all off, /cache closednever: nothing to draw or announce

On a 1-hour cache with the defaults that's about 120 wakes per hour instead of 3,600. Each wake redraws only the band and the pane, which takes about 1–5 ms per redraw (measured in a live session).

Options

Use /cache setup, the /config menu, or ~/.claude/settings.json under pluginConfigs["cache-countdown@cache-countdown"].options (marketplace install) or pluginConfigs["cache-countdown"].options (--plugin-dir):

OptionDefault
ttlautoauto \5m \1h; the last two pin it
tickSeconds60countdown step outside the final stretch; 60 shows 59m, below 60 shows m:ss
warnSeconds60the final stretch: the band turns red and the countdown uses finalTickSeconds
finalTickSeconds1countdown step inside the final stretch
toasttrueexpiry toasts on/off
toastAt1m,10s,5s,1stimes left at which a toast fires, once each per cache entry, with units: 30m, 15m, 5m, 1m, 5m, 2m, 1m, 90s, 1h (a bare number is seconds). Keep marks at least 3 s apart: Claude Code drops a toast that comes within 2 s of the previous one
contextAlertsAt50,25,10a toast each time the conversation crosses one of these levels of the context window (% remaining); off disables. Independent of the cache toasts
compactWhenRemainingPct60once the window remaining is at or below this %, an expired cache suggests /compact instead of "keep going" (60 = 400k used on a 1M window, 80k on 200k)
bandtruethe meter row above the input box
statusfalsea short footer line beside your statusline

Which cache lifetime (ttl: auto)

The first match wins:

  1. FORCE_PROMPT_CACHING_5M=1 → 5m
  2. CLAUDE_CODE_PROMPT_CACHE_TTL → 5m / 1h
  3. the promptCacheTtl setting, read merged over user, project, local, --settings and managed settings
  4. ENABLE_PROMPT_CACHING_1H=1 → 1h
  5. the account: a Claude subscription within plan usage → 1h; usage credits, an API key or a cloud provider → 5m

Request timing then corrects it. A cache hit more than 5 minutes after the previous request proves the 1h lifetime. A miss 5–60 minutes later, with the same model and a prompt that didn't shrink, says 5m. The /cache pane names the source in use. DISABLE_PROMPT_CACHING (and its _HAIKU, _SONNET and _OPUS forms) shows off.

Data and privacy

cache-countdown makes no network requests and sends nothing anywhere. It runs no shell commands, starts no processes, and reads no files. Everything it shows comes from inside your Claude Code session, and its data stays on your machine.

It reads:

  • The token counts (cache read, cache write, uncached input, output) and model name of each main-loop request, from turn.step.
  • The session's context window size and rate-limit info, from $.session.usage(). The rate-limit info is used only to tell a Claude subscription (1h cache) apart from API billing (5m cache).
  • The promptCacheTtl setting, and the environment variables CLAUDE_CODE_PROMPT_CACHE_TTL, FORCE_PROMPT_CACHING_5M, ENABLE_PROMPT_CACHING_1H and DISABLE_PROMPT_CACHING (plus its _HAIKU, _SONNET and _OPUS forms).

It writes:

  • Its own options, through $.config.set, only when you press Save in /cache setup, and only the ones you changed. They land under pluginConfigs in your Claude Code user settings, the same place /config puts them. The complete list of keys it can write is cache-countdown.ttl, .tickSeconds, .warnSeconds, .finalTickSeconds, .toast, .toastAt, .contextAlertsAt, .compactWhenRemainingPct, .band and .status. Each is spelled out in hooks/cache-countdown.tsx (writeSetting). It never sets environment variables, the permission mode, permissions, hooks, your statusline or any other setting.
  • One flag, setupSeen, in the plugin's own store, so the first-run hint shows only once.

Request history lives in session memory ($.state) and is gone when the session ends. There is no telemetry, no account and no remote server, so there is no privacy policy.

What it hooks

  • session.start: reads the settings and env vars above, registers /cache.
  • session.end: on /clear, resets the meter.
  • turn.step: records each main-loop request's token counts (subagents are skipped; they have their own cache prefixes). It passes every request through unchanged.
  • command.run: answers /cache, /cache setup and /cache stop. Every other command passes straight through untouched; it never sees, changes or blocks them.
  • ui.close: notices when its own pane closes.
  • ui.render: draws the meter row (AbovePrompt) and its two panes; everything else is passed through.

It changes nothing about requests, prompts, tools or other commands. Request history lives in $.state, so a hot reload keeps it.

Develop

claude plugin validate .
claude plugin test .
tsc -p .

hooks/cache.ts and hooks/setup.ts are pure logic with no engine calls. hooks/cache-countdown.tsx is the wiring.

Source 4 files
hooks/cache-countdown.tsx 734 lines
1/**
2 * cache-countdown: the wiring. turn.step records each main-loop request and
3 * builds the view the band and the pane draw from, once per request; one
4 * self-scheduling clock.after timer then carries only the time left, waking
5 * when the countdown text changes or a toast is due, and not at all once the
6 * cache has expired or caching is off. /cache opens the detail pane, /cache
7 * setup the walkthrough (setup.ts holds its pure half).
8 */
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register, TurnUsage } from 'claude-code'
11
12import type { CacheSample, CacheView, SetupChange, SetupDraft } from '../types'
13import {
14  accountKind,
15  advise,
16  baseLifetime,
17  buildView,
18  cachingOff,
19  contextToast,
20  countdownText,
21  crossLevels,
22  effectiveLifetime,
23  expiryToast,
24  formatCount,
25  marksBehind,
26  nextDelay,
27  observe,
28  readConfig,
29  remainingMs,
30  splitCells,
31  statusText,
32  takeMarks,
33  textBar,
34} from './cache'
35import type { Account, AdviceState, Config, Lifetime } from './cache'
36import { CUSTOM, FIELDS, PRESETS, REVIEW_STEP, STEP_COUNT, changes, display, draftFromOptions, encode, isListed } from './setup'
37import type { Field } from './setup'
38
39const PANE = 'cache'
40const SETUP = 'cache-setup'
41const MAX_SAMPLES = 200
42
43const TICK = { plugin: 'cache-countdown', key: 'tick' } as const
44const samplesAtom = atom({ plugin: 'cache-countdown', key: 'samples' } as const, [])
45const viewAtom = atom({ plugin: 'cache-countdown', key: 'view' } as const, null)
46const tickAtom = atom(TICK, 0)
47const observedAtom = atom({ plugin: 'cache-countdown', key: 'observed' } as const, null)
48const alertsAtom = atom({ plugin: 'cache-countdown', key: 'alerts' } as const, [])
49const paneAtom = atom({ plugin: 'cache-countdown', key: 'paneOpen' } as const, false)
50const draftAtom = atom({ plugin: 'cache-countdown', key: 'draft' } as const, null)
51const noteAtom = atom({ plugin: 'cache-countdown', key: 'setupNote' } as const, '')
52const pendingAtom = atom({ plugin: 'cache-countdown', key: 'pending' } as const, [])
53const stepAtom = atom({ plugin: 'cache-countdown', key: 'setupStep' } as const, 0)
54
55// ---------------------------------------------------------------- module state (starts over on every load)
56
57/** the options as stored (the walkthrough compares against these) */
58let current: Readonly<Record<string, unknown>> = {}
59/** the options, checked and parsed */
60let cfg: Config = readConfig({})
61
62type Environment = { ttl?: string; force5m?: string; enable1h?: string; off: { all?: string; haiku?: string; sonnet?: string; opus?: string } }
63let env: Environment = { off: {} }
64let settingTtl: unknown
65let account: Account = 'other'
66let contextWindow = 0
67
68let timer: ReturnType<EngineInterface['clock']['after']> | null = null
69let busy = false
70let again = false
71/** bumped by every stop, so a wake already under way does not schedule another */
72let epoch = 0
73/** how many of cfg.marks the current countdown has used up */
74let shown = 0
75let lastStatus: string | undefined
76let paneShown = false
77
78// ---------------------------------------------------------------- reading the world
79
80async function readEnvironment($: EngineInterface) {
81  const [ttl, force5m, enable1h, all, haiku, sonnet, opus] = await Promise.all([
82    $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
83    $.env.get('FORCE_PROMPT_CACHING_5M'),
84    $.env.get('ENABLE_PROMPT_CACHING_1H'),
85    $.env.get('DISABLE_PROMPT_CACHING'),
86    $.env.get('DISABLE_PROMPT_CACHING_HAIKU'),
87    $.env.get('DISABLE_PROMPT_CACHING_SONNET'),
88    $.env.get('DISABLE_PROMPT_CACHING_OPUS'),
89  ])
90  env = { ttl, force5m, enable1h, off: { all, haiku, sonnet, opus } }
91  const merged = await $.settings.read().catch(() => ({}) as Readonly<Record<string, unknown>>)
92  settingTtl = merged.promptCacheTtl
93}
94
95async function refreshUsage($: EngineInterface) {
96  try {
97    const usage = await $.session.usage()
98    contextWindow = usage.context && usage.context.window > 0 ? usage.context.window : 0
99    account = accountKind(usage.rateLimits ?? [])
100  } catch {
101    // keep the last reading
102  }
103}
104
105const baseNow = (): Lifetime =>
106  baseLifetime({ option: cfg.ttl, force5m: env.force5m, envTtl: env.ttl, settingTtl, enable1h: env.enable1h, account })
107
108/** Rebuilds the view from the kept samples and stores it: once per request, at load and on /clear. */
109async function rebuild($: EngineInterface): Promise<CacheView> {
110  const samples = await read($, samplesAtom)
111  const lifetime = effectiveLifetime(baseNow(), await read($, observedAtom))
112  const view = buildView({ samples, lifetime, off: cachingOff(env.off, samples.at(-1)?.model), window: contextWindow })
113  await update($, viewAtom, () => view)
114  return view
115}
116
117// ---------------------------------------------------------------- per request
118
119async function recordStep($: EngineInterface, turnId: string, model: string, at: number, usage: TurnUsage) {
120  const samples = await read($, samplesAtom)
121  const prev = samples.at(-1)
122  const sample: CacheSample = {
123    at,
124    turnId,
125    turnNo: prev ? (prev.turnId === turnId ? prev.turnNo : prev.turnNo + 1) : 1,
126    model: usage.model || model,
127    read: usage.cache_read_input_tokens ?? 0,
128    write: usage.cache_creation_input_tokens ?? 0,
129    fresh: usage.input_tokens ?? 0,
130    output: usage.output_tokens ?? 0,
131  }
132  await update($, samplesAtom, list => [...list, sample].slice(-MAX_SAMPLES))
133  await refreshUsage($)
134  if (!baseNow().pinned) {
135    const before = await read($, observedAtom)
136    const after = observe(before, prev, sample)
137    if (after !== before) await update($, observedAtom, () => after)
138  }
139  const view = await rebuild($)
140  await checkContext($, view)
141  $.ui.log(
142    `step read=${sample.read} write=${sample.write} new=${sample.fresh} model=${sample.model} ttl=${view.ttlLabel} source=${view.source} window=${contextWindow || 'unknown'}`,
143    { to: 'debug' },
144  )
145  await restartCountdown($, view)
146}
147
148async function checkContext($: EngineInterface, view: CacheView) {
149  if (!view.last || view.windowLeft === null || cfg.levels.length === 0) return
150  const before = await read($, alertsAtom)
151  const { announce, announced } = crossLevels(cfg.levels, before, view.windowLeft)
152  if (announced.join() !== before.join()) await update($, alertsAtom, () => announced)
153  if (announce !== undefined) $.ui.toast(contextToast(view.windowLeft, view.last.prompt, view.window, cfg.compactWhenRemainingPct))
154}
155
156// ---------------------------------------------------------------- the timer
157
158function stopTimer() {
159  epoch++
160  again = false
161  timer?.cancel()
162  timer = null
163}
164
165const isLive = (view: CacheView | null): view is CacheView & { last: NonNullable<CacheView['last']> } =>
166  !!view && !!view.last && !view.off && !view.last.uncached
167
168/** A new countdown (a request arrived, or the module loaded): every mark still ahead is armed again. */
169async function restartCountdown($: EngineInterface, view: CacheView) {
170  stopTimer()
171  if (!isLive(view)) {
172    await setStatus($, statusText(view, 0, cfg))
173    return
174  }
175  shown = marksBehind(cfg.marks, remainingMs(view.last.at, view.ttl, await $.clock.now()))
176  await tick($)
177}
178
179/** One wake at a time; a wake asked for during one runs once right after it. */
180async function tick($: EngineInterface) {
181  if (busy) {
182    again = true
183    return
184  }
185  busy = true
186  try {
187    do {
188      again = false
189      timer?.cancel()
190      timer = null
191      const mine = epoch
192      const delay = await wake($)
193      if (!again && delay !== null && mine === epoch) timer = $.clock.after(delay, () => void tick($))
194    } while (again)
195  } finally {
196    busy = false
197  }
198}
199
200/** What one wake does: the toast due, the time left for the band and pane, the footer line; then how long to sleep. */
201async function wake($: EngineInterface): Promise<number | null> {
202  const view = await read($, viewAtom)
203  if (!isLive(view)) {
204    await setStatus($, statusText(view, 0, cfg))
205    return null
206  }
207  const left = remainingMs(view.last.at, view.ttl, await $.clock.now())
208  if (cfg.toast) {
209    const due = takeMarks(cfg.marks, shown, left)
210    shown = due.shown
211    if (due.fire !== undefined) $.ui.toast(expiryToast(due.fire, view.last.prompt))
212  }
213  if (cfg.band || paneShown) await $.state.set(TICK, left)
214  await setStatus($, statusText(view, left, cfg))
215  // nothing to draw and nothing to announce: sleep until a request or /cache asks again
216  if (!cfg.toast && !cfg.status && !cfg.band && !paneShown) return null
217  return nextDelay(left, cfg, cfg.toast ? cfg.marks : [], shown)
218}
219
220async function setStatus($: EngineInterface, text: string | undefined) {
221  if (!cfg.status || text === lastStatus) return
222  lastStatus = text
223  $.ui.status(text)
224}
225
226// ---------------------------------------------------------------- session
227
228async function startSession($: EngineInterface) {
229  stopTimer()
230  shown = 0
231  lastStatus = undefined
232  await readEnvironment($)
233  await refreshUsage($)
234  await $.command.register({
235    name: 'cache',
236    description: 'Prompt cache meter: countdown, last request, per-turn table',
237    argumentHint: '[setup|stop]',
238    immediate: true,
239  })
240  paneShown = await read($, paneAtom)
241  const view = await rebuild($)
242  const off = view.off ? `, prompt caching is off (${view.off})` : ''
243  $.ui.log(`cache-countdown loaded: ${view.ttlLabel} cache (${view.source})${off}, /cache opens the pane`, { to: 'debug' })
244  await applyPending($)
245  await firstRun($)
246  await restartCountdown($, view)
247}
248
249async function firstRun($: EngineInterface) {
250  const seen = await $.store.get('setupSeen').catch(() => true)
251  if (seen) return
252  $.ui.toast('cache-countdown is on: /cache setup walks through its settings')
253  await $.store.set('setupSeen', true).catch(() => undefined)
254}
255
256async function clearSession($: EngineInterface) {
257  stopTimer()
258  shown = 0
259  await update($, samplesAtom, () => [])
260  await update($, observedAtom, () => null)
261  await update($, alertsAtom, () => [])
262  await rebuild($)
263  if (cfg.status || lastStatus !== undefined) $.ui.status(undefined)
264  lastStatus = undefined
265}
266
267// ---------------------------------------------------------------- /cache
268
269async function setPane($: EngineInterface, open: boolean) {
270  paneShown = open
271  if ((await read($, paneAtom)) !== open) await update($, paneAtom, () => open)
272}
273
274async function closePane($: EngineInterface) {
275  await $.ui.close({ id: PANE }).catch(() => undefined)
276  await setPane($, false)
277}
278
279async function openPane($: EngineInterface): Promise<string> {
280  await $.ui.open({ id: PANE, title: 'cache', columns: 64, rows: 24, focus: true, closeOnEscape: true })
281  await setPane($, true)
282  const view = await read($, viewAtom)
283  const left = view?.last ? remainingMs(view.last.at, view.ttl, await $.clock.now()) : 0
284  // the pane draws from the tick: a live countdown wakes now (and writes it), anything else writes it once
285  if (isLive(view) && left > 0) await tick($)
286  else await $.state.set(TICK, left)
287  if (!view) return 'cache: not read yet · /cache stop closes'
288  return `${view.ttlLabel} cache (${view.source}) · ${advise(view, left, cfg).text} · /cache stop closes`
289}
290
291async function runCommand($: EngineInterface, args: string): Promise<{ text: string }> {
292  const word = args.trim().toLowerCase()
293  if (word === 'setup') {
294    await openSetup($)
295    return { text: 'cache setup opened: start from a preset, adjust each setting, then Save' }
296  }
297  if (word === 'stop') {
298    await closePane($)
299    return { text: 'cache pane closed' }
300  }
301  return { text: await openPane($) }
302}
303
304// ---------------------------------------------------------------- drawing helpers
305
306const STATE_COLOR: Record<AdviceState, string> = {
307  off: 'gray',
308  cold: 'gray',
309  uncached: 'gray',
310  expired: 'red',
311  soon: 'red',
312  miss: 'yellow',
313  warm: 'green',
314}
315
316/** green while plenty is left, yellow in the last third, red in the final stretch */
317function clockColor(left: number, ttl: number): string {
318  if (left <= cfg.warnSeconds * 1000) return 'red'
319  return left <= (ttl * 1000) / 3 ? 'yellow' : 'green'
320}
321
322/** what the setup walkthrough shows as "Detected now" */
323async function snapshot($: EngineInterface, now: number) {
324  const view = await read($, viewAtom)
325  const left = view?.last ? remainingMs(view.last.at, view.ttl, now) : 0
326  return {
327    ttl: view ? (view.off ? 'off' : view.ttlLabel) : 'unknown',
328    source: view ? (view.off ?? view.source) : 'not read yet',
329    left,
330  }
331}
332
333// ---------------------------------------------------------------- setup walkthrough: actions
334
335/**
336 * One config.set per option, each with its key spelled out, so anyone reading the
337 * source (the plugin directory's scan included) can see exactly which settings it
338 * can write: this plugin's own options under pluginConfigs, nothing else.
339 */
340async function writeSetting($: EngineInterface, change: SetupChange): Promise<{ deny?: string }> {
341  const value = change.value
342  switch (change.key) {
343    case 'ttl': return $.config.set({ key: 'cache-countdown.ttl', value: value })
344    case 'tickSeconds': return $.config.set({ key: 'cache-countdown.tickSeconds', value: value })
345    case 'warnSeconds': return $.config.set({ key: 'cache-countdown.warnSeconds', value: value })
346    case 'finalTickSeconds': return $.config.set({ key: 'cache-countdown.finalTickSeconds', value: value })
347    case 'toast': return $.config.set({ key: 'cache-countdown.toast', value: value })
348    case 'toastAt': return $.config.set({ key: 'cache-countdown.toastAt', value: value })
349    case 'contextAlertsAt': return $.config.set({ key: 'cache-countdown.contextAlertsAt', value: value })
350    case 'compactWhenRemainingPct': return $.config.set({ key: 'cache-countdown.compactWhenRemainingPct', value: value })
351    case 'band': return $.config.set({ key: 'cache-countdown.band', value: value })
352    case 'status': return $.config.set({ key: 'cache-countdown.status', value: value })
353    default: return { deny: 'not an option of this plugin' }
354  }
355}
356
357/**
358 * Writes the wizard's queued settings. Each write reloads the mod, which can end
359 * this environment mid-loop, so the queue lives in $.state: an item is taken off
360 * before it is written, and the next load's session.start carries on.
361 */
362async function applyPending($: EngineInterface) {
363  for (;;) {
364    const queue = (await read($, pendingAtom)) as SetupChange[]
365    const head = queue[0]
366    if (!head) return
367    await update($, pendingAtom, q => (q as SetupChange[]).slice(1))
368    const r = await writeSetting($, head).catch((err: unknown) => ({ deny: String(err) }))
369    const line = r.deny ? `${head.key} not saved (${r.deny})` : `${head.key} = ${encode(head.value)}`
370    await update($, noteAtom, note => (note ? `${note} · ${line}` : line))
371    if (queue.length === 1) $.ui.toast(`cache-countdown settings: ${(await read($, noteAtom)) as string}`)
372  }
373}
374
375async function openSetup($: EngineInterface) {
376  await update($, draftAtom, () => draftFromOptions(current))
377  await update($, noteAtom, () => '')
378  await update($, stepAtom, () => 0)
379  await $.ui.open({ id: SETUP, title: 'cache setup', focus: true, closeOnEscape: true, columns: 76, rows: 22 })
380  await $.store.set('setupSeen', true).catch(() => undefined)
381}
382
383async function saveSetup($: EngineInterface) {
384  await commitTyping($)
385  const draft = ((await read($, draftAtom)) as SetupDraft | null) ?? draftFromOptions(current)
386  const todo = changes(draft, current)
387  await $.ui.close({ id: SETUP }).catch(() => undefined)
388  await update($, draftAtom, () => null)
389  if (todo.length === 0) {
390    $.ui.toast('cache-countdown: nothing changed')
391    return
392  }
393  await update($, noteAtom, () => '')
394  await update($, pendingAtom, () => todo)
395  await applyPending($)
396}
397
398async function pickSetup($: EngineInterface, key: string, value: string | number | boolean) {
399  await update($, draftAtom, d => ({ ...((d as SetupDraft | null) ?? draftFromOptions(current)), [key]: value }))
400}
401
402/** custom…: mark the step custom and put the keyboard in its text field (focus waits for the field to be drawn) */
403async function startCustom($: EngineInterface, key: string) {
404  await update($, draftAtom, d => ({ ...((d as SetupDraft | null) ?? draftFromOptions(current)), [`${key}:custom`]: true }))
405  await $.ui.focus({ requestId: SETUP, key: `in:${key}` }).catch(() => undefined)
406}
407
408// text typed into a custom field and not yet committed: held here so typing never redraws (a redraw per key resets the field)
409const typing = new Map<string, string>()
410
411/** Commit a custom field's text: the raw text for the field, and the cleaned value when it is valid. */
412async function typeCustom($: EngineInterface, key: string, raw: string) {
413  typing.delete(key)
414  const clean = FIELDS.find(f => f.key === key)?.custom?.normalize(raw)
415  await update($, draftAtom, d => ({
416    ...((d as SetupDraft | null) ?? draftFromOptions(current)),
417    [`${key}:text`]: raw,
418    [`${key}:custom`]: true,
419    ...(clean !== undefined ? { [key]: clean } : {}),
420  }))
421}
422
423/** Before leaving a step (Next, Back, review, Save): commit whatever is still being typed. */
424async function commitTyping($: EngineInterface) {
425  for (const [key, raw] of [...typing]) await typeCustom($, key, raw)
426}
427
428async function gotoStep($: EngineInterface, step: number) {
429  await commitTyping($)
430  await update($, stepAtom, () => Math.max(0, Math.min(REVIEW_STEP, step)))
431}
432
433async function presetSetup($: EngineInterface, draft: SetupDraft) {
434  await update($, draftAtom, () => ({ ...draft }))
435}
436
437// ---------------------------------------------------------------- hooks
438
439export const register: Register = (on, options) => {
440  current = options
441  cfg = readConfig(options)
442
443  on('session.start', async ($, e, next) => {
444    const started = await next(e)
445    await startSession($)
446    return started
447  })
448
449  on('session.end', async ($, e, next) => {
450    if (e.reason === 'clear') {
451      try {
452        await clearSession($)
453      } catch (err) {
454        $.ui.log(`cache-countdown: reset after /clear failed (${String(err)})`, { to: 'debug' })
455      }
456    }
457    return next(e)
458  })
459
460  on('turn.step', async function* ($, e, next) {
461    // subagents have cache prefixes of their own: their requests say nothing about this one
462    if (e.agentId) return yield* next(e)
463    const at = await $.clock.now()
464    const result = yield* next(e)
465    if (result.usage) {
466      try {
467        await recordStep($, e.turnId, e.model, at, result.usage)
468      } catch (err) {
469        $.ui.log(`cache-countdown: could not record a request (${String(err)})`, { to: 'debug' })
470      }
471    }
472    return result
473  })
474
475  on('command.run', { command: 'cache' }, async ($, e) => runCommand($, e.args))
476
477  on('ui.close', { id: PANE }, async ($, e, next) => {
478    const closed = await next(e)
479    await setPane($, false).catch(() => undefined)
480    return closed
481  })
482
483  // the meter row above the input box
484  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
485    if (!cfg.band || e.props.hasSurvey) return next(e)
486    const view = await read($, viewAtom)
487    if (!view) return next(e)
488    const { Box, Text } = $.ui.resolve(e)
489    if (view.off && !view.last) {
490      return (
491        <Box flexDirection="row">
492          <Text dimColor wrap="truncate-end">{`cache · ${advise(view, 0, cfg).text}`}</Text>
493        </Box>
494      )
495    }
496    if (await read($, paneAtom)) return next(e)
497    const last = view.last
498    if (!last) return next(e)
499    const left = await read($, tickAtom)
500    const advice = advise(view, left, cfg)
501    const wide = e.props.bodyColumns >= 90
502    const timed = advice.state !== 'off' && advice.state !== 'uncached'
503    // the figures never shrink or wrap; only the advice tail gives way on a narrow body
504    return (
505      <Box flexDirection="row" columnGap={1}>
506        <Box flexDirection="row" columnGap={1} flexShrink={0}>
507          <Text color={STATE_COLOR[advice.state]}>●</Text>
508          <Text bold color="cyan">cache</Text>
509          <Text color={STATE_COLOR[advice.state]}>{textBar(last.hit / 100, wide ? 10 : 6)}</Text>
510          <Text bold>{`${last.hit}%`}</Text>
511          {wide ? <Text color="green">{`read ${formatCount(last.read)}`}</Text> : <Text dimColor>{`prompt ${formatCount(last.prompt)}`}</Text>}
512          {wide ? <Text color="yellow">{`wrote ${formatCount(last.write)}`}</Text> : null}
513          {wide ? <Text color="blue">{`new ${formatCount(last.fresh)}`}</Text> : null}
514          {timed ? <Text bold color={clockColor(left, view.ttl)}>{`⏱ ${countdownText(left, cfg)}`}</Text> : null}
515        </Box>
516        <Text dimColor wrap="truncate-end">{view.off ? advice.text : `${view.ttlLabel} · ${advice.text}`}</Text>
517      </Box>
518    )
519  })
520
521  // the /cache pane
522  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
523    const view = await read($, viewAtom)
524    const left = await read($, tickAtom)
525    const { Box, Text, Button } = $.ui.resolve(e)
526    // remote surfaces collapse runs of spaces; no-break spaces keep aligned text aligned
527    const aligned = (text: string) => (e.surface === 'terminal' ? text : text.replace(/ /g, ' '))
528    const width = Math.max(32, Math.min(e.props.bodyColumns || 64, 80))
529    const close = <Button key="close" label="close" role="dismiss" onPress={() => void closePane($)} />
530    if (!view) {
531      return (
532        <Box flexDirection="column">
533          <Text bold color="cyan">PROMPT CACHE</Text>
534          <Text dimColor>not read yet</Text>
535          {close}
536        </Box>
537      )
538    }
539    const advice = advise(view, left, cfg)
540    const last = view.last
541    const ttlMs = view.ttl * 1000
542    const lifeCells = Math.max(10, width - 24)
543    const barCells = Math.max(10, width - 2)
544    const parts = last ? splitCells([last.read, last.write, last.fresh], barCells) : []
545    const colors = ['green', 'yellow', 'blue']
546    const room = Math.max(1, (e.props.scroll?.bodyRows ?? 24) - 15)
547    const rows = view.rows.slice(-room)
548    const COLS = [6, 7, 9, 9, 9, 6]
549    const cells = (key: string, values: string[], bold?: boolean) => (
550      <Box key={key} flexDirection="row">
551        {values.map((v, i) => (
552          <Box key={`${key}:${i}`} width={COLS[i] ?? 8} justifyContent={i === 0 ? 'flex-start' : 'flex-end'}>
553            <Text bold={bold}>{v}</Text>
554          </Box>
555        ))}
556      </Box>
557    )
558    return (
559      <Box flexDirection="column">
560        <Box key="title" flexDirection="column">
561          <Text bold color="cyan">PROMPT CACHE</Text>
562          <Text dimColor wrap="wrap">{view.off ? `off · ${view.off}` : `${view.ttlLabel} lifetime · ${view.source}`}</Text>
563        </Box>
564        {isLive(view) ? (
565          <Box key="life" flexDirection="row" columnGap={1} marginTop={1}>
566            <Text color={clockColor(left, view.ttl)}>{left > 0 ? `⏱ ${countdownText(left, cfg)} left` : '⏱ expired'}</Text>
567            <Text color={clockColor(left, view.ttl)}>{textBar(left / ttlMs, lifeCells)}</Text>
568            <Text>{`${Math.round((100 * left) / ttlMs)}%`}</Text>
569          </Box>
570        ) : null}
571        <Box key="advice" flexDirection="column" marginTop={isLive(view) ? 0 : 1}>
572          <Text color={STATE_COLOR[advice.state]} wrap="wrap">{advice.text}</Text>
573          <Text dimColor>{last ? `${last.model} · prompt ${formatCount(last.prompt)} tokens` : 'no request yet'}</Text>
574        </Box>
575        {last ? (
576          <Box key="last" flexDirection="column" marginTop={1}>
577            <Box flexDirection="row" justifyContent="space-between" width={barCells}>
578              <Text bold>last request</Text>
579              <Text>{`${last.hit}% hit`}</Text>
580            </Box>
581            <Box flexDirection="row">
582              {parts.map((n, i) => (n > 0 ? <Box key={`seg:${i}`} width={n} height={1} backgroundColor={colors[i] ?? 'gray'} /> : null))}
583            </Box>
584            <Box flexDirection="row" columnGap={2}>
585              <Text color="green">{`■ read ${formatCount(last.read)}`}</Text>
586              <Text color="yellow">{`■ wrote ${formatCount(last.write)}`}</Text>
587              <Text color="blue">{`■ new ${formatCount(last.fresh)}`}</Text>
588            </Box>
589          </Box>
590        ) : null}
591        {view.rows.length ? (
592          <Box key="turns" flexDirection="column" marginTop={1}>
593            {cells('head', ['turn', 'steps', 'read', 'wrote', 'new', 'hit'], true)}
594            {rows.map(r =>
595              cells(`turn:${r.label}`, [r.label, String(r.steps), formatCount(r.read), formatCount(r.write), formatCount(r.fresh), `${r.hit}%`]),
596            )}
597            {cells('all', [view.total.label, String(view.total.steps), formatCount(view.total.read), formatCount(view.total.write), formatCount(view.total.fresh), `${view.total.hit}%`], true)}
598          </Box>
599        ) : null}
600        <Box key="foot" flexDirection="row" columnGap={2} marginTop={1}>
601          {close}
602          <Text dimColor>{aligned('Esc or /cache stop closes')}</Text>
603        </Box>
604      </Box>
605    )
606  })
607
608  // the /cache setup walkthrough
609  on('ui.render', { component: 'Pane', requestId: SETUP }, async ($, e) => {
610    const draft = ((await read($, draftAtom)) as SetupDraft | null) ?? draftFromOptions(current)
611    const step = Math.max(0, Math.min(REVIEW_STEP, (await read($, stepAtom)) as number))
612    const s = await snapshot($, await $.clock.now())
613    if (e.surface === 'mobile') {
614      const { Box, Text } = $.ui.resolve(e)
615      return (
616        <Box flexDirection="column">
617          <Text bold>cache-countdown setup</Text>
618          <Text dimColor>No pickers on this surface: use /config, or run /cache setup in the terminal or desktop app.</Text>
619        </Box>
620      )
621    }
622    const { Box, Text, Button, Input } = $.ui.resolve(e)
623    const terminal = e.surface === 'terminal'
624    // breathing room everywhere but the terminal's inline pane, whose height Claude Code caps
625    const gap = !terminal || e.props.placement === 'dock' ? 1 : 0
626    const count = changes(draft, current).length
627    const preset = PRESETS.find(p => FIELDS.every(f => encode(p.draft[f.key] ?? '') === encode(draft[f.key] ?? '')))
628
629    // one choice: a button that is the click target, ● on the picked one in the accent colour, a dim hint beside it
630    const option = (key: string, label: string, hint: string | undefined, picked: boolean, onPress: () => void) => (
631      <Box key={`opt:${key}`} flexDirection="row" columnGap={1}>
632        <Button key={`pick:${key}`} label={`${picked ? '●' : '○'} ${label}`} variant={picked ? 'primary' : undefined} onPress={onPress} />
633        {hint ? <Text dimColor wrap="wrap">{hint}</Text> : null}
634      </Box>
635    )
636    const heading = (title: string) => (
637      <Box key="head" flexDirection="column">
638        <Text dimColor>{`cache-countdown setup · step ${step + 1} of ${STEP_COUNT}`}</Text>
639        <Text bold color="cyan">{title}</Text>
640      </Box>
641    )
642    const help = (lines: string[]) => (
643      <Box key="help" flexDirection="column" marginTop={gap}>
644        {lines.map((line, i) => (
645          <Text key={`help:${i}`} wrap="wrap">{line}</Text>
646        ))}
647      </Box>
648    )
649    const nav = (
650      <Box key="nav" flexDirection="row" columnGap={2} marginTop={gap}>
651        {step > 0 ? <Button key="back" label="Back" onPress={() => void gotoStep($, step - 1)} /> : null}
652        {step < REVIEW_STEP ? (
653          <Button key="next" label="Next" variant="primary" onPress={() => void gotoStep($, step + 1)} />
654        ) : (
655          <Button key="save" label={count ? `Save ${count} change${count === 1 ? '' : 's'}` : 'Save'} variant="primary" onPress={() => void saveSetup($)} />
656        )}
657        {step < REVIEW_STEP ? <Button key="review" label="Skip to review" onPress={() => void gotoStep($, REVIEW_STEP)} /> : null}
658        <Button key="cancel" label="Cancel" role="dismiss" onPress={() => void $.ui.close({ id: SETUP })} />
659      </Box>
660    )
661
662    if (step === 0) {
663      return (
664        <Box flexDirection="column">
665          {heading('Start from a preset')}
666          {help([
667            'cache-countdown shows how much of each request Claude served from its prompt cache, and counts down to when that cache expires.',
668            'Pick a starting point. The next steps explain each setting so you can adjust it, or skip straight to the review.',
669          ])}
670          <Box key="choices" flexDirection="column" marginTop={gap}>
671            {PRESETS.map(p => option(`preset:${p.key}`, p.label, p.about, preset?.key === p.key, () => void presetSetup($, p.draft)))}
672            {preset ? null : <Text key="own" dimColor>{'● your own picks (keep them, or choose a preset)'}</Text>}
673          </Box>
674          {nav}
675        </Box>
676      )
677    }
678
679    if (step === REVIEW_STEP) {
680      return (
681        <Box flexDirection="column">
682          {heading('Review and save')}
683          {help(['Click a row to change it. Save writes your Claude Code settings, like /config, and the mod reloads with them.'])}
684          <Box key="rows" flexDirection="column" marginTop={gap}>
685            {FIELDS.map((f, i) => {
686              const changed = encode(draft[f.key] ?? '') !== encode(draftFromOptions(current)[f.key] ?? '')
687              return (
688                <Box key={`row:${f.key}`} flexDirection="row" columnGap={1}>
689                  <Button key={`edit:${f.key}`} plain label={`${f.label}: ${display(f, draft[f.key])}`} onPress={() => void gotoStep($, i + 1)} />
690                  {changed ? <Text color="yellow">changed</Text> : null}
691                </Box>
692              )
693            })}
694          </Box>
695          {nav}
696        </Box>
697      )
698    }
699
700    const f = FIELDS[step - 1] as Field
701    const value = draft[f.key]
702    const customOn = draft[`${f.key}:custom`] === true || (f.custom !== undefined && value !== undefined && !isListed(f, value))
703    const pick = (v: string | number | boolean) => {
704      void update($, draftAtom, d => ({ ...((d as SetupDraft | null) ?? draftFromOptions(current)), [f.key]: v, [`${f.key}:custom`]: false }))
705    }
706    return (
707      <Box flexDirection="column">
708        {heading(f.title)}
709        {help(f.key === 'ttl' ? [...f.help, `Detected now: ${s.ttl} (${s.source}).`] : f.help)}
710        <Box key="choices" flexDirection="column" marginTop={gap}>
711          {f.choices.map(c => option(`${f.key}:${encode(c.value)}`, c.label, c.hint, !customOn && encode(c.value) === encode(value ?? ''), () => pick(c.value)))}
712          {f.custom ? option(`${f.key}:${CUSTOM}`, 'custom…', 'type your own below', customOn, () => void startCustom($, f.key)) : null}
713          {f.custom ? (
714            <Box key="custom-field" flexDirection="row" columnGap={1}>
715              <Text dimColor>custom:</Text>
716              <Input
717                key={`in:${f.key}`}
718                placeholder={f.custom.placeholder}
719                value={typeof draft[`${f.key}:text`] === 'string' ? (draft[`${f.key}:text`] as string) : value !== undefined && !isListed(f, value) ? encode(value) : ''}
720                submitLabel="use"
721                onInput={raw => {
722                  typing.set(f.key, raw)
723                }}
724                onSubmit={raw => void typeCustom($, f.key, raw)}
725              />
726            </Box>
727          ) : null}
728        </Box>
729        {nav}
730      </Box>
731    )
732  })
733}
734
hooks/cache.ts 449 lines
1/**
2 * cache.ts: cache-countdown's pure half. Parsing the options, choosing the
3 * cache lifetime, reading request timing, the advice, the countdown text and
4 * when the timer next has to wake, the per-request view, and the number
5 * formats. No `$`, no engine, no timers: everything here is a function of its
6 * arguments, so the tests call it directly.
7 */
8import type { CacheLast, CacheObserved, CacheSample, CacheTurnRow, CacheView } from '../types'
9
10// ---------------------------------------------------------------- options
11
12export const DEFAULT_MARKS = [60, 10, 5, 1]
13export const DEFAULT_LEVELS = [50, 25, 10]
14
15export type Config = {
16  /** 'auto', '5m' or '1h' */
17  ttl: string
18  warnSeconds: number
19  tickSeconds: number
20  finalTickSeconds: number
21  compactWhenRemainingPct: number
22  band: boolean
23  status: boolean
24  toast: boolean
25  /** seconds left at which a toast fires, descending */
26  marks: number[]
27  /** % of the context window remaining at which an alert fires, descending */
28  levels: number[]
29}
30
31const positive = (v: unknown, fallback: number): number => {
32  const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
33  return Number.isFinite(n) && n > 0 ? n : fallback
34}
35
36const flag = (v: unknown, fallback: boolean): boolean => {
37  if (typeof v === 'boolean') return v
38  if (typeof v === 'string') {
39    const s = v.trim().toLowerCase()
40    if (s === 'true') return true
41    if (s === 'false') return false
42  }
43  return fallback
44}
45
46const listText = (v: unknown): string | undefined => {
47  if (typeof v === 'string') return v
48  if (typeof v === 'number') return String(v)
49  if (Array.isArray(v)) return v.map(String).join(',')
50  return undefined
51}
52
53/** The options as `register` receives them, checked and filled in. */
54export function readConfig(o: Readonly<Record<string, unknown>>): Config {
55  const ttl = typeof o.ttl === 'string' ? o.ttl.trim().toLowerCase() : ''
56  return {
57    ttl: ttl === '5m' || ttl === '1h' ? ttl : 'auto',
58    warnSeconds: positive(o.warnSeconds, 60),
59    tickSeconds: positive(o.tickSeconds, 60),
60    finalTickSeconds: positive(o.finalTickSeconds, 1),
61    compactWhenRemainingPct: Math.min(100, positive(o.compactWhenRemainingPct, 60)),
62    band: flag(o.band, true),
63    status: flag(o.status, false),
64    toast: flag(o.toast, true),
65    marks: parseMarks(o.toastAt),
66    levels: parsePercents(o.contextAlertsAt),
67  }
68}
69
70const SPAN = /^(\d+(?:\.\d+)?|\.\d+)\s*(h|hr|hrs|hours?|m|min|mins|minutes?|s|sec|secs|seconds?)?$/
71
72/** One span ("1h", "1.5 min", "90s", or a bare number of seconds) in seconds; undefined when it is not one. */
73export function parseSpan(v: unknown): number | undefined {
74  const text = typeof v === 'number' ? String(v) : typeof v === 'string' ? v.trim().toLowerCase() : ''
75  const m = SPAN.exec(text)
76  if (!m) return undefined
77  const n = Number(m[1])
78  const unit = m[2] ?? 's'
79  const seconds = unit.startsWith('h') ? n * 3600 : unit.startsWith('m') ? n * 60 : n
80  if (!Number.isFinite(seconds) || seconds <= 0) return undefined
81  return Math.round(seconds * 1000) / 1000
82}
83
84/** The toast marks: a comma list of spans, deduplicated, longest first; nothing valid gives the default. */
85export function parseMarks(v: unknown): number[] {
86  const text = listText(v) ?? ''
87  const marks = [...new Set(text.split(/[,;]+/).map(parseSpan).filter((s): s is number => s !== undefined))]
88  return marks.length ? marks.sort((a, b) => b - a) : [...DEFAULT_MARKS]
89}
90
91/** A comma list of whole percents 1..99, deduplicated, highest first; off/none/empty is none; nothing valid gives `fallback`. */
92export function parsePercents(v: unknown, fallback: number[] = DEFAULT_LEVELS): number[] {
93  const text = listText(v)
94  if (text === undefined) return [...fallback]
95  const t = text.trim().toLowerCase()
96  if (t === '' || t === 'off' || t === 'none') return []
97  const found = new Set<number>()
98  for (const part of t.split(/[,;\s]+/)) {
99    const m = /^(\d+)%?$/.exec(part)
100    if (!m) continue
101    const n = Number(m[1])
102    if (n >= 1 && n <= 99) found.add(n)
103  }
104  return found.size ? [...found].sort((a, b) => b - a) : [...fallback]
105}
106
107// ---------------------------------------------------------------- lifetime
108
109export type Account = 'subscription' | 'credits' | 'other'
110
111/** What the session's rate-limit windows say about the account. */
112export function accountKind(limits: readonly { kind: string; percentUsed: number }[]): Account {
113  const plan = limits.filter(l => /^(five_hour|seven_day)/.test(l.kind))
114  if (plan.length === 0) return 'other'
115  return plan.some(l => l.percentUsed >= 100) ? 'credits' : 'subscription'
116}
117
118/** What the lifetime is decided from, as read at session start. */
119export type LifetimeInputs = {
120  /** the ttl option: 'auto', '5m' or '1h' */
121  option: string
122  force5m?: string
123  envTtl?: string
124  settingTtl?: unknown
125  enable1h?: string
126  account: Account
127}
128
129export type Lifetime = { ttl: number; source: string; pinned: boolean }
130
131const TRUTHY = new Set(['1', 'true', 'yes', 'on'])
132export const isTruthy = (v: string | undefined) => v !== undefined && TRUTHY.has(v.trim().toLowerCase())
133
134const ttlValue = (v: unknown): number | undefined => {
135  const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
136  return s === '5m' ? 300 : s === '1h' ? 3600 : undefined
137}
138
139/** The cache lifetime before request timing has a say, first match wins. */
140export function baseLifetime(i: LifetimeInputs): Lifetime {
141  const option = ttlValue(i.option)
142  if (option) return { ttl: option, source: 'ttl option', pinned: true }
143  if (isTruthy(i.force5m)) return { ttl: 300, source: 'FORCE_PROMPT_CACHING_5M', pinned: false }
144  const env = ttlValue(i.envTtl)
145  if (env) return { ttl: env, source: 'CLAUDE_CODE_PROMPT_CACHE_TTL', pinned: false }
146  const setting = ttlValue(i.settingTtl)
147  if (setting) return { ttl: setting, source: 'promptCacheTtl setting', pinned: false }
148  if (isTruthy(i.enable1h)) return { ttl: 3600, source: 'ENABLE_PROMPT_CACHING_1H', pinned: false }
149  if (i.account === 'subscription') return { ttl: 3600, source: 'Claude subscription default', pinned: false }
150  if (i.account === 'credits') return { ttl: 300, source: 'usage credits (plan limit reached)', pinned: false }
151  return { ttl: 300, source: 'API key or cloud provider', pinned: false }
152}
153
154export const ttlLabel = (ttl: number) => (ttl >= 3600 && ttl % 3600 === 0 ? `${ttl / 3600}h` : `${Math.round(ttl / 60)}m`)
155
156/** The lifetime in force: the base one, corrected by what traffic showed unless the option pins it. */
157export function effectiveLifetime(base: Lifetime, observed: CacheObserved | null): Lifetime {
158  if (base.pinned || !observed) return base
159  if (observed.ttl === base.ttl) return { ...base, source: `${base.source}, confirmed by traffic` }
160  return { ttl: observed.ttl, source: `observed from request timing (${base.source} said ${ttlLabel(base.ttl)})`, pinned: false }
161}
162
163/** The variable that turns caching off for this model, or null. */
164export function cachingOff(env: { all?: string; haiku?: string; sonnet?: string; opus?: string }, model: string | undefined): string | null {
165  if (isTruthy(env.all)) return 'DISABLE_PROMPT_CACHING'
166  const m = (model ?? '').toLowerCase()
167  if (m.includes('haiku') && isTruthy(env.haiku)) return 'DISABLE_PROMPT_CACHING_HAIKU'
168  if (m.includes('sonnet') && isTruthy(env.sonnet)) return 'DISABLE_PROMPT_CACHING_SONNET'
169  if (m.includes('opus') && isTruthy(env.opus)) return 'DISABLE_PROMPT_CACHING_OPUS'
170  return null
171}
172
173// ---------------------------------------------------------------- reading traffic
174
175/*
176 * Thresholds, in one place:
177 * - a request READ MOST of what was cached when it read at least HALF of the
178 *   previous request's prompt (the prefix it shares with this one); less, with
179 *   something written, is a miss.
180 * - a prompt SHRANK when it is under HALF of the previous prompt: that is
181 *   /compact or /clear rebuilding a smaller conversation, not a miss.
182 * - SLACK covers clock jitter and the time a request takes to reach the cache:
183 *   a hit has to come more than 5 minutes + 20 s after the previous request to
184 *   prove that a 5-minute entry would not have survived.
185 */
186export const MOST = 0.5
187export const SHRANK = 0.5
188export const SLACK_MS = 20_000
189const FIVE_MIN_MS = 300_000
190const HOUR_MS = 3_600_000
191
192export const promptSize = (s: { read: number; write: number; fresh: number }) => s.read + s.write + s.fresh
193
194const shrank = (prev: CacheSample, cur: CacheSample) => promptSize(cur) < promptSize(prev) * SHRANK
195const readMost = (prev: CacheSample, cur: CacheSample) => cur.read >= promptSize(prev) * MOST
196
197/** What a request says about the lifetime, given the one before it: the updated observation (unchanged when it proves nothing). */
198export function observe(observed: CacheObserved | null, prev: CacheSample | undefined, cur: CacheSample): CacheObserved | null {
199  if (!prev || prev.model !== cur.model) return observed
200  const gap = cur.at - prev.at
201  if (gap <= FIVE_MIN_MS + SLACK_MS) return observed
202  if (cur.read > 0 && readMost(prev, cur)) return { ttl: 3600, proven: true }
203  const missed = cur.write > 0 && !readMost(prev, cur) && !shrank(prev, cur)
204  if (missed && gap < HOUR_MS && !observed?.proven) return { ttl: 300, proven: false }
205  return observed
206}
207
208/** Why a request wrote the cache instead of reading it, or null when it did not miss. */
209export function missCause(prev: CacheSample | undefined, cur: CacheSample, ttl: number): string | null {
210  if (!prev) return null
211  if (cur.read === 0 && cur.write === 0) return null
212  if (shrank(prev, cur)) return null
213  if (cur.write === 0 || readMost(prev, cur)) return null
214  if (prev.model !== cur.model) return `model changed (${prev.model} → ${cur.model})`
215  if (cur.at - prev.at > ttl * 1000) return `the ${ttlLabel(ttl)} cache had lapsed`
216  return 'prompt prefix changed (effort, tools, system prompt or CLAUDE.md)'
217}
218
219// ---------------------------------------------------------------- the view
220
221export const hitPercent = (s: { read: number; write: number; fresh: number }) => {
222  const size = promptSize(s)
223  return size > 0 ? Math.round((s.read / size) * 100) : 0
224}
225
226/** % of the window left after a prompt of `size`, clamped 0..100. */
227export const windowLeftPct = (window: number, size: number) => Math.max(0, Math.min(100, Math.round((100 * (window - size)) / window)))
228
229export type ViewInputs = {
230  samples: readonly CacheSample[]
231  lifetime: Lifetime
232  off: string | null
233  window: number
234}
235
236function row(label: string, list: readonly CacheSample[]): CacheTurnRow {
237  const sum = { read: 0, write: 0, fresh: 0 }
238  for (const s of list) {
239    sum.read += s.read
240    sum.write += s.write
241    sum.fresh += s.fresh
242  }
243  return { label, steps: list.length, ...sum, hit: hitPercent(sum) }
244}
245
246/** Everything the band and pane draw, computed once when a request arrives. */
247export function buildView(i: ViewInputs): CacheView {
248  const n = i.samples.length
249  const cur = i.samples[n - 1]
250  const prev = i.samples[n - 2]
251  let last: CacheLast | null = null
252  if (cur) {
253    const prompt = promptSize(cur)
254    last = {
255      at: cur.at,
256      model: cur.model,
257      read: cur.read,
258      write: cur.write,
259      fresh: cur.fresh,
260      output: cur.output,
261      prompt,
262      hit: hitPercent(cur),
263      uncached: cur.read === 0 && cur.write === 0,
264      cause: missCause(prev, cur, i.lifetime.ttl),
265    }
266  }
267  const turns: CacheSample[][] = []
268  for (const s of i.samples) {
269    const group = turns[turns.length - 1]
270    if (group && group[0]?.turnId === s.turnId) group.push(s)
271    else turns.push([s])
272  }
273  return {
274    ttl: i.lifetime.ttl,
275    ttlLabel: ttlLabel(i.lifetime.ttl),
276    source: i.lifetime.source,
277    off: i.off,
278    window: i.window,
279    windowLeft: i.window > 0 && last ? windowLeftPct(i.window, last.prompt) : null,
280    last,
281    rows: turns.map(t => row(String(t[0]?.turnNo ?? ''), t)),
282    total: row('all', i.samples),
283  }
284}
285
286/** Remaining cache life in ms: counted from the start of the last request, never below 0. */
287export const remainingMs = (start: number, ttl: number, now: number) => Math.max(0, start + ttl * 1000 - now)
288
289// ---------------------------------------------------------------- advice
290
291export type AdviceState = 'off' | 'cold' | 'uncached' | 'expired' | 'soon' | 'miss' | 'warm'
292export type Advice = { state: AdviceState; text: string }
293
294/** The window used for the /compact decision when the real one is not known. */
295export const ASSUMED_WINDOW = 200_000
296
297/** One line of advice for the view at `left` ms of cache life. */
298export function advise(view: CacheView, left: number, s: Pick<Config, 'warnSeconds' | 'compactWhenRemainingPct'>): Advice {
299  if (view.off) return { state: 'off', text: `off: prompt caching is off (${view.off})` }
300  const last = view.last
301  if (!last) return { state: 'cold', text: 'cold: no request yet' }
302  if (last.uncached) return { state: 'uncached', text: 'not cached: the prompt is under the model minimum, or caching is off' }
303  if (left <= 0) {
304    const pct = windowLeftPct(view.window > 0 ? view.window : ASSUMED_WINDOW, last.prompt)
305    const shown = view.window > 0 ? ` (${pct}% of window remaining)` : ''
306    const tail = pct <= s.compactWhenRemainingPct ? '. /compact first, or /clear if done' : ', keep going'
307    return { state: 'expired', text: `expired: the next message rebuilds ${formatCount(last.prompt)}${shown}${tail}` }
308  }
309  if (left <= s.warnSeconds * 1000) return { state: 'soon', text: 'expires soon: any message refreshes it' }
310  if (last.cause) return { state: 'miss', text: `miss: ${last.cause}` }
311  return { state: 'warm', text: 'warm: keep going' }
312}
313
314// ---------------------------------------------------------------- countdown and timer
315
316export type Steps = Pick<Config, 'warnSeconds' | 'tickSeconds' | 'finalTickSeconds'>
317
318/** The countdown step (ms) for `left` ms: tickSeconds outside the final stretch, finalTickSeconds inside it. */
319export const stepMs = (left: number, s: Steps) => (left > s.warnSeconds * 1000 ? s.tickSeconds : s.finalTickSeconds) * 1000
320
321/** The countdown: whole minutes rounded up while the step is a minute or more, else m:ss with the seconds rounded up. */
322export function countdownText(left: number, s: Steps): string {
323  if (stepMs(left, s) >= 60_000) return `${Math.ceil(left / 60_000)}m`
324  const total = Math.ceil(left / 1000)
325  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
326}
327
328/**
329 * How long the timer sleeps from `left` ms: until the next step boundary (the
330 * countdown's next value), the start of the final stretch, or the next toast
331 * mark not yet shown, whichever comes first; null once nothing is left.
332 * `marks` are seconds, descending; `shown` how many of them are used up.
333 */
334export function nextDelay(left: number, s: Steps, marks: readonly number[] = [], shown = 0): number | null {
335  if (left <= 0) return null
336  const step = stepMs(left, s)
337  const warn = s.warnSeconds * 1000
338  let target = Math.floor((left - 1) / step) * step
339  if (left > warn) target = Math.max(target, warn)
340  const mark = marks.slice(shown).find(m => m * 1000 < left)
341  if (mark !== undefined) target = Math.max(target, mark * 1000)
342  return Math.max(1, left - Math.max(0, target))
343}
344
345/**
346 * The toast marks at `left` ms: every mark at or above it is passed; of the
347 * passed ones not shown yet, only the smallest (the newest) fires.
348 */
349export function takeMarks(marks: readonly number[], shown: number, left: number): { fire: number | undefined; shown: number } {
350  let passed = shown
351  while (passed < marks.length && (marks[passed] ?? 0) * 1000 >= left) passed++
352  const fire = passed > shown && left > 0 ? marks[passed - 1] : undefined
353  return { fire, shown: passed }
354}
355
356/** How many marks are already behind a countdown starting at `left` ms (they never fire). */
357export const marksBehind = (marks: readonly number[], left: number) => marks.filter(m => m * 1000 >= left).length
358
359/** A span as a toast says it: `1 hr`, `30 min`, `1:30`, `10s`. */
360export function spanWords(seconds: number): string {
361  if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600} hr`
362  if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60} min`
363  if (seconds > 60) return `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, '0')}`
364  return `${seconds}s`
365}
366
367export function expiryToast(mark: number, prompt: number): string {
368  const action = mark > 10 ? `send a message to keep ${formatCount(prompt)} warm` : 'send a message now'
369  return `cache expires in ${spanWords(mark)}: ${action}`
370}
371
372/** The footer line, or undefined while there is nothing to say. */
373export function statusText(view: CacheView | null, left: number, s: Steps): string | undefined {
374  if (!view) return undefined
375  if (view.off) return 'cache: off'
376  if (!view.last) return undefined
377  if (view.last.uncached) return 'cache: not cached'
378  return `cache ${view.last.hit}% · ${left > 0 ? countdownText(left, s) : 'expired'}`
379}
380
381// ---------------------------------------------------------------- context window alerts
382
383/**
384 * The levels crossed at `pct` % remaining, and the one to announce: the lowest
385 * level newly crossed. A level stays announced only while the window is still
386 * at or below it, so climbing back above it (after /compact) re-arms it.
387 */
388export function crossLevels(levels: readonly number[], announced: readonly number[], pct: number): { announce: number | undefined; announced: number[] } {
389  const crossed = levels.filter(l => pct <= l)
390  const fresh = crossed.filter(l => !announced.includes(l))
391  return { announce: fresh.length ? Math.min(...fresh) : undefined, announced: crossed }
392}
393
394export function contextToast(pct: number, used: number, window: number, compactPct: number): string {
395  const hint = pct <= compactPct ? ' · /compact or /clear frees room' : ''
396  return `context: ${pct}% of window remaining (${formatCount(used)} of ${formatCount(window)} used)${hint}`
397}
398
399// ---------------------------------------------------------------- formats
400
401/** 300, 84.2k, 1.2M. */
402export function formatCount(n: number): string {
403  const v = Math.max(0, Math.round(n))
404  const short = (x: number) => x.toFixed(1).replace(/\.0$/, '')
405  if (v < 1000) return String(v)
406  if (v < 999_950) return `${short(v / 1000)}k`
407  return `${short(v / 1_000_000)}M`
408}
409
410/** 3:20, or 1:00:00 from an hour up; seconds rounded up. */
411export function formatClock(ms: number): string {
412  const total = Math.ceil(Math.max(0, ms) / 1000)
413  const h = Math.floor(total / 3600)
414  const m = Math.floor((total % 3600) / 60)
415  const sec = String(total % 60).padStart(2, '0')
416  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
417}
418
419/** Whole cells for each part, adding up to `cells`; a part above zero gets at least one cell when there is room. */
420export function splitCells(parts: readonly number[], cells: number): number[] {
421  const sum = parts.reduce((a, b) => a + Math.max(0, b), 0)
422  if (sum <= 0 || cells <= 0) return parts.map(() => 0)
423  const exact = parts.map(p => (Math.max(0, p) / sum) * cells)
424  const out = exact.map(Math.floor)
425  let left = cells - out.reduce((a, b) => a + b, 0)
426  const byFraction = exact.map((x, i) => ({ i, f: x - Math.floor(x) })).sort((a, b) => b.f - a.f)
427  for (const { i } of byFraction) {
428    if (left <= 0) break
429    out[i] = (out[i] ?? 0) + 1
430    left--
431  }
432  // a part above zero shows: take a cell from the widest part for each one left at zero
433  for (let i = 0; i < parts.length; i++) {
434    if ((parts[i] ?? 0) <= 0 || (out[i] ?? 0) > 0) continue
435    let widest = -1
436    for (let j = 0; j < out.length; j++) if ((out[j] ?? 0) > 1 && (widest < 0 || (out[j] ?? 0) > (out[widest] ?? 0))) widest = j
437    if (widest < 0) break
438    out[widest] = (out[widest] ?? 0) - 1
439    out[i] = 1
440  }
441  return out
442}
443
444/** A bar of `cells` characters, `filled` of them solid. */
445export const textBar = (fraction: number, cells: number) => {
446  const filled = Math.max(0, Math.min(cells, Math.round(fraction * cells)))
447  return '█'.repeat(filled) + '░'.repeat(cells - filled)
448}
449
hooks/setup.ts 253 lines
1/**
2 * setup.ts: the /cache setup walkthrough's pure half. One step per setting,
3 * each with what it is for, its choices and their hints; presets; the diff
4 * between the picks and what settings hold. No `$`.
5 */
6import type { SetupChange, SetupDraft } from '../types'
7import { parsePercents, parseSpan } from './cache'
8
9export type { SetupChange, SetupDraft }
10
11type Value = string | number | boolean
12export type Choice = { value: Value; label: string; /** one line on why you would pick it */ hint?: string }
13export type Field = {
14  key: string
15  /** the step's heading */
16  title: string
17  /** the review page's row label */
18  label: string
19  /** what the setting is for, in plain words */
20  help: string[]
21  choices: Choice[]
22  /** a free-text choice: what to show in the empty field and how to clean what was typed (undefined: not valid) */
23  custom?: { placeholder: string; normalize: (raw: string) => string | undefined }
24}
25
26/** The value of the "custom…" choice. */
27export const CUSTOM = '__custom'
28
29const normalizeMarks = (raw: string) => {
30  const marks = raw.split(/[,;]+/).map(t => t.trim()).filter(t => parseSpan(t) !== undefined)
31  return marks.length ? marks.join(',') : undefined
32}
33const normalizePercents = (raw: string) => {
34  if (raw.trim().toLowerCase() === 'off') return 'off'
35  const marks = parsePercents(raw, [])
36  return marks.length ? marks.join(',') : undefined
37}
38
39export const FIELDS: Field[] = [
40  {
41    key: 'ttl',
42    title: 'Cache lifetime',
43    label: 'Cache lifetime',
44    help: [
45      'Claude caches the start of your conversation so each message re-sends it cheaply. The cache expires after this long without a message; the next message then pays to rebuild it.',
46      'auto follows Claude Code: 1 hour on a Claude subscription, 5 minutes on an API key or cloud provider, and corrects itself from request timing.',
47    ],
48    choices: [
49      { value: 'auto', label: 'auto', hint: 'default · recommended' },
50      { value: '1h', label: '1 hour', hint: 'pin it if auto guesses wrong' },
51      { value: '5m', label: '5 minutes', hint: 'pin it if auto guesses wrong' },
52    ],
53  },
54  {
55    key: 'tickSeconds',
56    title: 'Countdown step',
57    label: 'Countdown step',
58    help: ['How often the countdown above the input box updates while plenty of time is left.'],
59    choices: [
60      { value: 60, label: 'every minute', hint: 'default · shows "59m", near-zero cost' },
61      { value: 10, label: 'every 10 seconds', hint: 'shows "59:40"' },
62      { value: 1, label: 'every second', hint: 'shows "59:42", redraws every second' },
63    ],
64  },
65  {
66    key: 'warnSeconds',
67    title: 'Final stretch',
68    label: 'Final stretch',
69    help: ['The last stretch before the cache expires: the meter turns red and the countdown switches to the final-stretch step.'],
70    choices: [
71      { value: 30, label: 'last 30 seconds' },
72      { value: 60, label: 'last 60 seconds', hint: 'default' },
73      { value: 120, label: 'last 2 minutes' },
74      { value: 300, label: 'last 5 minutes' },
75    ],
76  },
77  {
78    key: 'finalTickSeconds',
79    title: 'Final stretch step',
80    label: 'Final stretch step',
81    help: ['How often the countdown updates inside the final stretch.'],
82    choices: [
83      { value: 1, label: 'every second', hint: 'default' },
84      { value: 5, label: 'every 5 seconds' },
85      { value: 10, label: 'every 10 seconds' },
86    ],
87  },
88  {
89    key: 'toasts',
90    title: 'Cache expiry toasts',
91    label: 'Cache expiry toasts',
92    help: [
93      "Small pop-ups in Claude Code's top-right corner (not your OS notification tray) warning that the cache is about to expire.",
94      'Send any message before the countdown hits zero and the cache stays warm for free.',
95    ],
96    choices: [
97      { value: '1m,10s,5s,1s', label: '1m, 10s, 5s, 1s', hint: 'default' },
98      { value: '30m,15m,5m,1m', label: '30m, 15m, 5m, 1m', hint: 'early warnings on a 1-hour cache' },
99      { value: '5m,2m,1m', label: '5m, 2m, 1m' },
100      { value: '1m', label: '1m only' },
101      { value: 'off', label: 'off', hint: 'no expiry toasts' },
102    ],
103    custom: { placeholder: 'e.g. 45m, 20m, 2m (h, m, s)', normalize: normalizeMarks },
104  },
105  {
106    key: 'contextAlertsAt',
107    title: 'Context window alerts',
108    label: 'Context alerts',
109    help: [
110      "A pop-up each time your conversation crosses a fill level of the model's context window, e.g. \"25% of window remaining\".",
111      "Handy if your status line doesn't show context usage. Separate from the cache toasts.",
112    ],
113    choices: [
114      { value: '50,25,10', label: '50%, 25%, 10% remaining', hint: 'default' },
115      { value: '75,50,25,10,5', label: '75, 50, 25, 10, 5% remaining', hint: 'more warnings' },
116      { value: '25,10', label: '25%, 10% remaining', hint: 'only when it is getting tight' },
117      { value: 'off', label: 'off' },
118    ],
119    custom: { placeholder: 'e.g. 75,50,25,10,5 (% of window remaining)', normalize: normalizePercents },
120  },
121  {
122    key: 'compactWhenRemainingPct',
123    title: '/compact suggestion',
124    label: '/compact suggestion',
125    help: [
126      'After the cache expires, the meter says "keep going", or suggests /compact (shrink the conversation) once this little of the context window is left.',
127      'Rebuilding a huge context is the expensive moment.',
128    ],
129    choices: [
130      { value: 75, label: 'at 75% remaining', hint: 'suggest early' },
131      { value: 60, label: 'at 60% remaining', hint: 'default' },
132      { value: 40, label: 'at 40% remaining' },
133      { value: 20, label: 'at 20% remaining', hint: 'only when nearly full' },
134    ],
135  },
136  {
137    key: 'band',
138    title: 'Meter above the input box',
139    label: 'Meter above input box',
140    help: ['The one-line meter above the box you type in: cache hit %, tokens, countdown and advice.'],
141    choices: [
142      { value: true, label: 'on', hint: 'default' },
143      { value: false, label: 'off', hint: 'keep /cache and the toasts only' },
144    ],
145  },
146  {
147    key: 'status',
148    title: 'Footer line',
149    label: 'Footer line',
150    help: ['A short copy of the meter ("cache 86% · 59m") on its own line in the footer, beside your status line.'],
151    choices: [
152      { value: false, label: 'off', hint: 'default' },
153      { value: true, label: 'on' },
154    ],
155  },
156]
157
158/** The manifest defaults, as the walkthrough's rows hold them. */
159export const DEFAULTS: SetupDraft = {
160  ttl: 'auto',
161  tickSeconds: 60,
162  warnSeconds: 60,
163  finalTickSeconds: 1,
164  toasts: '1m,10s,5s,1s',
165  contextAlertsAt: '50,25,10',
166  compactWhenRemainingPct: 60,
167  band: true,
168  status: false,
169}
170
171export type PresetName = 'recommended' | 'quiet' | 'live'
172
173export const PRESETS: { key: PresetName; label: string; about: string; draft: SetupDraft }[] = [
174  { key: 'recommended', label: 'Recommended', about: 'minute steps, 4 cache toasts, context alerts at 50/25/10%', draft: DEFAULTS },
175  {
176    key: 'quiet',
177    label: 'Quiet',
178    about: 'the meter only: no toasts, no alerts',
179    draft: { ...DEFAULTS, finalTickSeconds: 10, toasts: 'off', contextAlertsAt: 'off' },
180  },
181  {
182    key: 'live',
183    label: 'Live',
184    about: 'per-second countdown, footer line, more toasts and alerts',
185    draft: { ...DEFAULTS, tickSeconds: 1, toasts: '5m,2m,1m', contextAlertsAt: '75,50,25,10,5', status: true },
186  },
187]
188
189/** Steps: the preset, one per setting, then the review. */
190export const STEP_COUNT = FIELDS.length + 2
191export const REVIEW_STEP = STEP_COUNT - 1
192
193/** The value a choice carries as text. */
194export const encode = (v: Value) => String(v)
195
196/** A choice's text back to the field's type, from that field's choices. */
197export function decode(field: Field, raw: string): Value {
198  const hit = field.choices.find(c => encode(c.value) === raw)
199  if (hit) return hit.value
200  if (typeof field.choices[0]?.value === 'number') return Number(raw)
201  if (typeof field.choices[0]?.value === 'boolean') return raw === 'true'
202  return raw
203}
204
205/** Whether the field's value is one of its listed choices. */
206export const isListed = (field: Field, value: Value | undefined) => value !== undefined && field.choices.some(c => encode(c.value) === encode(value))
207
208/** What the review page shows for a value: the choice's label, or the custom text. */
209export function display(field: Field, value: Value | undefined): string {
210  const hit = field.choices.find(c => value !== undefined && encode(c.value) === encode(value))
211  return hit ? hit.label : value === undefined ? '' : `custom: ${encode(value)}`
212}
213
214/** Merge the toast pair into the walkthrough's one "toasts" row. */
215export function toastsRow(toast: unknown, toastAt: unknown): string {
216  if (toast === false) return 'off'
217  return typeof toastAt === 'string' && toastAt.trim() ? toastAt.replace(/\s+/g, '') : '1m,10s,5s,1s'
218}
219
220/** The walkthrough's "toasts" row back into the two settings it stands for. */
221export function splitToasts(row: Value): { toast: boolean; toastAt?: string } {
222  return row === 'off' ? { toast: false } : { toast: true, toastAt: String(row) }
223}
224
225/** The draft the walkthrough opens with: what settings hold now, as its rows. */
226export function draftFromOptions(values: Readonly<Record<string, unknown>>): SetupDraft {
227  const draft: SetupDraft = { ...DEFAULTS }
228  for (const f of FIELDS) {
229    if (f.key === 'toasts') continue
230    const v = values[f.key]
231    if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') draft[f.key] = v
232  }
233  draft.toasts = toastsRow(values.toast, values.toastAt)
234  return draft
235}
236
237/** The settings to write: each userConfig field whose value the draft changes. */
238export function changes(draft: SetupDraft, values: Readonly<Record<string, unknown>>): SetupChange[] {
239  const out: SetupChange[] = []
240  for (const f of FIELDS) {
241    const v = draft[f.key]
242    if (v === undefined) continue
243    if (f.key === 'toasts') {
244      const { toast, toastAt } = splitToasts(v)
245      if (values.toast !== toast) out.push({ key: 'toast', value: toast })
246      if (toastAt !== undefined && values.toastAt !== toastAt) out.push({ key: 'toastAt', value: toastAt })
247      continue
248    }
249    if (values[f.key] !== v) out.push({ key: f.key, value: v })
250  }
251  return out
252}
253
types/index.d.ts 107 lines
1/**
2 * cache-countdown's $.state contract: the values it keeps for the session
3 * (they survive a hot reload of the module, and nothing else).
4 */
5
6/** The /cache setup walkthrough's picks, one entry per row (plus `<row>:custom` / `<row>:text` helpers). */
7export type SetupDraft = Record<string, string | number | boolean>
8
9/** One setting the walkthrough will write. */
10export type SetupChange = { key: string; value: string | number | boolean }
11
12/** One main-loop model request, as turn.step reported it. */
13export type CacheSample = {
14  /** when the request started (clock ms): the cache lifetime counts from here */
15  at: number
16  turnId: string
17  /** 1, 2, 3... in the order turns arrived this session */
18  turnNo: number
19  model: string
20  /** prompt read from the cache */
21  read: number
22  /** prompt written to the cache */
23  write: number
24  /** prompt neither read nor written (uncached input) */
25  fresh: number
26  output: number
27}
28
29/** What request timing has shown about the cache lifetime. */
30export type CacheObserved = {
31  /** seconds: 300 or 3600 */
32  ttl: number
33  /** true once a late hit proved 1h; a later miss does not undo it */
34  proven: boolean
35}
36
37/** One row of the per-turn table (or the totals row). */
38export type CacheTurnRow = {
39  label: string
40  steps: number
41  read: number
42  write: number
43  fresh: number
44  /** whole percent of the prompt served from the cache */
45  hit: number
46}
47
48/** The last request, summed up once when it arrived. */
49export type CacheLast = {
50  /** start of the request (clock ms) */
51  at: number
52  model: string
53  read: number
54  write: number
55  fresh: number
56  output: number
57  /** read + write + fresh */
58  prompt: number
59  /** whole percent */
60  hit: number
61  /** read 0 and wrote 0 */
62  uncached: boolean
63  /** why it missed, or null when it did not */
64  cause: string | null
65}
66
67/** Everything the band, the pane and the timer draw from, rebuilt once per request (never per tick). */
68export type CacheView = {
69  /** cache lifetime in seconds */
70  ttl: number
71  /** `5m` or `1h` */
72  ttlLabel: string
73  /** where the lifetime comes from */
74  source: string
75  /** set when prompt caching is off: the variable that turned it off */
76  off: string | null
77  /** the model's context window, 0 when unknown */
78  window: number
79  /** % of the window left after the last request, null when the window is unknown */
80  windowLeft: number | null
81  last: CacheLast | null
82  /** per turn, oldest first */
83  rows: CacheTurnRow[]
84  /** every kept request together, labelled `all` */
85  total: CacheTurnRow
86}
87
88declare module 'claude-code' {
89  interface PluginState {
90    'cache-countdown': {
91      /** the last 200 main-loop requests */
92      samples: CacheSample[]
93      view: CacheView | null
94      /** ms left on the cache at the last wake; the only value a wake writes for the band and pane */
95      tick: number
96      observed: CacheObserved | null
97      /** context-window levels (% remaining) already announced */
98      alerts: number[]
99      paneOpen: boolean
100      draft: SetupDraft | null
101      setupNote: string
102      pending: SetupChange[]
103      setupStep: number
104    }
105  }
106}
107