Band above the prompt: context window used/max and prompt-cache TTL countdown + hit rate; optional auto-compact just before the cache expires.

A Claude Code mod (function-hooks plugin). It draws a band above the prompt:
● cache 40m ●●●●●●●●●●●●···· 99% hit │ ctx 656k / 1M █████████████░░░░░░░ 66% [Compact]
/compact (between turns only).The band draws on the terminal and the desktop app. Claude Code does not draw this band on mobile, but auto-compact still runs there.
/config, or pluginConfigs.ctx-cache-band in settings.json)| Field | Default | |
|---|---|---|
cacheTtlMinutes | "60" | "5" or "60". Not auto-detected: set it to the TTL your sessions actually use. |
autoCompact | true | |
compactLeadMinutes | 3 | |
minCompactTokens | 150000 |
Clone into ~/.claude/mods/ctx-cache-band and add to the env block of the user ~/.claude/settings.json (a project's settings are never read for this):
"CLAUDE_CODE_PLUGIN_DIRS": "<absolute path to the clone>",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
Separate several folders with ; on Windows and : elsewhere. Restart Claude Code. Edits to the clone reload on their own.
In the cloud environment's settings (the environment menu in the session title bar, then Edit):
scripts/cloud-setup.sh. CLAUDE_CODE_PLUGIN_DIRS=/root/.claude/mods/ctx-cache-band CLAUDE_CODE_PLUGIN_DIR_WATCH=1 ``New sessions pick it up. If this repo is private and the setup script's clone fails, either make the repo public (it holds nothing sensitive) or give the environment access to it.
/plugin install ctx-cache-band --marketplace gregpa/ctx-cache-band
claude plugin validate .
claude plugin test .hooks/register.tsx 154 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import {
5 bar,
6 cacheRemainingMs,
7 fillColor,
8 formatRemaining,
9 formatTokens,
10 hitPercent,
11 shouldAutoCompact,
12} from './logic'
13
14const lastRequestAt = atom({ plugin: 'ctx-cache-band', key: 'lastRequestAt' } as const, null)
15const hit = atom({ plugin: 'ctx-cache-band', key: 'hitPercent' } as const, null)
16const now = atom({ plugin: 'ctx-cache-band', key: 'now' } as const, 0)
17const isTurnRunning = atom({ plugin: 'ctx-cache-band', key: 'isTurnRunning' } as const, false)
18const compactedAt = atom({ plugin: 'ctx-cache-band', key: 'compactedAt' } as const, null)
19
20const TICK_MS = 15_000
21const DOTS = 18
22const CTX_CELLS = 20
23
24export const register: Register = (on, options) => {
25 const ttlMs = (options.cacheTtlMinutes === '5' ? 5 : 60) * 60_000
26 const leadMs = Math.min(Number(options.compactLeadMinutes ?? 3) * 60_000, ttlMs - TICK_MS)
27 const minTokens = Number(options.minCompactTokens ?? 150_000)
28 const isAutoCompact = options.autoCompact !== false
29 let isCompacting = false
30
31 on('session.start', async ($, e, next) => {
32 await update($, now, () => Date.now())
33 $.clock.every(TICK_MS, () => {
34 void (async () => {
35 const t = await $.clock.now()
36 await update($, now, () => t)
37 if (!isAutoCompact || isCompacting) return
38 const { context } = await $.session.usage()
39 const decision = shouldAutoCompact({
40 now: t,
41 lastRequestAt: await read($, lastRequestAt),
42 compactedAt: await read($, compactedAt),
43 isTurnRunning: await read($, isTurnRunning),
44 contextTokens: context.tokens,
45 ttlMs,
46 leadMs,
47 minTokens,
48 })
49 if (!decision.go) return
50 isCompacting = true
51 try {
52 $.ui.toast(`Cache expires soon; compacting ${formatTokens(context.tokens ?? 0)} context while it is warm`)
53 const result = await $.session.compact()
54 if ('skip' in result) {
55 $.ui.toast(`Auto-compact skipped: ${result.skip}`)
56 } else if (result.usage) {
57 const pct = hitPercent(result.usage)
58 $.ui.toast(`Compacted. Summarizer cache hit: ${pct ?? '?'}%`, { timeoutMs: 8000 })
59 }
60 } catch {
61 // A turn started between the check and the call; the next idle window retries.
62 } finally {
63 isCompacting = false
64 }
65 })()
66 })
67
68 return next(e)
69 })
70
71 on('turn.start', async ($, e, next) => {
72 await update($, isTurnRunning, () => true)
73 return next(e)
74 })
75
76 on('turn.complete', async ($, e, next) => {
77 if (e.agentId !== undefined) return next(e)
78 const t = await $.clock.now()
79 await update($, isTurnRunning, () => false)
80 if (e.usage) {
81 const pct = hitPercent(e.usage)
82 await update($, lastRequestAt, () => t)
83 await update($, hit, () => pct)
84 }
85 await update($, now, () => t)
86 return next(e)
87 })
88
89 on('session.compact', async ($, e, next) => {
90 const result = await next(e)
91 if (e.trigger !== 'precompute' && e.agentId === undefined && !('skip' in result)) {
92 const t = await $.clock.now()
93 await update($, compactedAt, () => t)
94 await update($, now, () => t)
95 }
96 return result
97 }).catch(($, e, next) => next(e))
98
99 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
100 if (e.props.hasSurvey) return next(e)
101
102 const { Box, Button, Text } = $.ui.resolve(e)
103 const t = (await read($, now)) || Date.now()
104 const last = await read($, lastRequestAt)
105 const pct = await read($, hit)
106 const { context } = await $.session.usage()
107
108 const remaining = last === null ? null : cacheRemainingMs(t, last, ttlMs)
109 const left = remaining === null ? 0 : remaining / ttlMs
110 const dots = bar(left, DOTS)
111 const cacheColor = remaining === null ? 'gray' : fillColor(1 - left)
112
113 const used = context.tokens === undefined ? null : context.tokens / context.window
114 const ctx = bar(used ?? 0, CTX_CELLS)
115 const ctxColor = used === null ? 'gray' : fillColor(used)
116
117 return (
118 <Box flexDirection="row">
119 <Text color={cacheColor}>● </Text>
120 <Text dimColor>cache </Text>
121 <Text color={cacheColor} bold>
122 {remaining === null ? '—' : formatRemaining(remaining)}{' '}
123 </Text>
124 <Text color={cacheColor}>{'●'.repeat(dots.filled)}</Text>
125 <Text dimColor>{'·'.repeat(dots.empty)} </Text>
126 <Text>{pct === null ? '—' : `${pct}%`} hit </Text>
127 <Text dimColor>│ ctx </Text>
128 <Text color={ctxColor} bold>
129 {context.tokens === undefined ? '—' : formatTokens(context.tokens)}
130 </Text>
131 <Text dimColor> / {formatTokens(context.window)} </Text>
132 <Text color={ctxColor}>{'█'.repeat(ctx.filled)}</Text>
133 <Text dimColor>{'░'.repeat(ctx.empty)} </Text>
134 <Text>{context.percent === undefined ? '—' : `${context.percent}%`} </Text>
135 <Button
136 key="compact"
137 label="Compact"
138 onPress={async () => {
139 if (await read($, isTurnRunning)) {
140 $.ui.toast('Compact runs between turns; try again when this one ends')
141 return
142 }
143 try {
144 await $.session.compact()
145 } catch {
146 $.ui.toast('Compact refused while a turn runs')
147 }
148 }}
149 />
150 </Box>
151 )
152 })
153}
154hooks/logic.ts 65 lines1export type CompactCheck = {
2 now: number
3 lastRequestAt: number | null
4 compactedAt: number | null
5 isTurnRunning: boolean
6 contextTokens: number | undefined
7 ttlMs: number
8 leadMs: number
9 minTokens: number
10}
11
12export type CompactDecision = { go: true } | { go: false; why: string }
13
14/**
15 * Compact only inside the lead window: before it the cache is warm and idle
16 * time may still end; after expiry the compaction request itself re-reads
17 * the whole context uncached, which is the cost this exists to avoid.
18 */
19export const shouldAutoCompact = (c: CompactCheck): CompactDecision => {
20 if (c.isTurnRunning) return { go: false, why: 'turn running' }
21 if (c.lastRequestAt === null) return { go: false, why: 'no response yet' }
22 if (c.compactedAt !== null && c.compactedAt >= c.lastRequestAt) {
23 return { go: false, why: 'already compacted this idle window' }
24 }
25 const remaining = cacheRemainingMs(c.now, c.lastRequestAt, c.ttlMs)
26 if (remaining <= 0) return { go: false, why: 'cache already expired' }
27 if (remaining > c.leadMs) return { go: false, why: 'not in lead window' }
28 if ((c.contextTokens ?? 0) < c.minTokens) return { go: false, why: 'context below threshold' }
29 return { go: true }
30}
31
32export const cacheRemainingMs = (now: number, lastRequestAt: number, ttlMs: number) =>
33 Math.max(0, lastRequestAt + ttlMs - now)
34
35export const hitPercent = (u: {
36 input_tokens: number
37 cache_read_input_tokens: number
38 cache_creation_input_tokens: number
39}): number | null => {
40 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
41 return total === 0 ? null : Math.round((u.cache_read_input_tokens / total) * 100)
42}
43
44export const formatTokens = (n: number) => {
45 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
46 if (n >= 1000) return `${Math.round(n / 1000)}k`
47 return String(n)
48}
49
50export const formatRemaining = (ms: number) => {
51 if (ms <= 0) return 'expired'
52 if (ms >= 60_000) return `${Math.ceil(ms / 60_000)}m`
53 return `${Math.ceil(ms / 1000)}s`
54}
55
56/** Filled/empty cells for a bar of `width`, `fraction` clamped to 0..1. */
57export const bar = (fraction: number, width: number) => {
58 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width)
59 return { filled, empty: width - filled }
60}
61
62/** Green while comfortable, yellow when getting close, red when nearly gone. */
63export const fillColor = (usedFraction: number) =>
64 usedFraction >= 0.8 ? 'red' : usedFraction >= 0.5 ? 'yellow' : 'green'
65types/index.d.ts 15 lines1/** Epoch ms; null before the first main-loop response or compaction. */
2export type CtxCacheStamp = number | null
3
4declare module 'claude-code' {
5 interface PluginState {
6 'ctx-cache-band': {
7 lastRequestAt: CtxCacheStamp
8 hitPercent: number | null
9 now: number
10 isTurnRunning: boolean
11 compactedAt: CtxCacheStamp
12 }
13 }
14}
15