Band above the prompt with model, effort, context fill, rate-limit windows and session cost, cache state

A Claude Code mod that draws two rows at the right of the band above the prompt:
▰▰▰▱▱▱▱▱ 42% 84k/200k │ 5h 24% 7d 7%
$1.24 │ cache 97% 52m │ ✻ Opus 5.5 · high
/model switch shows within a second.cache cold once expired. Your next prompt after that pays to cache the whole conversation again, so that's a cheap moment to /compact. The countdown assumes the 1-hour cache that Claude Code uses on subscriptions. With 120k tokens or more in context on a cold cache, a "Cold cache" pane opens. That happens when you resume a session whose last reply is over an hour old, or when you send a prompt after the cache expired. The pane estimates, at list price, what sending as is costs (a 1h cache write, twice the input price) next to /compact first (one plain read). Press c to compact or x to close it. On a terminal under 144 columns a resume can't seat the pane, so a toast gives the same figures. It shows once until the cache is warm again. Any compaction closes it, including the one idle-compact runs about a minute after a cold resume.~.It costs no tokens: every figure comes from what Claude Code already tracks.
/plugin install context-meter --marketplace noash-xrc/claude-tools
Answer y to add the marketplace, then pick a scope.
claude plugin marketplace update noash-tools
claude plugin update context-meter@noash-tools
Restart Claude Code to load the new version.
MIT
hooks/register.tsx 282 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage, Timer, TurnStepInput } from 'claude-code'
3import type { ContextMeterCold, ContextMeterUsage } from '../types/index'
4
5const usageAtom = atom({ plugin: 'context-meter', key: 'usage' } as const, null)
6const modelAtom = atom({ plugin: 'context-meter', key: 'model' } as const, null)
7const cacheAtom = atom({ plugin: 'context-meter', key: 'cache' } as const, null)
8const coldAtom = atom({ plugin: 'context-meter', key: 'cold' } as const, null)
9const COLD_PANE = 'cold-cache'
10// auto-effort rewrites the effort after this mod's turn.step may have seen it, so its pick wins when set
11const autoEffortAtom = atom({ plugin: 'auto-effort', key: 'effort' } as const, null)
12
13// ponytail: assumes the 1-hour TTL Claude Code uses on subscriptions; API-key sessions on 5 minutes read too warm
14const CACHE_TTL_MS = 60 * 60 * 1000
15// past this, a prompt sent on a cold cache rewrites enough to be worth a warning
16const COLD_WARN_TOKENS = 120_000
17
18// List input price in $/MTok, first prefix match wins. A 1h cache write costs twice that, a plain read once.
19// ponytail: hardcoded list prices, update when they change; managed modelPricing is ignored
20const INPUT_PRICE: [RegExp, number][] = [
21 [/fable|mythos/, 10],
22 [/opus-5-5/, 4],
23 [/opus/, 5],
24 [/sonnet-5/, 2],
25 [/sonnet/, 3],
26 [/haiku-5/, 0.1],
27 [/haiku/, 1],
28]
29const inputPrice = (id: string) => INPUT_PRICE.find(([re]) => re.test(id))?.[1] ?? 4
30
31const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
32const BAR_CELLS = 8
33
34const k = (n: number) => (n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
35const heat = (percent: number) => (percent >= 85 ? 'error' : percent >= 60 ? 'warning' : 'success')
36
37// claude-opus-5-5 -> Opus 5.5, claude-haiku-4-5-20251001 -> Haiku 4.5; anything else as given
38const prettyModel = (id: string) => {
39 const [, family, major, minor] = /^(?:claude-)?([a-z]+)-(\d+)(?:-(\d{1,2}))?/i.exec(id) ?? []
40 if (!family) return id
41 return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}${minor ? `.${minor}` : ''}`
42}
43
44const model = (id: string, effort?: TurnStepInput['effort'] | unknown) => ({
45 name: prettyModel(id),
46 ...(effort === undefined || effort === null ? {} : { effort: String(effort) }),
47})
48
49const toUsage = ({ context, rateLimits, cost }: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>, approx = false): ContextMeterUsage => ({
50 tokens: context.tokens,
51 window: context.window,
52 percent: context.percent,
53 approx,
54 limits: rateLimits.map(r => ({ label: LIMIT_LABEL[r.kind] ?? r.kind, percent: r.percentUsed })),
55 usd: cost?.usd,
56})
57
58// After a compaction the last response's token count is stale until the next one,
59// so the fill comes from the engine's local estimate (no API call) and shows a "~".
60const refresh = async ($: EngineInterface, estimate = false) => {
61 const usage = await $.session.usage(estimate ? { breakdown: 'summary' } : undefined)
62 const total = usage.context.breakdown?.totalTokens
63 if (total === undefined) return update($, usageAtom, () => toUsage(usage))
64 const context = { window: usage.context.window, tokens: total, percent: Math.round((total / usage.context.window) * 100) }
65 return update($, usageAtom, () => toUsage({ ...usage, context }, true))
66}
67
68// Effort reaches turn.step only; until the first model call, read it from settings.
69const effortFromSettings = async ($: EngineInterface, id: string) => {
70 const settings = await $.settings.read()
71 const perModel = settings.modelSettings as Record<string, { effortLevel?: string }> | undefined
72 return perModel?.[id]?.effortLevel ?? (settings.effortLevel as string | undefined)
73}
74
75let seen = ''
76let watch: Timer | undefined
77// when the last main-thread request finished; each request refreshes the cache's TTL
78let cachedAt: number | undefined
79let warnedCold = false
80// the session the cache age belongs to: a resume (from the picker too) swaps it without a session.start
81let knownFor: string | undefined
82
83// A resumed session has no request in this process yet, so its last reply comes from the transcript.
84// ponytail: same lookup as idle-compact's; assumes ~/.claude/projects/<cwd with - for each symbol>/<id>.jsonl
85const lastReplyAt = async ($: EngineInterface, id: string) => {
86 const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))}/.claude`
87 const folder = (await $.session.cwd()).replace(/[^a-zA-Z0-9]/g, '-')
88 const lines = (await $.fs.read(`${home}/projects/${folder}/${id}.jsonl`)).split('\n')
89 const last = lines.findLast(line => line.includes('"type":"assistant"'))
90 const at = last === undefined ? NaN : Date.parse((JSON.parse(last) as { timestamp?: string }).timestamp ?? '')
91 return Number.isNaN(at) ? undefined : at
92}
93
94// Opens the cold-cache pane once per cold spell when the next prompt would rewrite a large context.
95// Opened unasked (on resume) the pane needs a wide terminal, so a toast says the same below that.
96const checkCold = async ($: EngineInterface) => {
97 if (warnedCold || cachedAt === undefined) return
98 const idleMs = (await $.clock.now()) - cachedAt
99 const tokens = (await read($, usageAtom))?.tokens ?? 0
100 if (idleMs < CACHE_TTL_MS || tokens < COLD_WARN_TOKENS) return
101 warnedCold = true
102 const id = await $.session.model()
103 const price = inputPrice(id)
104 const cold: ContextMeterCold = {
105 tokens,
106 idleMin: Math.round(idleMs / 60_000),
107 model: prettyModel(id),
108 price,
109 rewriteUsd: (tokens * price * 2) / 1e6,
110 compactUsd: (tokens * price) / 1e6,
111 }
112 await update($, coldAtom, () => cold)
113 const opened = await $.ui.open({ id: COLD_PANE, title: 'Cold cache' }).catch(() => undefined)
114 if (!opened?.isPlaced) {
115 $.ui.toast(`Cold cache, ${k(tokens)} context: the next prompt rewrites it for about $${cold.rewriteUsd.toFixed(2)}. /compact first costs about $${cold.compactUsd.toFixed(2)}.`, { timeoutMs: 10_000 })
116 }
117}
118
119// Reads the transcript once per session id, and only when this process has no request of its own for it.
120const syncSession = async ($: EngineInterface) => {
121 const id = await $.session.id()
122 if (id === knownFor) return
123 knownFor = id
124 cachedAt = await lastReplyAt($, id).catch(() => undefined)
125 warnedCold = false
126}
127
128const idle = (min: number) => (min >= 120 ? `${Math.floor(min / 60)}h ${min % 60}m` : `${min}m`)
129
130const cacheLeft = async ($: EngineInterface) => {
131 if (cachedAt === undefined) return
132 const left = Math.max(0, Math.ceil((cachedAt + CACHE_TTL_MS - (await $.clock.now())) / 60_000))
133 await update($, cacheAtom, prev => (prev === null || prev.left === left ? prev : { ...prev, left }))
134}
135
136export const register: Register = on => {
137 on('session.start', async ($, e, next) => {
138 const started = await next(e)
139 // clears the status line left by the first version of this mod
140 $.ui.status(undefined)
141 const id = await $.session.model()
142 const effort = await effortFromSettings($, id)
143 await update($, modelAtom, prev => prev ?? model(id, effort))
144 // /model fires no event, so poll; turn.step still supplies the effort of live calls
145 seen = id
146 watch?.cancel()
147 watch = $.clock.every(1000, async () => {
148 // a resumed session has no response of its own yet, and its first read can fail while it loads:
149 // keep trying, from the local estimate, until the band has a fill
150 if ((await read($, usageAtom))?.tokens === undefined) await refresh($, true).catch(() => undefined)
151 await syncSession($)
152 await cacheLeft($)
153 await checkCold($)
154 const now = await $.session.model()
155 if (now === seen) return
156 seen = now
157 await update($, modelAtom, () => model(now, undefined))
158 const level = await effortFromSettings($, now)
159 if (level && seen === now) await update($, modelAtom, () => model(now, level))
160 })
161 await refresh($).catch(() => undefined)
162 return started
163 })
164
165 // The prompt goes out anyway; the pane only says what it will cost.
166 on('prompt.submit', async ($, e, next) => {
167 if (e.turnId === undefined && !e.text.startsWith('/')) await syncSession($).then(() => checkCold($))
168 return next(e)
169 }).catch(($, e, next) => next(e))
170
171 on('session.measure', async ($, e, next) => {
172 await update($, usageAtom, () => toUsage(e))
173 return next(e)
174 })
175
176 // Every model call of the main loop: picks up /model and effort changes, and the
177 // fill after each tool round instead of only at the end of the turn.
178 on('turn.step', async function* ($, e, next) {
179 if (e.agentId !== undefined) return yield* next(e)
180 await update($, modelAtom, () => model(e.model, e.effort))
181 const result = yield* next(e)
182 $.clock.after(250, () => void refresh($))
183 const u = result.usage
184 const total = u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
185 if (u && total > 0) {
186 cachedAt = await $.clock.now()
187 knownFor = await $.session.id()
188 warnedCold = false
189 await update($, coldAtom, () => null)
190 await update($, cacheAtom, () => ({ hit: Math.round((u.cache_read_input_tokens / total) * 100), left: CACHE_TTL_MS / 60_000 }))
191 }
192 return result
193 })
194
195 on('session.compact', async ($, e, next) => {
196 const result = await next(e)
197 if (e.agentId !== undefined || 'skip' in result) return result
198 $.clock.after(1000, () => void refresh($, true))
199 // whoever compacted (idle-compact, /compact, the pane's button), the pane's choice is made
200 await update($, coldAtom, () => null)
201 await $.ui.close({ id: COLD_PANE }).catch(() => undefined)
202 return result
203 })
204
205 on('ui.render', { component: 'Pane', requestId: COLD_PANE }, async ($, e) => {
206 const { Box, Text, Button } = $.ui.resolve(e)
207 const c = await read($, coldAtom)
208 if (c === null) return <Text dimColor>The cache is warm again.</Text>
209 const row = (label: string, value: string, color?: string) => (
210 <Box key={label}>
211 <Text dimColor>{label.padEnd(16)}</Text>
212 <Text color={color} bold={color !== undefined}>{value}</Text>
213 </Box>
214 )
215 return (
216 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
217 <Text color="warning" bold>❄ The prompt cache has expired</Text>
218 <Text dimColor>Idle {idle(c.idleMin)}, past the 60m cache lifetime.</Text>
219 <Box flexDirection="column" marginY={1}>
220 {row('Context', `${k(c.tokens)} tokens`)}
221 {row('Model', `${c.model} · $${c.price}/MTok in`)}
222 {row('Send as is', `~$${c.rewriteUsd.toFixed(2)}`, 'error')}
223 {row('/compact first', `~$${c.compactUsd.toFixed(2)} + a small rewrite`, 'success')}
224 {row('/clear', '$0', 'success')}
225 </Box>
226 <Text dimColor italic>List prices; a subscription bills usage limits, not dollars.</Text>
227 <Box marginTop={1}>
228 <Button hotkey="c" variant="primary" onPress={async () => {
229 await $.ui.close({ id: COLD_PANE })
230 await $.session.compact({}).catch(() => $.ui.toast('Compaction failed; run /compact.'))
231 }}>Compact now</Button>
232 <Text> </Text>
233 <Button hotkey="x" onPress={() => $.ui.close({ id: COLD_PANE })}>Send as is</Button>
234 </Box>
235 </Box>
236 )
237 })
238
239 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
240 const [m, u, c, auto] = await Promise.all([read($, modelAtom), read($, usageAtom), read($, cacheAtom), read($, autoEffortAtom)])
241 if (u === null || e.props.hasSurvey) return next(e)
242
243 const { Box, Text } = $.ui.resolve(e)
244 const filled = u.percent === undefined ? 0 : Math.min(BAR_CELLS, Math.round((u.percent * BAR_CELLS) / 100))
245 const pct = u.percent === undefined ? '' : ` ${`${u.approx ? '~' : ''}${u.percent}%`.padStart(3)}`
246 const fill = ` ${u.tokens === undefined ? '-' : k(u.tokens)}/${k(u.window)}`
247 // the engine refuses its own node under a sized Box, so nothing above `inner` sets a width
248 const inner = await next(e)
249
250 return (
251 <Box>
252 {inner}
253 <Box key="meter" flexGrow={1} flexShrink={0} flexDirection="column" alignItems="flex-end">
254 <Box>
255 <Text color={u.percent === undefined ? 'inactive' : heat(u.percent)}>{'▰'.repeat(filled)}</Text>
256 <Text dimColor>{'▱'.repeat(BAR_CELLS - filled)}</Text>
257 <Text color={u.percent === undefined ? 'inactive' : heat(u.percent)} bold>{pct}</Text>
258 <Text dimColor>{fill}</Text>
259 {u.limits.map((l, i) => (
260 <Text key={l.label}>
261 <Text dimColor>{i === 0 ? ' │ ' : ' '}{l.label} </Text>
262 <Text color={heat(l.percent)}>{`${Math.round(l.percent)}%`.padStart(3)}</Text>
263 </Text>
264 ))}
265 </Box>
266 {(m || c || u.usd !== undefined) && (
267 <Box>
268 {u.usd !== undefined && <Text dimColor>${u.usd.toFixed(2)}{m || c ? ' │ ' : ''}</Text>}
269 {c && (c.left === 0
270 ? <Text color="error">cache cold</Text>
271 : <Text color={c.left <= 10 ? 'warning' : 'success'}>cache {c.hit}% {c.left}m</Text>)}
272 {c && m && <Text dimColor> │ </Text>}
273 {m && <Text color="claude" bold>✻ {m.name}</Text>}
274 {m && (auto ?? m.effort) && <Text dimColor italic> · {auto ?? m.effort}</Text>}
275 </Box>
276 )}
277 </Box>
278 </Box>
279 )
280 })
281}
282types/index.d.ts 37 lines1export type ContextMeterUsage = {
2 tokens?: number
3 window: number
4 percent?: number
5 approx?: boolean
6 limits: { label: string; percent: number }[]
7 usd?: number
8} | null
9
10export type ContextMeterModel = { name: string; effort?: string } | null
11
12// hit: share of the last main request's input read from the cache; left: whole minutes until it expires
13export type ContextMeterCache = { hit: number; left: number } | null
14
15// the cold-cache pane: what the next prompt would rewrite and what it would cost at list price
16export type ContextMeterCold = {
17 tokens: number
18 idleMin: number
19 model: string
20 price: number
21 rewriteUsd: number
22 compactUsd: number
23} | null
24
25declare module 'claude-code' {
26 interface PluginState {
27 'context-meter': { usage: ContextMeterUsage; model: ContextMeterModel; cache: ContextMeterCache; cold: ContextMeterCold }
28 }
29}
30
31// auto-effort's own declaration, repeated so the band can read the effort it picked
32declare module 'claude-code' {
33 interface PluginState {
34 'auto-effort': { effort: string | null }
35 }
36}
37