SLOPSHOPPER

cache-bar

Shows prompt cache hit rate and TTL status in Claude Code

newpanebandcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-bar
│ ┃ Prompt cache ✕ › fix the failing auth test and add an audit log call │ ┃ waiting for the first response │ ┃ ⏺ Read(src/auth.ts) │ ┃ Settings ⎿ Read 6 lines │ ┃ When about to expire ⏺ Update(src/auth.ts) │ ┃ Notify + Extend button ▾ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Toast notifications ⎿ 3 pass, 1 fail │ ┃ On ▾ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Cache TTL │ ┃ Auto-detect ▾ ✻ Worked for 42s · done 4:20 PM │ ┃ 5m assumed until a hit after 5m idle proves │ ┃ 1h › /cache │ ┃ │ ┃ Band above the prompt │ ┃ Compact ▾ │ ┃ │ ┃ Break sensitivity │ ┃ Medium ▾ │ ┃ Higher counts smaller drops as breaks; │ ┃ applies from the next request │ ┃ │ ┃ Language │ ┃ English ▾ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prompt cache
waiting for the first response Settings When about to expire Notify + Extend button ▾ Toast notifications On ▾ Cache TTL Auto-detect ▾ 5m assumed until a hit after 5m idle proves 1h Band above the prompt Compact ▾ Break sensitivity Medium ▾ Higher counts smaller drops as breaks; applies from the next request Language English ▾
README

claude-cache-bar

繁體中文

A Claude Code mod that shows what your prompt cache is doing: how long until it expires, how much each request read from it, when it broke and why. It warns you before the cache expires and can keep it warm for you.

A cache hit costs a tenth of the normal input price; a cache write costs 1.25× (5-minute TTL) or 2× (1-hour TTL). Step away for a few minutes too long and the next request pays to write the whole conversation again. cache-bar makes that visible.

Install

/plugin marketplace add TheTsungYing/claude-cache-bar
/plugin install cache-bar@claude-cache-bar

Needs Claude Code 2.1.286 or later (function-hook plugins, an early-access API).

What you get

Desktop (Code tab): a compact, one-row band above the prompt. It stays quiet while the cache is fine and adds color only when something needs you.

The band above the prompt, hovered: countdown ring and clock, Details, hit rate, context and a hit-rate trend line

  • A small countdown ring and clock for the cache TTL. Grey while fresh, orange at 20% left, red at 10%, a dashed grey ring once expired. Nothing blinks.
  • Hover over the band for the last request's hit rate, the context size and a hit-rate trend line (and, once expired, what the next request will rewrite).
  • While Claude is answering, the ring holds still and the clock shows …: each request refreshes the cache.
  • An Extend button when the cache is about to expire.
  • A red ⚠ when the cache broke; hover over it for the likely cause.
  • Details opens the side panel.
  • Don't want the band at all? Set Band above the prompt to Off in the side panel; the toasts still warn you, and /cache opens the panel to turn it back on.

Side panel (/cache or Details):

<img src="docs/images/panel-en.png" alt="The side panel: countdown, this conversation's totals, the per-request chart hovered on a break, cache breaks, extensions and settings" width="495">

<sub>Sample data, with the pointer on the request that broke the cache.</sub>

  • The countdown, with the TTL, last hit rate and context on one line.
  • This conversation: average hit rate, requests, breaks, extensions, peak context.
  • A per-request chart of what each request cost, in uncached-token equivalents (reads ×0.1, writes ×1.25 or ×2 by TTL): writes in orange, a hit-rate strip that only lights up on a dip or a break, and marks for idle gaps that came near expiry (from the point where the countdown turns orange, so 48 minutes on a 1-hour TTL) and keep-warm extensions. A break that dwarfs the rest is clipped so everyday bars stay readable. Hover a bar to read its numbers below the chart; otherwise it shows the latest request.
  • The list of cache breaks with their likely causes, and the extensions made; one line when there are none.
  • Quick settings for what happens near expiry (and, when extending automatically, how many times), toasts, the TTL mode (with how it was detected), the band and the language. Break sensitivity sits under the cache-breaks list, where a misjudged break shows.

Terminal and VS Code: one status line.

⚡ 3:42 · 94% · 48.2k
⚠ 0:28 /cache-extend · 94% · 48.2k

Commands

CommandWhat it does
/cacheOpens the side panel
`/cache lang [en\zh-TW]`Switches the language on the spot; with no language, to the other one
/cache-extendKeeps the cache warm now

Keeping the cache warm

Extending re-sends the main conversation's last request in the background through $.model.fork, followed by a one-word prompt. The request reads the whole cached prefix, which restarts the TTL. It never shows up in your conversation.

It costs about the context size at the cache-read price (0.1×) plus a few output tokens. A 50k context costs as much as about 5k fresh input tokens. Letting it expire instead costs a rewrite at 1.25× or 2×, so extending pays for itself up to roughly 12 times on a 5-minute cache and 20 on a 1-hour one.

With onExpiring set to auto, cache-bar extends on its own when the alert threshold is reached, within three limits: at most autoExtendMaxPerIdle times while you are away (default 3), only for a context of at least autoExtendMinContextK thousand tokens (default 20), and optionally not after autoExtendGiveUpMin minutes idle.

How the TTL is detected

Claude Code doesn't tell plugins whether your cache lives 5 minutes or 1 hour, so the countdown is an estimate. With ttlMode on auto, cache-bar assumes 5 minutes (it would rather warn early). When a request sent after more than 5 minutes idle still reads most of its input from the cache, the TTL must be 1 hour, and cache-bar remembers that across sessions. If, later, a request inside the hour misses completely with nothing else to blame, it goes back to 5 minutes.

Set ttlMode to 5m or 1h if you know which one you have.

Cache breaks

A request counts as a cache break when the context is large, its hit rate is low, and the previous request's was high. The thresholds depend on breakSensitivity:

SensitivityContext overThis request underPrevious over
low20k30%85%
medium10k50%80%
high5k70%70%

Likely causes: idle past the TTL, a model switch, a compaction, a change to the system prompt or CLAUDE.md, or a change to the tool list.

Settings

Each one is a row in /config. The side panel's quick settings change onExpiring, autoExtendMaxPerIdle, toast, ttlMode, band, breakSensitivity and language too, and /cache lang changes the language.

SettingValuesDefault
languageen, zh-TWenDisplay language
ttlModeauto, 5m, 1hautoCache TTL
onExpiringnotify, button, autobuttonNotify only; notify and offer Extend; extend automatically
bandcompact, offcompactDesktop band above the prompt; off leaves only the toasts
warnAtPercent1–9920Turn orange at this % of the TTL left
alertAtPercent1–9910Notify (and offer Extend) at this % left
toaston / offonShow a toast before the cache expires
autoExtendMaxPerIdle0–1003Auto-extend at most this many times while you are away
autoExtendMinContextK0–1000020Don't auto-extend a context smaller than this many thousand tokens
autoExtendGiveUpMin0–14400Stop auto-extending after this many minutes idle; 0 = no limit
breakSensitivitylow, medium, highmediumHow readily a drop counts as a break

Limits

  • The TTL is inferred, not read; the countdown is an estimate.
  • Only the main conversation is counted, not subagents.
  • Notifications are in-app toasts only. Sound and speech aren't used: on Windows they don't work.
  • A plugin can't switch your TTL to 1 hour.
  • Extending costs tokens.

Development

Load the plugin straight from disk:

claude --plugin-dir ./plugins/cache-bar

The desktop app takes no flag: set CLAUDE_CODE_PLUGIN_DIRS to the absolute path of plugins/cache-bar (and CLAUDE_CODE_PLUGIN_DIR_WATCH=1) in the env block of ~/.claude/settings.json, then open a new session. Saved edits reload the module.

Check and test it:

claude plugin validate ./plugins/cache-bar
claude plugin test ./plugins/cache-bar

For editor type-checking, run /plugin-types plugins/cache-bar/.claude/types inside Claude Code, then open plugins/cache-bar in your editor.

.claude-plugin/marketplace.json   marketplace listing
plugins/cache-bar/
  .claude-plugin/plugin.json      manifest and userConfig
  hooks/register.tsx              the hooks module
  hooks/core.ts                   pure logic: samples, TTL, breaks, keep-warm limits
  hooks/svg.ts                    the band's and panel's drawings
  hooks/i18n.ts                   English and Traditional Chinese strings
  types/index.d.ts                $.state contract
  tests/                          claude plugin test

License

MIT

