Puts context-window fill, 5-hour/7-day plan usage and session cost in the status line, and warns once when the context gets full.

cm-context-meter)Puts context-window fill, 5-hour/7-day plan usage and session cost in the status line, and warns once when the context gets full.
Puts a compact meter in Claude Code's status line:
ctx 42% · 5h 23% · 7d 61.5% · $1.20
Context-window fill, the 5-hour and 7-day plan usage windows (on subscriptions), and the session's cost. The verbose format spells it out: Context 42% (84k/200k) · 5-hour 23% · 7-day 61.5% · Cost $1.20. When the context reaches warnAtPercent a toast warns once; after a compaction brings it back under, it can warn again.
claude plugin marketplace add <owner>/<repo>
claude plugin install cm-context-meter@claudemods
| Key | Type | Default | Description |
|---|---|---|---|
showCost | boolean | true | Show the session's cost in US dollars, as /cost totals it. |
showRateLimits | boolean | true | Show the 5-hour and 7-day plan usage windows (subscriptions only). |
warnAtPercent | number (0–100) | 80 | Show a toast once when the context window reaches this percentage; 0 turns the warning off. |
format | string: compact / verbose | compact | compact: ctx 42% · 5h 23% · $1.20. verbose: Context 42% (84k/200k) · 5-hour 23% · Cost $1.20. |
intervalMs | number (5000–3600000) | 60000 | How often to refresh between turns, in milliseconds (at least 5000). |
| API | Why |
|---|---|
$.session.usage | Reads context tokens, plan rate limits and cost. Called without breakdown, so it is free (no token counting request). |
$.ui.status | Writes the meter to the status line. |
$.ui.toast | The one-time "context is getting full" warning. |
$.clock.every | Refreshes every intervalMs between turns. |
Tested with Claude Code 2.1.291.
session.start (first reading, then the timer), turn.complete (main loop only) and session.compact (refresh after a compaction; session.compact is a gating hook, so it carries a .catch that lets the compaction proceed).intervalMs was added beyond the original plan so the refresh cadence is tunable.hooks/format.ts as pure functions.MIT. See LICENSE. More at https://claudemods.app/mods/cm-context-meter.
hooks/register.ts 61 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { contextPercent, formatMeter, warnStep } from './format'
4import type { MeterOptions } from './format'
5
6type Meter = MeterOptions & {
7 warnAtPercent: number
8 /**
9 * Whether the warning toast has fired since the context was last below the
10 * threshold. A hot reload resets it, which at worst repeats one toast.
11 */
12 warned: boolean
13}
14
15/** Reads the session's usage and redraws the status line; toasts once at the threshold. */
16async function refresh($: EngineInterface, meter: Meter): Promise<void> {
17 // No `breakdown`: the status-line figures alone, which cost no request.
18 const usage = await $.session.usage()
19 $.ui.status(formatMeter(usage, meter))
20 const percent = contextPercent(usage)
21 const step = warnStep(percent, meter.warnAtPercent, meter.warned)
22 meter.warned = step.warned
23 if (step.toast) {
24 $.ui.toast(`Context is ${percent}% full. Consider /compact or /clear soon.`)
25 }
26}
27
28export const register: Register = (on, options) => {
29 const meter: Meter = {
30 showCost: options.showCost !== false,
31 showRateLimits: options.showRateLimits !== false,
32 format: options.format === 'verbose' ? 'verbose' : 'compact',
33 warnAtPercent: typeof options.warnAtPercent === 'number' ? options.warnAtPercent : 80,
34 warned: false,
35 }
36 const intervalMs = Math.max(5_000, typeof options.intervalMs === 'number' ? options.intervalMs : 60_000)
37
38 on('session.start', async ($, e, next) => {
39 const started = await next(e)
40 await refresh($, meter)
41 $.clock.every(intervalMs, () => {
42 refresh($, meter).catch(() => undefined)
43 })
44 return started
45 })
46
47 on('turn.complete', async ($, e, next) => {
48 const result = await next(e)
49 if (e.agentId === undefined) await refresh($, meter)
50 return result
51 })
52
53 // Never gates the compaction: it refreshes after `next` and, should that
54 // throw, the catch replays what `next` settled to.
55 on('session.compact', async ($, e, next) => {
56 const result = await next(e)
57 await refresh($, meter)
58 return result
59 }).catch(($, e, next) => next(e))
60}
61hooks/format.ts 75 lines1/**
2 * Pure helpers for cm-context-meter: no `$`, so they are unit-tested directly.
3 */
4import type { SessionUsage } from 'claude-code'
5
6export type MeterOptions = {
7 showCost: boolean
8 showRateLimits: boolean
9 format: 'compact' | 'verbose'
10}
11
12/** The context window's fill as a whole percentage, or undefined before any response. */
13export function contextPercent(usage: SessionUsage): number | undefined {
14 const { percent, tokens, window } = usage.context
15 if (typeof percent === 'number') return Math.round(percent)
16 if (typeof tokens === 'number' && window > 0) return Math.round((tokens / window) * 100)
17 return undefined
18}
19
20/** `84k`, `1.2M`, `950`: a token count, short. */
21export function shortTokens(n: number): string {
22 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
23 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
24 return String(n)
25}
26
27const LIMIT_LABELS: Record<string, { compact: string; verbose: string }> = {
28 five_hour: { compact: '5h', verbose: '5-hour' },
29 seven_day: { compact: '7d', verbose: '7-day' },
30 spend_limit: { compact: 'spend', verbose: 'Spend limit' },
31}
32
33function pct(n: number): string {
34 return `${Number.isInteger(n) ? n : n.toFixed(1)}%`
35}
36
37/**
38 * The status line: `ctx 42% · 5h 23% · 7d 61% · $1.20` (compact) or
39 * `Context 42% (84k/200k) · 5-hour 23% · 7-day 61% · Cost $1.20` (verbose).
40 * Figures the engine does not have are left out, never zeroed.
41 */
42export function formatMeter(usage: SessionUsage, options: MeterOptions): string {
43 const verbose = options.format === 'verbose'
44 const parts: string[] = []
45 const ctx = contextPercent(usage)
46 const ctxText = ctx === undefined ? '–' : `${ctx}%`
47 if (verbose) {
48 const tokens = usage.context.tokens
49 const detail = typeof tokens === 'number' ? ` (${shortTokens(tokens)}/${shortTokens(usage.context.window)})` : ''
50 parts.push(`Context ${ctxText}${detail}`)
51 } else {
52 parts.push(`ctx ${ctxText}`)
53 }
54 if (options.showRateLimits) {
55 for (const limit of usage.rateLimits) {
56 const label = LIMIT_LABELS[limit.kind]?.[verbose ? 'verbose' : 'compact'] ?? limit.kind
57 parts.push(`${label} ${pct(limit.percentUsed)}`)
58 }
59 }
60 if (options.showCost && usage.cost !== undefined) {
61 parts.push(`${verbose ? 'Cost ' : ''}$${usage.cost.usd.toFixed(2)}`)
62 }
63 return parts.join(' · ')
64}
65
66/**
67 * Whether to toast now: the context just reached `warnAt` and no toast has
68 * been shown since it was last below it. Returns the new "warned" flag too.
69 */
70export function warnStep(percent: number | undefined, warnAt: number, wasWarned: boolean): { toast: boolean; warned: boolean } {
71 if (percent === undefined || warnAt <= 0) return { toast: false, warned: wasWarned }
72 if (percent >= warnAt) return { toast: !wasWarned, warned: true }
73 return { toast: false, warned: false }
74}
75