SLOPSHOPPER

cache-status

Band above the prompt: prompt cache hits and TTL, context window, rate limits and cost.

newbandcommandtoaststatustimer
v0.3.0no licenseupdated 2026-10-20Glubin-yep/claude-cache-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-status
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cache ⎿ cache-status: Cache band: compact. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cache-status

A Claude Code mod: a band above the prompt showing prompt-cache hits and TTL, context window, rate limits and cost. /cache toggles between full and compact view.

Install

/plugin install cache-status --marketplace Glubin-yep/claude-cache-status

Answer y to add the marketplace, then pick a scope.

To load it in every session of a repository (including cloud sessions), add to that repository's .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "claude-cache-status": { "source": { "source": "github", "repo": "Glubin-yep/claude-cache-status" } }
  },
  "enabledPlugins": { "cache-status@claude-cache-status": true }
}
Source 2 files
hooks/register.tsx 346 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { CacheInfo, CtxInfo, LimitInfo, Totals } from '../types'
5
6const TTL_MS = 60 * 60 * 1000
7const HOT_MS = 10 * 60 * 1000
8const COOLING_MS = 10 * 60 * 1000
9const WARN_MS = 5 * 60 * 1000
10const SPARK = '▁▂▃▄▅▆▇█'
11
12// The SVG strip: one row of cells. It scales down to the band's width.
13const STRIP_W = 760
14const STRIP_H = 60
15
16// SVG cannot read the theme; these are GitHub-dark tones (the user runs dark).
17const HEX: Record<string, string> = {
18  green: '#3fb950',
19  yellow: '#d29922',
20  red: '#f85149',
21  cyan: '#39c5cf',
22  magenta: '#db61a2',
23  blue: '#58a6ff',
24}
25const INK = '#e6edf3'
26const MUTED = '#8b949e'
27const TRACK = 'rgba(139,148,158,0.22)'
28const RULE = 'rgba(139,148,158,0.18)'
29const FONT = "system-ui,-apple-system,'Segoe UI',sans-serif"
30
31const info = atom({ plugin: 'cache-status', key: 'info' } as const, null)
32const now = atom({ plugin: 'cache-status', key: 'now' } as const, 0)
33const ctx = atom({ plugin: 'cache-status', key: 'ctx' } as const, null)
34const hist = atom({ plugin: 'cache-status', key: 'hist' } as const, [])
35const limits = atom({ plugin: 'cache-status', key: 'limits' } as const, [])
36const totals = atom({ plugin: 'cache-status', key: 'totals' } as const, { read: 0, written: 0, cost: null })
37const isCompact = atom({ plugin: 'cache-status', key: 'isCompact' } as const, false)
38const isAlert = atom({ plugin: 'cache-status', key: 'isAlert' } as const, false)
39
40// Module-local: reset on reload, which at worst repeats one toast.
41let warnedFor = 0
42
43function fmt(n: number) {
44  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
45  if (n >= 1000) return `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k`
46  return `${n}`
47}
48
49function dur(ms: number) {
50  const m = Math.max(0, Math.ceil(ms / 60000))
51  if (m <= 60) return `${m}m`
52  const hours = Math.floor(m / 60)
53  if (hours < 24) return `${hours}h ${String(m % 60).padStart(2, '0')}m`
54  return `${Math.floor(hours / 24)}d ${hours % 24}h`
55}
56
57function clamp01(f: number) {
58  return Math.max(0, Math.min(1, f))
59}
60
61function heat(pct: number) {
62  return pct >= 80 ? 'green' : pct >= 40 ? 'yellow' : 'red'
63}
64
65function load(pct: number) {
66  return pct < 50 ? 'green' : pct < 80 ? 'yellow' : 'red'
67}
68
69function limitShort(kind: string) {
70  if (kind === 'five_hour') return '5h'
71  if (kind === 'seven_day') return '7d'
72  if (kind === 'spend_limit') return 'spend'
73  return kind
74}
75
76function limitName(kind: string) {
77  if (kind === 'five_hour') return '5-HOUR'
78  if (kind === 'seven_day') return 'WEEKLY'
79  if (kind === 'spend_limit') return 'SPEND'
80  return kind.toUpperCase()
81}
82
83function cacheState(sinceMs: number) {
84  const left = TTL_MS - sinceMs
85  if (left <= 0) return { icon: '❄', label: 'cold', color: 'blue', left: 0 }
86  if (sinceMs < HOT_MS) return { icon: '🔥', label: 'hot', color: 'magenta', left }
87  if (left < COOLING_MS) return { icon: '⚠', label: 'cooling', color: 'yellow', left }
88  return { icon: '●', label: 'warm', color: 'cyan', left }
89}
90
91type Meter = { kind: 'bar'; frac: number; color: string; marker: number | null } | { kind: 'spark'; values: number[] }
92
93type Cell = { label: string; labelColor: string; value: string; valueColor: string; unit: string; meter: Meter }
94
95function meterSvg(m: Meter, x: number, y: number, w: number) {
96  if (m.kind === 'bar') {
97    const fill = Math.round(clamp01(m.frac) * w)
98    let s = `<rect x="${x}" y="${y}" width="${w}" height="4" rx="2" fill="${TRACK}"/>`
99    if (fill > 0) s += `<rect x="${x}" y="${y}" width="${fill}" height="4" rx="2" fill="${HEX[m.color]}"/>`
100    if (m.marker !== null) {
101      const mx = x + Math.round(clamp01(m.marker) * w)
102      s += `<rect x="${mx - 1}" y="${y - 3}" width="2" height="10" rx="1" fill="${MUTED}"/>`
103    }
104    return s
105  }
106  const n = 16
107  const gap = 2
108  const bw = (w - gap * (n - 1)) / n
109  const h = 10
110  const start = n - m.values.length
111  let s = ''
112  for (let i = 0; i < n; i++) {
113    const v = i >= start ? m.values[i - start] : null
114    const bh = v === null ? 2 : Math.max(2, Math.round((v / 100) * h))
115    const fill = v === null ? TRACK : HEX[heat(v)]
116    s += `<rect x="${(x + i * (bw + gap)).toFixed(1)}" y="${y + 4 - bh}" width="${bw.toFixed(1)}" height="${bh}" rx="1" fill="${fill}"/>`
117  }
118  return s
119}
120
121function stripSvg(cells: Cell[]) {
122  const w = STRIP_W / cells.length
123  const pad = 14
124  let body = ''
125  cells.forEach((c, i) => {
126    const x = i * w
127    if (i > 0) body += `<rect x="${x}" y="8" width="1" height="${STRIP_H - 16}" fill="${RULE}"/>`
128    body +=
129      `<text x="${x + pad}" y="17" font-size="10" font-weight="600" letter-spacing="0.8" fill="${c.labelColor}">${c.label}</text>` +
130      `<text x="${x + pad}" y="40"><tspan font-size="20" font-weight="700" fill="${c.valueColor}">${c.value}</tspan>` +
131      (c.unit === '' ? '' : `<tspan dx="6" font-size="11" fill="${MUTED}">${c.unit}</tspan>`) +
132      `</text>` +
133      meterSvg(c.meter, x + pad, 50, w - pad * 2)
134  })
135  return (
136    `<svg xmlns="http://www.w3.org/2000/svg" width="${STRIP_W}" height="${STRIP_H}" viewBox="0 0 ${STRIP_W} ${STRIP_H}" ` +
137    `font-family="${FONT}">${body}</svg>`
138  )
139}
140
141function buildCells(c: CacheInfo, t: number, alert: boolean, history: number[], x: CtxInfo | null, ls: LimitInfo[], tot: Totals) {
142  const st = cacheState(Math.max(0, t - c.at))
143  const cells: Cell[] = [
144    {
145      label: alert ? 'CACHE MISS' : 'CACHE HIT',
146      labelColor: alert ? HEX.red : MUTED,
147      value: `${c.hit}%`,
148      valueColor: HEX[alert ? 'red' : heat(c.hit)],
149      unit: `↓${fmt(c.read)} ↑${fmt(c.written)}`,
150      meter: { kind: 'spark', values: history },
151    },
152    {
153      label: 'CACHE TTL',
154      labelColor: MUTED,
155      value: st.left > 0 ? dur(st.left) : '0m',
156      valueColor: HEX[st.color],
157      unit: `${st.icon} ${st.label}`,
158      meter: { kind: 'bar', frac: st.left / TTL_MS, color: st.color, marker: null },
159    },
160  ]
161  if (x !== null) {
162    cells.push({
163      label: 'CONTEXT',
164      labelColor: MUTED,
165      value: `${x.percent}%`,
166      valueColor: HEX[load(x.percent)],
167      unit: x.compactAt === null ? `${fmt(x.tokens)}/${fmt(x.window)}` : `⇲ ${fmt(Math.max(0, x.compactAt - x.tokens))} left`,
168      meter: { kind: 'bar', frac: x.percent / 100, color: load(x.percent), marker: x.compactAt === null ? null : x.compactAt / x.window },
169    })
170  }
171  for (const l of ls) {
172    cells.push({
173      label: `${limitName(l.kind)} LIMIT`,
174      labelColor: MUTED,
175      value: `${Math.round(l.percent)}%`,
176      valueColor: HEX[load(l.percent)],
177      unit: l.resetsAt === null ? '' : `↻ ${dur(l.resetsAt - t)}`,
178      meter: { kind: 'bar', frac: l.percent / 100, color: load(l.percent), marker: null },
179    })
180  }
181  const cachedFrac = tot.read + tot.written > 0 ? tot.read / (tot.read + tot.written) : 0
182  cells.push({
183    label: 'SESSION',
184    labelColor: MUTED,
185    value: tot.cost === null ? '—' : `$${tot.cost.toFixed(2)}`,
186    valueColor: INK,
187    unit: `♻ ${fmt(tot.read)}`,
188    meter: { kind: 'bar', frac: cachedFrac, color: 'green', marker: null },
189  })
190  return { cells, st }
191}
192
193async function refreshUsage($: any, withBreakdown: boolean) {
194  const u = await $.session.usage(withBreakdown ? { breakdown: 'summary' } : undefined)
195  const c = u.context
196  if (c.tokens !== undefined) {
197    const prev = await read($, ctx)
198    let compactAt = prev ? prev.compactAt : null
199    const b = c.breakdown
200    if (b) {
201      const buffer = b.categories
202        .filter((k: any) => k.kind === 'buffer')
203        .reduce((s: number, k: any) => s + k.tokens, 0)
204      compactAt = buffer > 0 ? b.rawMaxTokens - buffer : null
205    }
206    const v: CtxInfo = { tokens: c.tokens, window: c.window, percent: c.percent ?? 0, compactAt }
207    await update($, ctx, () => v)
208  }
209  const ls: LimitInfo[] = u.rateLimits.map((r: any) => ({
210    kind: r.kind,
211    percent: r.percentUsed,
212    resetsAt: r.resetsAt ? Date.parse(r.resetsAt) : null,
213  }))
214  await update($, limits, () => ls)
215  const cost = u.cost ? u.cost.usd : null
216  await update($, totals, t => ({ ...t, cost }))
217}
218
219async function tick($: any) {
220  const t = await $.clock.now()
221  await update($, now, () => t)
222  await refreshUsage($, false)
223  const c = await read($, info)
224  if (c && warnedFor !== c.at) {
225    const left = TTL_MS - (t - c.at)
226    if (left > 0 && left < WARN_MS) {
227      warnedFor = c.at
228      $.ui.toast(`❄ Prompt cache cools in ${dur(left)}: the next request re-writes ~${fmt(c.read + c.written)} tokens`, {
229        timeoutMs: 8000,
230      })
231    }
232  }
233}
234
235export const register: Register = on => {
236  on('session.start', async ($, e, next) => {
237    // The first version wrote the status line; clear whatever it left there.
238    $.ui.status(undefined)
239    await $.command.register({ name: 'cache', description: 'Toggle the cache status band between full and compact' })
240    $.clock.every(15000, () => tick($))
241    return next(e)
242  })
243
244  on('command.run', { command: 'cache' }, async $ => {
245    await update($, isCompact, v => !v)
246    return { text: (await read($, isCompact)) ? 'Cache band: compact.' : 'Cache band: full.' }
247  })
248
249  // Per request on the main loop: a turn's summed usage re-counts the cached
250  // prefix once per tool round-trip, and subagent turns would overwrite it.
251  on('turn.step', async function* ($, e, next) {
252    const r = yield* next(e)
253    const u = e.agentId === undefined ? r.usage : null
254    if (u) {
255      const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
256      const hit = total > 0 ? Math.round((u.cache_read_input_tokens / total) * 100) : 0
257      const prev = await read($, info)
258      const t = await $.clock.now()
259      const fresh: CacheInfo = { hit, read: u.cache_read_input_tokens, written: u.cache_creation_input_tokens, at: t }
260
261      const isMiss = prev !== null && prev.hit >= 80 && hit < 50 && total > 10000
262      if (isMiss) {
263        $.ui.toast(`⚠ Prompt cache miss: ${hit}% hit, re-wrote ${fmt(u.cache_creation_input_tokens)} tokens`, {
264          timeoutMs: 6000,
265        })
266      }
267      await update($, isAlert, () => isMiss)
268      await update($, info, () => fresh)
269      await update($, hist, list => [...list, hit].slice(-16))
270      await update($, totals, x => ({
271        ...x,
272        read: x.read + u.cache_read_input_tokens,
273        written: x.written + u.cache_creation_input_tokens,
274      }))
275      await update($, now, () => t)
276      await refreshUsage($, true)
277    }
278    return r
279  })
280
281  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
282    const c = await read($, info)
283    if (e.props.hasSurvey || c === null) {
284      return next(e)
285    }
286    const els: any = $.ui.resolve(e)
287    const { Box, Text, Button, Svg } = els
288    const t = await read($, now)
289    const x = await read($, ctx)
290    const history = await read($, hist)
291    const ls = await read($, limits)
292    const tot = await read($, totals)
293    const compact = await read($, isCompact)
294    const alert = await read($, isAlert)
295
296    const { cells, st } = buildCells(c, t, alert, history, x, ls, tot)
297    const toggle = <Button key="toggle" label={compact ? '▸' : '▾'} onPress={() => update($, isCompact, v => !v)} />
298
299    if (Svg && !compact) {
300      const alt = cells.map(k => `${k.label} ${k.value} ${k.unit}`).join(', ')
301      return (
302        <Box flexDirection="row" alignItems="center" justifyContent="space-between">
303          <Box flexDirection="row" flexGrow={1}>
304            <Svg source={stripSvg(cells)} alt={alt} />
305          </Box>
306          {toggle}
307        </Box>
308      )
309    }
310
311    // One text line: compact mode, and the terminal (no Svg there).
312    const hitColor = alert ? 'red' : heat(c.hit)
313    const sep = (k: string) => (
314      <Text key={k} dimColor>
315        {'  ·  '}
316      </Text>
317    )
318    return (
319      <Box flexDirection="row" paddingX={1} justifyContent="space-between" alignItems="center">
320        <Box flexDirection="row">
321          <Text color={hitColor} bold>{`⚡ ${c.hit}%`}</Text>
322          <Text> </Text>
323          {history.map((v, i) => (
324            <Text key={`s${i}`} color={heat(v)}>
325              {SPARK[Math.min(SPARK.length - 1, Math.floor((v / 100) * SPARK.length))]}
326            </Text>
327          ))}
328          {sep('a')}
329          <Text color={st.color} bold>{`${st.icon} ${st.left > 0 ? dur(st.left) : 'cold'}`}</Text>
330          {x !== null && sep('b')}
331          {x !== null && <Text color={load(x.percent)} bold>{`ctx ${x.percent}%`}</Text>}
332          {ls.map(l => (
333            <Box key={l.kind} flexDirection="row">
334              {sep(`l${l.kind}`)}
335              <Text color={load(l.percent)} bold>{`${limitShort(l.kind)} ${Math.round(l.percent)}%`}</Text>
336            </Box>
337          ))}
338          {tot.cost !== null && sep('c')}
339          {tot.cost !== null && <Text color="green" bold>{`$${tot.cost.toFixed(2)}`}</Text>}
340        </Box>
341        {toggle}
342      </Box>
343    )
344  })
345}
346
types/index.d.ts 20 lines
1export type CacheInfo = { hit: number; read: number; written: number; at: number }
2export type CtxInfo = { tokens: number; window: number; percent: number; compactAt: number | null }
3export type LimitInfo = { kind: string; percent: number; resetsAt: number | null }
4export type Totals = { read: number; written: number; cost: number | null }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'cache-status': {
9      info: CacheInfo | null
10      now: number
11      ctx: CtxInfo | null
12      hist: number[]
13      limits: LimitInfo[]
14      totals: Totals
15      isCompact: boolean
16      isAlert: boolean
17    }
18  }
19}
20