SLOPSHOPPER

context-meter

The session's context window fill, right above the prompt: gray while there is room, yellow when it is time to /compact, red when a new session is better.

newbandtimer
★ 210v0.1.0MITupdated 2026-10-22midudev/better-claude/plugins/context-meter
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 ⟨Claude Code's own drawing⟩ context 49% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ context 49%
README

Context Meter

A Claude Code mod: how full the session's context window is, always in sight, right above the prompt at its right end. No more typing /context to decide whether it is time to compact.

                                                                  context 58%
────────────────────────────────────────────────────────────────────────────
❯ revisa el módulo de recepción de pedidos
────────────────────────────────────────────────────────────────────────────
⏵⏵ auto mode on                                   5h ━───────── 2% · 4h 41m
  • Under 50%: dim gray, there is room.
  • From 50%: yellow, a good moment for /compact.
  • From 75%: red, compact now or start a new session.

Both thresholds are settings (warnAt, dangerAt) in /config. The footer stays as it is, so it sits well next to Sub Meter.

Before the first response of a session (a fresh or resumed one), and right after a compaction or a rewind, the engine has no reading yet. The meter then counts the window once, as /context does, and shows it with a ~ (context ~16%) until the next response brings the real figure.

Install

/plugin marketplace add midudev/better-claude
/plugin install context-meter@better-claude

How it works

One hooks module (hooks/register.tsx):

  • $.session.usage() gives the first reading at start; session.measure hands over context.percent after each response that moved it (the status line's used_percentage).
  • A resume or a rewind changes the fill without raising any event, so every 2 seconds it reads $.session.usage() again (a free call) and takes the figure when it moved.
  • While the engine has no figure, it calls $.session.usage({ breakdown: 'full' }) once, the count /context makes, and keeps that estimate until the next response.
  • A ui.render hook on AbovePrompt draws the meter at the right end of the band right above the prompt; whatever the engine or another plugin draws there (Image Preview's chips) stays above it.

Development

claude plugin validate .   # from this folder
claude plugin test .       # runs hooks/register.test.tsx against the engine

MIT © Miguel Ángel Durán (midudev)

Source 2 files
hooks/register.tsx 98 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode, SessionContextUsage, Timer } from 'claude-code'
3
4import type { Fill } from '../types'
5
6const SYNC_MS = 2000
7
8const percent = atom({ plugin: 'context-meter', key: 'percent' } as const, null as Fill)
9
10// One count at a time: a check that comes while one runs leaves it be.
11let isCounting = false
12let lastFill: Fill = null
13let timer: Timer | undefined
14
15/** Whether a drawing beneath shows nothing: no text, no control (the engine's own node is empty). */
16function isBlank (node: RenderNode | undefined): boolean {
17  if (node === undefined || node === null) return true
18  if (typeof node === 'string') return node.trim() === ''
19  return (node.type === 'Box' || node.type === 'Text') && (node.children ?? []).every(isBlank)
20}
21
22/** Writes the fill when it differs from the one shown (an equal write would redraw for nothing). */
23async function show ($: EngineInterface, next: Fill) {
24  if (lastFill?.value === next?.value && lastFill?.isEstimate === next?.isEstimate) return
25  lastFill = next
26  await update($, percent, () => next)
27}
28
29/**
30 * Takes the engine's figure; while it has none (no response yet in this window: a fresh, resumed,
31 * compacted or rewound session), counts the window once as /context does, and keeps that estimate
32 * until the next response. `canCount` false only takes a figure (the start, which counting would hold up).
33 */
34async function sync ($: EngineInterface, context: SessionContextUsage, canCount: boolean) {
35  if (context.percent !== undefined) return show($, { value: context.percent, isEstimate: false })
36  if (lastFill?.isEstimate || !canCount || isCounting) return
37
38  isCounting = true
39  try {
40    const { breakdown } = (await $.session.usage({ breakdown: 'full' })).context
41    await show($, breakdown ? { value: breakdown.percentage, isEstimate: true } : null)
42  } finally {
43    isCounting = false
44  }
45}
46
47export const register: Register = (on, options) => {
48  const warnAt = typeof options.warnAt === 'number' ? options.warnAt : 50
49  const dangerAt = typeof options.dangerAt === 'number' ? options.dangerAt : 75
50  const colorFor = (value: number) => (value >= dangerAt ? 'error' : value >= warnAt ? 'warning' : undefined)
51  isCounting = false
52  lastFill = null
53  timer?.cancel()
54  timer = undefined
55
56  on('session.start', async ($, e, next) => {
57    timer?.cancel()
58    isCounting = false
59    lastFill = null
60    await update($, percent, () => null)
61    await sync($, (await $.session.usage()).context, false)
62    // A resume or a rewind changes the fill without a measurement, so the meter reads the figure
63    // again (the plain call is free) and counts when the engine has none.
64    timer = $.clock.every(SYNC_MS, async () => { await sync($, (await $.session.usage()).context, true) })
65    return next(e)
66  })
67
68  on('session.end', ($, e, next) => {
69    timer?.cancel()
70    timer = undefined
71    return next(e)
72  })
73
74  on('session.measure', async ($, e, next) => {
75    if (e.changed.includes('context')) await sync($, e.context, true)
76    return next(e)
77  })
78
79  // Right above the prompt's top line, at its right end; what the engine and other plugins draw there stays above it.
80  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
81    if (e.props.hasSurvey) return next(e)
82    const [fill, beneath] = await Promise.all([read($, percent), next(e)])
83    if (fill === null) return beneath
84
85    const { Box, Text } = $.ui.resolve(e)
86    const color = colorFor(fill.value)
87
88    return (
89      <Box flexDirection="column">
90        {isBlank(beneath) ? null : beneath}
91        <Box key="context-meter" width={e.props.bodyColumns} justifyContent="flex-end">
92          <Text color={color} dimColor={color === undefined}>context {fill.isEstimate ? '~' : ''}{Math.round(fill.value)}%</Text>
93        </Box>
94      </Box>
95    )
96  })
97}
98
types/index.d.ts 13 lines
1/**
2 * The context window's fill, 0 to 100: the engine's figure from the last response, or, while it has
3 * none (a fresh, resumed, compacted or rewound session), /context's count of it (`isEstimate`).
4 * null when there is neither.
5 */
6export type Fill = { value: number; isEstimate: boolean } | null
7
8declare module 'claude-code' {
9  interface PluginState {
10    'context-meter': { percent: Fill }
11  }
12}
13