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.

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
/compact.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.
/plugin marketplace add midudev/better-claude
/plugin install context-meter@better-claude
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).$.session.usage() again (a free call) and takes the figure when it moved.$.session.usage({ breakdown: 'full' }) once, the count /context makes, and keeps that estimate until the next response.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.claude plugin validate . # from this folder
claude plugin test . # runs hooks/register.test.tsx against the engine
MIT © Miguel Ángel Durán (midudev)
hooks/register.tsx 98 lines1import { 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}
98types/index.d.ts 13 lines1/**
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