SLOPSHOPPER

context-meter

Always-on context window meter above the prompt, with usage limits (terminal and desktop app)

newpanebandguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ No breakdown yet — it arrives with the first │ ┃ response. ⏺ 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 │ │ › /context-details │ ⎿ context-meter: Context details opened. │ │ Context ████████████████████░░░░░░░░░░░░░░░░░░░░ 49% · 97k / 200k Details 5h 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context ████████████████████░░░░░░░░░░░░░░░░░░░░ 49% · 97k / 200k Details 5h 31%
Pane · Context
No breakdown yet — it arrives with the first response.
README

context-meter

A Claude Code mod that keeps your context window and usage limits in view, right above the prompt — in the terminal and in the desktop app.

Context ██▓▓▓▓▒▒░░░░░░░░░░░░ 20% · 40k / 200k          Details  5h ██░░░ 42% ↻2h10
■ Messages 36k  ■ System tools 14k  ■ Skills 10k                        7d ████░ 86% ↻3d
  • Row 1: context fill as a bar colored by category, percentage, tokens used / window size, and your 5-hour limit with the time until it resets.
  • Row 2: the largest context categories, and your weekly limit (plus a spend limit, if your account has one).
  • Colors: green below 50 %, yellow from 50 %, red from 80 %.

Install

In a Claude Code terminal session:

/plugin install context-meter --marketplace vienna-bio-center/claude-vbc-mods

Answer y to add the marketplace, then pick the user scope. The mod is active right away and in every session you start afterwards — including the desktop app's Code tab. (The install command itself only works in the terminal.)

Usage

Nothing to start: the band appears on its own once a session has its first response.

For a breakdown, press Details in the band or run:

/context-details

The details pane lists:

SectionWhat it shows
Categoriesevery context category with tokens and share of the window
Largest tool resultsthe 10 biggest tool outputs since the last /clear or compact
MCP serverstokens per server for tool schemas currently loaded
Memory filesCLAUDE.md, rules and auto-memory files with their size
Skills / agentswhat the skill listing and agent descriptions cost

Good to know

  • Updates: after every model response (also mid-turn), at the end of each turn, and every minute for the reset countdowns. No polling, no extra API calls.
  • Estimates: category figures are the same local estimate /context uses in summary mode. Tool result sizes are approximate (characters ÷ 4). Run /context for an exact count.
  • Usage limits appear only on a Claude subscription and only after the session's first response — before that there is no reading.
  • Narrow windows: below 110 columns the small limit bars are dropped, below 90 the reset times too.
  • Subagents are not counted: their responses and tool results don't land in your main context.

Development

claude plugin validate <path-to-this-folder>
claude plugin test <path-to-this-folder>

To run a working copy instead of the installed version, start Claude Code with claude --plugin-dir <path-to-this-folder>.

License

MIT © 2026 Vienna BioCenter

