SLOPSHOPPER

context-meter

A band above the prompt: context window fill, plan usage limits and session cost, a Recap button, and a per-day cost record read with /spend.

newbandcommandpromptmodeltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
› fix the failing auth test and add an audit log call ⏺ 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 › /spend ⎿ context-meter: No spend recorded yet. Context ██████░░░░░░ 49% 97.4k / 200k 5h 31% $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context ██████░░░░░░ 49% 97.4k / 200k 5h 31% $0.42
README

context-meter

A Claude Code mod that puts your usage above the prompt box, so you stop opening the context popover to check it.

The band with sample values: four pills, a button, and a three-line recap

A drawing of the band with sample values, made from the mod's own drawing code.

A 19-second walkthrough is in docs/demo.mp4. It is an animated mock of the desktop app, drawn in video/demo.html and rendered by video/render.mjs, not a screen recording.

What it shows

One row above the prompt:

  • Context: how full the context window is, as a bar, a percentage and tokens over the window.
  • 5h and 7d: your plan's five-hour and seven-day limits, as a percentage and the time until each resets.
  • Cost: what the session has cost so far at API list prices. On a subscription this is not a charge.
  • Recap: a button. Press it and the mod asks the model three things about the current session: where the work stands, what is waiting on you, and what comes next. The answer appears under the row and clears when you send your next message.

A percentage turns amber at 60% and red at 85%.

It also keeps a quiet record of cost per day. Type /spend to list the total for each of the last 14 days, summed across every session that has the mod loaded.

Install

The Claude Code documentation asks for 2.1.287 or later for mods.

To try it in one session:

git clone https://github.com/justerification/claude-code-context-meter
claude --plugin-dir ./claude-code-context-meter

To load it in every session, including the desktop app, add the folder to the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/path/to/claude-code-context-meter",
    "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
  }
}

The second line makes the desktop app reload the mod when you edit its files. Both are read when a session starts.

What it costs to run

The pills cost nothing: they read figures Claude Code already has. Each press of Recap is one extra model call over the session's transcript, mostly served from the prompt cache. Nothing is spent until you press it.

Limits

  • Claude Code reports only the five-hour and seven-day windows to mods. A per-model weekly limit and cloud session credits cannot be shown.
  • The desktop app draws mod buttons itself, so the Recap button cannot be styled to match the pills.
  • The daily cost record covers Claude Code sessions on one machine. It is kept in ~/.claude/mods-data/context-meter/cost/, one small file per session per day.
  • The pills are drawn as one SVG in the desktop app. The terminal gets a plain text row.
  • The mods API is early access and changes between releases. This was built and used on 2.1.286 in the desktop app, on macOS.

Changing it

Everything is in hooks/register.tsx. The colours are in HUES, the thresholds in tone, the pills in pillsOf, and the Recap question in WHERE_PROMPT. Check an edit with:

claude plugin validate .

Not affiliated

This is an independent project. It is not affiliated with or endorsed by Anthropic; Claude and Claude Code are named only to say what the mod works with.

Licence

MIT.

