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.

Shows how full the context window is and how the prompt cache is doing.
Context ┃ Window 17% ┃ Cache hit 99% ┃ Cache TTL 4m33s
| Cell | Meaning |
|---|---|
Window | Context fill. Green below 70%, orange from 70%, red from 85%. |
Cache hit | Share of the last prompt served from cache. Red below 20%. |
Cache TTL | Countdown 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 -.
/compact.hooks/register.tsx 141 lines1import 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