Minutes left on the prompt cache as a dot after the footer's mode labels: grey, yellow under 10 min, red cold. Estimated from main-loop requests.

A small dot in the prompt footer, after the mode labels, that tells you how long the prompt cache has left: ◕ 47m.

The prompt cache makes the next message cheaper and faster, but only while it is warm. If it has gone cold, the next message pays to write it again. The dot answers "will this message hit the cache?" before you send it.
○ cold once it has expired. The glyph drains as time passes.It is an estimate. No mod API exposes the statusline's cache figures, so the countdown restarts whenever a main-conversation request that read or wrote the cache is sent, and a resumed session starts from what Claude Code reports at start-up. The TTL defaults to 1 hour (ENABLE_PROMPT_CACHING_1H=1); a model switch reports the real one.
Status: used live in the desktop app.
Adapted from WQGGSEY's Cache TTL Timer 1.0.0 (MIT): the step timing, footer drawing and draining glyphs. See LICENSE.
claude plugin marketplace add bencsmartucph/ben-mods
claude plugin install cache-dot@ben-modshooks/register.ts 132 lines1// cache-dot: minutes left on the prompt cache, as a dot after the mode labels in the prompt
2// footer (`◕ 47m`), so you know before you send whether the next message reads the cache or
3// writes it again. Grey with time to spare, yellow under 10 min (under 2 min on a 5m TTL), red
4// `○ cold` once expired: the colour logic of the author's statusline.
5//
6// The statusline reads Claude Code's own `prompt_cache` figures; a mod can't (no `$` method
7// carries them, as of 2.1.301), so this estimates them the same way the engine does: the
8// countdown restarts when a main-conversation request that read or wrote the cache is sent.
9// A resumed session starts from SessionStart's `seconds_since_last_response`, and trusts its
10// `prompt_cache_likely_expired`. TTL defaults to 1h (ENABLE_PROMPT_CACHING_1H=1); a model
11// switch reports the real one.
12//
13// Structure (turn.step timing, SessionMode drawing, the draining glyphs) adapted from
14// WQGGSEY's Cache TTL Timer 1.0.0, MIT, see LICENSE.
15import type { Register } from 'claude-code'
16
17type Ttl = '5m' | '1h'
18const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
19const WARN_MS: Record<Ttl, number> = { '5m': 2 * 60_000, '1h': 10 * 60_000 } // statusline CACHE_SHOW_MIN
20const GLYPHS = ['●', '◕', '◑', '◔']
21const STYLE = { warm: { dimColor: true }, warn: { color: 'warning' }, cold: { color: 'error' } } as const
22
23// Module state: reset by a --plugin-dir hot reload until the next request; an installed copy keeps it.
24let lastHit: number | null = null // when the last cache-using main request was sent, $.clock ms
25let cold = false // the engine or the last response says the cache is gone
26let ttl: Ttl = '1h'
27let drawn = ''
28
29export type Readout = { level: 'warm' | 'warn' | 'cold'; text: string }
30
31// Pure, so the tests can check the thresholds directly.
32export function readout(hit: number | null, isCold: boolean, t: Ttl, now: number): Readout | null {
33 if (hit === null && !isCold) return null // caching not observed yet: nothing to warn about
34 const left = hit === null ? 0 : hit + TTL_MS[t] - now
35 if (isCold || left <= 0) return { level: 'cold', text: '○ cold' }
36 const minutes = left < 60_000 ? '<1m' : `${Math.floor(left / 60_000)}m`
37 const glyph = GLYPHS[Math.min(GLYPHS.length - 1, Math.floor((1 - left / TTL_MS[t]) * GLYPHS.length))]
38 return { level: left <= WARN_MS[t] ? 'warn' : 'warm', text: `${glyph} ${minutes}` }
39}
40
41// Context used, the statusline's context% (same figure: the window's used_percentage). Null before the
42// first response, or if the engine can't say. Statusline colours: cyan, yellow from 60%, red from 85%.
43const CTX_WARN = 60
44const CTX_CRIT = 85
45export function ctxStyle(pct: number) {
46 return pct >= CTX_CRIT ? { color: 'error' } : pct >= CTX_WARN ? { color: 'warning' } : { color: 'cyan' }
47}
48async function contextPercent($: any): Promise<number | null> {
49 try {
50 const p = (await $.session.usage()).context?.percent
51 return typeof p === 'number' ? Math.round(p) : null
52 } catch {
53 return null
54 }
55}
56
57async function current($: any): Promise<Readout | null> {
58 return readout(lastHit, cold, ttl, await $.clock.now())
59}
60
61async function tick($: any) {
62 const r = await current($)
63 const key = (r ? r.text : '') + '|' + (await contextPercent($))
64 if (key === drawn) return
65 drawn = key
66 $.ui.invalidate('ui.render')
67}
68
69export const register: Register = (on) => {
70 on('session.start', async ($, e, next) => {
71 $.clock.every(10_000, () => tick($))
72 return next(e)
73 })
74
75 // Each request to the model; a subagent caches its own prefix, so only the main loop counts.
76 on('turn.step', async function* ($, e, next) {
77 if (e.agentId) return yield* next(e)
78 const sentAt = await $.clock.now()
79 const result = yield* next(e)
80 const u = result.usage
81 if (u) {
82 // As the engine's summary: warm only if the last response read or wrote the cache.
83 if (u.cache_read_input_tokens + u.cache_creation_input_tokens > 0) {
84 lastHit = Math.max(lastHit ?? 0, sentAt)
85 cold = false
86 } else if (lastHit !== null) {
87 cold = true
88 }
89 await tick($)
90 }
91 return result
92 })
93
94 // Startup, /resume, /clear, compaction.
95 on('classic.SessionStart', async ($, e, next) => {
96 if (e.source === 'clear') {
97 lastHit = null
98 cold = false
99 } else if (e.source === 'compact') {
100 cold = lastHit !== null // the next message writes the compacted prompt afresh
101 } else if (typeof e.seconds_since_last_response === 'number') {
102 lastHit = (await $.clock.now()) - e.seconds_since_last_response * 1000
103 cold = e.prompt_cache_likely_expired === true
104 }
105 await tick($)
106 return next(e)
107 })
108
109 // A model switch is the one hook input that names the TTL in use.
110 on('classic.PostModelSwitch', async ($, e, next) => {
111 if (e.cache_ttl === '5m' || e.cache_ttl === '1h') ttl = e.cache_ttl
112 await tick($)
113 return next(e)
114 })
115
116 // The dim mode labels at the right of the prompt footer: keep them, then the dot and context %.
117 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
118 const r = await current($)
119 const pct = await contextPercent($)
120 if (!r && pct === null) return next(e)
121 const { Box, Text } = $.ui.resolve(e)
122 const sep = () => Text({ dimColor: true, children: [' · '] })
123 const parts: any[] = []
124 if (r) parts.push(Text({ ...STYLE[r.level], children: [r.text] }))
125 if (pct !== null) parts.push(...(parts.length ? [sep()] : []), Text({ ...ctxStyle(pct), children: [`${pct}%`] }))
126 const mine = parts.length === 1 ? parts[0] : Box({ flexDirection: 'row', alignItems: 'center', children: parts })
127 if (e.props.modes.length === 0) return mine
128 const theirs = await next(e)
129 return Box({ flexDirection: 'row', alignItems: 'center', children: [theirs, sep(), mine] })
130 })
131}
132