Source 2 files
hooks/register.tsx 362 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Reading, Where } from '../types'
5
6const reading = atom({ plugin: 'context-meter', key: 'reading' } as const, null)
7
8// Asked only when the button is pressed: each ask is a model call over the session's transcript.
9const IDLE: Where = { status: 'idle', lines: [] }
10const where = atom({ plugin: 'context-meter', key: 'where' } as const, IDLE)
11const WHERE_PROMPT = [
12  'Do not continue the work. Answer in exactly three short lines of plain text, no markdown, each under 110 characters:',
13  'Now: where the work in this session stands',
14  'Waiting on you: what needs the user to decide, check or do, or "nothing"',
15  'Next: the next step',
16].join('\n')
17
18const ask = async ($: EngineInterface) => {
19  await update($, where, () => ({ status: 'asking', lines: [] }))
20  const reply = await $.model.fork({ prompt: WHERE_PROMPT })
21  const lines = reply.isAnswered
22    ? reply.text.split('\n').map(line => line.trim()).filter(line => line !== '').slice(0, 3)
23    : [`No summary: ${reply.reason}`]
24  await update($, where, () => ({ status: 'ready', lines }))
25}
26
27// Each session keeps its own file under the day's folder; a day's total is the sum of that folder.
28
29const dayOf = (ms: number) => {
30  const d = new Date(ms)
31  const two = (n: number) => String(n).padStart(2, '0')
32
33  return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`
34}
35
36const ledger = async ($: EngineInterface) => `${(await $.env.get('HOME')) ?? ''}/.claude/mods-data/context-meter/cost`
37
38// A session's entry for one day: its running cost when the day began for it, and its running cost now.
39// `offset` carries what was spent before the session's own ledger started over (a /clear).
40type Entry = { start: number; now: number; offset: number }
41
42const entryIn = async ($: EngineInterface, path: string): Promise<Entry | undefined> => {
43  const text = await $.fs.read(path).catch(() => '')
44
45  try {
46    const entry = JSON.parse(text)
47
48    return typeof entry === 'object' && entry !== null && typeof entry.now === 'number' ? entry : undefined
49  } catch {
50    return undefined
51  }
52}
53
54const totalOf = async ($: EngineInterface, day: string) => {
55  const dir = `${await ledger($)}/${day}`
56  const entries = await $.fs.list(dir).catch(() => [])
57  let total = 0
58
59  for (const { name } of entries) {
60    const entry = await entryIn($, `${dir}/${name}`)
61    total += entry === undefined ? 0 : entry.now - entry.start
62  }
63
64  return total
65}
66
67// Writes the session's running cost into today's entry. The write states a total, never adds a
68// difference, so a reload or a restart that repeats it changes nothing.
69const count = async ($: EngineInterface, usd: number | undefined) => {
70  if (usd === undefined) {
71    return
72  }
73
74  const at = await $.clock.now()
75  const id = await $.session.id()
76  const root = await ledger($)
77  const file = `${root}/${dayOf(at)}/${id}.json`
78  let entry = await entryIn($, file)
79
80  if (entry === undefined) {
81    const before = await entryIn($, `${root}/${dayOf(at - 86_400_000)}/${id}.json`)
82    const { startedAt } = await $.session.usage()
83    // Carried over from yesterday; else a session begun today starts the day at zero; else its
84    // earlier days are unknown and the day starts from what it has spent so far.
85    entry =
86      before !== undefined
87        ? { start: before.now, now: before.now, offset: before.offset }
88        : dayOf(startedAt) === dayOf(at)
89          ? { start: 0, now: 0, offset: 0 }
90          : { start: usd, now: usd, offset: 0 }
91  }
92
93  // A running cost below the last one seen means the session's ledger started over.
94  const offset = entry.offset + usd < entry.now ? entry.now : entry.offset
95  await $.fs.write(file, JSON.stringify({ start: entry.start, now: offset + usd, offset }))
96}
97
98const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'Spend' }
99
100const compact = (n: number) =>
101  n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : n >= 100_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
102
103const tone = (percent: number) => (percent >= 85 ? 'error' : percent >= 60 ? 'warning' : 'success')
104
105const label = (kind: string) => {
106  if (LIMIT_LABELS[kind] !== undefined) {
107    return LIMIT_LABELS[kind]
108  }
109
110  const model = kind.replace(/^(seven_day|five_hour)_?/, '').replace(/_/g, ' ')
111  const window = kind.startsWith('five_hour') ? '5h' : kind.startsWith('seven_day') ? '7d' : ''
112
113  return `${window} ${model.charAt(0).toUpperCase()}${model.slice(1)}`.trim()
114}
115
116const hueOf = (kind: string) => (kind.startsWith('five_hour') ? 'blue' : kind.startsWith('seven_day') ? 'purple' : 'grey')
117
118const iconOf = (kind: string) => (kind.startsWith('five_hour') ? 'clock' : kind.startsWith('seven_day') ? 'calendar' : 'coin')
119
120const until = (resetsAt: string | undefined, now: number) => {
121  const ms = resetsAt === undefined ? NaN : Date.parse(resetsAt) - now
122
123  if (!(ms > 0)) {
124    return undefined
125  }
126
127  const minutes = Math.round(ms / 60_000)
128  const days = Math.floor(minutes / 1440)
129  const hours = Math.floor((minutes % 1440) / 60)
130
131  return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
132}
133
134// The desktop band: one SVG of pills, each a label, a bar, a percentage and a detail.
135const CHAR = 7.25
136const BAR = 48
137const PILL_H = 24
138// One colour per pill, mid-tones that read on a light and a dark window.
139const HUES = { green: '#2e8b4d', blue: '#2f6fd0', purple: '#7a54c7', grey: '#7b7f87', yellow: '#9a7300', orange: '#c2571a' }
140const TONES = { success: '#3f9d5c', warning: '#c98a12', error: '#d9483b' }
141
142// 16px line icons, drawn in the pill's own colour.
143const ICONS = {
144  gauge: '<path d="M3 11.5a5 5 0 1 1 10 0"/><path d="M8 11.5l2.4-3.6"/>',
145  clock: '<circle cx="8" cy="8" r="5.5"/><path d="M8 5v3.2l2 1.3"/>',
146  calendar: '<rect x="2.5" y="3.5" width="11" height="10" rx="2"/><path d="M2.5 7h11M5.5 2v3M10.5 2v3"/>',
147  coin: '<circle cx="8" cy="8" r="5.5"/><path d="M9.7 6.4c-.5-.7-3.2-.9-3.2.5 0 1.5 3.2.8 3.2 2.3 0 1.3-2.8 1.3-3.4.5M8 4.6v6.8"/>',
148}
149
150type Pill = { name: string; icon: keyof typeof ICONS; hue: keyof typeof HUES; percent?: number; detail?: string; hasBar?: boolean }
151
152const drawPills = (pills: Pill[]) => {
153  let x = 0
154  let body = ''
155
156  for (const pill of pills) {
157    const start = x
158    const hue = HUES[pill.hue]
159    let inner = ''
160    let cx = start + 12
161    const text = (s: string, fill: string, weight = 400, opacity = 1) => {
162      inner += `<text x="${cx}" y="16.5" fill="${fill}" fill-opacity="${opacity}" font-weight="${weight}">${s}</text>`
163      cx += s.length * CHAR + 8
164    }
165
166    inner += `<g transform="translate(${cx} 4)" fill="none" stroke="${hue}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">${ICONS[pill.icon]}</g>`
167    cx += 16 + 6
168
169    // A pill whose icon says what it is carries no name.
170    if (pill.name !== '') {
171      text(pill.name, hue, 600)
172    }
173
174    if (pill.percent !== undefined) {
175      // The pill's own colour until the figure needs attention, then amber, then red.
176      const level = tone(pill.percent)
177      const fill = level === 'success' ? hue : TONES[level]
178      const width = Math.max(pill.percent > 0 ? 3 : 0, (Math.min(100, pill.percent) / 100) * BAR)
179
180      if (pill.hasBar !== false) {
181        inner += `<rect x="${cx}" y="9.5" width="${BAR}" height="5" rx="2.5" fill="${hue}" fill-opacity="0.22"/>`
182        inner += `<rect x="${cx}" y="9.5" width="${width}" height="5" rx="2.5" fill="${fill}"/>`
183        cx += BAR + 8
184      }
185
186      text(`${Math.round(pill.percent)}%`, fill, 700)
187    }
188
189    if (pill.detail !== undefined) {
190      if (pill.percent !== undefined) {
191        inner += `<rect x="${cx}" y="6" width="1" height="12" fill="${hue}" fill-opacity="0.35"/>`
192        cx += 9
193        text(pill.detail, hue, 400, 0.8)
194      } else {
195        text(pill.detail, hue, 700)
196      }
197    }
198
199    const width = cx - 8 + 12 - start
200    body += `<rect x="${start}" y="0" width="${width}" height="${PILL_H}" rx="12" fill="${hue}" fill-opacity="0.13"/>${inner}`
201    x = start + width + 8
202  }
203
204  const total = Math.max(1, x - 8)
205
206  return {
207    width: total,
208    source: `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="${PILL_H}" viewBox="0 0 ${total} ${PILL_H}" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="12">${body}</svg>`,
209  }
210}
211
212const pillsOf = (now: Reading, at: number): Pill[] => [
213  {
214    name: 'Context',
215    icon: 'gauge',
216    hue: 'green',
217    percent: now.percent ?? 0,
218    detail: now.tokens === undefined ? 'no reading yet' : `${compact(now.tokens)} / ${compact(now.window)}`,
219  },
220  ...now.limits.map((limit: Limit) => ({
221    name: label(limit.kind),
222    icon: iconOf(limit.kind),
223    hue: hueOf(limit.kind),
224    // The plan windows are glance figures: percentage and reset only.
225    hasBar: false,
226    percent: limit.percentUsed,
227    detail: until(limit.resetsAt, at),
228  })),
229  ...(now.usd === undefined ? [] : [{ name: '', icon: 'coin' as const, hue: 'yellow' as const, detail: `$${now.usd.toFixed(2)}` }]),
230]
231
232// The summary's three parts, each drawn as a chip in the pills' style ahead of its line.
233const PARTS = [
234  { match: /^now\b/i, name: 'Now', hue: 'blue', icon: '<circle cx="8" cy="8" r="5.5"/><circle cx="8" cy="8" r="1.4"/>' },
235  { match: /^waiting\b/i, name: 'Waiting on you', hue: 'orange', icon: '<path d="M4.5 2.5h7M4.5 13.5h7M5.2 2.5c0 3.2 5.6 3.4 5.6 5.5s-5.6 2.3-5.6 5.5M10.8 2.5c0 3.2-5.6 3.4-5.6 5.5s5.6 2.3 5.6 5.5"/>' },
236  { match: /^next\b/i, name: 'Next', hue: 'green', icon: '<path d="M3 8h9.5M9 4.5L12.5 8 9 11.5"/>' },
237] as const
238const CHIP_W = 150
239
240const drawChip = (name: string, hue: string, icon: string) =>
241  `<svg xmlns="http://www.w3.org/2000/svg" width="${CHIP_W}" height="${PILL_H}" viewBox="0 0 ${CHIP_W} ${PILL_H}" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="12">` +
242  `<rect width="${CHIP_W}" height="${PILL_H}" rx="12" fill="${hue}" fill-opacity="0.13"/>` +
243  `<g transform="translate(12 4)" fill="none" stroke="${hue}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">${icon}</g>` +
244  `<text x="34" y="16.5" fill="${hue}" font-weight="700">${name}</text></svg>`
245
246// Splits "Now: the work stands here" into its part and its text; an unlabelled line gets no chip.
247const partOf = (line: string) => {
248  const colon = line.indexOf(':')
249  const part = colon === -1 ? undefined : PARTS.find(p => p.match.test(line.slice(0, colon).trim()))
250
251  return part === undefined ? { text: line } : { part, text: line.slice(colon + 1).trim() }
252}
253
254export const register: Register = on => {
255  on('session.start', async ($, e, next) => {
256    const { context, rateLimits, cost } = await $.session.usage()
257    const first: Reading = { ...context, limits: rateLimits, usd: cost?.usd }
258    await update($, reading, () => first)
259    await count($, cost?.usd)
260    await $.command.register({ name: 'spend', description: 'Show the cost of Claude Code sessions per day, last 14 days' })
261    // The countdowns read the clock while drawing, so they need a redraw between turns.
262    $.clock.every(20_000, () => $.ui.invalidate('ui.render'))
263
264    return next(e)
265  })
266
267  // A summary describes the turn it was asked in; the next prompt makes it stale.
268  on('prompt.submit', async ($, e, next) => {
269    await update($, where, () => IDLE)
270
271    return next(e)
272  })
273
274  on('session.measure', async ($, e, next) => {
275    const latest: Reading = { ...e.context, limits: e.rateLimits, usd: e.cost?.usd }
276    await update($, reading, () => latest)
277    await count($, e.cost?.usd)
278
279    return next(e)
280  })
281
282  on('command.run', { command: 'spend' }, async $ => {
283    const days = (await $.fs.list(await ledger($)).catch(() => [])).map(entry => entry.name).sort().slice(-14)
284    const rows = []
285
286    for (const day of days) {
287      rows.push(`${day}  $${(await totalOf($, day)).toFixed(2)}`)
288    }
289
290    return { text: rows.length === 0 ? 'No spend recorded yet.' : rows.join('\n') }
291  })
292
293  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
294    const now = await read($, reading)
295
296    if (e.props.hasSurvey || now === null) {
297      return next(e)
298    }
299
300    const pills = pillsOf(now, await $.clock.now())
301
302    if (e.surface === 'desktop') {
303      const { Box, Button, Svg, Text } = $.ui.resolve(e)
304      const asked = (await read($, where)) ?? IDLE
305      const { source, width } = drawPills(pills)
306      const alt = pills.map(p => [p.name || 'Cost', p.percent === undefined ? '' : `${Math.round(p.percent)}%`, p.detail ?? ''].join(' ')).join(', ')
307
308      return (
309        <Box flexDirection="column">
310          <Box alignItems="center" gap={1} flexWrap="wrap">
311            <Svg source={source} alt={alt} width={width} height={PILL_H} />
312            <Box flexShrink={0}>
313              {asked.status === 'ready' ? (
314                <Button key="hide" label="Hide" onPress={() => update($, where, () => IDLE)} />
315              ) : (
316                <Button key="where" label={asked.status === 'asking' ? 'Asking…' : 'Recap'} onPress={() => ask($)} />
317              )}
318            </Box>
319          </Box>
320          {asked.lines.map((line, i) => {
321            const { part, text } = partOf(line)
322
323            return (
324              <Box key={`where-${i}`} alignItems="center" gap={1} marginTop={1}>
325                {part !== undefined && (
326                  <Svg source={drawChip(part.name, HUES[part.hue], part.icon)} alt={part.name} width={CHIP_W} height={PILL_H} />
327                )}
328                <Text color={part === undefined ? undefined : HUES[part.hue]}>{text}</Text>
329              </Box>
330            )
331          })}
332        </Box>
333      )
334    }
335
336    const { Box, Text } = $.ui.resolve(e)
337    const CELLS = 12
338
339    return (
340      <Box gap={3}>
341        {pills.map(pill => {
342          const filled = Math.min(CELLS, Math.round(((pill.percent ?? 0) / 100) * CELLS))
343
344          return (
345            <Text key={pill.name}>
346              <Text dimColor>{pill.name} </Text>
347              {pill.percent !== undefined && (
348                <Text>
349                  {pill.hasBar !== false && <Text color={tone(pill.percent)}>{'█'.repeat(filled)}</Text>}
350                  {pill.hasBar !== false && <Text dimColor>{'░'.repeat(CELLS - filled)} </Text>}
351                  <Text color={tone(pill.percent)}>{Math.round(pill.percent)}% </Text>
352                </Text>
353              )}
354              <Text dimColor>{pill.detail ?? ''}</Text>
355            </Text>
356          )
357        })}
358      </Box>
359    )
360  })
361}
362
types/index.d.ts 13 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Reading = { tokens?: number; window: number; percent?: number; limits: Limit[]; usd?: number }
4
5// The on-demand summary of where the session stands.
6export type Where = { status: 'idle' | 'asking' | 'ready'; lines: string[] }
7
8declare module 'claude-code' {
9  interface PluginState {
10    'context-meter': { reading: Reading | null; where: Where }
11  }
12}
13