SLOPSHOPPER

context-meter

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

newpanebandtoaststatusprompt
★ 3v0.4.1MITupdated 2026-10-08noash-xrc/claude-tools/context-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
│ ┃ cold-cache ✕ › fix the failing auth test and add an audit log call │ ┃ The cache is warm again. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ⟨Claude Code's own drawing⟩ ▰▰▰ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▰▰▰
Pane · cold-cache
The cache is warm again.
README

Context meter

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
  • The first row shows how full the context window is, then how much of each rate-limit window you have used. Each figure turns yellow at 60% and red at 85%.
  • The second row shows the session's cost, the prompt cache, the model and its effort level. A /model switch shows within a second.
  • The cache figure is the share of the last request's input that came from the cache, then the minutes until it expires. It turns yellow under 10 minutes and shows 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.
  • After a compaction the fill is an estimate until the next response, marked with ~.

It costs no tokens: every figure comes from what Claude Code already tracks.

Install

/plugin install context-meter --marketplace noash-xrc/claude-tools

Answer y to add the marketplace, then pick a scope.

Update

claude plugin marketplace update noash-tools
claude plugin update context-meter@noash-tools

Restart Claude Code to load the new version.

License

MIT

Source 2 files
hooks/register.tsx 282 lines
1import { 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}
282
types/index.d.ts 37 lines
1export 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