Source 5 files
hooks/register.tsx 994 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { CacheBreak, CacheSample, Extension, Overrides } from '../types'
5import {
6  EMPTY_TTL,
7  EXTEND_COMMAND,
8  KEEP_WARM_PROMPT,
9  LANGUAGES,
10  MAX_EVENTS,
11  MAX_SAMPLES,
12  NARROW_COLUMNS,
13  NO_OVERRIDES,
14  SENSITIVITIES,
15  anchorOf,
16  appendCapped,
17  applyOverrides,
18  autoExtendSteps,
19  chartModel,
20  contextOf,
21  countdownOf,
22  effectiveTtl,
23  formatCauses,
24  formatClock,
25  formatPercent,
26  formatStatus,
27  formatTokens,
28  fromSelect,
29  guessCauses,
30  hitRateOf,
31  isBreak,
32  isTtlState,
33  learnTtl,
34  legendMarks,
35  normalizeOverrides,
36  notePrint,
37  parseCacheArgs,
38  readConfig,
39  readoutOf,
40  recentHits,
41  shouldAutoExtend,
42  summarize,
43  toBreak,
44  toExtension,
45  toSample,
46  withOverride,
47  withoutOverride,
48} from './core'
49import type { QuickSetting } from './core'
50import { LANGUAGE_NAMES, STRINGS } from './i18n'
51import {
52  BIG_CLOCK_SIZE,
53  BIG_RING_SIZE,
54  CHART_BARS,
55  CLOCK_SIZE,
56  COLORS,
57  NARROW_CHART_BARS,
58  RING_SIZE,
59  SPARK_HEIGHT,
60  SPARK_WIDTH,
61  chartSvg,
62  clockSvg,
63  idleRingSvg,
64  pausedRingSvg,
65  ringSvg,
66  sparklineSvg,
67} from './svg'
68
69const samples = atom({ plugin: 'cache-bar', key: 'samples' } as const, [] as CacheSample[])
70const breaks = atom({ plugin: 'cache-bar', key: 'breaks' } as const, [] as CacheBreak[])
71const extensions = atom({ plugin: 'cache-bar', key: 'extensions' } as const, [] as Extension[])
72const ttl = atom({ plugin: 'cache-bar', key: 'ttl' } as const, EMPTY_TTL)
73// Families, one member per id: many hooks fire at once (tool.describe once per
74// tool), and members never contend the way one shared value would.
75const changedAt = { plugin: 'cache-bar', key: 'changedAt' } as const
76const prints = { plugin: 'cache-bar', key: 'prints' } as const
77const clock = atom({ plugin: 'cache-bar', key: 'now' } as const, 0)
78const stage = atom({ plugin: 'cache-bar', key: 'stage' } as const, 'none')
79const extending = atom({ plugin: 'cache-bar', key: 'extending' } as const, false)
80const overrides = atom({ plugin: 'cache-bar', key: 'overrides' } as const, NO_OVERRIDES)
81const alertedFor = atom({ plugin: 'cache-bar', key: 'alertedFor' } as const, null as number | null)
82
83/** `$.store` key of the learned TTL, kept across sessions. */
84const TTL_STORE_KEY = 'ttl'
85
86/** `$.store` key of the settings picked in the panel. */
87const OVERRIDES_STORE_KEY = 'overrides'
88
89/** Requests the band's trend line spans. */
90const SPARK_POINTS = 24
91
92/** The side panel's id, and the command that opens it. */
93const PANE = 'cache-bar'
94const COMMAND = 'cache'
95
96/** Rows the panel's break and extension lists show, newest first. */
97const LIST_ROWS = 8
98
99/** Text that needs you: the theme's error colour, so it follows light and dark. */
100const TEXT_ALERT = 'error'
101
102export const register: Register = (on, options) => {
103  // The userConfig values, and those in force once the panel's picks apply.
104  const base = readConfig(options)
105  let config = base
106  let strings = STRINGS[config.language]
107  // `$.state` outlives a reload, so it may hold picks saved in an older shape.
108  const applyPicks = (picked: Overrides) => {
109    config = applyOverrides(base, normalizeOverrides(picked) ?? NO_OVERRIDES)
110    strings = STRINGS[config.language]
111  }
112  let shownStatus: string | undefined
113  // Once the desktop band draws, the status line would only repeat it.
114  let hasBand = false
115  let isWorking = false
116  let isForking = false
117  // Set by session.start: one keep-warm fork, resolving to what it reports
118  // (null when one is already running), toasted unless `shouldToast` is false.
119  // The buttons call it too, since a Button can't reach this plugin's own
120  // slash command.
121  let extendNow: ((trigger: Extension['trigger'], shouldToast?: boolean) => Promise<string | null>) | null = null
122  // Set by session.start: applies a setting picked in the panel or with
123  // `/cache lang` (as the Select's string), resolving to why it failed, or
124  // null once it is done.
125  let pickOption: ((field: QuickSetting, value: string) => Promise<string | null>) | null = null
126  // The ring's drawing stays the same while its anchor, TTL and phase do, so
127  // its SMIL countdown keeps running across the band's redraws.
128  let ring = { key: '', source: '' }
129  let smallClock = { key: '', source: '', width: 0, height: 0 }
130  // The panel's ring and clock, kept the same way. Dropped when the panel
131  // opens or closes: a fresh drawing restarts its SMIL from load.
132  let bigRing = { key: '', source: '' }
133  let bigClock = { key: '', source: '', width: 0, height: 0 }
134  const forgetPaneDrawings = () => {
135    bigRing = { key: '', source: '' }
136    bigClock = { key: '', source: '', width: 0, height: 0 }
137  }
138
139  on('session.start', async ($, e, next) => {
140    const stored = await $.store.get(TTL_STORE_KEY)
141
142    if (isTtlState(stored)) {
143      await update($, ttl, current => (current.detected === null ? stored : current))
144    }
145
146    const storedOverrides = normalizeOverrides(await $.store.get(OVERRIDES_STORE_KEY))
147
148    if (storedOverrides !== null) {
149      await update($, overrides, () => storedOverrides)
150    }
151
152    applyPicks(await read($, overrides))
153    await $.command.register({ name: COMMAND, description: strings.commandDescription })
154    await $.command.register({ name: EXTEND_COMMAND, description: strings.extendCommandDescription })
155
156    // A reload after a language change keeps the panel up under its old title.
157    const shownPane = (await $.ui.panes()).find(pane => pane.id === PANE)
158
159    if (shownPane !== undefined && shownPane.title !== strings.paneTitle) {
160      await $.ui.open({ id: PANE, title: strings.paneTitle })
161    }
162
163    extendNow = async (trigger, shouldToast = true) => {
164      if (isForking) {
165        return null
166      }
167
168      isForking = true
169      await update($, extending, () => true)
170      const sentAt = await $.clock.now()
171
172      try {
173        const outcome = await $.model.fork({ prompt: KEEP_WARM_PROMPT })
174        const extension = toExtension(outcome, sentAt, trigger)
175        await update($, extensions, list => appendCapped(list, extension, MAX_EVENTS))
176        const readK = formatTokens(extension.read)
177        const text = !outcome.isAnswered
178          ? strings.extendFailed(outcome.reason)
179          : trigger === 'manual'
180            ? strings.extended(readK)
181            : strings.autoExtended(readK)
182
183        // A quiet auto extension stays quiet; a failure is always told.
184        if (shouldToast && (trigger === 'manual' || config.toast || !outcome.isAnswered)) {
185          $.ui.toast(text)
186        }
187
188        return text
189      } catch (err) {
190        const reason = String(err)
191        const extension = toExtension({ isAnswered: false, reason }, sentAt, trigger)
192        await update($, extensions, list => appendCapped(list, extension, MAX_EVENTS))
193        const text = strings.extendFailed(reason)
194
195        if (shouldToast) {
196          $.ui.toast(text)
197        }
198
199        return text
200      } finally {
201        isForking = false
202        await update($, extending, () => false)
203      }
204    }
205
206    // Through `/config` where it has the row: the module reloads with the new
207    // options. A plugin folder on desktop gets no rows, so the pick is kept as
208    // an override instead, in `$.state` (redrawing) and `$.store`.
209    pickOption = async (field, raw) => {
210      const value = fromSelect(field, raw)
211
212      if (value === null || value === config[field]) {
213        return null
214      }
215
216      try {
217        const rows = await $.config.list()
218        const row =
219          rows.find(r => r.key === `cache-bar.${field}`) ??
220          rows.find(r => r.key.startsWith('cache-bar') && r.key.endsWith(`.${field}`))
221
222        if (row === undefined) {
223          await update($, overrides, o => withOverride(normalizeOverrides(o) ?? NO_OVERRIDES, base, field, value))
224        } else {
225          const result = await $.config.set({ key: row.key, value })
226
227          if (result.deny !== undefined) {
228            return strings.settingFailed(result.deny)
229          }
230
231          // An override kept from before the row existed would still win over
232          // it while the value it replaced stands, so the row's pick drops it.
233          await update($, overrides, o => withoutOverride(normalizeOverrides(o) ?? NO_OVERRIDES, field))
234        }
235
236        const picked = await read($, overrides)
237        applyPicks(picked)
238        await $.store.set(OVERRIDES_STORE_KEY, picked)
239
240        if (field === 'language' && (await $.ui.panes()).some(pane => pane.id === PANE)) {
241          await $.ui.open({ id: PANE, title: strings.paneTitle })
242        }
243
244        // Once the panel closes, nothing on screen leads back: say how.
245        if (field === 'band' && value === 'off') {
246          $.ui.toast(strings.bandTurnedOff(COMMAND))
247        }
248
249        return null
250      } catch (err) {
251        return strings.settingFailed(String(err))
252      }
253    }
254
255    // The clock goes through $.state: a value the drawings read redraws them.
256    $.clock.every(1000, async () => {
257      const now = await $.clock.now()
258      await update($, clock, () => now)
259
260      const view = {
261        samples: await read($, samples),
262        breaks: await read($, breaks),
263        extensions: await read($, extensions),
264        ttl: await read($, ttl),
265        config,
266        isWorking,
267        isExtending: isForking,
268        now,
269      }
270      // The terminal and VS Code have no band: the status line is their display.
271      const text = hasBand ? undefined : formatStatus(view, strings)
272
273      if (text !== shownStatus) {
274        shownStatus = text
275        $.ui.status(text)
276      }
277
278      // Expiry notices: none while Claude answers, since each request refreshes.
279      const last = view.samples.at(-1)
280      const countdown = countdownOf(view.samples, view.extensions, config.ttlMode, view.ttl, config, now)
281      const nextStage = isWorking
282        ? 'working'
283        : countdown === null
284          ? 'none'
285          : `${countdown.anchor}|${countdown.ttl}|${countdown.phase}`
286
287      if ((await read($, stage)) !== nextStage) {
288        await update($, stage, () => nextStage)
289      }
290
291      if (isWorking || last === undefined || countdown === null || countdown.phase !== 'alert') {
292        return
293      }
294
295      if (shouldAutoExtend({ samples: view.samples, extensions: view.extensions, countdown, config, now })) {
296        void extendNow?.('auto')
297
298        return
299      }
300
301      if ((await read($, alertedFor)) === last.sentAt) {
302        return
303      }
304
305      await update($, alertedFor, () => last.sentAt)
306
307      if (config.toast) {
308        const notice = strings.expiresIn(formatClock(countdown.leftMs))
309        const isBandOff = hasBand && config.band === 'off'
310        const hint = hasBand && !isBandOff ? strings.pressExtend : strings.runExtend(EXTEND_COMMAND)
311        // With the band off, nothing on screen leads to the panel: the toast does.
312        const panel = isBandOff ? ` · ${strings.openPanel(COMMAND)}` : ''
313        $.ui.toast(`${config.onExpiring === 'notify' ? notice : `${notice} · ${hint}`}${panel}`)
314      }
315    })
316
317    return next(e)
318  })
319
320  // `/cache` opens the panel; `/cache lang [en|zh-TW]` switches the language,
321  // to the other one when none is named.
322  on('command.run', { command: COMMAND }, async ($, e) => {
323    const asked = parseCacheArgs(e.args, config.language)
324
325    if (asked.kind === 'unknown') {
326      return { text: strings.cacheUsage(COMMAND) }
327    }
328
329    if (asked.kind === 'language') {
330      const failed = await pickOption?.('language', asked.language)
331
332      return { text: failed ?? STRINGS[asked.language].languageSet(LANGUAGE_NAMES[asked.language]) }
333    }
334
335    forgetPaneDrawings()
336    const opened = await $.ui.open({ id: PANE, title: strings.paneTitle })
337
338    return opened.isPlaced ? {} : { text: strings.paneUnplaced(opened.reason) }
339  })
340
341  // Keeps the cache warm now, whatever the countdown says: the TTL is only an
342  // estimate. The answer is the command's output line rather than a toast.
343  on('command.run', { command: EXTEND_COMMAND }, async $ => {
344    if ((await read($, samples)).length === 0) {
345      return { text: strings.extendNothing }
346    }
347
348    if (isWorking) {
349      return { text: strings.extendBusy }
350    }
351
352    const text = await extendNow?.('manual', false)
353
354    return { text: text ?? strings.extendAlready }
355  })
356
357  on('turn.start', async ($, e, next) => {
358    isWorking = true
359
360    return next(e)
361  })
362
363  on('turn.complete', async ($, e, next) => {
364    isWorking = false
365
366    return next(e)
367  })
368
369  // Record each main-thread request's cache usage; judge breaks and the TTL.
370  on('turn.step', async function* ($, e, next) {
371    const sentAt = await $.clock.now()
372    const step = yield* next(e)
373
374    // Outside a turn while a keep-warm fork runs, the request is the fork's own:
375    // counting it would start a new idle stretch and lift the auto-extend cap.
376    if (e.agentId !== undefined || step.usage === null || (isForking && !isWorking)) {
377      return step
378    }
379
380    const at = await $.clock.now()
381    const previous = (await read($, samples)).at(-1)
382    const sample = toSample(step.usage, sentAt, at, previous)
383
384    if (previous !== undefined) {
385      const learned = await read($, ttl)
386      const marks = {
387        compactAt: (await read($, { ...changedAt, id: 'compact' })) ?? null,
388        systemAt: (await read($, { ...changedAt, id: 'system' })) ?? null,
389        toolsAt: (await read($, { ...changedAt, id: 'tools' })) ?? null,
390      }
391      // A keep-warm fork refreshed the cache too: idle counts from the later of
392      // the two, or a kept-warm 5m cache would read as proof of 1h.
393      const forks = (await read($, extensions)).filter(x => x.at < sentAt)
394      const refreshedAt = anchorOf([previous], forks) ?? previous.sentAt
395      const sinceRefresh = { ...sample, idleMs: sentAt - refreshedAt }
396      const causes = guessCauses(previous, sinceRefresh, marks, effectiveTtl(config.ttlMode, learned))
397
398      if (isBreak(previous, sample, config.breakSensitivity)) {
399        await update($, breaks, list => appendCapped(list, toBreak(previous, sample, causes), MAX_EVENTS))
400      }
401
402      const relearned = learnTtl(learned, previous, sinceRefresh, causes.filter(c => c !== 'idle'))
403
404      if (relearned !== learned) {
405        await update($, ttl, () => relearned)
406        await $.store.set(TTL_STORE_KEY, relearned)
407      }
408    }
409
410    await update($, samples, list => appendCapped(list, sample, MAX_SAMPLES))
411
412    return step
413  })
414
415  on('session.compact', async ($, e, next) => {
416    const result = await next(e)
417
418    // `precompute` only prepares a summary; the conversation is unchanged.
419    if (e.agentId === undefined && e.trigger !== 'precompute' && !('skip' in result)) {
420      await $.state.set({ ...changedAt, id: 'compact' }, await $.clock.now())
421    }
422
423    return result
424  })
425
426  // The next three only watch what the prompt is built from. A change seen
427  // once the conversation has started is a likely cause of a cache break.
428  // Marks are plain writes: concurrent ones all write about the same time.
429
430  on('prompt.section', async ($, e, next) => {
431    const result = await next(e)
432    let isChange = false
433    await update($, { ...prints, id: `section:${e.name}` }, seen => {
434      const noted = notePrint(seen ?? [], result.text ?? '')
435      isChange = noted.isChange
436
437      return noted.seen
438    })
439
440    if (isChange && (await read($, samples)).length > 0) {
441      await $.state.set({ ...changedAt, id: 'system' }, await $.clock.now())
442    }
443
444    return result
445  })
446
447  on('prompt.context', async ($, e, next) => {
448    const result = await next(e)
449    let isChange = false
450
451    for (const block of result.blocks) {
452      await update($, { ...prints, id: `context:${block.name}` }, seen => {
453        const noted = notePrint(seen ?? [], block.text)
454        isChange ||= noted.isChange
455
456        return noted.seen
457      })
458    }
459
460    if (isChange && (await read($, samples)).length > 0) {
461      await $.state.set({ ...changedAt, id: 'system' }, await $.clock.now())
462    }
463
464    return result
465  })
466
467  on('tool.describe', async ($, e, next) => {
468    const result = await next(e)
469    let isChange = false
470    await update($, { ...prints, id: `tool:${e.tool}` }, seen => {
471      const noted = notePrint(seen ?? [], `${result.description}\u0000${String(result.isDeferred)}`)
472      isChange = noted.isChange
473
474      return noted.seen
475    })
476
477    if (isChange && (await read($, samples)).length > 0) {
478      await $.state.set({ ...changedAt, id: 'tools' }, await $.clock.now())
479    }
480
481    return result
482  })
483
484  // The band above the prompt, desktop only, kept to one quiet row: ring,
485  // clock and Details. Hit rate, context and the trend line show on hover;
486  // a break mark and the extend button appear only when they need you.
487  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
488    if (e.surface !== 'desktop' || e.props.hasSurvey) {
489      return next(e)
490    }
491
492    // Set even when the band is off: the desktop keeps no status line then.
493    hasBand = true
494    // Redraw on the stage, not every second: the ring and the clock count down
495    // by SMIL, and a per-second redraw of the band resets an open Select's
496    // highlight in the panel too.
497    await read($, stage)
498    applyPicks(await read($, overrides))
499
500    if (config.band === 'off') {
501      return next(e)
502    }
503
504    const { Box, Text, Button, Svg } = $.ui.resolve(e)
505    const now = await $.clock.now()
506    const list = await read($, samples)
507    const last = list.at(-1)
508    const s = strings
509    const details = (
510      <Button
511        key="details"
512        plain
513        dimColor
514        label={s.details}
515        onPress={() => {
516          forgetPaneDrawings()
517          void $.ui.open({ id: PANE, title: s.paneTitle })
518        }}
519      />
520    )
521
522    if (last === undefined) {
523      return (
524        <Box key="band" flexDirection="row" alignItems="center" gap={1}>
525          <Svg key="ring" alt={s.ringAlt} source={idleRingSvg()} width={RING_SIZE} height={RING_SIZE} />
526          {details}
527          <Box display="none" hover={{ display: 'flex' }}>
528            <Text dimColor>{s.waiting}</Text>
529          </Box>
530        </Box>
531      )
532    }
533
534    const breakList = await read($, breaks)
535    const countdown = countdownOf(list, await read($, extensions), config.ttlMode, await read($, ttl), config, now)
536    const isExtending = await read($, extending)
537
538    if (countdown === null) {
539      return next(e)
540    }
541
542    const context = formatTokens(contextOf(last))
543    const isAnswering = e.props.isWorking
544    const ringKey = isAnswering ? 'working' : `${countdown.anchor}|${countdown.ttl}|${countdown.phase}`
545
546    if (ring.key !== ringKey) {
547      ring = { key: ringKey, source: isAnswering ? pausedRingSvg() : ringSvg(countdown) }
548    }
549
550    const lastBreak = breakList.at(-1)
551    const didBreak = lastBreak !== undefined && lastBreak.at === last.sentAt
552    const isExpired = !isAnswering && countdown.phase === 'expired'
553    const canExtend = !isAnswering && countdown.phase === 'alert' && config.onExpiring !== 'notify'
554    const clockColor =
555      countdown.phase === 'warn' ? COLORS.warn : countdown.phase === 'alert' ? COLORS.alert : COLORS.neutral
556
557    if (smallClock.key !== ringKey) {
558      smallClock = { key: ringKey, ...clockSvg(countdown.leftMs, clockColor, CLOCK_SIZE) }
559    }
560
561    return (
562      <Box key="band" flexDirection="row" alignItems="center" gap={1}>
563        <Svg key="ring" alt={s.ringAlt} source={ring.source} width={RING_SIZE} height={RING_SIZE} />
564        {/* Answering: the countdown waits, since each request refreshes the cache. */}
565        {isAnswering ? (
566          <Text dimColor>…</Text>
567        ) : isExpired ? null : (
568          <Svg
569            key="clock"
570            alt={formatClock(countdown.leftMs)}
571            source={smallClock.source}
572            width={smallClock.width}
573            height={smallClock.height}
574          />
575        )}
576        {isExtending ? (
577          <Text dimColor>{s.extending}</Text>
578        ) : canExtend ? (
579          <Button key="extend" variant="primary" label={s.extendShort} onPress={() => void extendNow?.('manual')} />
580        ) : null}
581        {details}
582        {didBreak ? (
583          <Box key="break" flexDirection="row" gap={1}>
584            <Text color={TEXT_ALERT}>⚠</Text>
585            <Box display="none" hover={{ display: 'flex' }}>
586              <Text color={TEXT_ALERT}>{s.broke(formatCauses(lastBreak.causes, s))}</Text>
587            </Box>
588          </Box>
589        ) : null}
590        {/* Shown while the pointer is over the band; after the buttons, so it never moves them. */}
591        <Box display="none" hover={{ display: 'flex' }} flexDirection="row" alignItems="center" gap={1}>
592          {isExpired ? (
593            <Text dimColor>
594              {s.expired} · {s.rewriteNext(context)}
595            </Text>
596          ) : null}
597          <Text>
598            <Text dimColor>{s.hit} </Text>
599            {formatPercent(hitRateOf(last))}
600          </Text>
601          <Text>
602            <Text dimColor>{s.context} </Text>
603            {context}
604          </Text>
605          {list.length > 1 ? (
606            <Svg
607              key="spark"
608              alt={s.sparkAlt}
609              source={sparklineSvg(recentHits(list, breakList, SPARK_POINTS))}
610              width={SPARK_WIDTH}
611              height={SPARK_HEIGHT}
612            />
613          ) : null}
614        </Box>
615      </Box>
616    )
617  })
618
619  on('ui.close', async ($, e, next) => {
620    if (e.id === PANE) {
621      forgetPaneDrawings()
622    }
623
624    return next(e)
625  })
626
627  // The side panel: the countdown large, this conversation's totals, the
628  // per-request chart, breaks, extensions and quick settings. Drawings where
629  // the surface has Svg; the terminal gets the text.
630  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
631    const ui = $.ui.resolve(e)
632    const { Box, Text, Button } = ui
633    const Svg = 'Svg' in ui ? ui.Svg : null
634    const Select = 'Select' in ui ? ui.Select : null
635    // Redraw on the stage, not every second: the ring and clock count down by
636    // SMIL. Without Svg (the terminal) the clock is text, so it reads `now`.
637    await read($, stage)
638    applyPicks(await read($, overrides))
639    const s = strings
640
641    if (Svg === null) {
642      await read($, clock)
643    }
644
645    const now = await $.clock.now()
646    const list = await read($, samples)
647    const breakList = await read($, breaks)
648    const extensionList = await read($, extensions)
649    const learned = await read($, ttl)
650    const isExtending = await read($, extending)
651    const last = list.at(-1)
652    const countdown = countdownOf(list, extensionList, config.ttlMode, learned, config, now)
653
654    const setOption = async (field: QuickSetting, value: string) => {
655      const failed = await pickOption?.(field, value)
656
657      if (failed !== null && failed !== undefined) {
658        $.ui.toast(failed)
659      }
660    }
661
662    // How the TTL was settled; it explains the TTL setting, so it sits there.
663    const ttlNote =
664      config.ttlMode !== 'auto'
665        ? null
666        : learned.detected === '1h' && learned.idleMs !== null
667          ? s.ttlLearned('1h', Math.floor(learned.idleMs / 60_000))
668          : s.ttlAssumed
669    const title = (text: string) => <Text bold>{text}</Text>
670    const stat = (label: string, value: string) => (
671      <Text>
672        <Text dimColor>{label} </Text>
673        {value}
674      </Text>
675    )
676
677    const toastName = (isOn: boolean) => (isOn ? s.toastOptions.on : s.toastOptions.off)
678    const sensitivitySelect =
679      Select === null ? null : (
680        <Box flexDirection="column">
681          <Text dimColor>{s.breakSensitivityLabel}</Text>
682          <Select
683            key="breakSensitivity"
684            value={config.breakSensitivity}
685            options={SENSITIVITIES.map(v => ({ value: v, label: s.breakSensitivityOptions[v] }))}
686            onSelect={value => void setOption('breakSensitivity', value)}
687          />
688          <Text dimColor>{s.breakSensitivityNote}</Text>
689        </Box>
690      )
691
692    const settings = (withSensitivity: boolean) => (
693      <Box flexDirection="column">
694        {title(s.settingsTitle)}
695        {Select === null ? (
696          <Text dimColor>
697            {s.onExpiringLabel}: {s.onExpiringOptions[config.onExpiring]}
698            {config.onExpiring === 'auto' ? ` (${s.autoExtendTimes(config.autoExtendMaxPerIdle)})` : ''} ·{' '}
699            {s.toastLabel}: {toastName(config.toast)} · {s.ttlModeLabel}: {s.ttlModeOptions[config.ttlMode]} ·{' '}
700            {s.bandLabel}: {s.bandOptions[config.band]} · {s.breakSensitivityLabel}:{' '}
701            {s.breakSensitivityOptions[config.breakSensitivity]} · {s.languageLabel}: {LANGUAGE_NAMES[config.language]}
702            {ttlNote === null ? '' : `\n${ttlNote}`}
703          </Text>
704        ) : (
705          <Box flexDirection="column" gap={1}>
706            <Box flexDirection="column">
707              <Text dimColor>{s.onExpiringLabel}</Text>
708              <Select
709                key="onExpiring"
710                value={config.onExpiring}
711                options={(['notify', 'button', 'auto'] as const).map(v => ({ value: v, label: s.onExpiringOptions[v] }))}
712                onSelect={value => void setOption('onExpiring', value)}
713              />
714            </Box>
715            {/* Only automatic extension has a limit to set. */}
716            {config.onExpiring === 'auto' ? (
717              <Box flexDirection="column">
718                <Text dimColor>{s.autoExtendMaxLabel}</Text>
719                <Select
720                  key="autoExtendMaxPerIdle"
721                  value={String(config.autoExtendMaxPerIdle)}
722                  options={autoExtendSteps(config.autoExtendMaxPerIdle).map(n => ({
723                    value: String(n),
724                    label: s.autoExtendTimes(n),
725                  }))}
726                  onSelect={value => void setOption('autoExtendMaxPerIdle', value)}
727                />
728              </Box>
729            ) : null}
730            <Box flexDirection="column">
731              <Text dimColor>{s.toastLabel}</Text>
732              <Select
733                key="toast"
734                value={String(config.toast)}
735                options={[true, false].map(v => ({ value: String(v), label: toastName(v) }))}
736                onSelect={value => void setOption('toast', value)}
737              />
738            </Box>
739            <Box flexDirection="column">
740              <Text dimColor>{s.ttlModeLabel}</Text>
741              <Select
742                key="ttlMode"
743                value={config.ttlMode}
744                options={(['auto', '5m', '1h'] as const).map(v => ({ value: v, label: s.ttlModeOptions[v] }))}
745                onSelect={value => void setOption('ttlMode', value)}
746              />
747              {ttlNote === null ? null : <Text dimColor>{ttlNote}</Text>}
748            </Box>
749            <Box flexDirection="column">
750              <Text dimColor>{s.bandLabel}</Text>
751              <Select
752                key="band"
753                value={config.band}
754                options={(['compact', 'off'] as const).map(v => ({ value: v, label: s.bandOptions[v] }))}
755                onSelect={value => void setOption('band', value)}
756              />
757            </Box>
758            {withSensitivity ? sensitivitySelect : null}
759            <Box flexDirection="column">
760              <Text dimColor>{s.languageLabel}</Text>
761              <Select
762                key="language"
763                value={config.language}
764                options={LANGUAGES.map(v => ({ value: v, label: LANGUAGE_NAMES[v] }))}
765                onSelect={value => void setOption('language', value)}
766              />
767            </Box>
768          </Box>
769        )}
770      </Box>
771    )
772
773    if (last === undefined || countdown === null) {
774      return (
775        <Box flexDirection="column" gap={1}>
776          <Box flexDirection="row" alignItems="center" gap={2}>
777            {Svg === null ? null : (
778              <Svg
779                key="ring"
780                alt={s.ringAlt}
781                source={idleRingSvg(BIG_RING_SIZE)}
782                width={BIG_RING_SIZE}
783                height={BIG_RING_SIZE}
784              />
785            )}
786            <Text dimColor>{s.waiting}</Text>
787          </Box>
788          {settings(true)}
789        </Box>
790      )
791    }
792
793    const context = formatTokens(contextOf(last))
794    const ringKey = isWorking ? 'working' : `${countdown.anchor}|${countdown.ttl}|${countdown.phase}`
795
796    if (bigRing.key !== ringKey) {
797      bigRing = { key: ringKey, source: isWorking ? pausedRingSvg(BIG_RING_SIZE) : ringSvg(countdown, BIG_RING_SIZE) }
798    }
799
800    const clockColor =
801      countdown.phase === 'warn' ? COLORS.warn : countdown.phase === 'alert' ? COLORS.alert : undefined
802
803    if (bigClock.key !== ringKey) {
804      bigClock = { key: ringKey, ...clockSvg(countdown.leftMs, clockColor ?? COLORS.neutral, BIG_CLOCK_SIZE) }
805    }
806
807    const canExtend = !isWorking && countdown.phase === 'alert' && config.onExpiring !== 'notify'
808    const summary = summarize(list, breakList, extensionList)
809    const isQuiet = breakList.length === 0 && extensionList.length === 0
810    // A narrow panel gets fewer, wider bars and a three-line readout. Its
811    // width comes in cells; ~8.7px each on desktop, a guess only for spacing.
812    const isNarrow = e.props.bodyColumns < NARROW_COLUMNS
813    const chart = chartModel(
814      list,
815      breakList,
816      extensionList,
817      config.ttlMode,
818      learned,
819      isNarrow ? NARROW_CHART_BARS : CHART_BARS,
820      config.warnAtPercent,
821    )
822    const chartFirst = chart.bars[0]?.number ?? 0
823    const chartMarks = legendMarks(chart)
824    const chartDrawing = chartSvg(
825      chart,
826      `${chart.isCapped ? '≤ ' : ''}${formatTokens(Math.round(chart.top))}`,
827      s.chartRange(chartFirst, chartFirst + chart.bars.length - 1),
828      chart.bars.map(bar => readoutOf(bar, s, isNarrow)),
829      e.props.bodyColumns * 8.7,
830    )
831    // The request an extension kept warm: the last one sent before it.
832    const idleSince = (at: number) => [...list].reverse().find(x => x.sentAt < at)?.sentAt ?? null
833    const numberOf = (sentAt: number) => {
834      const i = list.findIndex(x => x.sentAt === sentAt)
835
836      return i < 0 ? null : i + 1
837    }
838    const swatch = (color: string, glyph: string, label: string) => (
839      <Text>
840        <Text color={color}>{glyph}</Text>
841        <Text dimColor> {label}</Text>
842      </Text>
843    )
844
845    return (
846      <Box flexDirection="column" gap={1}>
847        <Box flexDirection="row" alignItems="center" gap={2}>
848          {Svg === null ? null : (
849            <Svg key="ring" alt={s.ringAlt} source={bigRing.source} width={BIG_RING_SIZE} height={BIG_RING_SIZE} />
850          )}
851          <Box flexDirection="column">
852            {isWorking ? (
853              <Text dimColor>{s.working}</Text>
854            ) : countdown.phase === 'expired' ? (
855              <Text dimColor>
856                {s.expired} · {s.rewriteNext(context)}
857              </Text>
858            ) : Svg === null ? (
859              <Text bold color={clockColor}>
860                {formatClock(countdown.leftMs)}
861              </Text>
862            ) : (
863              <Svg
864                key="clock"
865                alt={formatClock(countdown.leftMs)}
866                source={bigClock.source}
867                width={bigClock.width}
868                height={bigClock.height}
869              />
870            )}
871            <Text dimColor>
872              TTL {s.ttl(config.ttlMode, countdown.ttl)} · {s.lastHit} {formatPercent(hitRateOf(last))} · {s.context}{' '}
873              {context}
874            </Text>
875          </Box>
876          {isExtending ? (
877            <Text dimColor>{s.extending}</Text>
878          ) : canExtend ? (
879            <Button
880              key="extend"
881              variant="primary"
882              label={s.extend(context)}
883              onPress={() => void extendNow?.('manual')}
884            />
885          ) : null}
886        </Box>
887
888        <Box flexDirection="column">
889          {title(s.summaryTitle)}
890          <Text>
891            {stat(s.averageHit, summary.averageHitRate === null ? '–' : formatPercent(summary.averageHitRate))} ·{' '}
892            {stat(s.requests, String(summary.requests))} · {stat(s.breaks, String(summary.breaks))} ·{' '}
893            {stat(s.extensionsCount, String(summary.extensions))} ·{' '}
894            {stat(s.peakContext, formatTokens(summary.peakContext))}
895          </Text>
896        </Box>
897
898        {Svg === null ? null : (
899          <Box flexDirection="column">
900            {title(s.chartTitle)}
901            <Svg key="chart" alt={s.chartAlt} source={chartDrawing.source} height={chartDrawing.height} isInteractive />
902            {/* What every chart has on one row; the occasional marks, when shown, on a second. */}
903            <Box flexDirection="row" columnGap={2} rowGap={0} flexWrap="wrap">
904              {/* Opaque swatches: the bars' see-through greys vanish as text. */}
905              {swatch(COLORS.neutral, '■', s.legend.read)}
906              {swatch(COLORS.written, '■', s.legend.written)}
907              {swatch(COLORS.neutral, '□', s.legend.uncached)}
908              <Text>
909                <Text color={COLORS.neutral}>▬</Text>
910                {chartMarks.dip ? <Text color={COLORS.dip}>▬</Text> : null}
911                {chartMarks.low ? <Text color={COLORS.warn}>▬</Text> : null}
912                <Text dimColor> {s.legend.rate}</Text>
913              </Text>
914            </Box>
915            {chartMarks.broke || chartMarks.gap || chartMarks.extension ? (
916              <Box flexDirection="row" columnGap={2} rowGap={0} flexWrap="wrap">
917                {chartMarks.broke ? swatch(COLORS.alert, '▬', s.legend.broke) : null}
918                {chartMarks.gap ? swatch(COLORS.neutral, '┊', s.legend.gap) : null}
919                {chartMarks.extension ? swatch(COLORS.neutral, '▲', s.legend.extension) : null}
920              </Box>
921            ) : null}
922          </Box>
923        )}
924
925        {/* Nothing to list: one line says so, rather than two empty sections. */}
926        {isQuiet ? (
927          <Text dimColor>{s.quietHistory}</Text>
928        ) : (
929          <Box flexDirection="column" gap={1}>
930            <Box flexDirection="column">
931              {title(s.breaksTitle)}
932              {breakList.length === 0 ? <Text dimColor>{s.noBreaks}</Text> : null}
933              {breakList
934                .slice(-LIST_ROWS)
935                .reverse()
936                .map(b => (
937                  <Box flexDirection="column">
938                    <Text>
939                      <Text color={TEXT_ALERT}>● </Text>
940                      {s.breakLine(
941                        numberOf(b.at),
942                        formatPercent(b.previousHitRate),
943                        formatPercent(b.hitRate),
944                        formatTokens(b.rewritten),
945                      )}
946                    </Text>
947                    <Text dimColor>  {formatCauses(b.causes, s)}</Text>
948                  </Box>
949                ))}
950              {/* Tuned where a misjudged break shows. */}
951              {sensitivitySelect === null ? null : <Box marginTop={1}>{sensitivitySelect}</Box>}
952            </Box>
953
954            <Box flexDirection="column">
955              {title(s.extensionsTitle)}
956              {extensionList.length === 0 ? <Text dimColor>{s.noExtensions}</Text> : null}
957              {extensionList
958                .slice(-LIST_ROWS)
959                .reverse()
960                .map(x => (
961                  <Text>
962                    <Text dimColor>{s.idleAt(formatClock(x.at - (idleSince(x.at) ?? x.at)))} · </Text>
963                    {s.trigger[x.trigger]} ·{' '}
964                    {x.isAnswered ? (
965                      s.extensionRead(formatTokens(x.read))
966                    ) : (
967                      <Text color={TEXT_ALERT}>{s.extensionFailed(x.reason ?? '')}</Text>
968                    )}
969                  </Text>
970                ))}
971            </Box>
972          </Box>
973        )}
974
975        {settings(isQuiet)}
976      </Box>
977    )
978  })
979
980  // /clear starts a new conversation: its first request is a cold write, not a break.
981  on('session.end', async ($, e, next) => {
982    if (e.reason === 'clear') {
983      await update($, samples, () => [])
984      await update($, breaks, () => [])
985      await update($, extensions, () => [])
986      await $.state.set({ ...changedAt, id: 'compact' }, null)
987      await $.state.set({ ...changedAt, id: 'system' }, null)
988      await $.state.set({ ...changedAt, id: 'tools' }, null)
989    }
990
991    return next(e)
992  })
993}
994
hooks/core.ts 853 lines
1// Pure logic over plain data: no `$` here, so tests can call it directly.
2
3import type {
4  BandMode,
5  BreakCause,
6  BreakSensitivity,
7  CacheBreak,
8  CacheSample,
9  ChangeMarks,
10  Extension,
11  Language,
12  OnExpiring,
13  Override,
14  Overrides,
15  SessionSummary,
16  Ttl,
17  TtlMode,
18  TtlState,
19} from '../types'
20import type { Strings } from './i18n'
21
22export const MAX_SAMPLES = 500
23export const MAX_EVENTS = 100
24
25const MINUTE = 60_000
26
27export const TTL_MS: Record<Ttl, number> = { '5m': 5 * MINUTE, '1h': 60 * MINUTE }
28
29/** Slack on idle gaps, so clock jitter near a TTL edge proves nothing. */
30const IDLE_MARGIN_MS = 15_000
31
32/** Fewer cached tokens than this say nothing about the TTL. */
33const MIN_PROOF_TOKENS = 1024
34
35// ---- Config
36
37export type { BandMode, OnExpiring }
38
39export type Config = {
40  language: Language
41  ttlMode: TtlMode
42  onExpiring: OnExpiring
43  band: BandMode
44  warnAtPercent: number
45  alertAtPercent: number
46  toast: boolean
47  autoExtendMaxPerIdle: number
48  autoExtendMinContextK: number
49  autoExtendGiveUpMin: number
50  breakSensitivity: BreakSensitivity
51}
52
53export const LANGUAGES: readonly Language[] = ['en', 'zh-TW']
54
55const pick = <T extends string>(value: unknown, allowed: readonly T[], fallback: T): T =>
56  allowed.find(item => item === value) ?? fallback
57
58const clamp = (value: unknown, fallback: number, min: number, max: number) =>
59  typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback
60
61/** Reads `register`'s options, falling back to the defaults on anything odd. */
62export const readConfig = (options: Readonly<Record<string, unknown>>): Config => ({
63  language: pick(options.language, LANGUAGES, 'en'),
64  ttlMode: pick(options.ttlMode, ['auto', '5m', '1h'], 'auto'),
65  onExpiring: pick(options.onExpiring, ['notify', 'button', 'auto'], 'button'),
66  band: pick(options.band, ['compact', 'off'], 'compact'),
67  warnAtPercent: clamp(options.warnAtPercent, 20, 1, 99),
68  alertAtPercent: clamp(options.alertAtPercent, 10, 1, 99),
69  toast: typeof options.toast === 'boolean' ? options.toast : true,
70  autoExtendMaxPerIdle: clamp(options.autoExtendMaxPerIdle, 3, 0, 100),
71  autoExtendMinContextK: clamp(options.autoExtendMinContextK, 20, 0, 10_000),
72  autoExtendGiveUpMin: clamp(options.autoExtendGiveUpMin, 0, 0, 24 * 60),
73  breakSensitivity: pick(options.breakSensitivity, SENSITIVITIES, 'medium'),
74})
75
76// ---- Settings picked in the panel
77
78const ON_EXPIRING: readonly OnExpiring[] = ['notify', 'button', 'auto']
79const TTL_MODES: readonly TtlMode[] = ['auto', '5m', '1h']
80const BAND_MODES: readonly BandMode[] = ['compact', 'off']
81export const SENSITIVITIES: readonly BreakSensitivity[] = ['low', 'medium', 'high']
82
83/** The panel's steps for the auto-extend limit; a value set elsewhere is added. */
84export const AUTO_EXTEND_STEPS: readonly number[] = [0, 1, 3, 5, 10]
85
86export type QuickSetting = keyof Overrides
87
88/** Overrides by field, typed so a generic field keeps its value's type. */
89type Picks = { [K in QuickSetting]: Override<Config[K]> | null }
90
91const oneOf =
92  <T extends string>(allowed: readonly T[]) =>
93  (value: unknown): value is T =>
94    allowed.some(a => a === value)
95
96/** What each field takes: the same bounds `readConfig` holds it to. */
97const IS_VALUE: { [K in QuickSetting]: (value: unknown) => value is Config[K] } = {
98  onExpiring: oneOf(ON_EXPIRING),
99  ttlMode: oneOf(TTL_MODES),
100  band: oneOf(BAND_MODES),
101  language: oneOf(LANGUAGES),
102  breakSensitivity: oneOf(SENSITIVITIES),
103  toast: (value): value is boolean => typeof value === 'boolean',
104  autoExtendMaxPerIdle: (value): value is number =>
105    typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100,
106}
107
108export const QUICK_SETTINGS = Object.keys(IS_VALUE) as QuickSetting[]
109
110export const NO_OVERRIDES: Overrides = {
111  onExpiring: null,
112  ttlMode: null,
113  band: null,
114  language: null,
115  breakSensitivity: null,
116  toast: null,
117  autoExtendMaxPerIdle: null,
118}
119
120/** A Select's string as the field's value, or null when it isn't one. */
121export const fromSelect = <K extends QuickSetting>(field: K, raw: string): Config[K] | null => {
122  const value: unknown =
123    field === 'toast'
124      ? raw === 'true'
125        ? true
126        : raw === 'false'
127          ? false
128          : null
129      : field === 'autoExtendMaxPerIdle'
130        ? raw.trim() === ''
131          ? null
132          : Number(raw)
133        : raw
134
135  return IS_VALUE[field](value) ? value : null
136}
137
138/** The auto-extend Select's steps, with a value set elsewhere among them. */
139export const autoExtendSteps = (current: number): number[] =>
140  AUTO_EXTEND_STEPS.includes(current) ? [...AUTO_EXTEND_STEPS] : [...AUTO_EXTEND_STEPS, current].sort((a, b) => a - b)
141
142const isOverride = (field: QuickSetting, value: unknown) =>
143  value === null ||
144  (typeof value === 'object' &&
145    'value' in value &&
146    'over' in value &&
147    IS_VALUE[field](value.value) &&
148    IS_VALUE[field](value.over))
149
150/**
151 * A `$.store` value as Overrides, or null when it is malformed. A field saved
152 * before it existed (`band`, `language`, `breakSensitivity`, ...) reads as no
153 * override, so an upgrade keeps the rest.
154 */
155export const normalizeOverrides = (value: unknown): Overrides | null => {
156  if (typeof value !== 'object' || value === null || !('onExpiring' in value) || !('ttlMode' in value)) {
157    return null
158  }
159
160  const saved = value as Record<string, unknown>
161  const fields = QUICK_SETTINGS.map(field => [field, saved[field] ?? null] as const)
162
163  if (!fields.every(([field, pick]) => isOverride(field, pick))) {
164    return null
165  }
166
167  return { ...NO_OVERRIDES, ...Object.fromEntries(fields) } as Overrides
168}
169
170const applyOne = <K extends QuickSetting>(config: Config, base: Config, field: K, pick: Picks[K]) => {
171  if (pick !== null && pick.over === base[field]) {
172    config[field] = pick.value
173  }
174}
175
176/** The settings in force: each override while the value it replaced still stands. */
177export const applyOverrides = (base: Config, o: Overrides): Config => {
178  const picks: Picks = o
179  const config = { ...base }
180
181  for (const field of QUICK_SETTINGS) {
182    applyOne(config, base, field, picks[field])
183  }
184
185  return config
186}
187
188/**
189 * Records a pick; picking the `userConfig` value again clears the override,
190 * and a value the field doesn't take leaves the picks as they were.
191 */
192export const withOverride = <K extends QuickSetting>(o: Overrides, base: Config, field: K, value: unknown): Overrides => {
193  if (!IS_VALUE[field](value)) {
194    return o
195  }
196
197  const pick: Override<Config[K]> | null = value === base[field] ? null : { value, over: base[field] }
198
199  return { ...o, [field]: pick }
200}
201
202/** Drops the override for `field`, once a `/config` row holds that setting. */
203export const withoutOverride = (o: Overrides, field: QuickSetting): Overrides => ({ ...o, [field]: null })
204
205// ---- /cache arguments
206
207/** What `/cache` was asked: open the panel, switch the language, or neither. */
208export type CacheCommand = { kind: 'open' } | { kind: 'language'; language: Language } | { kind: 'unknown' }
209
210const LANGUAGE_ALIASES: Partial<Record<string, Language>> = {
211  en: 'en',
212  english: 'en',
213  zh: 'zh-TW',
214  'zh-tw': 'zh-TW',
215  tw: 'zh-TW',
216  中文: 'zh-TW',
217  繁中: 'zh-TW',
218}
219
220/** Reads `/cache`'s arguments: nothing opens the panel; `lang` alone switches to the other language. */
221export const parseCacheArgs = (args: string, current: Language): CacheCommand => {
222  const [verb, value, ...rest] = args.trim().toLowerCase().split(/\s+/).filter(word => word !== '')
223
224  if (verb === undefined) {
225    return { kind: 'open' }
226  }
227
228  if ((verb !== 'lang' && verb !== 'language') || rest.length > 0) {
229    return { kind: 'unknown' }
230  }
231
232  if (value === undefined) {
233    return { kind: 'language', language: current === 'en' ? 'zh-TW' : 'en' }
234  }
235
236  const language = LANGUAGE_ALIASES[value]
237
238  return language === undefined ? { kind: 'unknown' } : { kind: 'language', language }
239}
240
241// ---- Samples
242
243export type Usage = {
244  model: string
245  input_tokens: number
246  output_tokens: number
247  cache_read_input_tokens: number
248  cache_creation_input_tokens: number
249}
250
251export const toSample = (
252  usage: Usage,
253  sentAt: number,
254  at: number,
255  previous: CacheSample | undefined,
256): CacheSample => ({
257  sentAt,
258  at,
259  model: usage.model,
260  read: usage.cache_read_input_tokens,
261  written: usage.cache_creation_input_tokens,
262  uncached: usage.input_tokens,
263  output: usage.output_tokens,
264  idleMs: previous === undefined ? null : sentAt - previous.sentAt,
265})
266
267export const appendCapped = <T>(list: readonly T[], item: T, max: number): T[] =>
268  [...list, item].slice(-max)
269
270export const inputOf = (s: CacheSample) => s.read + s.written + s.uncached
271
272/** Everything the next request carries: this one's input plus its output. */
273export const contextOf = (s: CacheSample) => inputOf(s) + s.output
274
275export const hitRateOf = (s: CacheSample) => {
276  const input = inputOf(s)
277
278  return input === 0 ? 0 : s.read / input
279}
280
281// ---- TTL
282
283export const effectiveTtl = (mode: TtlMode, ttl: TtlState): Ttl =>
284  mode === 'auto' ? (ttl.detected ?? '5m') : mode
285
286export const EMPTY_TTL: TtlState = { detected: null, at: null, idleMs: null }
287
288export const isTtlState = (value: unknown): value is TtlState =>
289  typeof value === 'object' &&
290  value !== null &&
291  'detected' in value &&
292  (value.detected === null || value.detected === '5m' || value.detected === '1h')
293
294/**
295 * Learns the TTL from one request. A solid hit after more than 5 minutes idle
296 * proves 1h. Once 1h is known, a clean miss inside the hour with nothing else
297 * to blame sends it back to 5m. `otherCauses` are the break causes besides idle.
298 */
299export const learnTtl = (
300  ttl: TtlState,
301  previous: CacheSample,
302  sample: CacheSample,
303  otherCauses: readonly BreakCause[],
304): TtlState => {
305  const idle = sample.idleMs
306
307  if (idle === null || idle <= TTL_MS['5m'] + IDLE_MARGIN_MS || idle >= TTL_MS['1h'] - IDLE_MARGIN_MS) {
308    return ttl
309  }
310
311  const isHit = sample.read >= MIN_PROOF_TOKENS && hitRateOf(sample) >= 0.5
312
313  if (isHit) {
314    return ttl.detected === '1h' ? ttl : { detected: '1h', at: sample.sentAt, idleMs: idle }
315  }
316
317  const wasCached = previous.read + previous.written >= MIN_PROOF_TOKENS
318  const isCleanMiss = sample.read === 0 && sample.written >= MIN_PROOF_TOKENS
319
320  if (ttl.detected === '1h' && wasCached && isCleanMiss && otherCauses.length === 0) {
321    return { detected: '5m', at: sample.sentAt, idleMs: idle }
322  }
323
324  return ttl
325}
326
327// ---- Countdown
328
329/** When the cache was last refreshed: a request sent, or an answered keep-warm fork. */
330export const anchorOf = (samples: readonly CacheSample[], extensions: readonly Extension[]) => {
331  const times = [
332    samples.at(-1)?.sentAt ?? null,
333    ...extensions.filter(x => x.isAnswered && x.read > 0).map(x => x.at),
334  ].filter((t): t is number => t !== null)
335
336  return times.length === 0 ? null : Math.max(...times)
337}
338
339export const remainingMs = (anchor: number, ttl: Ttl, now: number) => anchor + TTL_MS[ttl] - now
340
341/** fresh, then warn and alert as the TTL runs low, then expired. */
342export type Phase = 'fresh' | 'warn' | 'alert' | 'expired'
343
344export type Countdown = {
345  anchor: number
346  ttl: Ttl
347  totalMs: number
348  leftMs: number
349  /** How much of the TTL is left at `warnAtPercent` and `alertAtPercent`, ms. */
350  warnMs: number
351  alertMs: number
352  phase: Phase
353}
354
355export const phaseOf = (leftMs: number, warnMs: number, alertMs: number): Phase =>
356  leftMs <= 0 ? 'expired' : leftMs <= alertMs ? 'alert' : leftMs <= warnMs ? 'warn' : 'fresh'
357
358/** Where the countdown stands; null before the first request. */
359export const countdownOf = (
360  samples: readonly CacheSample[],
361  extensions: readonly Extension[],
362  mode: TtlMode,
363  learned: TtlState,
364  config: Pick<Config, 'warnAtPercent' | 'alertAtPercent'>,
365  now: number,
366): Countdown | null => {
367  const anchor = anchorOf(samples, extensions)
368
369  if (anchor === null) {
370    return null
371  }
372
373  const ttl = effectiveTtl(mode, learned)
374  const totalMs = TTL_MS[ttl]
375  const leftMs = remainingMs(anchor, ttl, now)
376  const warnMs = (totalMs * config.warnAtPercent) / 100
377  const alertMs = (totalMs * Math.min(config.alertAtPercent, config.warnAtPercent)) / 100
378
379  return { anchor, ttl, totalMs, leftMs, warnMs, alertMs, phase: phaseOf(leftMs, warnMs, alertMs) }
380}
381
382// ---- Keeping the cache warm
383
384/** The one user message a keep-warm fork sends after the cached prefix. */
385export const KEEP_WARM_PROMPT = 'Reply with the single word: ok'
386
387type ForkUsage = Pick<Usage, 'cache_read_input_tokens' | 'cache_creation_input_tokens'>
388
389/** What `$.model.fork` resolved to, as far as an Extension needs it. */
390export type ForkOutcome =
391  | { isAnswered: true; usage: ForkUsage }
392  | { isAnswered: false; reason: string; usage?: ForkUsage }
393
394/** `at` is when the fork was sent: the cache refreshes there. */
395export const toExtension = (outcome: ForkOutcome, at: number, trigger: Extension['trigger']): Extension => ({
396  at,
397  trigger,
398  isAnswered: outcome.isAnswered,
399  reason: outcome.isAnswered ? null : outcome.reason,
400  read: outcome.usage?.cache_read_input_tokens ?? 0,
401  written: outcome.usage?.cache_creation_input_tokens ?? 0,
402})
403
404/** Extensions tried since the last real request: this idle stretch's. */
405export const extensionsThisIdle = (samples: readonly CacheSample[], extensions: readonly Extension[]) => {
406  const since = samples.at(-1)?.sentAt ?? Number.NEGATIVE_INFINITY
407
408  return extensions.filter(x => x.at > since)
409}
410
411export type AutoExtendCheck = {
412  samples: readonly CacheSample[]
413  extensions: readonly Extension[]
414  countdown: Countdown
415  config: Pick<Config, 'onExpiring' | 'autoExtendMaxPerIdle' | 'autoExtendMinContextK' | 'autoExtendGiveUpMin'>
416  now: number
417}
418
419/**
420 * Whether to extend on its own now: in `auto` mode, once the alert threshold
421 * is reached and before expiry, within the three limits (a give-up time of 0
422 * means none), and at most one try per refresh (a failed fork leaves the
423 * anchor where it was; no retry storm).
424 */
425export const shouldAutoExtend = ({ samples, extensions, countdown, config, now }: AutoExtendCheck) => {
426  const last = samples.at(-1)
427
428  if (config.onExpiring !== 'auto' || last === undefined || countdown.phase !== 'alert') {
429    return false
430  }
431
432  const tried = extensionsThisIdle(samples, extensions)
433
434  return (
435    tried.length < config.autoExtendMaxPerIdle &&
436    contextOf(last) >= config.autoExtendMinContextK * 1000 &&
437    (config.autoExtendGiveUpMin === 0 || now - last.sentAt < config.autoExtendGiveUpMin * MINUTE) &&
438    tried.every(x => x.at <= countdown.anchor)
439  )
440}
441
442// ---- Breaks
443
444export const SENSITIVITY: Record<BreakSensitivity, { minContext: number; below: number; above: number }> = {
445  low: { minContext: 20_000, below: 0.3, above: 0.85 },
446  medium: { minContext: 10_000, below: 0.5, above: 0.8 },
447  high: { minContext: 5_000, below: 0.7, above: 0.7 },
448}
449
450export const isBreak = (previous: CacheSample, sample: CacheSample, sensitivity: BreakSensitivity) => {
451  const t = SENSITIVITY[sensitivity]
452
453  return contextOf(sample) > t.minContext && hitRateOf(sample) < t.below && hitRateOf(previous) > t.above
454}
455
456/** What changed between two requests that could have cost the cache, most likely first. */
457export const guessCauses = (
458  previous: CacheSample,
459  sample: CacheSample,
460  marks: ChangeMarks,
461  ttl: Ttl,
462): BreakCause[] => {
463  const isBetween = (t: number | null) => t !== null && t > previous.sentAt && t <= sample.at
464  const causes: BreakCause[] = []
465
466  if (isBetween(marks.compactAt)) causes.push('compact')
467  if (sample.model !== previous.model) causes.push('model')
468  if (sample.idleMs !== null && sample.idleMs > TTL_MS[ttl]) causes.push('idle')
469  if (isBetween(marks.systemAt)) causes.push('system')
470  if (isBetween(marks.toolsAt)) causes.push('tools')
471
472  return causes
473}
474
475export const toBreak = (previous: CacheSample, sample: CacheSample, causes: BreakCause[]): CacheBreak => ({
476  at: sample.sentAt,
477  hitRate: hitRateOf(sample),
478  previousHitRate: hitRateOf(previous),
479  rewritten: sample.written,
480  causes,
481})
482
483// ---- Fingerprints of what the prompt is built from
484
485const HASHES_PER_KEY = 8
486
487/** FNV-1a, 32 bits. */
488export const hashText = (text: string) => {
489  let h = 0x811c9dc5
490
491  for (let i = 0; i < text.length; i++) {
492    h ^= text.charCodeAt(i)
493    h = Math.imul(h, 0x01000193)
494  }
495
496  return h >>> 0
497}
498
499/**
500 * Records `text` among the hashes one key has seen. A change is a hash not
501 * seen before: remembering several per key keeps a subagent's variant of a
502 * section from reading as a change every time it alternates with the main
503 * thread's.
504 */
505export const notePrint = (seen: readonly number[], text: string): { seen: number[]; isChange: boolean } => {
506  const hash = hashText(text)
507
508  return seen.includes(hash)
509    ? { seen: [...seen], isChange: false }
510    : { seen: [...seen, hash].slice(-HASHES_PER_KEY), isChange: true }
511}
512
513// ---- Hit-rate history
514
515/** The last `count` samples' hit rates, each with whether it broke the cache. */
516export const recentHits = (samples: readonly CacheSample[], breaks: readonly CacheBreak[], count: number) => {
517  const broke = new Set(breaks.map(b => b.at))
518
519  return samples.slice(-count).map(s => ({ rate: hitRateOf(s), isBreak: broke.has(s.sentAt) }))
520}
521
522// ---- The panel chart
523
524/** What an input token costs against an uncached one: cache writes by TTL. */
525export const COST_WEIGHTS = { read: 0.1, written: { '5m': 1.25, '1h': 2 } } as const
526
527/**
528 * How long an idle stretch may run before the chart marks it: until the
529 * countdown would have turned to warn, `warnAtPercent` of the TTL left. The
530 * TTL is the one known when the request was sent; unknown, 5m, as the
531 * countdown assumes.
532 */
533export const gapMarkMs = (ttl: Ttl | null, warnAtPercent: number) =>
534  TTL_MS[ttl ?? '5m'] * (1 - warnAtPercent / 100)
535
536/** Fewer bars than this are too few for a percentile: the scale tops at the largest. */
537const CAP_MIN_BARS = 10
538
539/** The scale tops at the 90th percentile only when the largest bar is this many times it. */
540const CAP_RATIO = 3
541
542/**
543 * The TTL a request was written under, as known when it was sent: the mode
544 * set, or under auto what had been proved by then; null while nothing had.
545 * A later proof leaves earlier requests as they were.
546 */
547export const ttlWhenSent = (s: CacheSample, mode: TtlMode, learned: TtlState): Ttl | null =>
548  mode !== 'auto'
549    ? mode
550    : learned.detected !== null && learned.at !== null && learned.at <= s.sentAt
551      ? learned.detected
552      : null
553
554export type Cost = { read: number; written: number; uncached: number }
555
556/** A request's input in uncached-token equivalents; an unknown TTL bills writes as 5m. */
557export const costOf = (s: CacheSample, ttl: Ttl | null): Cost => ({
558  read: s.read * COST_WEIGHTS.read,
559  written: s.written * COST_WEIGHTS.written[ttl ?? '5m'],
560  uncached: s.uncached,
561})
562
563export const totalOf = (c: Cost) => c.read + c.written + c.uncached
564
565/**
566 * Where the chart's scale tops out. Normally at the largest bar; when some
567 * dwarf the 90th percentile (a break rewrites the whole prefix), at the
568 * largest of the rest, so the everyday bars stay readable and only those
569 * clip. Not at the percentile itself: context grows, so the latest bars are
570 * the tallest everyday ones, and they would clip every time.
571 */
572export const chartScale = (totals: readonly number[]): { top: number; isCapped: boolean } => {
573  const max = Math.max(0, ...totals)
574
575  if (totals.length < CAP_MIN_BARS) {
576    return { top: max, isCapped: false }
577  }
578
579  const sorted = [...totals].sort((a, b) => a - b)
580  const p90 = sorted[Math.ceil(sorted.length * 0.9) - 1] ?? 0
581  const limit = p90 * CAP_RATIO
582
583  return p90 > 0 && max > limit
584    ? { top: Math.max(...sorted.filter(t => t <= limit)), isCapped: true }
585    : { top: max, isCapped: false }
586}
587
588/** The hit-rate strip's colour class: only what needs a look gets a colour. */
589export type HitLevel = 'ok' | 'dip' | 'low' | 'broke'
590
591/** The first request has nothing cached to hit, so it reads as ok. */
592export const hitLevelOf = (s: CacheSample, isBreak: boolean): HitLevel => {
593  if (isBreak) {
594    return 'broke'
595  }
596
597  if (s.idleMs === null) {
598    return 'ok'
599  }
600
601  const rate = hitRateOf(s)
602
603  return rate >= 0.95 ? 'ok' : rate >= 0.8 ? 'dip' : 'low'
604}
605
606export type ChartBar = {
607  /** The request's number in this session, from 1. */
608  number: number
609  sample: CacheSample
610  cost: Cost
611  /** Bar height over the scale's top, 0..1. */
612  height: number
613  isClipped: boolean
614  level: HitLevel
615  isLatest: boolean
616  /** Whether the write weight came from a known TTL. */
617  isTtlKnown: boolean
618  /** The idle before this request when it reached the warn point (gapMarkMs), ms; null otherwise. */
619  gapMs: number | null
620  /** Answered keep-warm forks since the request before this one. */
621  extensionsBefore: number
622  /** The break this request caused, if it did. */
623  broke: CacheBreak | null
624  /** Its model, when it differs from the request before's; null otherwise. */
625  newModel: string | null
626}
627
628export type ChartModel = {
629  bars: ChartBar[]
630  /** The scale's top, in uncached-token equivalents. */
631  top: number
632  isCapped: boolean
633  /** Answered keep-warm forks after the latest request. */
634  extensionsAfter: number
635}
636
637/** The last `count` requests as the panel chart draws them. */
638export const chartModel = (
639  samples: readonly CacheSample[],
640  breaks: readonly CacheBreak[],
641  extensions: readonly Extension[],
642  mode: TtlMode,
643  learned: TtlState,
644  count: number,
645  warnAtPercent: number,
646): ChartModel => {
647  const brokeAt = new Map(breaks.map(b => [b.at, b]))
648  const kept = extensions.filter(x => x.isAnswered && x.read > 0)
649  const start = Math.max(0, samples.length - count)
650  const shown = samples.slice(start)
651  const ttls = shown.map(s => ttlWhenSent(s, mode, learned))
652  const costs = shown.map((s, i) => costOf(s, ttls[i] ?? null))
653  const { top, isCapped } = chartScale(costs.map(totalOf))
654
655  const bars = shown.map((s, i): ChartBar => {
656    const cost = costs[i]!
657    const total = totalOf(cost)
658    const previous = samples[start + i - 1]
659
660    return {
661      number: start + i + 1,
662      sample: s,
663      cost,
664      height: top === 0 ? 0 : Math.min(1, total / top),
665      isClipped: total > top,
666      level: hitLevelOf(s, brokeAt.has(s.sentAt)),
667      isLatest: i === shown.length - 1,
668      isTtlKnown: ttls[i] !== null,
669      gapMs: s.idleMs !== null && s.idleMs >= gapMarkMs(ttls[i] ?? null, warnAtPercent) ? s.idleMs : null,
670      extensionsBefore:
671        previous === undefined ? 0 : kept.filter(x => x.at > previous.sentAt && x.at <= s.sentAt).length,
672      broke: brokeAt.get(s.sentAt) ?? null,
673      newModel: previous !== undefined && previous.model !== s.model ? s.model : null,
674    }
675  })
676  const latest = shown.at(-1)
677
678  return {
679    bars,
680    top,
681    isCapped,
682    extensionsAfter: latest === undefined ? 0 : kept.filter(x => x.at > latest.sentAt).length,
683  }
684}
685
686/** Which of the chart's occasional marks it shows, so the legend lists only those. */
687export type LegendMarks = { dip: boolean; low: boolean; broke: boolean; gap: boolean; extension: boolean }
688
689export const legendMarks = (model: ChartModel): LegendMarks => ({
690  dip: model.bars.some(b => b.level === 'dip'),
691  low: model.bars.some(b => b.level === 'low'),
692  broke: model.bars.some(b => b.level === 'broke'),
693  gap: model.bars.some(b => b.gapMs !== null),
694  extension: model.extensionsAfter > 0 || model.bars.some(b => b.extensionsBefore > 0),
695})
696
697/** A model id as the readout names it: `claude-opus-5-5` reads `opus-5-5`. */
698export const shortModel = (model: string) => model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
699
700/** Below this many body columns the panel is narrow: fewer bars, a three-line readout. */
701export const NARROW_COLUMNS = 50
702
703/**
704 * The chart's readout lines for one request: when it was sent, after how
705 * much idle, any keep-warm forks and a model change; then what it cost, or
706 * the break it caused. `≈` marks a cost whose write weight is a guess. Two
707 * lines, or three when `isNarrow`, the second one split where it would not fit.
708 */
709export const readoutOf = (bar: ChartBar, s: Strings, isNarrow = false): string[] => {
710  const r = s.readout
711  const sample = bar.sample
712  const line = (parts: readonly (string | null)[]) => parts.filter(x => x !== null).join(' · ')
713  const head = line([
714    `#${bar.number}`,
715    formatTimeOfDay(sample.sentAt),
716    // Past the hour, the chart's own `1h05` rather than a clock reading 65:00.
717    sample.idleMs === null ? null : r.idle(sample.idleMs < 3_600_000 ? formatClock(sample.idleMs) : formatGap(sample.idleMs)),
718    bar.extensionsBefore === 0 ? null : r.extended(bar.extensionsBefore),
719    bar.newModel === null ? null : `→ ${shortModel(bar.newModel)}`,
720  ])
721  const [first, second] =
722    bar.broke !== null
723      ? [
724          [r.broke, r.rewrote(formatTokens(bar.broke.rewritten))],
725          [r.likely(formatCauses(bar.broke.causes, s))],
726        ]
727      : [
728          [
729            `${r.cost} ${bar.isTtlKnown ? '' : '≈'}${formatTokens(Math.round(totalOf(bar.cost)))}`,
730            `${r.hit} ${(hitRateOf(sample) * 100).toFixed(1)}%`,
731          ],
732          [
733            `${r.written} ${formatTokens(sample.written)}`,
734            `${r.uncached} ${formatTokens(sample.uncached)}`,
735            `${r.read} ${formatTokens(sample.read)}`,
736          ],
737        ]
738
739  if (isNarrow) {
740    return [head, line(first), line(second)]
741  }
742
743  // One line: the hit rate goes last, after the token split it comes from.
744  return [head, bar.broke !== null ? line([...first, ...second]) : line([first[0]!, ...second, first[1]!])]
745}
746
747// ---- Summary
748
749export const summarize = (
750  samples: readonly CacheSample[],
751  breaks: readonly CacheBreak[],
752  extensions: readonly Extension[],
753): SessionSummary => {
754  const sum = (f: (s: CacheSample) => number) => samples.reduce((n, s) => n + f(s), 0)
755  const read = sum(s => s.read)
756  const written = sum(s => s.written)
757  const uncached = sum(s => s.uncached)
758  const input = read + written + uncached
759
760  return {
761    startedAt: samples[0]?.sentAt ?? null,
762    endedAt: samples.at(-1)?.at ?? null,
763    requests: samples.length,
764    averageHitRate: input === 0 ? null : read / input,
765    breaks: breaks.length,
766    extensions: extensions.length,
767    read,
768    written,
769    uncached,
770    peakContext: samples.reduce((n, s) => Math.max(n, contextOf(s)), 0),
771  }
772}
773
774// ---- Formatting
775
776export const formatTokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
777
778export const formatPercent = (rate: number) => `${Math.round(rate * 100)}%`
779
780/** Local wall-clock time, hh:mm:ss. */
781export const formatTimeOfDay = (ms: number) => {
782  const d = new Date(ms)
783
784  return [d.getHours(), d.getMinutes(), d.getSeconds()].map(x => String(x).padStart(2, '0')).join(':')
785}
786
787/** An idle gap as the chart labels it: `12m`, or `1h05` past the hour. */
788export const formatGap = (ms: number) => {
789  const minutes = Math.floor(ms / 60_000)
790
791  return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
792}
793
794/** m:ss, minutes unbounded (a 1h TTL reads 59:12). */
795export const formatClock = (ms: number) => {
796  const seconds = Math.max(0, Math.ceil(ms / 1000))
797
798  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
799}
800
801export const formatCauses = (causes: readonly BreakCause[], s: Strings) =>
802  causes.length === 0 ? s.unknownCause : causes.map(c => s.causes[c]).join(', ')
803
804/** The slash command that keeps the cache warm, without its slash. */
805export const EXTEND_COMMAND = 'cache-extend'
806
807export type StatusView = {
808  samples: readonly CacheSample[]
809  breaks: readonly CacheBreak[]
810  extensions: readonly Extension[]
811  ttl: TtlState
812  config: Pick<Config, 'ttlMode' | 'onExpiring' | 'warnAtPercent' | 'alertAtPercent'>
813  isWorking: boolean
814  isExtending: boolean
815  now: number
816}
817
818/**
819 * The one-line status for the terminal and VS Code: `⚡ 3:42 · 94% · 48.2k`,
820 * and `⚠ 0:28 /cache-extend` once the cache is about to expire.
821 */
822export const formatStatus = (v: StatusView, s: Strings) => {
823  const last = v.samples.at(-1)
824  const countdown = countdownOf(v.samples, v.extensions, v.config.ttlMode, v.ttl, v.config, v.now)
825
826  if (last === undefined || countdown === null) {
827    return `⚡ ${s.waiting}`
828  }
829
830  const context = formatTokens(contextOf(last))
831  const tail = [formatPercent(hitRateOf(last)), context]
832  const lastBreak = v.breaks.at(-1)
833
834  if (lastBreak !== undefined && lastBreak.at === last.sentAt) {
835    tail.push(`⚠ ${s.broke(formatCauses(lastBreak.causes, s))}`)
836  }
837
838  const clock = formatClock(countdown.leftMs)
839  const head = v.isExtending
840    ? `⚡ ${s.extending}`
841    : v.isWorking
842      ? `⚡ ${s.working}`
843      : countdown.phase === 'expired'
844        ? `⚠ ${s.expired} · ${s.rewriteNext(context)}`
845        : countdown.phase === 'alert' && v.config.onExpiring !== 'notify'
846          ? `⚠ ${clock} /${EXTEND_COMMAND}`
847          : countdown.phase === 'fresh'
848            ? `⚡ ${clock}`
849            : `⚠ ${clock}`
850
851  return [head, ...tail].join(' · ')
852}
853
hooks/i18n.ts 325 lines
1import type { BandMode, BreakCause, BreakSensitivity, Language, Ttl, TtlMode } from '../types'
2
3export type Strings = {
4  waiting: string
5  expired: string
6  hit: string
7  context: string
8  requests: string
9  breaks: string
10  ttl: (mode: TtlMode, ttl: Ttl) => string
11  causes: Record<BreakCause, string>
12  unknownCause: string
13  working: string
14  /** The extend button: `k` is the context it would read, formatted. */
15  extend: (k: string) => string
16  /** The band's extend button, kept short. */
17  extendShort: string
18  extending: string
19  /** Expired: the next request writes the whole context again. */
20  rewriteNext: (k: string) => string
21  expiresIn: (clock: string) => string
22  pressExtend: string
23  /** The same hint where there is no band to press: run the command. */
24  runExtend: (command: string) => string
25  /** With the band off, where the details went. */
26  openPanel: (command: string) => string
27  /** Toasted on turning the band off: how to bring it back. */
28  bandTurnedOff: (command: string) => string
29  extended: (k: string) => string
30  autoExtended: (k: string) => string
31  extendFailed: (reason: string) => string
32  /** Alt text for the band's drawings. */
33  broke: (causes: string) => string
34  ringAlt: string
35  sparkAlt: string
36  /** The side panel. */
37  paneTitle: string
38  commandDescription: string
39  extendCommandDescription: string
40  /** `/cache-extend` with nothing to do. */
41  extendNothing: string
42  extendBusy: string
43  extendAlready: string
44  /** The band's button that opens the panel. */
45  details: string
46  paneUnplaced: (reason: string) => string
47  /** How the TTL was learned: a hit after `minutes` idle proved it. */
48  ttlLearned: (ttl: Ttl, minutes: number) => string
49  ttlAssumed: string
50  lastHit: string
51  summaryTitle: string
52  averageHit: string
53  extensionsCount: string
54  peakContext: string
55  chartTitle: string
56  chartAlt: string
57  /** The requests the chart shows, by number: first and last. */
58  chartRange: (first: number, last: number) => string
59  /** The chart's readout row: labels, and phrases taking formatted values. */
60  readout: {
61    cost: string
62    written: string
63    uncached: string
64    read: string
65    hit: string
66    idle: (clock: string) => string
67    extended: (count: number) => string
68    broke: string
69    rewrote: (tokens: string) => string
70    likely: (causes: string) => string
71  }
72  legend: {
73    read: string
74    written: string
75    uncached: string
76    rate: string
77    broke: string
78    gap: string
79    extension: string
80  }
81  breaksTitle: string
82  noBreaks: string
83  /** One break: request number, hit rate before and after, tokens rewritten. */
84  breakLine: (n: number | null, before: string, after: string, k: string) => string
85  extensionsTitle: string
86  noExtensions: string
87  /** Neither a break nor an extension yet. */
88  quietHistory: string
89  trigger: { manual: string; auto: string }
90  extensionRead: (k: string) => string
91  extensionFailed: (reason: string) => string
92  settingsTitle: string
93  onExpiringLabel: string
94  onExpiringOptions: Record<'notify' | 'button' | 'auto', string>
95  ttlModeLabel: string
96  ttlModeOptions: Record<TtlMode, string>
97  bandLabel: string
98  bandOptions: Record<BandMode, string>
99  languageLabel: string
100  breakSensitivityLabel: string
101  breakSensitivityOptions: Record<BreakSensitivity, string>
102  /** Under the sensitivity Select: what it changes, and from when. */
103  breakSensitivityNote: string
104  toastLabel: string
105  toastOptions: { on: string; off: string }
106  autoExtendMaxLabel: string
107  /** An auto-extend limit as the Select lists it. */
108  autoExtendTimes: (n: number) => string
109  /** `/cache lang` done: `name` is the language's own name. */
110  languageSet: (name: string) => string
111  /** `/cache` with arguments it doesn't know. */
112  cacheUsage: (command: string) => string
113  settingFailed: (reason: string) => string
114  /** When an extension ran: `clock` idle since the last request. */
115  idleAt: (clock: string) => string
116}
117
118/** Each language in its own words, so the one you can read is always findable. */
119export const LANGUAGE_NAMES: Record<Language, string> = { en: 'English', 'zh-TW': '繁體中文' }
120
121const ttl = (mode: TtlMode, value: Ttl) => (mode === 'auto' ? `auto→${value}` : value)
122
123export const STRINGS: Record<Language, Strings> = {
124  en: {
125    waiting: 'waiting for the first response',
126    expired: 'expired',
127    hit: 'hit',
128    context: 'ctx',
129    requests: 'req',
130    breaks: 'breaks',
131    ttl,
132    causes: {
133      idle: 'idle past TTL',
134      model: 'model switched',
135      compact: 'compacted',
136      system: 'system prompt or CLAUDE.md changed',
137      tools: 'tool list changed',
138    },
139    unknownCause: 'cause unknown',
140    working: 'answering',
141    extend: k => `Extend · ~${k} read`,
142    extendShort: 'Extend',
143    extending: 'extending…',
144    rewriteNext: k => `next request rewrites ${k}`,
145    expiresIn: clock => `Prompt cache expires in ${clock}`,
146    pressExtend: 'press Extend on the bar to keep it',
147    runExtend: command => `run /${command} to keep it`,
148    openPanel: command => `/${command} for details`,
149    bandTurnedOff: command => `Band off · run /${command} to turn it back on`,
150    extended: k => `Cache extended (read ${k})`,
151    autoExtended: k => `Cache extended automatically (read ${k})`,
152    extendFailed: reason => `Couldn't extend the cache: ${reason}`,
153    broke: causes => `cache broke: ${causes}`,
154    ringAlt: 'cache TTL countdown',
155    sparkAlt: 'hit rate per request',
156    paneTitle: 'Prompt cache',
157    commandDescription: 'Open the prompt cache panel (lang en|zh-TW switches the language)',
158    extendCommandDescription: 'Keep the prompt cache warm now',
159    extendNothing: 'Nothing is cached yet: no request has been sent',
160    extendBusy: 'Claude is answering: each request refreshes the cache',
161    extendAlready: 'Already extending the cache',
162    details: 'Details',
163    paneUnplaced: reason => `Couldn't open the cache panel: ${reason}`,
164    ttlLearned: (value, minutes) => `${value} detected: a hit after ${minutes}m idle`,
165    ttlAssumed: '5m assumed until a hit after 5m idle proves 1h',
166    lastHit: 'last hit',
167    summaryTitle: 'This conversation',
168    averageHit: 'avg hit',
169    extensionsCount: 'extended',
170    peakContext: 'peak ctx',
171    chartTitle: 'Per request · token cost',
172    chartAlt: 'cost of each request in uncached-token equivalents, with its hit rate and idle gaps',
173    chartRange: (first, last) => `#${first}–${last}`,
174    readout: {
175      cost: 'cost',
176      written: 'write',
177      uncached: 'miss',
178      read: 'read',
179      hit: 'hit',
180      idle: clock => `idle ${clock}`,
181      extended: count => `extended ×${count}`,
182      broke: 'break',
183      rewrote: tokens => `rewrote ${tokens}`,
184      likely: causes => `likely: ${causes}`,
185    },
186    legend: {
187      read: 'read ×0.1',
188      written: 'write',
189      uncached: 'miss',
190      rate: 'hit rate',
191      broke: 'break',
192      gap: 'idle near expiry',
193      extension: 'extended',
194    },
195    breaksTitle: 'Cache breaks',
196    noBreaks: 'none',
197    breakLine: (n, before, after, k) => `${n === null ? '' : `request #${n} · `}${before} → ${after} · rewrote ${k}`,
198    extensionsTitle: 'Extensions',
199    noExtensions: 'none',
200    quietHistory: 'No breaks · no extensions',
201    trigger: { manual: 'manual', auto: 'auto' },
202    extensionRead: k => `read ${k}`,
203    extensionFailed: reason => `failed: ${reason}`,
204    settingsTitle: 'Settings',
205    onExpiringLabel: 'When about to expire',
206    onExpiringOptions: { notify: 'Notify only', button: 'Notify + Extend button', auto: 'Extend automatically' },
207    ttlModeLabel: 'Cache TTL',
208    ttlModeOptions: { auto: 'Auto-detect', '5m': '5 minutes', '1h': '1 hour' },
209    bandLabel: 'Band above the prompt',
210    bandOptions: { compact: 'Compact', off: 'Off' },
211    languageLabel: 'Language',
212    breakSensitivityLabel: 'Break sensitivity',
213    breakSensitivityOptions: { low: 'Low', medium: 'Medium', high: 'High' },
214    breakSensitivityNote: 'Higher counts smaller drops as breaks; applies from the next request',
215    toastLabel: 'Toast notifications',
216    toastOptions: { on: 'On', off: 'Off' },
217    autoExtendMaxLabel: 'Auto-extend per idle stretch',
218    autoExtendTimes: n => (n === 0 ? 'Never' : n === 1 ? 'Once' : `Up to ${n} times`),
219    languageSet: name => `Language: ${name}`,
220    cacheUsage: command => `/${command} opens the panel · /${command} lang en|zh-TW switches the language`,
221    settingFailed: reason => `Couldn't change the setting: ${reason}`,
222    idleAt: clock => `${clock} idle`,
223  },
224  'zh-TW': {
225    waiting: '等待第一次回應',
226    expired: '已過期',
227    hit: '命中',
228    context: '上下文',
229    requests: '請求',
230    breaks: '失效',
231    ttl,
232    causes: {
233      idle: '閒置超過 TTL',
234      model: '剛換模型',
235      compact: '剛執行 compact',
236      system: 'system prompt 或 CLAUDE.md 變動',
237      tools: '工具清單變動',
238    },
239    unknownCause: '原因不明',
240    working: '回覆中',
241    extend: k => `延長(約讀取 ${k})`,
242    extendShort: '延長',
243    extending: '延長中…',
244    rewriteNext: k => `下次請求將重寫 ${k}`,
245    expiresIn: clock => `快取將在 ${clock} 後過期`,
246    pressExtend: '按橫條上的「延長」即可保留',
247    runExtend: command => `輸入 /${command} 即可保留`,
248    openPanel: command => `輸入 /${command} 查看詳細資訊`,
249    bandTurnedOff: command => `已關閉橫條 · 輸入 /${command} 可重新開啟`,
250    extended: k => `已延長快取(讀取 ${k})`,
251    autoExtended: k => `已自動延長快取(讀取 ${k})`,
252    extendFailed: reason => `無法延長快取:${reason}`,
253    broke: causes => `快取失效:${causes}`,
254    ringAlt: '快取 TTL 倒數',
255    sparkAlt: '每次請求的命中率',
256    paneTitle: 'Prompt 快取',
257    commandDescription: '開啟 prompt 快取面板(lang en|zh-TW 切換語言)',
258    extendCommandDescription: '立即延長 prompt 快取',
259    extendNothing: '還沒有送出請求,沒有可延長的快取',
260    extendBusy: '回覆中:每次請求都會讓快取重新計時',
261    extendAlready: '正在延長快取',
262    details: '詳細資訊',
263    paneUnplaced: reason => `無法開啟快取面板:${reason}`,
264    ttlLearned: (value, minutes) => `判定為 ${value}:閒置 ${minutes} 分鐘後仍命中`,
265    ttlAssumed: '暫定 5m,閒置超過 5 分鐘仍命中即改判為 1h',
266    lastHit: '上次命中',
267    summaryTitle: '本次對話',
268    averageHit: '平均命中',
269    extensionsCount: '延長',
270    peakContext: '最大上下文',
271    chartTitle: '每次請求 · 等效 token',
272    chartAlt: '每次請求的等效成本(以未命中 token 計),含命中率與閒置',
273    chartRange: (first, last) => `第 ${first}–${last} 次`,
274    readout: {
275      cost: '等效',
276      written: '寫入',
277      uncached: '未命中',
278      read: '讀取',
279      hit: '命中',
280      idle: clock => `閒置 ${clock}`,
281      extended: count => `延長 ×${count}`,
282      broke: '失效',
283      rewrote: tokens => `重寫 ${tokens}`,
284      likely: causes => `可能原因:${causes}`,
285    },
286    legend: {
287      read: '讀取 ×0.1',
288      written: '寫入',
289      uncached: '未命中',
290      rate: '命中率',
291      broke: '失效',
292      gap: '閒置近過期',
293      extension: '延長',
294    },
295    breaksTitle: '快取失效',
296    noBreaks: '無',
297    breakLine: (n, before, after, k) => `${n === null ? '' : `第 ${n} 次請求 · `}${before} → ${after} · 重寫 ${k}`,
298    extensionsTitle: '延長紀錄',
299    noExtensions: '無',
300    quietHistory: '未失效 · 未延長',
301    trigger: { manual: '手動', auto: '自動' },
302    extensionRead: k => `讀取 ${k}`,
303    extensionFailed: reason => `失敗:${reason}`,
304    settingsTitle: '設定',
305    onExpiringLabel: '快過期時',
306    onExpiringOptions: { notify: '只通知', button: '通知 + 延長按鈕', auto: '自動延長' },
307    ttlModeLabel: '快取 TTL',
308    ttlModeOptions: { auto: '自動偵測', '5m': '5 分鐘', '1h': '1 小時' },
309    bandLabel: '輸入框上方橫條',
310    bandOptions: { compact: '精簡', off: '關閉' },
311    languageLabel: '語言',
312    breakSensitivityLabel: '失效判定靈敏度',
313    breakSensitivityOptions: { low: '低', medium: '中', high: '高' },
314    breakSensitivityNote: '越高越容易判定為失效,從下一次請求起生效',
315    toastLabel: '跳出通知',
316    toastOptions: { on: '開', off: '關' },
317    autoExtendMaxLabel: '每段閒置自動延長',
318    autoExtendTimes: n => (n === 0 ? '不延長' : `最多 ${n} 次`),
319    languageSet: name => `語言:${name}`,
320    cacheUsage: command => `/${command} 開啟面板 · /${command} lang en|zh-TW 切換語言`,
321    settingFailed: reason => `無法變更設定:${reason}`,
322    idleAt: clock => `閒置 ${clock} 時`,
323  },
324}
325
hooks/svg.ts 342 lines
1// SVG documents for the desktop band, as pure strings. Animation is SMIL in a
2// plain (image) Svg: the countdown runs on its own, so a drawing only has to
3// change when the anchor, the TTL or the phase does.
4
5import { formatGap } from './core'
6import type { ChartModel, HitLevel, Phase } from './core'
7
8// Claude's palette: a warm neutral while all is well, colour only when
9// something needs you. Mid tones, since a drawing can't follow the theme.
10export const COLORS = {
11  neutral: '#8F8B83',
12  warn: '#D97757',
13  alert: '#E24B4A',
14  expired: '#8F8B83',
15  track: '#8F8B8340',
16  // The chart: reads are the cheap, normal case, so they stay grey; writes
17  // cost, so they take the accent.
18  read: '#8F8B8380',
19  written: '#D97757',
20  uncached: '#8F8B8333',
21  // A hit rate that slipped but didn't break: between grey and the accent.
22  dip: '#E0B04A',
23  label: '#8F8B83',
24} as const
25
26/** One line of text high, so the band stays one row. */
27export const RING_SIZE = 16
28
29/** The side panel's ring. */
30export const BIG_RING_SIZE = 48
31
32const n = (x: number) => Number(x.toFixed(2))
33
34/** SMIL `begin` offset, seconds from when the image loads; never negative. */
35const at = (ms: number) => `${n(Math.max(0, ms) / 1000)}s`
36
37const svg = (width: number, height: number, body: string) =>
38  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`
39
40/** A ring of `size` pixels: the band's keeps a 2px stroke, larger ones scale it. */
41const ringGeometry = (size: number) => {
42  const stroke = Math.max(2, n(size / 12))
43  const r = size / 2 - stroke
44
45  return { stroke, c: size / 2, r, circumference: 2 * Math.PI * r }
46}
47
48type RingGeometry = ReturnType<typeof ringGeometry>
49
50const circle = (g: RingGeometry, attrs: string, children = '') =>
51  `<circle cx="${g.c}" cy="${g.c}" r="${n(g.r)}" fill="none" stroke-width="${g.stroke}" ${attrs}>${children}</circle>`
52
53export type RingView = {
54  leftMs: number
55  totalMs: number
56  warnMs: number
57  alertMs: number
58  phase: Phase
59}
60
61/**
62 * The countdown ring, shrinking from what is left now to nothing over the
63 * time left. It turns orange at the warn threshold, red at the alert one, and
64 * becomes a grey dashed circle at expiry, all by SMIL timing. Nothing blinks.
65 */
66export const ringSvg = ({ leftMs, totalMs, warnMs, alertMs, phase }: RingView, size = RING_SIZE) => {
67  const g = ringGeometry(size)
68  const dash = `${g.stroke} ${g.stroke}`
69
70  if (phase === 'expired') {
71    return svg(size, size, circle(g, `stroke="${COLORS.expired}" stroke-dasharray="${dash}"`))
72  }
73
74  const fraction = Math.min(1, Math.max(0, leftMs / totalMs))
75  const startOffset = n(g.circumference * (1 - fraction))
76  const color = phase === 'fresh' ? COLORS.neutral : phase === 'warn' ? COLORS.warn : COLORS.alert
77  const toWarn = leftMs - warnMs
78  const toAlert = leftMs - alertMs
79
80  const shrink = `<animate attributeName="stroke-dashoffset" from="${startOffset}" to="${n(g.circumference)}" dur="${at(leftMs)}" fill="freeze"/>`
81  const turnWarn = phase === 'fresh' ? `<set attributeName="stroke" to="${COLORS.warn}" begin="${at(toWarn)}" fill="freeze"/>` : ''
82  const turnAlert = phase !== 'alert' ? `<set attributeName="stroke" to="${COLORS.alert}" begin="${at(toAlert)}" fill="freeze"/>` : ''
83
84  const track = circle(
85    g,
86    `stroke="${COLORS.track}"`,
87    `<set attributeName="stroke" to="${COLORS.expired}" begin="${at(leftMs)}" fill="freeze"/>` +
88      `<set attributeName="stroke-dasharray" to="${dash}" begin="${at(leftMs)}" fill="freeze"/>`,
89  )
90  const arc = circle(
91    g,
92    `stroke="${color}" stroke-linecap="round" stroke-dasharray="${n(g.circumference)}" stroke-dashoffset="${startOffset}" transform="rotate(-90 ${g.c} ${g.c})"`,
93    shrink + turnWarn + turnAlert + `<set attributeName="visibility" to="hidden" begin="${at(leftMs)}" fill="freeze"/>`,
94  )
95
96  return svg(size, size, track + arc)
97}
98
99/**
100 * A full grey ring, still: Claude is answering and the countdown waits. Claude
101 * shows its own activity, so this one doesn't move.
102 */
103export const pausedRingSvg = (size = RING_SIZE) => svg(size, size, circle(ringGeometry(size), `stroke="${COLORS.neutral}"`))
104
105/** No request yet: an empty grey ring. */
106export const idleRingSvg = (size = RING_SIZE) => svg(size, size, circle(ringGeometry(size), `stroke="${COLORS.track}"`))
107
108export const SPARK_WIDTH = 72
109export const SPARK_HEIGHT = 16
110
111/** Hit rate per request as a line, 0% at the bottom; a red dot where the cache broke. */
112export const sparklineSvg = (points: readonly { rate: number; isBreak: boolean }[]) => {
113  const pad = 2.5
114  const step = points.length > 1 ? (SPARK_WIDTH - 2 * pad) / (points.length - 1) : 0
115  const xy = points.map((p, i) => ({ x: n(pad + i * step), y: n(pad + (1 - p.rate) * (SPARK_HEIGHT - 2 * pad)), p }))
116  const baseline = `<line x1="${pad}" y1="${SPARK_HEIGHT - pad}" x2="${SPARK_WIDTH - pad}" y2="${SPARK_HEIGHT - pad}" stroke="${COLORS.track}" stroke-width="1"/>`
117  const line = `<polyline points="${xy.map(q => `${q.x},${q.y}`).join(' ')}" fill="none" stroke="${COLORS.neutral}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>`
118  const dots = xy
119    .filter(q => q.p.isBreak)
120    .map(q => `<circle cx="${q.x}" cy="${q.y}" r="2.2" fill="${COLORS.alert}"/>`)
121    .join('')
122
123  return svg(SPARK_WIDTH, SPARK_HEIGHT, baseline + line + dots)
124}
125
126/** The chart's height with a two-line readout; each further line adds `READOUT_LINE`. */
127export const CHART_HEIGHT = 172
128const READOUT_LINE = 17
129
130/** Bars the chart draws at most: the latest requests; fewer in a narrow panel. */
131export const CHART_BARS = 40
132export const NARROW_CHART_BARS = 20
133
134/** The hit-rate strip's colours: grey while all is well. */
135const LEVEL_COLORS: Record<HitLevel, string> = {
136  ok: COLORS.track,
137  dip: COLORS.dip,
138  low: COLORS.warn,
139  broke: COLORS.alert,
140}
141
142const escapeXml = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
143
144/** A percentage of the chart's width, as an SVG length. */
145const pct = (x: number) => `${n(x)}%`
146
147const label = (x: string, y: number, text: string, size: number, anchor: 'start' | 'end' = 'start', dx = 0) =>
148  `<text x="${x}" y="${y}"${dx === 0 ? '' : ` dx="${dx}"`} font-size="${size}" text-anchor="${anchor}">${escapeXml(text)}</text>`
149
150// The chart is drawn interactive, in a frame of its own, for its hover. The
151// frame paints white unless the document allows a dark scheme, so it does,
152// on a transparent ground. Hovering a bar shows its readout in place of the
153// latest one's; no script, only CSS.
154const CHART_STYLE =
155  '<style>' +
156  ':root{color-scheme:light dark}' +
157  'svg{background:transparent}' +
158  `text{font-family:sans-serif;fill:${COLORS.label}}` +
159  `.hit{fill:${COLORS.neutral};fill-opacity:0;pointer-events:all}` +
160  '.b:hover .hit{fill-opacity:.15}' +
161  '.r{visibility:hidden}' +
162  '.b:hover .r{visibility:visible}' +
163  'svg:has(.b:hover) .def{visibility:hidden}' +
164  '</style>'
165
166/**
167 * One bar per request: its cost in uncached-token equivalents, stacked read /
168 * written / uncached. A strip above colours each request's hit rate; a dashed
169 * line and its length mark idle that neared expiry, ▲ a keep-warm fork. A bar past
170 * a capped scale's top ends in a chevron. `topLabel` and `rangeLabel` head it;
171 * below it, the latest request's `readouts` lines, or the hovered one's.
172 *
173 * It fills the frame's width and keeps its height: across, everything is laid
174 * out in percentages, so the bars stretch and the text keeps its size.
175 * `widthPx` is a guess at that width, used only to keep mark labels apart.
176 * Returns the markup and its height in pixels.
177 */
178export const chartSvg = (
179  model: ChartModel,
180  topLabel: string,
181  rangeLabel: string,
182  readouts: readonly (readonly string[])[],
183  widthPx: number,
184) => {
185  const lines = Math.max(2, ...readouts.map(r => r.length))
186  const height = CHART_HEIGHT + (lines - 2) * READOUT_LINE
187  const stripY = 18
188  const top = 30
189  const bottom = 116
190  const marksY = 128
191  const plot = bottom - top
192  // Percent of the width: an edge margin, and each request's slot.
193  const edge = 0.8
194  const slot = (100 - 2 * edge) / Math.max(model.bars.length, 12)
195  const barWidth = slot * 0.55
196  // Marks below the axis skip a label that would run into the one before.
197  let labelEnd = Number.NEGATIVE_INFINITY
198
199  const mark = (x: number, gapMs: number | null, extensions: number, isEnd = false) => {
200    const line =
201      gapMs === null
202        ? ''
203        : `<line x1="${pct(x)}" y1="${top - 2}" x2="${pct(x)}" y2="${bottom}" stroke="${COLORS.label}" stroke-width="1" stroke-dasharray="2 3"/>`
204    const text = [gapMs === null ? '' : formatGap(gapMs), extensions === 0 ? '' : '▲'.repeat(Math.min(extensions, 3))]
205      .filter(t => t !== '')
206      .join(' ')
207    const xPx = (x / 100) * widthPx
208
209    if (text === '' || xPx < labelEnd) {
210      return line
211    }
212
213    labelEnd = xPx + text.length * 6 + 4
214
215    return line + (isEnd ? label('100%', marksY, text, 10, 'end', -4) : label(pct(x), marksY, text, 10, 'start', 1))
216  }
217
218  const readout = (rows: readonly string[] | undefined, className: string) =>
219    rows === undefined
220      ? ''
221      : `<g class="${className}">${rows.map((row, j) => label('4', 148 + j * READOUT_LINE, row, 12)).join('')}</g>`
222
223  const marks: string[] = []
224  const bars = model.bars
225    .map((b, i) => {
226      const x = edge + i * slot
227      const barX = pct(x + (slot - barWidth) / 2)
228      const total = b.cost.read + b.cost.written + b.cost.uncached
229      // Clipped bars keep their mix: each part shrinks with the whole.
230      const scale = total === 0 ? 0 : (b.height * plot) / total
231      let base = bottom
232      const part = (amount: number, color: string) => {
233        const h = amount * scale
234        base -= h
235
236        return h <= 0 ? '' : `<rect x="${barX}" y="${n(base)}" width="${pct(barWidth)}" height="${n(h)}" fill="${color}"/>`
237      }
238      // A path takes no percentages, so the chevron sits in a nested svg placed by one.
239      const chevron = b.isClipped
240        ? `<svg x="${pct(x + slot / 2)}" y="${top - 5}" overflow="visible"><path d="M-3 3L0 0L3 3" fill="none" stroke="${COLORS.neutral}" stroke-width="1.2"/></svg>`
241        : ''
242
243      marks.push(mark(x, b.gapMs, b.extensionsBefore))
244
245      // The hover target spans the bar's column, strip to axis.
246      return (
247        '<g class="b">' +
248        `<rect class="hit" x="${pct(x)}" y="${stripY - 2}" width="${pct(slot)}" height="${bottom - stripY + 4}"/>` +
249        `<rect x="${pct(x + slot * 0.05)}" y="${stripY}" width="${pct(slot * 0.9)}" height="4" fill="${LEVEL_COLORS[b.level]}"/>` +
250        part(b.cost.read, b.isLatest ? COLORS.neutral : COLORS.read) +
251        part(b.cost.written, COLORS.written) +
252        part(b.cost.uncached, COLORS.uncached) +
253        chevron +
254        readout(readouts[i], 'r') +
255        '</g>'
256      )
257    })
258    .join('')
259
260  marks.push(mark(edge + model.bars.length * slot, null, model.extensionsAfter, true))
261  const axis = `<line x1="${pct(edge)}" y1="${bottom}" x2="${pct(100 - edge)}" y2="${bottom}" stroke="${COLORS.track}" stroke-width="1"/>`
262  const heads = label('4', 12, topLabel, 12) + label('100%', 12, rangeLabel, 12, 'end', -4)
263
264  const source =
265    `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${height}">` +
266    CHART_STYLE +
267    axis +
268    marks.join('') +
269    heads +
270    readout(readouts.at(-1), 'def') +
271    bars +
272    '</svg>'
273
274  return { source, height }
275}
276
277/** Font sizes of the band's clock and the panel's. */
278export const CLOCK_SIZE = 14
279export const BIG_CLOCK_SIZE = 22
280
281const CLOCK_FONT = 'ui-monospace, Menlo, Consolas, monospace'
282
283/** One clock digit: `floor(shown / period) % base` of the seconds shown. */
284type ClockDigit = { period: number; base: number; isLeading: boolean }
285
286/**
287 * The countdown as m:ss that runs by itself: each digit is a strip of glyphs
288 * in a clipped column, stepped by a discrete SMIL translate, so the clock
289 * needs no redraw. It stops at 0:00. Seconds round up, as `formatClock` does.
290 */
291export const clockSvg = (leftMs: number, color: string, fontSize = BIG_CLOCK_SIZE) => {
292  const height = Math.round(fontSize * 1.34)
293  const baseline = Math.round(fontSize * 1.0)
294  const digitWidth = Math.round(fontSize * 0.62)
295  const colonWidth = Math.round(fontSize * 0.34)
296  const exact = Math.max(0, leftMs / 1000)
297  const shown = Math.ceil(exact)
298  // The first second ticks off here; the rest one second apart.
299  const firstTick = shown === 0 ? 0 : exact - (shown - 1)
300  const zeroAt = firstTick + shown - 1
301  const digits: (ClockDigit | ':')[] = [
302    ...(shown >= 600 ? [{ period: 600, base: 10, isLeading: true }] : []),
303    { period: 60, base: 10, isLeading: false },
304    ':',
305    { period: 10, base: 6, isLeading: false },
306    { period: 1, base: 10, isLeading: false },
307  ]
308  const glyph = (x: number, y: number, text: string) =>
309    `<text x="${n(x)}" y="${y}" text-anchor="middle" font-family="${CLOCK_FONT}" font-size="${fontSize}" font-weight="700" fill="${color}">${text}</text>`
310
311  let x = 0
312  const body = digits
313    .map(d => {
314      if (d === ':') {
315        x += colonWidth
316
317        return glyph(x - colonWidth / 2, baseline, ':')
318      }
319
320      const at = x
321      x += digitWidth
322      const valueAt = (seconds: number) => Math.floor(seconds / d.period) % d.base
323      const now = valueAt(shown)
324      const strip = Array.from({ length: d.base }, (_, i) =>
325        glyph(digitWidth / 2, baseline + i * height, d.isLeading && i === 0 ? '' : String(i)),
326      ).join('')
327      const offset = (value: number) => `0 ${-value * height}`
328      // Each step lasts one period; after the shown value, base steps cycle.
329      const steps = Array.from({ length: d.base }, (_, i) => offset((now - 1 - i + 2 * d.base) % d.base))
330      const begin = firstTick + (shown % d.period)
331      const tick =
332        shown === 0 || begin > zeroAt
333          ? ''
334          : `<animateTransform attributeName="transform" type="translate" calcMode="discrete" values="${steps.join(';')}" dur="${d.period * d.base}s" begin="${n(begin)}s" end="${n(zeroAt + 0.5)}s" repeatCount="indefinite" fill="freeze"/>`
335
336      return `<svg x="${at}" y="0" width="${digitWidth}" height="${height}" overflow="hidden"><g transform="translate(${offset(now)})">${tick}${strip}</g></svg>`
337    })
338    .join('')
339
340  return { source: svg(x, height, body), width: x, height }
341}
342
types/index.d.ts 156 lines
1export type Language = 'en' | 'zh-TW'
2
3/** The TTL setting: `auto` infers it from what the cache does. */
4export type TtlMode = 'auto' | '5m' | '1h'
5
6export type Ttl = '5m' | '1h'
7
8export type BreakSensitivity = 'low' | 'medium' | 'high'
9
10export type OnExpiring = 'notify' | 'button' | 'auto'
11
12/** The desktop band above the prompt: compact, or not drawn at all. */
13export type BandMode = 'compact' | 'off'
14
15/**
16 * A setting picked in the panel or with `/cache lang` where `$.config` has
17 * no row for it (a plugin folder on desktop). It stands while the `userConfig`
18 * value it replaced, `over`, is unchanged: a later change in settings wins.
19 */
20export type Override<T> = { value: T; over: T }
21
22export type Overrides = {
23  onExpiring: Override<OnExpiring> | null
24  ttlMode: Override<TtlMode> | null
25  band: Override<BandMode> | null
26  language: Override<Language> | null
27  breakSensitivity: Override<BreakSensitivity> | null
28  toast: Override<boolean> | null
29  autoExtendMaxPerIdle: Override<number> | null
30}
31
32/** One main-thread model request's prompt cache usage. */
33export type CacheSample = {
34  /** When the request was sent, ms since the epoch. The cache refreshes here. */
35  sentAt: number
36  /** When the response arrived, ms since the epoch. */
37  at: number
38  model: string
39  /** Input tokens served from the cache. */
40  read: number
41  /** Input tokens written to the cache. */
42  written: number
43  /** Input tokens neither read from nor written to the cache. */
44  uncached: number
45  output: number
46  /** Time since the previous request was sent, ms; null for the first one. */
47  idleMs: number | null
48}
49
50/** What the plugin has learned about the cache TTL. Persisted in `$.store`. */
51export type TtlState = {
52  /** `1h` once a hit after more than 5 minutes idle proved it; null until then. */
53  detected: Ttl | null
54  /** When `detected` was last set, ms since the epoch. */
55  at: number | null
56  /** The idle gap that proved it, ms. */
57  idleMs: number | null
58}
59
60/** A guess at why a cache break happened. */
61export type BreakCause = 'idle' | 'model' | 'compact' | 'system' | 'tools'
62
63/** A request that read far less of the cache than the one before it. */
64export type CacheBreak = {
65  /** The breaking request's `sentAt`. */
66  at: number
67  /** Hit rate of this request and the one before it, 0..1. */
68  hitRate: number
69  previousHitRate: number
70  /** Tokens written again because the cache missed. */
71  rewritten: number
72  /** Likely causes, most likely first; empty when none fits. */
73  causes: BreakCause[]
74}
75
76/** One keep-warm request made through `$.model.fork`. */
77export type Extension = {
78  at: number
79  trigger: 'manual' | 'auto'
80  isAnswered: boolean
81  /** Why it got no answer, when `isAnswered` is false. */
82  reason: string | null
83  read: number
84  written: number
85}
86
87/** Things that can break the cache when they change. */
88export type ChangeKind = 'compact' | 'system' | 'tools'
89
90/** When things that can break the cache last changed, ms since the epoch. */
91export type ChangeMarks = {
92  compactAt: number | null
93  /** A system prompt section or the CLAUDE.md context block changed. */
94  systemAt: number | null
95  /** A tool appeared or its description changed. */
96  toolsAt: number | null
97}
98
99/**
100 * One conversation's totals. Reserved for a cross-session history kept in
101 * `$.store`; for now computed from this session's state.
102 */
103export type SessionSummary = {
104  startedAt: number | null
105  endedAt: number | null
106  requests: number
107  /** Hit rate over all input tokens, 0..1; null with no requests. */
108  averageHitRate: number | null
109  breaks: number
110  extensions: number
111  /** Totals over all requests. */
112  read: number
113  written: number
114  uncached: number
115  peakContext: number
116}
117
118declare module 'claude-code' {
119  interface PluginState {
120    'cache-bar': {
121      /** This session's main-thread requests, oldest first, capped. */
122      samples: CacheSample[]
123      breaks: CacheBreak[]
124      extensions: Extension[]
125      ttl: TtlState
126      /**
127       * When each ChangeKind last changed (the id), ms since the epoch; null
128       * for never. One member each, so concurrent hooks never contend.
129       */
130      changedAt: StateFamily<number | null>
131      /**
132       * Hashes seen per prompt section, context block and tool (the id, as
133       * `section:<name>`, `context:<name>`, `tool:<name>`).
134       */
135      prints: StateFamily<number[]>
136      /** The clock, written every second; read by drawings without Svg (the terminal panel). */
137      now: number
138      /**
139       * The countdown's stage: `working`, `none`, or `<anchor>|<ttl>|<phase>`.
140       * Written when it changes; the band and panel redraw on it, not on `now`,
141       * since a redraw every second resets an open Select's highlight.
142       */
143      stage: string
144      /** Settings picked in the panel; also kept in `$.store`. */
145      overrides: Overrides
146      /** True while a keep-warm fork is in flight. */
147      extending: boolean
148      /**
149       * The `sentAt` of the request whose idle stretch already got its expiry
150       * notice; null before any. One notice per idle stretch.
151       */
152      alertedFor: number | null
153    }
154  }
155}
156