A countdown in the prompt footer: under the hint line on the terminal, left of the model picker on the desktop: time left on the prompt cache and how much of…

How long until Claude Code's prompt cache goes cold, in the prompt footer of the terminal and the desktop app.
cache ━━━━━━━━━━ 47 min · 98%
The bar shrinks as the cache ages. The percent is the share of the last request the cache served. With plenty of time left the line is light olive. It turns khaki at 10 minutes left, amber at 5 and terracotta at 2. Once the cache expires the bar empties, the line goes grey and says how many tokens the next turn will write again, with /compact from 100k tokens up.
Where it sits:
auto mode on (shift+tab to cycle)), which stays as Claude Code draws it. On a narrow window the percent goes first, then bar cells.━ wider than a letter. The desktop has no hint line to sit beside.This is a Claude Code mod: hooks that run inside Claude Code itself. A one-second clock redraws the line, so the countdown moves while you're idle. It only repaints when the text or colour changes, which is once a minute until the last 5 minutes.
On a Claude subscription Claude Code asks for the 1-hour cache by itself. API keys, cloud providers and usage credits get 5 minutes. The mod follows Claude Code's documented order (FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting, ENABLE_PROMPT_CACHING_1H, then the account), notices when a subscription runs out of plan usage and falls back to credits, and checks itself against request timing: a cache hit 20 minutes after the previous request proves the hour.
On a 5-minute cache the colour steps scale down to 50, 25 and 10 seconds.
The countdown assumes the next request reads what the last one cached. Some changes break that before the time runs out, and the line says so.
/model or the desktop picker, before you send anything, the line turns amber: cache ━━━━━━━━━━ other model · rewrites 81k. The number is what the other model will write, less anything it still holds from earlier in the conversation. Switch back and the timer returns./usage), the percent gives way to the cause for the rest of that turn: · rebuilt: model when the engine changed the model itself (a fallback, a skill's model), · rebuilt: effort when the effort level changed (on most models each level has its own cache), or · rebuilt when the cause is out of a mod's sight: fast mode turned on, tools changed, an early eviction. A prompt that shrank (/compact, cleared tool results) or went back with /rewind does not count./compact. The line clears until the next request, because the old size no longer applies.Claude Code works out the likely cause of a miss itself, for /usage and status line scripts (prompt_cache.last_miss_cause), but it does not pass it to mods, so the mod reads it from the token counts and the events it can see.
In Ghostty on macOS, a big cache also raises a desktop notification, so a session in a background tab is not missed:
cache expires in 0:10: any message refreshes it (170k tokens);/model switch and an effort change stay quiet.By default only prompts of 100k tokens or more raise one; the notify option changes that. Other terminals and the desktop app get none, and Ghostty may hold one back while you are looking at that window.
The mod passes the text to a small script that finds the session's terminal and writes the OSC 777 sequence Ghostty turns into a notification. Put the script where the mod looks for it:
mkdir -p ~/.claude/hooks
cp ~/claude-cache-timer/scripts/cache-notify.sh ~/.claude/hooks/
Without it nothing is sent. If no banner shows up, open System Settings → Notifications → Ghostty: notifications can be allowed there with the Desktop box unticked.
Tested on Claude Code 2.1.289 and 2.1.290. Mods are early access, and their $ API may change between releases.
git clone https://github.com/Sanexxxx777/claude-cache-timer ~/claude-cache-timer
Try it for one session:
claude --plugin-dir ~/claude-cache-timer
Load it in every session (the desktop app included) through the env block of ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/claude-cache-timer" } }
The line appears after the first request of a session.
Set them in ~/.claude/settings.json:
{ "pluginConfigs": { "cache-timer": { "options": { "lang": "ru" } } } }
| Option | Values | Default |
|---|---|---|
lang | auto reads LANG (ru_* gives Russian), or ru / en | auto |
ttl | auto, or pin 5m / 1h | auto |
toast | one toast when the last stage starts, for prompts of 20k tokens or more | true |
notify | the Ghostty notification: for prompts of 100k tokens or more, all, or off | 100k |
Hooks: session.start, session.end, turn.step (main-loop requests only, subagents have their own cache), classic.PostModelSwitch (which model the next request goes to), classic.PostCompact, and ui.render on PromptHint (terminal) and SessionMode (desktop). It reads HOME, LANG, TERM_PROGRAM and the three cache variables above, plus promptCacheTtl from your settings files. It makes no network calls and writes no files. The one process it starts is the notification script, and only in Ghostty: bash ~/.claude/hooks/cache-notify.sh <title> <body>, which writes one escape sequence to the session's terminal and nothing else. claude plugin validate . prints the same list.
claude plugin test .
bash tests/cache-notify-test.sh
38 tests: colour stages for both lifetimes, time format, lifetime rules, when a rebuild counts and what caused it, how the line fits a narrow terminal, the line itself on the terminal and desktop surfaces, with a model switch, a rebuild and a compaction, and which rebuilds raise a Ghostty notification, from what size, never outside Ghostty or on the desktop. The script test writes to a temp file instead of a terminal: the sequence, its sanitising, silence outside Ghostty, exit 0 when it cannot write.
The lifetime rules and the timing check are adapted from prompt-cache-control in claude-code-templates by Daniel Ávila (MIT). This mod keeps only the countdown and draws it in the footer.
MIT, see LICENSE. Made by Aleksandr_NFA (Telegram) · Sanexxxx777 (GitHub).
hooks/register.tsx 361 lines1/**
2 * cache-timer: a cache countdown in the prompt footer, right after the
3 * engine's hint line ("auto mode on ...") on the terminal; on the desktop in
4 * the slot left of the model picker.
5 *
6 * кэш ━━━━━━━━━━ 47 мин · 98%
7 *
8 * The bar and the time are the cache's life left; the dim percent is how much
9 * of the last request the cache served. Light olive while calm, warmer at 10,
10 * 5 and 2 minutes left, dimmed once expired with what the next turn will cost.
11 * A rebuild the countdown did not predict puts its cause in the percent's
12 * place (· сброс: модель) for the rest of its turn and until the next one
13 * reads the cache; after a model switch, before anything is sent, the line
14 * says what the other model will write (другая модель · перезапишет 120k).
15 * In Ghostty a big cache also raises a macOS notification, through
16 * ~/.claude/hooks/cache-notify.sh: 2 minutes before it expires, and on a
17 * rebuild nobody asked for, so a session in a background tab is not missed.
18 *
19 * - turn.step: each main-loop request's usage (subagents have their own cache)
20 * - classic.PostModelSwitch: the model the next request goes to
21 * - classic.PostCompact: a new, shorter history, nothing cached for it yet
22 * - clock.every(1000): redraws only when the line changes, so from 5 minutes
23 * up it redraws once a minute
24 * - ui.render on PromptHint: the engine's line kept whole, the timer after
25 * it; on a narrow terminal the percent goes first, then bar cells
26 * - ui.render on SessionMode (desktop): the engine's mode labels, then ours
27 *
28 * Adapted from prompt-cache-control by claude-code-templates (MIT).
29 */
30import type { EngineInterface, Register } from 'claude-code'
31import {
32 accountOf,
33 baseModel,
34 decideTtl,
35 filledCells,
36 fitBar,
37 fmtLeft,
38 fmtTokens,
39 hitRatio,
40 keepObserved,
41 langOf,
42 missCause,
43 notifyMin,
44 notifyRebuild,
45 observeTtl,
46 promptTokens,
47 remainingMs,
48 STAGE_COLOR,
49 stageOf,
50 touchedCache,
51 WORDS,
52} from './cache.ts'
53import type { Account, CacheEnv, Cause, Fit, Lang, Sample, Stage, Ttl } from './cache.ts'
54
55const BAR = 10
56// an expired cache this big is worth a /compact before the next turn rewrites it
57const COMPACT_AT = 100_000
58// below this a lapsing cache costs too little to interrupt anyone about
59const TOAST_MIN_TOKENS = 20_000
60// a rebuild and a switch's pending rewrite: amber, whatever the time left
61const ALERT = STAGE_COLOR.five
62
63let last: Sample | undefined
64let prev: Sample | undefined
65// each model's own cache: its last main-loop request, by baseModel
66const seen = new Map<string, Sample>()
67// the model a switch named, until a request goes out
68let nextModel: string | undefined
69// the model the person last switched to, by baseModel: its rebuild was asked for,
70// even when a request already in flight came back on the old one first
71let chosenModel: string | undefined
72// a rebuild the countdown did not predict, kept while its turn lasts
73let reset: { cause: Cause; turnId: string } | undefined
74let ttl: Ttl = '5m'
75let observed: Ttl | undefined
76let account: Account | undefined
77let env: CacheEnv = {}
78let setting: unknown
79let lang: Lang = 'en'
80let timer: { cancel: () => void } | undefined
81let lastKey = ''
82let toastedFor = 0
83let notifiedFor = 0
84// where a notification can go: Ghostty's tab of this session, by the notifier script
85let notifier: { script: string; title: string } | undefined
86
87// the conversation starts over (a new session, /clear, a compaction): nothing of it cached yet
88function forget() {
89 last = undefined
90 prev = undefined
91 seen.clear()
92 nextModel = undefined
93 reset = undefined
94 lastKey = ''
95}
96
97// fire and forget: the script finds the session's tab itself and stays silent without one
98function notify($: EngineInterface, body: string) {
99 if (!notifier) return
100 void $.process.run(['bash', notifier.script, notifier.title, body], { timeoutMs: 5000 }).catch(() => undefined)
101}
102
103// the promptCacheTtl setting: local over project over user settings
104async function readSetting($: EngineInterface): Promise<unknown> {
105 const home = await $.env.get('HOME').catch(() => undefined)
106 const cwd = await $.session.cwd().catch(() => undefined)
107 const files = [cwd && `${cwd}/.claude/settings.local.json`, cwd && `${cwd}/.claude/settings.json`, home && `${home}/.claude/settings.json`]
108 for (const file of files) {
109 if (!file) continue
110 try {
111 const value = JSON.parse(await $.fs.read(file)).promptCacheTtl
112 if (value === '5m' || value === '1h') return value
113 } catch {
114 // missing or unreadable: the next file
115 }
116 }
117 return undefined
118}
119
120async function refreshTtl($: EngineInterface, option: unknown) {
121 // a subscription that runs out of plan usage moves to credits mid-session
122 const now = accountOf((await $.session.usage().catch(() => undefined))?.rateLimits ?? [])
123 observed = keepObserved(observed, account, now)
124 if (now !== 'other') account = now
125 const base = decideTtl(option, env, setting, now)
126 const pinned = option === '5m' || option === '1h'
127 ttl = pinned ? base : (observed ?? base)
128}
129
130function view(now: number) {
131 if (!last || !touchedCache(last)) return undefined
132 const left = remainingMs(last, ttl, now)
133 const stage: Stage = stageOf(left, ttl)
134 const size = promptTokens(last)
135 // after a switch the next request reads only what the other model cached itself, if it still holds
136 let rewrite: number | undefined
137 if (nextModel !== undefined && baseModel(nextModel) !== baseModel(last.model) && stage !== 'cold') {
138 const own = seen.get(baseModel(nextModel))
139 rewrite = Math.max(0, size - (own && remainingMs(own, ttl, now) > 0 ? promptTokens(own) : 0))
140 }
141 return { left, stage, size, hit: Math.round(hitRatio(last) * 100), rewrite, reset: reset?.cause }
142}
143
144const keyOf = (v: ReturnType<typeof view>) =>
145 v ? `${v.stage}|${v.stage === 'cold' ? '' : fmtLeft(v.left, lang)}|${v.hit}|${v.rewrite}|${v.reset}` : ''
146
147export const register: Register = (on, options) => {
148 const wantToast = options.toast !== false
149 const minNotify = notifyMin(options.notify)
150
151 on('session.start', async ($, e, next) => {
152 const r = await next(e)
153 forget()
154 observed = undefined
155 account = undefined
156 toastedFor = 0
157 notifiedFor = 0
158 chosenModel = undefined
159 const none = () => undefined
160 env = {
161 enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H').catch(none),
162 force5m: await $.env.get('FORCE_PROMPT_CACHING_5M').catch(none),
163 ttlVar: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(none),
164 }
165 lang = langOf(options.lang, await $.env.get('LANG').catch(none))
166 setting = await readSetting($)
167 const home = await $.env.get('HOME').catch(none)
168 const inGhostty = e.surface === 'terminal' && (await $.env.get('TERM_PROGRAM').catch(none)) === 'ghostty'
169 notifier =
170 inGhostty && home && minNotify !== undefined
171 ? { script: `${home}/.claude/hooks/cache-notify.sh`, title: `Claude Code · ${e.cwd.split('/').filter(Boolean).pop() ?? e.cwd}` }
172 : undefined
173 await refreshTtl($, options.ttl)
174 $.ui.log(`cache-timer loaded: ${ttl} cache, lang ${lang}`, { to: 'debug' })
175
176 timer?.cancel()
177 timer = $.clock.every(1000, () => {
178 const v = view(Date.now())
179 const key = keyOf(v)
180 if (key !== lastKey) {
181 lastKey = key
182 $.ui.invalidate('ui.render')
183 }
184 // one toast per cache entry, on entering the last stage; after a switch no message refreshes it
185 if (wantToast && v && last && v.stage === 'two' && v.rewrite === undefined && v.size >= TOAST_MIN_TOKENS && toastedFor !== last.startedAt) {
186 toastedFor = last.startedAt
187 $.ui.toast(WORDS[lang].toast(fmtLeft(v.left, lang), fmtTokens(v.size)))
188 }
189 // the same moment for a tab out of sight, from the notify option's size up
190 if (notifier && minNotify !== undefined && v && last && v.stage === 'two' && v.rewrite === undefined && v.size >= minNotify && notifiedFor !== last.startedAt) {
191 notifiedFor = last.startedAt
192 notify($, WORDS[lang].toast(fmtLeft(v.left, lang), fmtTokens(v.size)))
193 }
194 })
195 return r
196 })
197
198 on('session.end', async ($, e, next) => {
199 // /clear starts a new conversation in the same process, and a new cache
200 if (e.reason === 'clear') {
201 forget()
202 observed = undefined
203 $.ui.invalidate('ui.render')
204 return next(e)
205 }
206 timer?.cancel()
207 timer = undefined
208 return next(e)
209 })
210
211 on('turn.step', async function* ($, e, next) {
212 if (e.agentId) return yield* next(e)
213 const startedAt = Date.now()
214 const r = yield* next(e)
215 if (r.usage) {
216 prev = last
217 last = {
218 // the engine's id, as a model switch names it; the API's only when it gave none
219 model: e.model || r.usage.model,
220 effort: e.effort,
221 startedAt,
222 read: r.usage.cache_read_input_tokens,
223 write: r.usage.cache_creation_input_tokens,
224 fresh: r.usage.input_tokens,
225 }
226 observed = observeTtl(prev, last, observed)
227 await refreshTtl($, options.ttl)
228 const cause = missCause(prev, last, ttl)
229 if (cause) reset = { cause, turnId: e.turnId }
230 else if (reset?.turnId !== e.turnId) reset = undefined
231 const size = promptTokens(last)
232 const byUser = nextModel !== undefined || baseModel(last.model) === chosenModel
233 if (cause && minNotify !== undefined && size >= minNotify && notifyRebuild(cause, byUser)) {
234 notify($, WORDS[lang].rebuilt(fmtTokens(size), cause))
235 }
236 seen.set(baseModel(last.model), last)
237 nextModel = undefined
238 lastKey = ''
239 $.ui.invalidate('ui.render')
240 }
241 return r
242 })
243
244 // /model, the desktop picker, the SDK: the next request reads only the new model's own cache.
245 // Ours runs before next, and a failure of it passes the event on untouched
246 on('classic.PostModelSwitch', ($, e, next) => {
247 nextModel = e.to_model
248 chosenModel = baseModel(e.to_model)
249 lastKey = ''
250 $.ui.invalidate('ui.render')
251 return next(e)
252 }).catch(($, e, next) => next(e))
253
254 // the history is now a summary: the next request caches it afresh, by design
255 on('classic.PostCompact', ($, e, next) => {
256 forget()
257 $.ui.invalidate('ui.render')
258 return next(e)
259 }).catch(($, e, next) => next(e))
260
261 // Terminal: after the engine's hint line ("auto mode on (shift+tab to
262 // cycle) · ← 1 agent"), kept whole as the engine draws it; the terminal puts
263 // the pair on two rows. The desktop draws no hint line (tried 04.10.2026).
264 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
265 const v = view(Date.now())
266 if (!v || e.surface !== 'terminal') return next(e)
267 const fit = fitTerminal(e.viewport?.columns ?? 100, e.props.hint.length, v)
268 if (!fit) return next(e)
269 const engineLine = await next(e)
270 const kit = $.ui.resolve(e)
271 return (
272 <kit.Box flexDirection="row" columnGap={2}>
273 {engineLine}
274 {drawTimer(kit, v, fit)}
275 </kit.Box>
276 )
277 })
278
279 // Desktop only: the slot left of the model picker, the engine's mode labels first.
280 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
281 const v = view(Date.now())
282 if (!v || e.surface !== 'desktop') return next(e)
283 const kit = $.ui.resolve(e)
284 const modes = e.props.modes
285 return (
286 <kit.Box flexDirection="row" columnGap={1}>
287 {modes.length > 0 ? <kit.Text dimColor>{`${modes.join(' & ')} ·`}</kit.Text> : null}
288 {drawTimer(kit, v, DESKTOP_FIT)}
289 </kit.Box>
290 )
291 })
292}
293
294type View = NonNullable<ReturnType<typeof view>>
295type Kit = ReturnType<EngineInterface['ui']['resolve']>
296
297// the desktop draws ━ about twice as wide as a letter and cuts the slot at
298// about 10 of them plus a word: 5 keep the time and percent whole
299const DESKTOP_FIT: Fit = { bar: 5, hit: true }
300
301// after the time: the percent, or a rebuild's cause in its place
302const tailOf = (v: View) => (v.reset ? `· ${WORDS[lang].reset[v.reset]}` : `· ${v.hit}%`)
303
304// the columns left of the terminal row after the hint line and the gap, and what the timer needs besides its bar
305function fitTerminal(columns: number, hintLen: number, v: View): Fit | undefined {
306 const words = WORDS[lang]
307 const head = v.stage === 'cold' ? words.cold : v.rewrite !== undefined ? words.other : fmtLeft(v.left, lang)
308 // cold and switched lines truncate their own tail; a live one makes room for the percent or the cause
309 const tail = v.stage === 'cold' || v.rewrite !== undefined ? undefined : tailOf(v).length + 1
310 return fitBar(columns - hintLen - 2, words.cache.length + 2 + head.length, BAR, tail)
311}
312
313/**
314 * label, bar, time and percent (or a rebuild's cause); once cold an empty dim
315 * bar and what the next turn rewrites; after a model switch an empty bar and
316 * what the other model will write.
317 */
318function drawTimer({ Box, Text }: Kit, v: View, fit: Fit) {
319 const w = WORDS[lang]
320 if (v.stage === 'cold') {
321 const tail = v.size >= COMPACT_AT ? ` · ${w.compact}` : ''
322 return (
323 <Box flexDirection="row" columnGap={1}>
324 <Text dimColor>{w.cache}</Text>
325 <Text dimColor>{'━'.repeat(fit.bar)}</Text>
326 <Text dimColor wrap="truncate-end">{`${w.cold} · ${w.rewrite(fmtTokens(v.size))}${tail}`}</Text>
327 </Box>
328 )
329 }
330 if (v.rewrite !== undefined) {
331 return (
332 <Box flexDirection="row" columnGap={1}>
333 <Text color={ALERT}>{w.cache}</Text>
334 <Text dimColor>{'━'.repeat(fit.bar)}</Text>
335 <Text color={ALERT} wrap="truncate-end">{`${w.other} · ${w.rewrite(fmtTokens(v.rewrite))}`}</Text>
336 </Box>
337 )
338 }
339 const color = STAGE_COLOR[v.stage]
340 const filled = filledCells(v.left, ttl, fit.bar)
341 return (
342 <Box flexDirection="row" columnGap={1}>
343 <Text color={color}>{w.cache}</Text>
344 <Box flexDirection="row">
345 {filled > 0 ? <Text color={color}>{'━'.repeat(filled)}</Text> : null}
346 {filled < fit.bar ? <Text dimColor>{'━'.repeat(fit.bar - filled)}</Text> : null}
347 </Box>
348 <Text color={color}>{fmtLeft(v.left, lang)}</Text>
349 {fit.hit ? (
350 v.reset ? (
351 <Text color={ALERT} wrap="truncate-end">
352 {tailOf(v)}
353 </Text>
354 ) : (
355 <Text dimColor>{tailOf(v)}</Text>
356 )
357 ) : null}
358 </Box>
359 )
360}
361hooks/cache.ts 261 lines1/**
2 * cache.ts: the pure half of cache-timer (no `$`, no engine), so it is tested
3 * without one.
4 *
5 * The lifetime rules (decideTtl, accountOf) and the timing check (observeTtl)
6 * are adapted from prompt-cache-control by claude-code-templates, MIT,
7 * https://github.com/davila7/claude-code-templates. See LICENSE.
8 *
9 * From Anthropic's prompt-caching docs: the cache lives 5 minutes, or 1 hour
10 * when asked for; every read refreshes it for free; the lifetime counts from
11 * the START of the request that wrote or read it. A prompt is input_tokens
12 * (uncached) + cache_read_input_tokens + cache_creation_input_tokens.
13 */
14
15export type Ttl = '5m' | '1h'
16export type Account = 'subscription' | 'credits' | 'other'
17
18export type CacheEnv = {
19 enable1h?: string
20 force5m?: string
21 /** CLAUDE_CODE_PROMPT_CACHE_TTL */
22 ttlVar?: string
23}
24
25/** One main-loop request as the API reported it. */
26export type Sample = {
27 model: string
28 /** the effort the request asked for; absent on a model without one */
29 effort?: string | number
30 /** ms since the epoch when the request started */
31 startedAt: number
32 read: number
33 write: number
34 fresh: number
35}
36
37const isOn = (v: string | undefined) => v === '1' || v?.toLowerCase() === 'true'
38const asTtl = (v: unknown): Ttl | undefined => (v === '5m' || v === '1h' ? v : undefined)
39
40/**
41 * The lifetime Claude Code asks for on the main conversation, first match wins
42 * (code.claude.com/docs/en/prompt-caching): the mod's own option,
43 * FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl
44 * setting, ENABLE_PROMPT_CACHING_1H, then the account: 1 hour on a
45 * subscription within plan usage, 5 minutes otherwise.
46 */
47export function decideTtl(option: unknown, env: CacheEnv, setting?: unknown, account?: Account): Ttl {
48 return (
49 asTtl(option) ??
50 (isOn(env.force5m) ? '5m' : undefined) ??
51 asTtl(env.ttlVar) ??
52 asTtl(setting) ??
53 (isOn(env.enable1h) ? '1h' : undefined) ??
54 (account === 'subscription' ? '1h' : '5m')
55 )
56}
57
58/**
59 * The account from the rate-limit windows of the last response: a five-hour
60 * or seven-day window means a subscription; one at 100% means requests now
61 * draw on usage credits (5-minute cache). No window says nothing.
62 */
63export function accountOf(windows: readonly { kind: string; percentUsed: number }[]): Account {
64 const plan = windows.filter(w => w.kind === 'five_hour' || w.kind === 'seven_day')
65 if (plan.length === 0) return 'other'
66 return plan.some(w => w.percentUsed >= 100) ? 'credits' : 'subscription'
67}
68
69/**
70 * What the traffic proved holds for one way of billing: a plan that runs out
71 * onto usage credits (5 minutes) or a new window back onto it starts over.
72 */
73export const keepObserved = (observed: Ttl | undefined, was: Account | undefined, now: Account): Ttl | undefined =>
74 was === undefined || was === 'other' || now === 'other' || was === now ? observed : undefined
75
76export const ttlMs = (ttl: Ttl) => (ttl === '1h' ? 3_600_000 : 300_000)
77export const promptTokens = (s: Sample) => s.read + s.write + s.fresh
78
79/** Share of the prompt the cache served, 0 to 1. */
80export function hitRatio(s: Sample): number {
81 const total = promptTokens(s)
82 return total === 0 ? 0 : s.read / total
83}
84
85/** A request that read and wrote nothing touched no cache entry: nothing to count down. */
86export const touchedCache = (s: Sample) => s.read + s.write > 0
87
88export function remainingMs(s: Sample, ttl: Ttl, now: number): number {
89 return touchedCache(s) ? Math.max(0, s.startedAt + ttlMs(ttl) - now) : 0
90}
91
92// requests are timed from their start; slack keeps a hit landing just inside
93// 5 minutes from reading as proof of the hour
94const SLACK_MS = 10_000
95
96/**
97 * What the traffic says about the lifetime. The mod API passes only token
98 * counts, not the TTL of a write, so: a hit more than 5 minutes after the
99 * previous request proves 1 hour (sticky); a same-model miss 5 to 60 minutes
100 * later, on a prompt that did not shrink, says 5 minutes (a later hit wins).
101 */
102export function observeTtl(prev: Sample | undefined, cur: Sample, known: Ttl | undefined): Ttl | undefined {
103 if (!prev || !touchedCache(prev) || cur.model !== prev.model) return known
104 const gap = cur.startedAt - prev.startedAt
105 const before = promptTokens(prev)
106 if (gap <= ttlMs('5m') + SLACK_MS) return known
107 if (cur.read >= before * 0.5) return '1h'
108 if (known === '1h') return known
109 const lapsed = cur.write > 0 && promptTokens(cur) >= before * 0.7 && gap < ttlMs('1h') + SLACK_MS
110 return lapsed ? '5m' : known
111}
112
113/** Why a cache that should still have been warm was written again. */
114export type Cause = 'model' | 'effort' | 'other'
115
116// a model id without its context tag (`[1m]`), so the engine's and a hook's spellings compare
117export const baseModel = (m: string) => m.replace(/\[.*\]$/, '').toLowerCase()
118
119/**
120 * A rebuild the countdown did not predict, by the rule Claude Code counts its
121 * /usage misses with: the request wrote again more than 5% and at least 2,000
122 * tokens of what the warm cache held; what it did not read but did not write
123 * either was cut away (/rewind). A prompt that shrank (a compaction, cleared
124 * tool results) rebuilds by design, and a lapsed cache already showed as
125 * cold. Each model has its own cache; so, on most models, does each effort
126 * level (Opus 5.5, Sonnet 5.5 and Fable 5.1 keep theirs). Anything else is a
127 * cause the API does not report: fast mode turned on, tools changed, an early
128 * eviction.
129 */
130export function missCause(prev: Sample | undefined, cur: Sample, ttl: Ttl): Cause | undefined {
131 if (!prev || !touchedCache(prev)) return undefined
132 if (cur.startedAt - prev.startedAt >= ttlMs(ttl) - SLACK_MS) return undefined
133 const held = promptTokens(prev)
134 if (promptTokens(cur) < held * 0.7) return undefined
135 const lost = Math.min(held - cur.read, cur.write + cur.fresh)
136 if (lost <= held * 0.05 || lost < 2_000) return undefined
137 if (baseModel(prev.model) !== baseModel(cur.model)) return 'model'
138 return prev.effort !== cur.effort ? 'effort' : 'other'
139}
140
141/**
142 * Colour stage by time left. On a 1-hour cache the steps are 10, 5 and 2
143 * minutes; a 5-minute cache gets the same fractions of its life (50, 25, 10 s).
144 */
145export type Stage = 'calm' | 'ten' | 'five' | 'two' | 'cold'
146
147const STEPS_1H_MS = { ten: 600_000, five: 300_000, two: 120_000 }
148
149export function stageOf(leftMs: number, ttl: Ttl): Stage {
150 if (leftMs <= 0) return 'cold'
151 const k = ttlMs(ttl) / ttlMs('1h')
152 if (leftMs <= STEPS_1H_MS.two * k) return 'two'
153 if (leftMs <= STEPS_1H_MS.five * k) return 'five'
154 if (leftMs <= STEPS_1H_MS.ten * k) return 'ten'
155 return 'calm'
156}
157
158/** Light olive while calm, then khaki, amber, terracotta; the expired line is dimmed instead. */
159export const STAGE_COLOR: Record<Exclude<Stage, 'cold'>, string> = {
160 calm: '#A4AE6B',
161 ten: '#C6B55E',
162 five: '#D79A4C',
163 two: '#CF6A4A',
164}
165
166export type Lang = 'ru' | 'en'
167
168export const WORDS: Record<
169 Lang,
170 {
171 cache: string
172 min: string
173 cold: string
174 other: string
175 rewrite: (t: string) => string
176 reset: Record<Cause, string>
177 compact: string
178 toast: (left: string, t: string) => string
179 rebuilt: (t: string, cause: Cause) => string
180 }
181> = {
182 ru: {
183 cache: 'кэш',
184 min: 'мин',
185 cold: 'остыл',
186 other: 'другая модель',
187 rewrite: t => `перезапишет ${t}`,
188 reset: { model: 'сброс: модель', effort: 'сброс: усилие', other: 'сброс' },
189 compact: '/compact',
190 toast: (left, t) => `кэш остынет через ${left}: любое сообщение продлит его (${t} токенов)`,
191 rebuilt: (t, cause) =>
192 `кэш записан заново (${t} токенов): ${cause === 'model' ? 'модель сменилась сама' : 'причина не видна: быстрый режим, инструменты или сервер'}`,
193 },
194 en: {
195 cache: 'cache',
196 min: 'min',
197 cold: 'expired',
198 other: 'other model',
199 rewrite: t => `rewrites ${t}`,
200 reset: { model: 'rebuilt: model', effort: 'rebuilt: effort', other: 'rebuilt' },
201 compact: '/compact',
202 toast: (left, t) => `cache expires in ${left}: any message refreshes it (${t} tokens)`,
203 rebuilt: (t, cause) =>
204 `cache written again (${t} tokens): ${cause === 'model' ? 'the model changed on its own' : 'no visible cause: fast mode, tools or the server'}`,
205 },
206}
207
208/**
209 * The smallest prompt a desktop notification is worth, from the `notify`
210 * option: 100k by default, any size, or none.
211 */
212export const notifyMin = (option: unknown): number | undefined =>
213 option === 'off' ? undefined : option === 'all' ? 0 : 100_000
214
215/**
216 * A rebuild worth a notification is one nobody asked for: a model the engine
217 * changed by itself (a fallback, a skill's model) or no visible cause. A
218 * /model switch and an effort change were the person's own doing.
219 */
220export const notifyRebuild = (cause: Cause, switchedByUser: boolean) =>
221 cause === 'other' || (cause === 'model' && !switchedByUser)
222
223export const langOf = (option: unknown, envLang: string | undefined): Lang =>
224 option === 'ru' || option === 'en' ? option : envLang?.toLowerCase().startsWith('ru') ? 'ru' : 'en'
225
226/** Whole minutes from 5 minutes up (calm, redraws once a minute), m:ss below. */
227export function fmtLeft(ms: number, lang: Lang): string {
228 const secs = Math.max(0, Math.ceil(ms / 1000))
229 if (secs >= 300) return `${Math.ceil(secs / 60)} ${WORDS[lang].min}`
230 return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`
231}
232
233export function fmtTokens(n: number): string {
234 if (n < 1000) return String(n)
235 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
236 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
237}
238
239/** Filled cells for the part of the lifetime left. */
240export const filledCells = (leftMs: number, ttl: Ttl, width: number) =>
241 Math.round(Math.min(1, Math.max(0, leftMs / ttlMs(ttl))) * width)
242
243export type Fit = { bar: number; hit: boolean }
244
245// the dim "· 98%" and the gap before it
246const HIT_COLS = 6
247const MIN_BAR = 4
248
249/**
250 * How the timer fits `free` columns when `fixed` go to its label, time and
251 * gaps: the tail ("· 98%", or a rebuild's cause, `tail` columns with its gap)
252 * goes first, then bar cells down to MIN_BAR; undefined when even that does
253 * not fit.
254 */
255export function fitBar(free: number, fixed: number, max: number, tail = HIT_COLS): Fit | undefined {
256 const withHit = Math.min(max, free - fixed - tail)
257 if (withHit >= MIN_BAR) return { bar: withHit, hit: true }
258 const bare = Math.min(max, free - fixed)
259 return bare >= MIN_BAR ? { bar: bare, hit: false } : undefined
260}
261