SLOPSHOPPER

context-cache

Bottom-grid row: context fill and prompt cache (hit rate, countdown from the TTL the API reports). Toasts when context is full or the cache expired.

newspinnertoasttimer
★ 1v0.3.5no licenseupdated 2026-10-03Lunik/gmz-claude-marketplace/context-cache
A shopper browsing a rack in a slop shop
README

context-cache

Shows how full the context window is and how the prompt cache is doing.

Context ┃ Window 17% ┃ Cache hit 99% ┃ Cache TTL 4m33s

Cells

CellMeaning
WindowContext fill. Green below 70%, orange from 70%, red from 85%.
Cache hitShare of the last prompt served from cache. Red below 20%.
Cache TTLCountdown to cache expiry, from the TTL the API reports for the entry it just wrote (5 minutes or 1 hour). expired once it ran out.

Cache hit and Cache TTL only show once the context holds at least 20k tokens, below that a cold cache costs little. Until then, and before the first response, they read -.

Toasts

  • Context crosses 70%, 85% or 95%: consider /compact.
  • Cache miss: a response re-wrote 20k+ tokens at full price with a hit rate under 20%.
  • Cache expires in 60 seconds: send a prompt to keep it.
  • Cache expired: the next prompt re-reads the context uncached.

Behavior

  • Row in the bottom-right grid, refreshed every second.
  • Only the main conversation counts; a subagent has its own cache entry.
Source 1 files
hooks/register.tsx 141 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3const CONTEXT_STEPS = [70, 85, 95]
4const BIG = 20_000 // tokens: below this a cold cache costs little
5const TICK = 1_000
6const NEAR = 60_000 // ms left when the "cache about to expire" toast fires
7
8// highest step crossed, 0 if none
9export const step = (steps: number[], pct: number) => steps.filter(s => pct >= s).pop() ?? 0
10
11export const color = (pct: number) => (pct >= 85 ? 'error' : pct >= 70 ? '#e0af68' : '#9ece6a')
12
13export const fmt = (ms: number) => {
14  const s = Math.max(0, Math.round(ms / 1000))
15  return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
16}
17
18// share of the prompt the cache served, 0-100; undefined when nothing was sent
19export const hitRate = (u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) => {
20  const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
21  return total ? Math.round((u.cache_read_input_tokens / total) * 100) : undefined
22}
23
24const TTL_5M = 300_000
25const TTL_1H = 3_600_000
26
27// TTL of the cache entry a response just wrote, from the API's own `cache_creation` split;
28// undefined when it wrote nothing or the field is absent
29export const ttlOf = (u: object) => {
30  const c = (u as { cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number } }).cache_creation
31  return c?.ephemeral_1h_input_tokens ? TTL_1H : c?.ephemeral_5m_input_tokens ? TTL_5M : undefined
32}
33
34// ponytail: 5m until a response reports otherwise; module vars reset on hot reload (cache unknown until next turn)
35let ttl = TTL_5M
36let ctxWarned = 0
37let lastAt = 0
38let hit: number | undefined
39let isCacheNotified = false
40let isNearNotified = false
41
42// ponytail: also started by the first render, since a hot reload does not fire session.start
43let ticking = false
44const startTick = ($: EngineInterface) => {
45  if (ticking) return
46  ticking = true
47  $.clock.every(TICK, async () => {
48    const tokens = (await $.session.usage()).context.tokens ?? 0
49    const left = lastAt ? lastAt + ttl - (await $.clock.now()) : undefined
50    if (left !== undefined && left > 0 && left <= NEAR && tokens >= BIG && !isNearNotified) {
51      isNearNotified = true
52      $.ui.toast(`Cache expires in ${fmt(left)}: send a prompt to keep ${Math.round(tokens / 1000)}k tokens cached.`)
53    }
54    if (left !== undefined && left <= 0 && tokens >= BIG && !isCacheNotified) {
55      isCacheNotified = true
56      $.ui.toast(`Cache expired: next prompt re-reads ${Math.round(tokens / 1000)}k tokens uncached. Consider /compact first.`)
57    }
58    $.ui.invalidate('ui.render')
59  })
60}
61
62export const register: Register = on => {
63  // ponytail: 5m until a response reports otherwise; no usage split in the response = stays 5m
64  // ponytail: module vars reset on hot reload; worst case a repeat toast, cache unknown until next turn
65
66  on('session.start', ($, e, next) => {
67    startTick($)
68    return next(e)
69  })
70
71  // every model request, not just turn end: tool-loop steps refresh cache + TTL too
72  on('turn.step', async function* ($, e, next) {
73    const res = yield* next(e)
74    // main loop only: a subagent has its own cache entry
75    if (res.usage && !e.agentId) {
76      hit = hitRate(res.usage)
77      ttl = ttlOf(res.usage) ?? ttl
78      lastAt = await $.clock.now()
79      isCacheNotified = false
80      isNearNotified = false
81      const wrote = res.usage.cache_creation_input_tokens
82      if (wrote >= BIG && (hit ?? 0) < 20) {
83        $.ui.toast(`Cache miss: re-wrote ${Math.round(wrote / 1000)}k tokens at full price`)
84      }
85      $.ui.invalidate('ui.render')
86    }
87    return res
88  })
89
90  on('session.measure', async ($, e, next) => {
91    const pct = e.context.percent ?? 0
92    const cs = step(CONTEXT_STEPS, pct)
93    if (cs > ctxWarned) $.ui.toast(`Context ${pct}% full, consider /compact`)
94    ctxWarned = cs // drops after /compact or /clear
95    $.ui.invalidate('ui.render')
96    return next(e)
97  })
98
99  // stacked above the usage bar (SessionMode, bottom right)
100  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
101    startTick($)
102    const { percent, tokens = 0 } = (await $.session.usage()).context
103    const left = lastAt ? lastAt + ttl - (await $.clock.now()) : undefined
104    const isCache = left !== undefined && tokens >= BIG
105    const other = await next(e)
106    const { Box, Text } = $.ui.resolve(e)
107    const line = (
108      <Box>
109        <Box width={11}><Text bold color="#7dcfff">Context</Text></Box>
110        <Box>
111          <Text color="#565f89"> ┃ </Text>
112          <Box width={24}>
113            <Text dimColor>Window </Text>
114            <Text bold color={percent === undefined ? '#565f89' : color(percent)}>{percent === undefined ? '-' : `${percent}%`}</Text>
115          </Box>
116        </Box>
117        <Box>
118          <Text color="#565f89"> ┃ </Text>
119          <Box width={24}>
120            <Text dimColor>Cache hit </Text>
121            <Text bold color={!isCache || hit === undefined ? '#565f89' : hit < 20 ? 'error' : '#c0caf5'}>{isCache && hit !== undefined ? `${hit}%` : '-'}</Text>
122          </Box>
123        </Box>
124        <Box>
125          <Text color="#565f89"> ┃ </Text>
126          <Box>
127            <Text dimColor>Cache TTL </Text>
128            <Text bold color={!isCache ? '#565f89' : left > 0 ? '#c0caf5' : 'error'}>{!isCache ? '-' : left > 0 ? fmt(left) : 'expired'}</Text>
129          </Box>
130        </Box>
131      </Box>
132    )
133    return other.type === 'engine' ? line : (
134      <Box flexDirection="column" alignItems="flex-start">
135        {other}
136        {line}
137      </Box>
138    )
139  })
140}
141