Source 2 files
hooks/register.tsx 431 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  EngineInterface,
4  ModelUsage,
5  Register,
6  SessionContextBreakdown,
7  SessionContextUsage,
8} from 'claude-code'
9
10import type { Details, Fill, Heavy, Item, Limit, Part } from '../types'
11
12const PANE = 'context-meter'
13const fill = atom({ plugin: 'context-meter', key: 'fill' } as const, null)
14const details = atom({ plugin: 'context-meter', key: 'details' } as const, null)
15const heavy = atom({ plugin: 'context-meter', key: 'heavy' } as const, [])
16const limits = atom({ plugin: 'context-meter', key: 'limits' } as const, [])
17const now = atom({ plugin: 'context-meter', key: 'now' } as const, 0)
18
19const toFill = (c: SessionContextUsage): Fill => ({
20  tokens: c.tokens ?? null,
21  window: c.window,
22  percent: c.percent ?? null,
23})
24
25export const kilo = (n: number) =>
26  n >= 1_000_000
27    ? `${(n / 1_000_000).toFixed(1)}M`
28    : n >= 10_000
29      ? `${Math.round(n / 1000)}k`
30      : n >= 1000
31        ? `${(n / 1000).toFixed(1)}k`
32        : `${n}`
33
34export const bar = (percent: number, width: number) => {
35  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
36  return { filled: '█'.repeat(filled), empty: '░'.repeat(width - filled) }
37}
38
39export const tone = (percent: number) =>
40  percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'success'
41
42/** Splits `width` cells over the used rows in order, cumulative rounding so nothing gaps. */
43export const segments = (parts: Part[], max: number, width: number) => {
44  const out: { color: string; cells: number }[] = []
45  let sum = 0
46  let drawn = 0
47  for (const p of parts) {
48    if (p.kind !== 'used' || p.tokens <= 0) continue
49    sum += p.tokens
50    const end = Math.min(width, Math.round((sum / max) * width))
51    if (end > drawn) out.push({ color: p.color, cells: end - drawn })
52    drawn = Math.max(drawn, end)
53  }
54  return { used: out, rest: width - drawn }
55}
56
57const top = (items: Item[], n: number) =>
58  [...items].sort((a, b) => b.tokens - a.tokens).slice(0, n)
59
60export const toDetails = (b: SessionContextBreakdown, home: string): Details => {
61  const servers = new Map<string, Item>()
62  for (const t of b.mcpTools) {
63    if (!t.isLoaded) continue
64    const s = servers.get(t.serverName) ?? { name: t.serverName, detail: '', tokens: 0 }
65    s.tokens += t.tokens
66    s.detail = `${Number(s.detail || 0) + 1}`
67    servers.set(t.serverName, s)
68  }
69  const tilde = (p: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p)
70
71  return {
72    total: b.totalTokens,
73    max: b.rawMaxTokens,
74    percent: b.percentage,
75    parts: b.categories.map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind })),
76    compactAt: b.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
77    mcp: top([...servers.values()].map(s => ({ ...s, detail: `${s.detail} Tools` })), 8),
78    memory: top(b.memoryFiles.map(f => ({ name: tilde(f.path), detail: f.type, tokens: f.tokens })), 8),
79    skills: top(
80      (b.skills?.skillFrontmatter ?? []).map(s => ({ name: s.name, detail: s.pluginName ?? s.source, tokens: s.tokens })),
81      6,
82    ),
83    skillsTotal: b.skills?.tokens ?? 0,
84    agentsTotal: b.agents.reduce((n, a) => n + a.tokens, 0),
85  }
86}
87
88/** Keeps the 10 largest results; tokens estimated at 4 characters each. */
89export const addHeavy = (list: Heavy[], one: Heavy) =>
90  [...list, one].sort((a, b) => b.tokens - a.tokens).slice(0, 10)
91
92const LABEL_KEYS = ['file_path', 'command', 'pattern', 'url', 'query', 'description', 'prompt', 'path', 'skill']
93
94export const labelOf = (input: Record<string, unknown>) => {
95  for (const k of LABEL_KEYS) {
96    const v = input[k]
97    if (typeof v === 'string' && v.trim()) return v.replace(/\s+/g, ' ').trim()
98  }
99  return ''
100}
101
102const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'Budget' }
103
104export const limitLabel = (kind: string) => LIMIT_LABEL[kind] ?? kind.replace(/_/g, ' ')
105
106/** Time left until a window resets, coarse enough to stay true for a minute. */
107export const until = (at: number | null, nowMs: number) => {
108  if (at === null) return ''
109  const min = Math.max(0, Math.round((at - nowMs) / 60_000))
110  if (min < 60) return `${min}m`
111  const h = Math.floor(min / 60)
112  if (h < 48) return `${h}h${String(min % 60).padStart(2, '0')}`
113  return `${Math.round(h / 24)}d`
114}
115
116export const toLimits = (list: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Limit[] =>
117  list.map(l => {
118    const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
119    return { kind: l.kind, percent: l.percentUsed, resetsAt: Number.isNaN(at) ? null : at }
120  })
121
122/** Input side of one response: what the window held when the model answered. */
123export const stepTokens = (u: ModelUsage) =>
124  u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
125
126/** `tokens`, when given, is a fresher reading (a step that just answered) than the session's. */
127async function measure($: EngineInterface, tokens?: number) {
128  const usage = await $.session.usage({ breakdown: 'summary' })
129  const f = toFill(usage.context)
130  await update($, fill, () =>
131    tokens === undefined ? f : { ...f, tokens, percent: Math.round((tokens / f.window) * 100) },
132  )
133  await update($, limits, () => toLimits(usage.rateLimits))
134  await update($, now, () => Date.now())
135  const b = usage.context.breakdown
136  if (b) {
137    const home = (await $.env.get('HOME')) ?? ''
138    await update($, details, () => toDetails(b, home))
139  }
140}
141
142export const register: Register = on => {
143  on('session.start', async ($, e, next) => {
144    const result = await next(e)
145    await $.command.register({
146      name: 'context-details',
147      description: 'Shows what is using up the context window',
148    })
149    await measure($)
150    // keeps the reset countdowns honest while the session sits idle
151    $.clock.every(60_000, () => {
152      void update($, now, () => Date.now())
153    })
154    return result
155  })
156
157  on('session.measure', async ($, e, next) => {
158    const result = await next(e)
159    await measure($)
160    return result
161  })
162
163  // Live during a turn: each answered model call of the main loop measures again.
164  on('turn.step', async function* ($, e, next) {
165    const r = yield* next(e)
166    if (e.agentId === undefined && r.usage) {
167      // the bar is a nicety: a failed reading must never touch the turn
168      try {
169        await measure($, stepTokens(r.usage))
170      } catch {}
171    }
172    return r
173  })
174
175  on('session.compact', async ($, e, next) => {
176    const result = await next(e)
177    if (e.agentId === undefined && !('skip' in result)) await update($, heavy, () => [])
178    return result
179  }).catch(($, e, next) => next(e))
180
181  on('session.end', async ($, e, next) => {
182    if (e.reason === 'clear') await update($, heavy, () => [])
183    return next(e)
184  })
185
186  on('tool.call', async ($, e, next) => {
187    const ran = await next(e)
188    if (e.agentId === undefined && ran.text) {
189      const one: Heavy = {
190        id: e.tool_use_id,
191        tool: e.tool,
192        label: labelOf(e as unknown as Record<string, unknown>).slice(0, 120),
193        tokens: Math.round(ran.text.length / 4),
194      }
195      await update($, heavy, list => addHeavy(list, one))
196    }
197    return ran
198  }).catch(($, e, next) => next(e))
199
200  on('command.run', { command: 'context-details' }, async $ => {
201    await measure($)
202    await $.ui.open({ id: PANE, title: 'Context' })
203    return { text: 'Context details opened.' }
204  })
205
206  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
207    if (e.props.hasSurvey) return next(e)
208
209    const current = await read($, fill)
210    const d = await read($, details)
211    const { Box, Text, Button } = $.ui.resolve(e)
212
213    if (d === null && (current === null || current.percent === null || current.tokens === null)) {
214      return (
215        <Box>
216          <Text dimColor>
217            Context ░░░░░░░░░░ no reading yet
218            {current ? ` · window ${kilo(current.window)}` : ''}
219          </Text>
220        </Box>
221      )
222    }
223
224    const percent = d?.percent ?? current?.percent ?? 0
225    const used = d?.total ?? current?.tokens ?? 0
226    const max = d?.max ?? current?.window ?? 1
227    const cols = e.props.bodyColumns
228    const color = tone(percent)
229
230    const lim = await read($, limits)
231    const nowMs = (await read($, now)) || Date.now()
232    const showMini = cols >= 110
233    const showReset = cols >= 90
234    const shown = lim.map(l => ({
235      ...l,
236      label: limitLabel(l.kind),
237      pct: `${Math.round(l.percent)}%`,
238      reset: showReset ? until(l.resetsAt, nowMs) : '',
239      mini: showMini ? bar(l.percent, 5) : null,
240    }))
241    const lenOf = (l: (typeof shown)[number]) =>
242      l.label.length + 1 + (l.mini ? 6 : 0) + l.pct.length + (l.reset ? l.reset.length + 2 : 0)
243    // 5h on the first row, the week (and any other window) on the second
244    const first = shown.filter(l => l.kind === 'five_hour')
245    const second = shown.filter(l => l.kind !== 'five_hour')
246    const limitW = shown.length ? Math.max(...[first, second].map(g => g.reduce((n, l) => n + lenOf(l), 0) + Math.max(0, g.length - 1) * 3)) : 0
247    const width = Math.max(8, Math.min(40, cols - 30 - limitW - 10))
248
249    const barRow = d
250      ? (() => {
251          const sg = segments(d.parts, d.max, width)
252          return [
253            ...sg.used.map((seg, i) => (
254              <Text key={`s${i}`} color={seg.color}>
255                {'█'.repeat(seg.cells)}
256              </Text>
257            )),
258            <Text key="rest" dimColor>
259              {'░'.repeat(sg.rest)}
260            </Text>,
261          ]
262        })()
263      : (() => {
264          const b = bar(percent, width)
265          return [
266            <Text key="f" color={color}>{b.filled}</Text>,
267            <Text key="e" dimColor>{b.empty}</Text>,
268          ]
269        })()
270
271    const legendRoom = cols - (limitW ? limitW + 2 : 0)
272    const legend: Part[] = []
273    let taken = 0
274    for (const part of d ? d.parts.filter(x => x.kind === 'used' && x.tokens > 0).sort((a, b) => b.tokens - a.tokens) : []) {
275      const len = 2 + part.name.length + 1 + kilo(part.tokens).length + (legend.length ? 2 : 0)
276      if (legend.length >= 5 || taken + len > legendRoom) break
277      legend.push(part)
278      taken += len
279    }
280
281    const limitBox = (key: string, group: typeof shown) => (
282      <Box key={key} width={limitW}>
283        {group.map((l, i) => (
284          <Box key={`l-${l.kind}`}>
285            {i > 0 && <Text dimColor> │ </Text>}
286            <Text dimColor>{l.label} </Text>
287            {l.mini && <Text color={tone(l.percent)}>{l.mini.filled}</Text>}
288            {l.mini && <Text dimColor>{l.mini.empty} </Text>}
289            <Text color={tone(l.percent)} bold>
290              {l.pct}
291            </Text>
292            {l.reset && <Text dimColor> ↻{l.reset}</Text>}
293          </Box>
294        ))}
295      </Box>
296    )
297
298    return (
299      <Box flexDirection="column">
300        <Box justifyContent="space-between">
301          <Box>
302            <Text dimColor>Context </Text>
303            {barRow}
304            <Text color={color} bold>
305              {' '}
306              {percent}%
307            </Text>
308            <Text dimColor wrap="truncate-end">
309              {' '}
310              · {kilo(used)} / {kilo(max)}
311            </Text>
312          </Box>
313          <Box>
314            <Button
315              key="details"
316              label="Details"
317              plain
318              dimColor
319              onPress={() => $.ui.open({ id: PANE, title: 'Context' })}
320            />
321            {limitW > 0 && <Text>  </Text>}
322            {limitW > 0 && limitBox('row1', first)}
323          </Box>
324        </Box>
325        {(legend.length > 0 || second.length > 0) && (
326          <Box justifyContent="space-between">
327            <Box columnGap={2}>
328              {legend.map(part => (
329                <Box key={part.name}>
330                  <Text color={part.color}>■ </Text>
331                  <Text dimColor>
332                    {part.name} {kilo(part.tokens)}
333                  </Text>
334                </Box>
335              ))}
336            </Box>
337            {limitW > 0 && limitBox('row2', second)}
338          </Box>
339        )}
340      </Box>
341    )
342  })
343
344  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
345    const { Box, Text } = $.ui.resolve(e)
346    const d = await read($, details)
347    const big = await read($, heavy)
348    const cols = e.props.bodyColumns
349    const nameW = Math.max(16, Math.min(48, cols - 28))
350    const cut = (s: string) => (s.length > nameW ? `${s.slice(0, nameW - 1)}…` : s)
351
352    const row = (key: string, name: string, tokens: number, extra: string, color?: string) => (
353      <Box key={key}>
354        {color !== undefined && <Text color={color}>■ </Text>}
355        <Box width={nameW}>
356          <Text wrap="truncate-end">{cut(name)}</Text>
357        </Box>
358        <Box width={8} justifyContent="flex-end">
359          <Text bold>{kilo(tokens)}</Text>
360        </Box>
361        <Text dimColor> {extra}</Text>
362      </Box>
363    )
364
365    const section = (key: string, title: string) => (
366      <Box key={key} marginTop={1}>
367        <Text bold underline>
368          {title}
369        </Text>
370      </Box>
371    )
372
373    if (d === null) {
374      return <Text dimColor>No breakdown yet — it arrives with the first response.</Text>
375    }
376
377    const share = (n: number) => `${((n / d.max) * 100).toFixed(1)}%`
378
379    return (
380      <Box flexDirection="column">
381        <Text>
382          <Text bold color={tone(d.percent)}>
383            {d.percent}%
384          </Text>
385          <Text dimColor>
386            {' '}
387            · {kilo(d.total)} of {kilo(d.max)} tokens
388            {d.compactAt ? ` · auto-compact at ${kilo(d.compactAt)}` : ' · auto-compact off'}
389          </Text>
390        </Text>
391
392        {section('h-cat', 'Categories')}
393        {d.parts
394          .filter(p => p.tokens > 0)
395          .map(p =>
396            row(
397              `c-${p.name}`,
398              p.name,
399              p.tokens,
400              p.kind === 'deferred' ? 'on demand, not counted' : share(p.tokens),
401              p.color,
402            ),
403          )}
404
405        {section('h-heavy', 'Largest tool results (since /clear or compact, ≈)')}
406        {big.length === 0 ? (
407          <Text dimColor>none yet</Text>
408        ) : (
409          big.map(h => row(`h-${h.id}`, h.label ? `${h.tool}: ${h.label}` : h.tool, h.tokens, ''))
410        )}
411
412        {d.mcp.length > 0 && section('h-mcp', 'MCP servers (loaded tool schemas)')}
413        {d.mcp.map(m => row(`m-${m.name}`, m.name, m.tokens, m.detail))}
414
415        {d.memory.length > 0 && section('h-mem', 'Memory files')}
416        {d.memory.map(m => row(`f-${m.name}`, m.name, m.tokens, m.detail))}
417
418        {d.skills.length > 0 && section('h-sk', `Skills (listing total ${kilo(d.skillsTotal)})`)}
419        {d.skills.map(s => row(`k-${s.name}`, s.name, s.tokens, s.detail))}
420
421        {d.agentsTotal > 0 && section('h-ag', 'Agent descriptions')}
422        {d.agentsTotal > 0 && row('a-total', 'all together', d.agentsTotal, '')}
423
424        <Box marginTop={1}>
425          <Text dimColor>Estimated like /context (summary). Exact count: /context</Text>
426        </Box>
427      </Box>
428    )
429  })
430}
431
types/index.d.ts 35 lines
1export type Fill = { tokens: number | null; window: number; percent: number | null }
2
3export type Part = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' | 'deferred' }
4
5export type Item = { name: string; detail: string; tokens: number }
6
7export type Details = {
8  total: number
9  max: number
10  percent: number
11  parts: Part[]
12  compactAt: number | null
13  mcp: Item[]
14  memory: Item[]
15  skills: Item[]
16  skillsTotal: number
17  agentsTotal: number
18}
19
20export type Limit = { kind: string; percent: number; resetsAt: number | null }
21
22export type Heavy = { id: string; tool: string; label: string; tokens: number }
23
24declare module 'claude-code' {
25  interface PluginState {
26    'context-meter': {
27      fill: Fill | null
28      details: Details | null
29      heavy: Heavy[]
30      limits: Limit[]
31      now: number
32    }
33  }
34}
35