SLOPSHOPPER

cache-dot

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.

newspinnertimer
v0.2.1MITupdated 2026-10-23bencsmartucph/ben-mods/cache-dot
A shopper browsing a rack in a slop shop
README

cache-dot

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

The prompt footer with the cache dot: 59 minutes left on the prompt cache and 4% of the context window used

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.

  • Grey with time to spare, yellow under 10 minutes (under 2 minutes on a 5-minute cache), red ○ cold once it has expired. The glyph drains as time passes.
  • The context window's % used sits beside it, coloured at 60% and 85%.
  • It appears after the first reply, once caching has been seen.

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.

Install

claude plugin marketplace add bencsmartucph/ben-mods
claude plugin install cache-dot@ben-mods
Source 1 files
hooks/register.ts 132 lines
1// 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