Context-window usage at the bottom right of Claude Code, next to the sub-meter bar: tokens used, a bar, and a color that says when to /compact.

A Claude Code mod (plugin) that shows how much of the context window the session has used, right next to the sub-meter usage bar in the prompt footer.
Status: early version. Needs Claude Code 2.1.306 or later. The plugin lives in
context-meter/; this repository is also its marketplace.

Rendered with the plugin's own formatting code, at the default thresholds and a 1M window; not a screenshot of a session. Colors approximate the terminal theme.
ctx ━────────┃ 142k/1M 14%
┃ marks the cell where auto-compact will run. It is hidden when auto-compact is off, and it follows /autocompact./compact the first time the context enters red. It is raised once per stretch in red, and again after the context drops below yellow, after a compaction, or after /clear. The toast text is in Spanish by default, and can be switched to English with toastLanguage.ctx – is shown while there is no figure yet, for example in a new session and right after a compaction, until the next response.sub-meter enabled in your settings, the meter also keeps free the room for the widest sub-meter bar, so ctx is the one that shrinks first on a narrow terminal, whichever of the two plugins is drawn first.context-meter is a separate plugin and does not modify sub-meter.
In a Claude Code session in the terminal:
/plugin install context-meter --marketplace pablosalasd2004/context-mod
From a shell:
claude plugin install context-meter --marketplace pablosalasd2004/context-mod
For the desktop app, run the shell command above once. It installs the plugin at user scope, and the desktop app's local sessions read the same settings as the terminal, so the plugin is there too. To manage it from the app, click + next to the prompt box and open the plugin browser (Add plugins, or Plugins then Browse plugins). Plugins are not available in WSL sessions of the desktop app.
To run the plugin from a folder on your machine (for development), register the folder as a marketplace from its parent directory, then install from it:
git clone https://github.com/pablosalasd2004/context-mod
claude plugin marketplace add ./context-mod
claude plugin install context-meter@context-mod
Run these from the folder that holds the clone, and use the name of your own folder after ./ if it is not context-mod.
claude plugin marketplace add . is rejected: the source must be owner/repo, a URL, or a path that starts with ./. The marketplace then reads the plugin from the folder itself, so edits take effect after /reload-plugins, without a new version. Do not load the same folder with --plugin-dir at the same time: the hooks would run twice.
Set the options with /plugin configure context-meter@context-mod in a session. The plugin reads them when it loads, so restart Claude Code after changing one.
| Option | Default | What it does |
|---|---|---|
warnTokensK | 500 | Thousands of tokens at which the bar turns yellow. |
criticalTokensK | 700 | Thousands of tokens at which the bar turns red and the toast is raised. |
showToast | on | Raise the toast when the context enters red. |
toastLanguage | es | Language of the toast: es (Spanish) or en (English). |
showMarker | on | Mark the auto-compact cell on the bar. |
A value that is not a number of at least 1 takes its default, and a red threshold at or below the yellow one sends both thresholds back to the defaults.
/status.$.session.usage() and session.measure). It never counts tokens itself, so its numbers are the ones the engine reports./autocompact or the auto-compact switch in /config. A changed threshold made some other way shows up after the next /clear, /resume or /reload-plugins.ctx – instead, so the number never goes back up by itself.sub-meter detection. The meter reads enabledPlugins from your settings when the session starts and after /clear, /resume and a fork. A plugin that you enable or disable in the middle of a session is picked up after /reload-plugins. The detection looks for the name sub-meter in enabledPlugins, so it is not sure to work with a copy loaded from a folder rather than installed.sub-meter is a fixed 29 columns, its widest form. If sub-meter changes its layout, that number needs updating.enabledPlugins setting, draws in the footer and raises a toast; it starts no process and uses no network or file access. claude plugin validate ./context-meter lists every call it makes on its calls: line.context-meter is verified in a terminal session and through the test kit on the desktop surface. It has not been looked at in the desktop app.
The plugin has no build step and no runtime dependencies. Commands run from the root of the repository; the package manager is pnpm.
claude plugin validate ./context-meter --strict
claude plugin validate .
claude plugin test ./context-meter
pnpm dlx --package typescript tsc -p ./context-meter
tsc -p needs the types the engine writes into context-meter/.claude-plugin/types/ when it loads the plugin, so load it once first. To load the folder for a session with hot reload, from a terminal:
claude --plugin-dir ./context-meter
The maintainer's own notes (verification order, conventions and the third-party development skills) are in CLAUDE.md.
MIT. Parts of the footer measurement are adapted from sub-meter (MIT, Miguel Ángel Durán); its notice is in context-meter/LICENSE, which also ships with the plugin.
hooks/register.tsx 233 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Reading } from '../types'
5import {
6 AROUND, SEPARATOR, SUB_METER_WIDTH, barRuns, detailFor, filledCells, formatTokens, holdsPlugin, isBlank, isPluginEnabled, levelFor, markerCell,
7 roomOf, thresholdsFor, toastText, treeWidth, widthOf
8} from './meter'
9
10const reading = atom({ plugin: 'context-meter', key: 'reading' } as const, null as Reading | null)
11const compactAt = atom({ plugin: 'context-meter', key: 'compactAt' } as const, null as number | null)
12const stale = atom({ plugin: 'context-meter', key: 'stale' } as const, null as number | null)
13const isWorking = atom({ plugin: 'context-meter', key: 'isWorking' } as const, false)
14const isAlerted = atom({ plugin: 'context-meter', key: 'isAlerted' } as const, false)
15const isSubMeterOn = atom({ plugin: 'context-meter', key: 'isSubMeterOn' } as const, false)
16
17// While a turn runs the engine's figure moves with each response, but it is only announced when the turn ends.
18const REFRESH_MS = 2000
19
20// Columns of `ctx –`, which the meter shows while it has no figure.
21const DASH = 5
22
23// The hint line as last drawn on the terminal, in columns. A render hook cannot write the session's
24// state, so this one value is kept here; a reload loses it until the hint is drawn again.
25let hintWidth = 0
26
27/**
28 * Raises the toast the first time the context enters red. It stays quiet while the context stays red or
29 * between yellow and red, and is armed again below yellow and when there is no figure (a compaction, /clear).
30 */
31async function judge ($: EngineInterface, options: PluginOptions, tokens: number | null, window: number) {
32 const alerted = await read($, isAlerted)
33 const { warn, critical } = thresholdsFor(window, options)
34 if (tokens === null || tokens < warn) {
35 if (alerted) await update($, isAlerted, () => false)
36 return
37 }
38 if (tokens < critical || alerted) return
39 await update($, isAlerted, () => true)
40 if (options.showToast === false) return
41 const percent = Math.round((tokens / window) * 100)
42 await $.ui.toast(toastText(options, tokens, percent))
43}
44
45/**
46 * Keeps a context figure from the engine for the footer. After a compaction the engine goes on
47 * reporting the figure from before it, so that one is not kept; the first different figure is.
48 * A window that is not a positive size gives nothing to measure against, so it is no figure at all.
49 */
50async function take ($: EngineInterface, options: PluginOptions, context: { tokens?: number; window: number }) {
51 if (!(context.window > 0)) return
52 const old = await read($, stale)
53 if (old !== null) {
54 if (context.tokens === old) return
55 await update($, stale, () => null)
56 }
57 const tokens = context.tokens ?? null
58 const current = await read($, reading)
59 if (current !== null && current.tokens === tokens && current.window === context.window) return
60 await update($, reading, () => ({ tokens, window: context.window }))
61 await judge($, options, tokens, context.window)
62}
63
64/**
65 * Reads the context and the auto-compact point from the engine; where the engine cannot say, the meter
66 * keeps what it had. The breakdown is the costly part, so this runs at the start and when the point may
67 * have moved, not at each measurement.
68 */
69async function seed ($: EngineInterface, options: PluginOptions) {
70 try {
71 const { context } = await $.session.usage({ breakdown: 'summary' })
72 await take($, options, context)
73 await update($, compactAt, () => context.breakdown?.autoCompactThreshold ?? null)
74 } catch {
75 // no reading this time
76 }
77}
78
79/** Notes whether sub-meter is enabled; where the settings cannot be read, the meter keeps what it knew. */
80async function noteSubMeter ($: EngineInterface) {
81 try {
82 const enabled = await $.settings.read({ key: 'enabledPlugins' })
83 await update($, isSubMeterOn, () => isPluginEnabled(enabled, 'sub-meter'))
84 } catch {
85 // the footer is shared as before
86 }
87}
88
89/** Asks the engine for the figure again, but only while a turn of the main conversation runs. */
90async function tick ($: EngineInterface, options: PluginOptions) {
91 try {
92 if (!(await read($, isWorking))) return
93 const { context } = await $.session.usage()
94 await take($, options, context)
95 } catch {
96 // the next tick tries again
97 }
98}
99
100/** A compaction emptied the context: show no figure until a response reports the new one. */
101async function afterCompaction ($: EngineInterface, options: PluginOptions) {
102 try {
103 const { context } = await $.session.usage()
104 await update($, stale, () => context.tokens ?? null)
105 await update($, reading, current => (current === null ? null : { ...current, tokens: null }))
106 await update($, isAlerted, () => false)
107 } catch {
108 // the next measurement sets it right
109 }
110 await seed($, options)
111}
112
113export const register: Register = (on, options) => {
114 // Timers stop when the module reloads, so this is where one starts.
115 on('session.start', async ($, e, next) => {
116 await noteSubMeter($)
117 await seed($, options)
118 $.clock.every(REFRESH_MS, () => {
119 void tick($, options)
120 })
121 return next(e)
122 })
123
124 on('turn.start', async ($, e, next) => {
125 if (e.agentId === undefined) await update($, isWorking, () => true)
126 return next(e)
127 })
128
129 on('turn.complete', async ($, e, next) => {
130 if (e.agentId === undefined) await update($, isWorking, () => false)
131 return next(e)
132 })
133
134 // /clear, /resume and a fork reset the state without a new session.start. A compaction also raises this
135 // with source `compact`, while the engine still reports the figure from before it.
136 on('classic.SessionStart', async ($, e, next) => {
137 if (e.source !== 'compact') {
138 await noteSubMeter($)
139 await seed($, options)
140 }
141 return next(e)
142 })
143
144 on('session.compact', async ($, e, next) => {
145 const result = await next(e)
146 if (e.trigger !== 'precompute' && e.agentId === undefined && result.skip === undefined) await afterCompaction($, options)
147 return result
148 }).catch(($, e, next) => next(e))
149
150 on('session.measure', async ($, e, next) => {
151 const before = await read($, reading)
152 await take($, options, e.context)
153 if (before === null || before.window !== e.context.window) await seed($, options)
154 return next(e)
155 })
156
157 // The auto-compact point moves with /autocompact and with the /config toggle.
158 on('command.run', { command: 'autocompact' }, async ($, e, next) => {
159 const result = await next(e)
160 await seed($, options)
161 return result
162 }).catch(($, e, next) => next(e))
163
164 on('config.set', { key: 'autoCompact' }, async ($, e, next) => {
165 const result = await next(e)
166 await seed($, options)
167 return result
168 }).catch(($, e, next) => next(e))
169
170 // The hint after the mode label shares the row with the meter, so its width tells how much room is left.
171 // Adapted from sub-meter, MIT, Copyright (c) 2026 Miguel Ángel Durán (midudev); the notice is in ../LICENSE.
172 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
173 if (e.surface === 'terminal' && widthOf(e.props.hint) !== hintWidth) {
174 hintWidth = widthOf(e.props.hint)
175 $.ui.invalidate('ui.render')
176 }
177 return next(e)
178 })
179
180 // The meter, then what is beneath it (the engine's mode labels, another plugin's switch).
181 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
182 const current = await read($, reading)
183 const point = await read($, compactAt)
184 const beneath = await next(e)
185 const { Box, Text } = $.ui.resolve(e)
186
187 // What is beneath is drawn after the meter, and the row has only so much room for both.
188 const hasModes = e.props.modes.length > 0
189 const hasBeneath = hasModes || !isBlank(beneath)
190 const below = (hasModes ? widthOf(e.props.modes.join(' & ')) : 0) + treeWidth(beneath)
191 // A plugin beneath is measured here and gives up room itself; with none, one above may wrap the meter.
192 const above = (await read($, isSubMeterOn)) ? SUB_METER_WIDTH + SEPARATOR.length : AROUND
193 const room =
194 roomOf(e.surface, e.viewport?.columns, hintWidth) - (hasBeneath ? below + SEPARATOR.length : 0) - (holdsPlugin(beneath) ? 0 : above)
195 const separator = hasBeneath ? <Text dimColor>{SEPARATOR}</Text> : null
196
197 if (current === null || current.tokens === null) {
198 if (room < DASH) return beneath
199 return (
200 <Box flexDirection="row">
201 <Text dimColor>ctx –</Text>
202 {separator}
203 {beneath}
204 </Box>
205 )
206 }
207
208 const percent = Math.round((current.tokens / current.window) * 100)
209 const words = { tokens: formatTokens(current.tokens), window: formatTokens(current.window), percent: `${percent}%` }
210 const detail = detailFor(room, words)
211 if (detail === null) return beneath
212
213 const level = levelFor(current.tokens, thresholdsFor(current.window, options))
214 const marker = options.showMarker === false ? null : markerCell(point ?? undefined, current.window)
215 const runs = barRuns(filledCells(current.tokens, current.window), marker)
216
217 return (
218 <Box flexDirection="row">
219 <Text dimColor>ctx</Text>
220 {detail.hasBar
221 ? runs.map((run, i) => {
222 const cells = (i === 0 ? ' ' : '') + (run.isMarker ? '┃' : run.isFilled ? '━' : '─').repeat(run.cells)
223 return run.isFilled ? <Text color={level}>{cells}</Text> : <Text dimColor>{cells}</Text>
224 })
225 : null}
226 <Text>{` ${words.tokens}${detail.hasWindow ? '/' + words.window : ''}${detail.hasPercent ? ' ' + words.percent : ''}`}</Text>
227 {separator}
228 {beneath}
229 </Box>
230 )
231 })
232}
233hooks/meter.ts 168 lines1import type { RenderNode } from 'claude-code'
2
3import type { Level } from '../types'
4
5/** Token counts at which the meter turns yellow and red. */
6export type Thresholds = { warn: number; critical: number }
7
8/** A token count as the meter writes it: `999`, `142k`, `1M`. */
9export function formatTokens (tokens: number): string {
10 if (tokens < 1_000) return String(tokens)
11 const thousands = Math.round(tokens / 1_000)
12 if (thousands < 1_000) return `${thousands}k`
13 return `${Math.round(tokens / 100_000) / 10}M`
14}
15
16const DEFAULT_WARN_K = 500
17const DEFAULT_CRITICAL_K = 700
18// The window the thresholds are written for: where one does not fit the window, its share of this one is used.
19const REFERENCE_WINDOW = 1_000_000
20
21const isThousands = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 1
22
23/**
24 * The thresholds for a window, from the config's `warnTokensK` and `criticalTokensK`.
25 * A value that is not a number of at least 1 takes its default; a red threshold at or below the
26 * yellow one sends both to the defaults. Where the red threshold reaches the window, both keep
27 * their share of the window instead, so the color can still change.
28 */
29export function thresholdsFor (window: number, options: Readonly<Record<string, unknown>>): Thresholds {
30 const warnK = isThousands(options.warnTokensK) ? options.warnTokensK : DEFAULT_WARN_K
31 const criticalK = isThousands(options.criticalTokensK) ? options.criticalTokensK : DEFAULT_CRITICAL_K
32 const isOrdered = warnK < criticalK
33 const warn = (isOrdered ? warnK : DEFAULT_WARN_K) * 1_000
34 const critical = (isOrdered ? criticalK : DEFAULT_CRITICAL_K) * 1_000
35 if (critical < window) return { warn, critical }
36 return { warn: (warn * window) / REFERENCE_WINDOW, critical: (critical * window) / REFERENCE_WINDOW }
37}
38
39/**
40 * The toast that suggests /compact, in the language of the config's `toastLanguage`: English for `en`,
41 * Spanish for anything else, which is the default.
42 */
43export function toastText (options: Readonly<Record<string, unknown>>, tokens: number, percent: number): string {
44 const figure = `${formatTokens(tokens)} (${percent}%)`
45 return options.toastLanguage === 'en' ? `Context at ${figure}: consider running /compact` : `Contexto en ${figure}: conviene hacer /compact`
46}
47
48/** How many cells wide the bar is. */
49export const CELLS = 10
50
51/** How many cells of the bar the context fills. */
52export function filledCells (tokens: number, window: number): number {
53 if (!(window > 0)) return 0
54 return Math.round(Math.min(Math.max(tokens / window, 0), 1) * CELLS)
55}
56
57/** The cell (1 to CELLS) where auto-compact will run, or null with auto-compact off. */
58export function markerCell (threshold: number | undefined, window: number): number | null {
59 if (threshold === undefined || !(window > 0)) return null
60 return Math.min(Math.max(Math.round((threshold / window) * CELLS), 1), CELLS)
61}
62
63/** A stretch of the bar drawn alike: `isMarker` runs are the single cell auto-compact is marked in. */
64export type BarRun = { cells: number; isFilled: boolean; isMarker: boolean }
65
66/** The bar as runs of alike cells, left to right; `marker` is the 1-based cell to mark, or null. */
67export function barRuns (filled: number, marker: number | null): BarRun[] {
68 const runs: BarRun[] = []
69 for (let cell = 1; cell <= CELLS; cell++) {
70 const isFilled = cell <= filled
71 const isMarker = cell === marker
72 const last = runs.at(-1)
73 if (last !== undefined && !isMarker && !last.isMarker && last.isFilled === isFilled) last.cells += 1
74 else runs.push({ cells: 1, isFilled, isMarker })
75 }
76 return runs
77}
78
79// The footer measurements below (and `isBlank`, `widthOf`, `holdsPlugin`, `roomOf`) follow the ones in
80// sub-meter, MIT, Copyright (c) 2026 Miguel Ángel Durán (midudev); the notice is in ../LICENSE.
81// The terminal footer's row: two columns of padding at each end, the permission mode label at the left
82// (20 columns for the longest) and at least two columns between that and what is drawn at the right.
83const FOOTER_PADDING = 4
84const FOOTER_GAP = 2
85const MODE_LABEL = 20
86
87/** Separates the meter from what is drawn after it. */
88export const SEPARATOR = ' · '
89
90// Kept free, when no other plugin is beneath, for one that may wrap the meter and draw after it.
91export const AROUND = 1 + SEPARATOR.length
92
93// The most columns sub-meter draws: `week`, its ten-cell bar, ` 100%` and ` ↻ 6d 23h`. When sub-meter is
94// enabled and nothing is beneath the meter, sub-meter may be wrapping it, and it measures the meter and
95// shrinks itself to fit, so the meter keeps this much (and a separator) free for it instead.
96export const SUB_METER_WIDTH = 29
97
98/** Whether `enabledPlugins` (settings.json's map of `name@marketplace`) has `name` on in some marketplace. */
99export function isPluginEnabled (enabledPlugins: unknown, name: string): boolean {
100 if (typeof enabledPlugins !== 'object' || enabledPlugins === null || Array.isArray(enabledPlugins)) return false
101 return Object.entries(enabledPlugins).some(([id, isOn]) => isOn === true && id.startsWith(`${name}@`))
102}
103
104/**
105 * Columns a text takes on one row: one per character, none for a variation selector (U+FE0E, U+FE0F).
106 * Wide characters (CJK, most emoji) are not counted double, which suits the footer's own labels.
107 */
108export function widthOf (text: string): number {
109 return [...text.replace(/[\uFE0E\uFE0F]/g, '')].length
110}
111
112/**
113 * Whether a drawing shows nothing: no text and no control. The engine's own node is no drawing of
114 * its own here, it stands for the mode labels, which are told apart by the footer's `modes`.
115 */
116export function isBlank (node: RenderNode | undefined): boolean {
117 if (node === undefined || node === null) return true
118 if (typeof node === 'string') return node.trim() === ''
119 if (node.type === 'engine') return true
120 if (node.type !== 'Box' && node.type !== 'Text') return false
121 return (node.children ?? []).every(isBlank)
122}
123
124/** Columns the text of a drawing takes on one row, not counting the engine's own node. */
125export function treeWidth (node: RenderNode | undefined): number {
126 if (node === undefined || node === null || (typeof node !== 'string' && node.type === 'engine')) return 0
127 if (typeof node === 'string') return widthOf(node)
128 return (node.children ?? []).reduce((sum, child) => sum + treeWidth(child), 0)
129}
130
131/** Whether a drawing holds another plugin's: the engine marks the root of each hook's tree with its plugin. */
132export function holdsPlugin (node: RenderNode | undefined): boolean {
133 if (node === undefined || node === null || typeof node === 'string' || node.type === 'engine') return false
134 return 'part' in node || (node.children ?? []).some(holdsPlugin)
135}
136
137/**
138 * Columns the footer leaves at its right end, beside the mode label and the hint after it: all of
139 * them off the terminal or where no width was measured.
140 */
141export function roomOf (surface: string, columns: number | undefined, hintWidth: number): number {
142 if (surface !== 'terminal' || columns === undefined) return Infinity
143 return columns - FOOTER_PADDING - FOOTER_GAP - MODE_LABEL - (hintWidth > 0 ? 1 + hintWidth : 0)
144}
145
146/** What the meter still draws: the bar, the window beside the tokens, the percent. */
147export type Detail = { hasBar: boolean; hasWindow: boolean; hasPercent: boolean }
148
149/** The most the meter can draw in `room` columns, or null when not even the tokens fit. */
150export function detailFor (room: number, words: { tokens: string; window: string; percent: string }): Detail | null {
151 const label = 'ctx'.length
152 const tokens = 1 + words.tokens.length
153 const window = 1 + words.window.length
154 const percent = 1 + words.percent.length
155 const fit = [
156 { hasBar: true, hasWindow: true, hasPercent: true, width: label + 1 + CELLS + tokens + window + percent },
157 { hasBar: false, hasWindow: true, hasPercent: true, width: label + tokens + window + percent },
158 { hasBar: false, hasWindow: false, hasPercent: true, width: label + tokens + percent },
159 { hasBar: false, hasWindow: false, hasPercent: false, width: label + tokens }
160 ].find(({ width }) => width <= room)
161 return fit === undefined ? null : { hasBar: fit.hasBar, hasWindow: fit.hasWindow, hasPercent: fit.hasPercent }
162}
163
164/** The color for a token count: `claude` is the normal one. */
165export function levelFor (tokens: number, thresholds: Thresholds): Level {
166 return tokens >= thresholds.critical ? 'error' : tokens >= thresholds.warn ? 'warning' : 'claude'
167}
168types/index.d.ts 24 lines1/** The meter's color, as the theme names it: normal, past the yellow threshold, past the red one. */
2export type Level = 'claude' | 'warning' | 'error'
3
4/** The last context figure: input tokens of the last response (null until one reports) against the window. */
5export type Reading = { tokens: number | null; window: number }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'context-meter': {
10 reading: Reading | null
11 /** Tokens at which auto-compact runs; null when it is off or not known yet. */
12 compactAt: number | null
13 /** The figure the engine keeps reporting after a compaction, until a response reports a new one. */
14 stale: number | null
15 /** Whether a turn of the main conversation is running: the meter polls the engine only then. */
16 isWorking: boolean
17 /** Whether the toast already went off for this stretch in red. */
18 isAlerted: boolean
19 /** Whether sub-meter is enabled in the settings, and so may share the footer row with the meter. */
20 isSubMeterOn: boolean
21 }
22 }
23}
24