Prompt-cache countdown, cache hit rate, context weather and weekly limit in the permission-mode line under the prompt (not the status line).

A tiny Claude Code mod, installed as a plugin, that shows the prompt-cache countdown, the cache hit rate, how full the context is and your weekly limit in the permission-mode line, the row under the prompt that shows ⏵⏵ auto mode on. It is not your status line, which it leaves alone.
| Part | Meaning |
|---|---|
☀️ 🌧️ ⛈️ ctx 29% | How full the context window is, and its weather: sunny up to half the auto-compact threshold, rainy up to 80 % of it, stormy above. The weather follows the context only, never the cache |
46 min | Time until the prompt cache expires. The cache lives for 5 min or 1 h after each response, depending on your session, and this counts down from that. Green, yellow in the last quarter, red in the last tenth, expired at zero |
hit 90% | Share of the last response's input tokens served from the cache |
7d 66% | Share of the weekly (seven-day) usage limit used so far |
⏳ 16.10. 08:00 | When the weekly limit resets, in local time |
There is no new panel or band. The figures go at the end of the row that already shows the permission mode (⏵⏵ auto mode on). Claude Code's docs call this row the status bar, its plugin API the prompt hint line. The mode stays Claude Code's own, in its own colour; only the (shift+tab to cycle) reminder is dropped.
It is a different row from the status line (statusLine in your settings), which sits above it. The mod does not touch your status line, so you can keep your own.
/compact, a new MCP server or tool)./compact at a good moment yourself.In Claude Code (terminal), type:
/plugin install cache-weather --marketplace vmurin/cache-weather
Answer y to add the marketplace, pick the user scope with Enter, and the line appears right away. In sessions that were already open, run /reload-plugins.
To remove it: /plugin uninstall cache-weather.
Tested on Claude Code 2.1.296.
claude plugin validate .
claude plugin test .
claude --plugin-dir . # run a session with this working copy
claude-code, claude-code-mod, claude-mod, claude-code-plugin, prompt-cache, cache-ttl, context-window, auto-compact, token-usage, rate-limit, statusline
hooks/register.tsx 210 lines1// Appends "☀️ ctx 29% · 46 min · hit 90% · 7d 66% · ⏳ 16.10. 08:00" to the hint line under the prompt:
2// the weather and the context fill it stands for first, then the cache, then the weekly limit.
3// The engine keeps drawing its own mode pill ("⏵⏵ auto mode on") in front of this tree.
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register, Timer } from 'claude-code'
7
8import type { CacheReading, CacheTtl } from '../types'
9import {
10 DEFAULT_THRESHOLD_PERCENT,
11 FIVE_MINUTES_MS,
12 WEATHER_EMOJI,
13 countdown,
14 hitPercent,
15 msToNextMinute,
16 percentOf,
17 readTranscriptTail,
18 RESET_ICON,
19 formatReset,
20 trimHint,
21 weather,
22 weeklyLimit,
23} from './logic'
24import type { CacheLevel } from './logic'
25
26/** Enough transcript to reach the newest responses even after long tool output. */
27const TRANSCRIPT_TAIL_BYTES = '2000000'
28/** The transcript's rows of a turn land on disk shortly after its Stop hooks run. */
29const TRANSCRIPT_SETTLE_MS = 2000
30
31const lastResponse = atom({ plugin: 'cache-weather', key: 'lastResponse' } as const, null)
32const ttl = atom({ plugin: 'cache-weather', key: 'ttl' } as const, { ms: FIVE_MINUTES_MS, isMeasured: false })
33const context = atom({ plugin: 'cache-weather', key: 'context' } as const, {
34 percent: null,
35 thresholdPercent: DEFAULT_THRESHOLD_PERCENT,
36})
37const weekly = atom({ plugin: 'cache-weather', key: 'weekly' } as const, null)
38/** Bumped each time the minute figure changes, so the line redraws; its value means nothing. */
39const tick = atom({ plugin: 'cache-weather', key: 'tick' } as const, 0)
40
41/** The theme keys the engine draws the hint line and its pills with. */
42const HINT_COLOR = 'inactive'
43const LEVEL_COLOR: Record<CacheLevel, 'success' | 'warning' | 'error' | 'inactive'> = {
44 fresh: 'success',
45 late: 'warning',
46 critical: 'error',
47 expired: 'inactive',
48}
49
50// The module's own: a reload starts it over, and session.start re-arms it.
51let tickTimer: Timer | undefined
52
53/** Redraws the line each time the countdown's minute figure changes, until the cache has expired. */
54async function scheduleTicks($: EngineInterface) {
55 tickTimer?.cancel()
56 tickTimer = undefined
57 const response = await read($, lastResponse)
58 if (response === null) return
59
60 const now = await $.clock.now()
61 const { ms } = await read($, ttl)
62 if (countdown(response.respondedAt, ms, now).level === 'expired') return
63
64 tickTimer = $.clock.after(msToNextMinute(response.respondedAt, now), () => {
65 void update($, tick, n => n + 1).then(() => scheduleTicks($))
66 })
67}
68
69async function setResponse($: EngineInterface, reading: CacheReading | null) {
70 await update($, lastResponse, () => reading)
71 await scheduleTicks($)
72}
73
74/**
75 * Context fill from the status line's figures; the threshold from the session's breakdown when it has one.
76 * Until the live window's first response (right after a compaction) the session reports no fill, so the
77 * figure the compaction left stands.
78 */
79async function refreshContext($: EngineInterface, options: { withThreshold: boolean }) {
80 const usage = await $.session.usage(options.withThreshold ? { breakdown: 'summary' } : undefined)
81 const { tokens, window } = usage.context
82 const threshold = usage.context.breakdown?.autoCompactThreshold
83 const week = weeklyLimit(usage.rateLimits)
84 await update($, weekly, () => week)
85
86 await update($, context, previous => {
87 const percent = tokens === undefined ? previous.percent : percentOf(tokens, window)
88 if (!options.withThreshold) return { ...previous, percent }
89 return { percent, thresholdPercent: threshold === undefined ? DEFAULT_THRESHOLD_PERCENT : percentOf(threshold, window) }
90 })
91}
92
93/** What the transcript says that the live events do not: the cache TTL, and on resume the last response. */
94async function syncFromTranscript($: EngineInterface, path: string, options: { seedResponse: boolean }) {
95 if (path === '') return
96 const tail = await $.process.run(['tail', '-c', TRANSCRIPT_TAIL_BYTES, path]).catch(() => undefined)
97 if (tail === undefined || tail.exitCode !== 0) return
98
99 const facts = readTranscriptTail(tail.stdout)
100 if (facts.ttlMs !== null) {
101 const measured: CacheTtl = { ms: facts.ttlMs, isMeasured: true }
102 await update($, ttl, () => measured)
103 }
104 if (options.seedResponse && facts.lastResponse !== null && (await read($, lastResponse)) === null) {
105 await setResponse($, facts.lastResponse)
106 } else {
107 await scheduleTicks($)
108 }
109}
110
111export const register: Register = on => {
112 on('session.start', async ($, e, next) => {
113 const started = await next(e)
114 await refreshContext($, { withThreshold: true })
115 await scheduleTicks($)
116 return started
117 })
118
119 on('classic.SessionStart', async ($, e, next) => {
120 if (e.source === 'clear') {
121 await setResponse($, null)
122 await update($, context, previous => ({ ...previous, percent: null }))
123 }
124 const result = await next(e)
125 await syncFromTranscript($, e.transcript_path, { seedResponse: e.source === 'resume' || e.source === 'startup' })
126 return result
127 })
128
129 on('classic.Stop', async ($, e, next) => {
130 const result = await next(e)
131 $.clock.after(TRANSCRIPT_SETTLE_MS, () => {
132 void syncFromTranscript($, e.transcript_path, { seedResponse: false })
133 })
134 return result
135 })
136
137 // Another model may have another window, and with it another threshold.
138 on('classic.PostModelSwitch', async ($, e, next) => {
139 const result = await next(e)
140 await refreshContext($, { withThreshold: true })
141 return result
142 })
143
144 // Each main-loop response refreshes the cache: the countdown restarts and the hit rate is this response's.
145 on('turn.step', async function* ($, e, next) {
146 const response = yield* next(e)
147 if (e.agentId === undefined && response.usage !== null) {
148 await setResponse($, { respondedAt: await $.clock.now(), hitPercent: hitPercent(response.usage) })
149 await refreshContext($, { withThreshold: false })
150 }
151 return response
152 })
153
154 on('session.measure', async ($, e, next) => {
155 if (e.changed.includes('rateLimits')) {
156 const week = weeklyLimit(e.rateLimits)
157 await update($, weekly, () => week)
158 }
159 if (e.changed.includes('context')) await refreshContext($, { withThreshold: false })
160 return next(e)
161 })
162
163 on('session.compact', async ($, e, next) => {
164 const result = await next(e)
165 if (e.agentId !== undefined || result.messages === undefined) return result
166
167 // The compacted conversation is a new prefix: nothing of it is cached until the next response.
168 await setResponse($, null)
169 const after = result.tokensAfter
170 const usage = await $.session.usage().catch(() => undefined)
171 if (after !== undefined && usage !== undefined) {
172 await update($, context, previous => ({ ...previous, percent: percentOf(after, usage.context.window) }))
173 }
174 return result
175 })
176
177 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
178 await read($, tick)
179 const [response, cacheTtl, ctx, week] = await Promise.all([
180 read($, lastResponse),
181 read($, ttl),
182 read($, context),
183 read($, weekly),
184 ])
185 const now = await $.clock.now()
186 const { Box, Text } = $.ui.resolve(e)
187
188 const timer = response === null ? null : countdown(response.respondedAt, cacheTtl.ms, now)
189 const minutesText = timer === null ? '–' : timer.level === 'expired' ? 'expired' : `${timer.minutes} min`
190 const hitText = response?.hitPercent == null ? '–' : `${response.hitPercent}%`
191 const ctxText = ctx.percent === null ? '–' : `${ctx.percent}%`
192 const hint = trimHint(e.props.hint)
193
194 return (
195 <Box flexDirection="row">
196 {hint === '' ? null : <Text color={HINT_COLOR}>{hint} · </Text>}
197 <Text>{WEATHER_EMOJI[weather(ctx.percent, ctx.thresholdPercent)]} </Text>
198 <Text color={HINT_COLOR}>ctx {ctxText} · </Text>
199 <Text color={timer === null ? HINT_COLOR : LEVEL_COLOR[timer.level]}>{minutesText}</Text>
200 <Text color={HINT_COLOR}>
201 {' '}
202 · hit {hitText}
203 {week === null ? '' : ` · 7d ${week.percent}%`}
204 {week?.resetsAt == null ? '' : ` · ${RESET_ICON} ${formatReset(week.resetsAt)}`}
205 </Text>
206 </Box>
207 )
208 })
209}
210hooks/logic.ts 155 lines1// Pure rules of the segment: no `$`, so the tests exercise them directly.
2
3import type { CacheReading, WeeklyLimit } from '../types'
4
5export const FIVE_MINUTES_MS = 5 * 60_000
6export const ONE_HOUR_MS = 60 * 60_000
7export const MINUTE_MS = 60_000
8
9/** Used when the session does not report its auto-compact threshold. */
10export const DEFAULT_THRESHOLD_PERCENT = 83
11
12export type Usage = {
13 input_tokens: number
14 cache_read_input_tokens: number
15 cache_creation_input_tokens: number
16}
17
18/** Share of the response's input the cache served, 0–100, or null for a response with no input. */
19export function hitPercent(usage: Usage): number | null {
20 const total = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
21 return total === 0 ? null : Math.round((usage.cache_read_input_tokens / total) * 100)
22}
23
24export type CacheLevel = 'fresh' | 'late' | 'critical' | 'expired'
25
26export type Countdown = {
27 /** Whole minutes left, rounded up, so the last partial minute still reads 1. */
28 minutes: number
29 level: CacheLevel
30}
31
32/** Green while fresh, yellow in the last quarter, red in the last tenth, expired at zero. */
33export function countdown(respondedAt: number, ttlMs: number, now: number): Countdown {
34 const remainingMs = Math.max(0, ttlMs - (now - respondedAt))
35 const fraction = remainingMs / ttlMs
36 const level: CacheLevel =
37 remainingMs === 0 ? 'expired' : fraction <= 0.1 ? 'critical' : fraction <= 0.25 ? 'late' : 'fresh'
38
39 return { minutes: Math.ceil(remainingMs / MINUTE_MS), level }
40}
41
42/** Delay until the countdown's minute figure next changes, counted from the response. */
43export function msToNextMinute(respondedAt: number, now: number): number {
44 const elapsed = Math.max(0, now - respondedAt)
45 return MINUTE_MS - (elapsed % MINUTE_MS)
46}
47
48export type Weather = 'sunny' | 'rainy' | 'stormy'
49
50export const WEATHER_EMOJI: Record<Weather, string> = { sunny: '☀️', rainy: '🌧️', stormy: '⛈️' }
51
52/** Sunny up to half the threshold, rainy up to 80 % of it, stormy above. */
53export function weather(percent: number | null, thresholdPercent: number): Weather {
54 const fill = percent ?? 0
55 if (fill <= thresholdPercent / 2) return 'sunny'
56 if (fill <= thresholdPercent * 0.8) return 'rainy'
57 return 'stormy'
58}
59
60export function percentOf(part: number, whole: number): number {
61 return Math.round((part / whole) * 100)
62}
63
64export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
65
66/** The seven-day window among the session's rate limits, or null off a subscription. */
67export function weeklyLimit(limits: readonly RateLimit[]): WeeklyLimit | null {
68 const week = limits.find(limit => limit.kind === 'seven_day')
69 if (week === undefined) return null
70 const resetsAt = week.resetsAt === undefined ? NaN : Date.parse(week.resetsAt)
71 return { percent: Math.round(week.percentUsed), resetsAt: Number.isNaN(resetsAt) ? null : resetsAt }
72}
73
74/** A reset moment in local time, day, month and hour: "16.10. 08:00". */
75export function formatReset(ms: number): string {
76 const date = new Date(ms)
77 const pad = (n: number) => String(n).padStart(2, '0')
78 return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}. ${pad(date.getHours())}:${pad(date.getMinutes())}`
79}
80
81/**
82 * The hourglass in front of the reset moment, asked for in its text presentation
83 * (U+FE0E), so a terminal that honours it draws it in the line's grey rather than
84 * as a colour emoji.
85 */
86export const RESET_ICON = '\u23F3\uFE0E'
87
88/** The engine's reminder of the mode-cycling key; the mode pill beside it says enough. */
89const CYCLE_HINT = /\(shift\+tab to cycle\)/g
90
91/** The engine's hint text without the mode-cycling reminder, its `·` separators tidied. */
92export function trimHint(hint: string): string {
93 return hint
94 .replace(CYCLE_HINT, '')
95 .split('·')
96 .map(part => part.trim())
97 .filter(part => part !== '')
98 .join(' · ')
99}
100
101export type TranscriptFacts = {
102 /** The TTL of the newest cache write the transcript records, or null when none says. */
103 ttlMs: number | null
104 /** The newest main-loop response, or null when the tail holds none. */
105 lastResponse: CacheReading | null
106}
107
108type TranscriptUsage = Usage & {
109 cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
110}
111
112type TranscriptEntry = {
113 type?: string
114 isSidechain?: boolean
115 timestamp?: string
116 message?: { usage?: TranscriptUsage }
117}
118
119/**
120 * Reads the tail of a session transcript (JSONL, newest last): the cache TTL
121 * of the newest response that wrote to the cache, and the newest response.
122 * The first line of a tail may be cut; unparsable lines are skipped.
123 */
124export function readTranscriptTail(tail: string): TranscriptFacts {
125 let ttlMs: number | null = null
126 let lastResponse: CacheReading | null = null
127 const lines = tail.split('\n')
128
129 for (let i = lines.length - 1; i >= 0 && (ttlMs === null || lastResponse === null); i--) {
130 const entry = parseEntry(lines[i] ?? '')
131 const usage = entry?.message?.usage
132 if (entry?.type !== 'assistant' || entry.isSidechain === true || usage === undefined) continue
133
134 if (lastResponse === null && entry.timestamp !== undefined) {
135 const respondedAt = Date.parse(entry.timestamp)
136 if (!Number.isNaN(respondedAt)) lastResponse = { respondedAt, hitPercent: hitPercent(usage) }
137 }
138 if (ttlMs === null) {
139 if ((usage.cache_creation?.ephemeral_1h_input_tokens ?? 0) > 0) ttlMs = ONE_HOUR_MS
140 else if ((usage.cache_creation?.ephemeral_5m_input_tokens ?? 0) > 0) ttlMs = FIVE_MINUTES_MS
141 }
142 }
143
144 return { ttlMs, lastResponse }
145}
146
147function parseEntry(line: string): TranscriptEntry | undefined {
148 if (!line.startsWith('{')) return undefined
149 try {
150 return JSON.parse(line) as TranscriptEntry
151 } catch {
152 return undefined
153 }
154}
155types/index.d.ts 24 lines1/** The last main-loop API response: when it arrived and how much of its input the cache served. */
2export type CacheReading = { respondedAt: number; hitPercent: number | null }
3
4/** The prompt-cache lifetime the session writes with, and whether the transcript showed it. */
5export type CacheTtl = { ms: number; isMeasured: boolean }
6
7/** The context window's fill and the auto-compact threshold, both as percent of the window. */
8export type ContextReading = { percent: number | null; thresholdPercent: number }
9
10/** The seven-day rate-limit window: how much of it is used, and when it resets (epoch ms). */
11export type WeeklyLimit = { percent: number; resetsAt: number | null }
12
13declare module 'claude-code' {
14 interface PluginState {
15 'cache-weather': {
16 lastResponse: CacheReading | null
17 ttl: CacheTtl
18 context: ContextReading
19 weekly: WeeklyLimit | null
20 tick: number
21 }
22 }
23}
24