Shows prompt cache hit rate and TTL status in Claude Code

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

…: each request refreshes the cache./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>
Terminal and VS Code: one status line.
⚡ 3:42 · 94% · 48.2k
⚠ 0:28 /cache-extend · 94% · 48.2k
| Command | What it does | |
|---|---|---|
/cache | Opens the side panel | |
| `/cache lang [en\ | zh-TW]` | Switches the language on the spot; with no language, to the other one |
/cache-extend | Keeps the cache warm now |
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.
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.
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:
| Sensitivity | Context over | This request under | Previous over |
|---|---|---|---|
low | 20k | 30% | 85% |
medium | 10k | 50% | 80% |
high | 5k | 70% | 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.
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.
| Setting | Values | Default | |
|---|---|---|---|
language | en, zh-TW | en | Display language |
ttlMode | auto, 5m, 1h | auto | Cache TTL |
onExpiring | notify, button, auto | button | Notify only; notify and offer Extend; extend automatically |
band | compact, off | compact | Desktop band above the prompt; off leaves only the toasts |
warnAtPercent | 1–99 | 20 | Turn orange at this % of the TTL left |
alertAtPercent | 1–99 | 10 | Notify (and offer Extend) at this % left |
toast | on / off | on | Show a toast before the cache expires |
autoExtendMaxPerIdle | 0–100 | 3 | Auto-extend at most this many times while you are away |
autoExtendMinContextK | 0–10000 | 20 | Don't auto-extend a context smaller than this many thousand tokens |
autoExtendGiveUpMin | 0–1440 | 0 | Stop auto-extending after this many minutes idle; 0 = no limit |
breakSensitivity | low, medium, high | medium | How readily a drop counts as a break |
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
hooks/register.tsx 994 lines1import { 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}
994hooks/core.ts 853 lines1// 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}
853hooks/i18n.ts 325 lines1import 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}
325hooks/svg.ts 342 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
342types/index.d.ts 156 lines1export 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