Context, cost and rate limits in the footer, warnings at thresholds you set, a compact helper, and /gauge for per-turn figures, the context breakdown and spend…

Gauge keeps track of how much context and usage a session spends. The right of the footer, under the prompt, shows the context, the cost and your rate-limit windows. Toasts warn you at thresholds you set. /gauge shows tokens and cost for each turn, what fills the context window, and your spend by day.
ctx 142k/200k 71% · $1.23 · 5h 31% · 7d 12%
The figures sit beside Claude Code's own mode labels at the right of the footer, and update after each reply and when a rate-limit window moves. A figure with no reading yet is left out. They are dim until a figure passes its warning threshold (below), which turns it yellow, and red from 95%.
Each warning fires once when its figure crosses the line. If the figure drops back under, for example after /compact, it warns again the next time it crosses.
| Setting | Default | Warns when |
|---|---|---|
warnTokens | 200000 | the context passes this many tokens |
warnPercent | 85 | the context passes this share of the window |
limitPercent | 90 | a rate-limit window passes this share |
Set any of them to 0 to turn it off.
compact sets what happens when the context passes compactTokens (0, the default, means the same as warnTokens):
suggest (the default): a toast tells you to run /compact. It replaces the token warning, so you get one toast.auto: Gauge compacts the conversation itself, after the running turn ends.off: nothing./gauge/gauge: the current figures, then your last 20 turns, newest first. Each row has the model, uncached input, cache reads, cache writes, output, cache hit rate, cost, how long it ran and how long ago it was. The last row adds them up. Subagent turns are left out of the table, but their cost is in the session total./gauge context: what fills the context window, by category, largest first. It uses a local estimate. /gauge context full counts exactly, the way /context does./gauge history: cost, turns and tokens for each project for each of the last 14 days. History is kept for historyDays days (default 90), across sessions.A turn's cost is how much the session total grew while it ran, so it includes any subagents it ran.
Change them in /config, or under pluginConfigs.gauge in your settings.
Claude Code v2.1.296 or later. Mods are an early access part of Claude Code, so a Claude Code release can break the plugin until it is updated.
/plugin install gauge --marketplace astrosteveo/claude-pluginshooks/register.tsx 281 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, SessionMeasureInput } from 'claude-code'
3
4import type { Day, Measure, Spend, Turn } from '../types'
5import * as fmt from './format'
6
7const measure = atom({ plugin: 'gauge', key: 'measure' } as const, null)
8const turns = atom({ plugin: 'gauge', key: 'turns' } as const, [])
9const warned = atom({ plugin: 'gauge', key: 'warned' } as const, [])
10const start = atom({ plugin: 'gauge', key: 'start' } as const, null)
11const banked = atom({ plugin: 'gauge', key: 'banked' } as const, 0)
12
13const MAX_TURNS = 100
14const DAY_MS = 86_400_000
15
16type Settings = {
17 warnTokens: number
18 warnPercent: number
19 limitPercent: number
20 compact: 'off' | 'suggest' | 'auto'
21 compactTokens: number
22 historyDays: number
23}
24
25function settings(options: PluginOptions): Settings {
26 const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : d)
27 const compact = options.compact === 'off' || options.compact === 'auto' ? options.compact : 'suggest'
28 const warnTokens = num(options.warnTokens, 200_000)
29
30 return {
31 warnTokens,
32 warnPercent: num(options.warnPercent, 85),
33 limitPercent: num(options.limitPercent, 90),
34 compact,
35 compactTokens: num(options.compactTokens, 0) || warnTokens,
36 historyDays: num(options.historyDays, 90),
37 }
38}
39
40function toMeasure(e: Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'>): Measure {
41 return {
42 tokens: e.context.tokens,
43 window: e.context.window,
44 percent: e.context.percent,
45 limits: e.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
46 usd: e.cost?.usd,
47 }
48}
49
50// What each threshold says once the figure passes it, keyed so each crossing
51// acts once and a figure that drops back re-arms.
52function crossings(m: Measure, s: Settings): Map<string, string> {
53 const past = new Map<string, string>()
54 if (s.warnTokens > 0 && m.tokens !== undefined && m.tokens >= s.warnTokens) {
55 past.set('tokens', `Context passed ${fmt.tokens(s.warnTokens)} tokens: now ${fmt.tokens(m.tokens)}.`)
56 }
57 if (s.warnPercent > 0 && m.percent !== undefined && m.percent >= s.warnPercent) {
58 past.set('percent', `Context is ${Math.round(m.percent)}% full.`)
59 }
60 if (s.compact !== 'off' && s.compactTokens > 0 && m.tokens !== undefined && m.tokens >= s.compactTokens) {
61 past.set(
62 'compact',
63 s.compact === 'auto'
64 ? `Context is ${fmt.tokens(m.tokens)} tokens: compacting.`
65 : `Context is ${fmt.tokens(m.tokens)} tokens. Run /compact to free it.`,
66 )
67 }
68 for (const l of m.limits) {
69 if (s.limitPercent > 0 && l.percentUsed >= s.limitPercent) {
70 past.set(`limit:${l.kind}`, `${fmt.limitName(l.kind)} rate limit is ${Math.round(l.percentUsed)}% used.`)
71 }
72 }
73
74 return past
75}
76
77// Whether a main-loop turn is running, and whether an automatic compaction
78// waits for it to end. A reload starts both over, between turns.
79let isRunning = false
80let isCompactDue = false
81// History writes run one after another so none reads a day another is writing.
82let writing: Promise<void> = Promise.resolve()
83
84function compactNow($: EngineInterface) {
85 isCompactDue = false
86 void $.session
87 .compact()
88 .then(r => {
89 if (r.skip !== undefined) $.ui.toast(`Compaction skipped: ${r.skip}`)
90 })
91 .catch(() => $.ui.toast('Gauge could not compact the conversation.'))
92}
93
94function addToday($: EngineInterface, add: Partial<Spend>) {
95 writing = writing.then(async () => {
96 const project = await $.session.root()
97 const key = `day:${fmt.dayKey(await $.clock.now())}`
98 const day = ((await $.store.get(key)) ?? {}) as Day
99 const was = day[project] ?? { usd: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 }
100 const next = { ...was }
101 for (const k of Object.keys(add) as (keyof Spend)[]) next[k] += add[k] ?? 0
102 await $.store.set(key, { ...day, [project]: next })
103 }).catch(() => {})
104
105 return writing
106}
107
108async function pruneHistory($: EngineInterface, days: number) {
109 const cutoff = fmt.dayKey((await $.clock.now()) - days * DAY_MS)
110 for (const key of await $.store.keys()) {
111 if (key.startsWith('day:') && key.slice(4) < cutoff) await $.store.delete(key)
112 }
113}
114
115// The cost the running or last turn added: the session total less the total
116// as it started.
117async function priceTurn($: EngineInterface, usd: number | undefined) {
118 const began = await read($, start)
119 if (usd === undefined || began === null) return
120 await update($, turns, list =>
121 list.map(t => (t.turnId === began.turnId ? { ...t, usd: Math.max(0, usd - began.usd) } : t)),
122 )
123}
124
125
126export const register: Register = (on, options) => {
127 const s = settings(options)
128
129 on('session.start', async ($, e, next) => {
130 const result = await next(e)
131 try {
132 await $.command.register({
133 name: 'gauge',
134 description: 'Context, cost and rate limits; /gauge context for the breakdown, /gauge history for spend by day',
135 })
136 } catch {
137 // A clash with another command leaves the footer and toasts working.
138 }
139 const usage = await $.session.usage()
140 const m = toMeasure(usage)
141 await update($, measure, () => m)
142 // A new session banks from the cost it starts at, so a resumed session's
143 // earlier spend is not counted twice. A reload keeps what it banked.
144 const { version } = await $.state.get({ plugin: 'gauge', key: 'banked' })
145 if (version === 0) await update($, banked, () => m.usd ?? 0)
146 // The figures draw in the footer; clear the notice an earlier version pinned.
147 $.ui.status(undefined)
148 await pruneHistory($, s.historyDays).catch(() => {})
149
150 return result
151 })
152
153 on('turn.start', async ($, e, next) => {
154 isRunning = true
155 const m = await read($, measure)
156 const usd = m?.usd ?? (await $.session.usage()).cost?.usd
157 await update($, start, () => (usd === undefined ? null : { turnId: e.turnId, usd }))
158
159 return next(e)
160 })
161
162 on('turn.complete', async ($, e, next) => {
163 const result = await next(e)
164 const u = e.usage
165 if (u !== undefined) {
166 void addToday($, {
167 input: u.input_tokens,
168 output: u.output_tokens,
169 cacheRead: u.cache_read_input_tokens,
170 cacheWrite: u.cache_creation_input_tokens,
171 turns: e.agentId === undefined ? 1 : 0,
172 })
173 }
174 if (e.agentId !== undefined) return result
175
176 isRunning = false
177 const turn: Turn = {
178 turnId: e.turnId,
179 at: (await $.clock.now()) - e.durationMs,
180 model: u?.model ?? 'unknown',
181 input: u?.input_tokens ?? 0,
182 cacheRead: u?.cache_read_input_tokens ?? 0,
183 cacheWrite: u?.cache_creation_input_tokens ?? 0,
184 output: u?.output_tokens ?? 0,
185 ms: e.durationMs,
186 isAborted: e.isAborted,
187 }
188 await update($, turns, list => [...list.filter(t => t.turnId !== turn.turnId), turn].slice(-MAX_TURNS))
189 // A measurement that came before the turn's end already holds its cost.
190 await priceTurn($, (await read($, measure))?.usd)
191 if (isCompactDue) compactNow($)
192
193 return result
194 })
195
196 on('session.measure', async ($, e, next) => {
197 const m = toMeasure(e)
198 await update($, measure, () => m)
199
200 if (e.changed.includes('cost') && m.usd !== undefined) {
201 await priceTurn($, m.usd)
202 const was = await read($, banked)
203 // A total below what was banked is a ledger that started over.
204 const added = m.usd >= was ? m.usd - was : m.usd
205 await update($, banked, () => m.usd ?? 0)
206 if (added > 0) void addToday($, { usd: added })
207 }
208
209 const past = crossings(m, s)
210 const before = new Set(await read($, warned))
211 const fresh = [...past.keys()].filter(k => !before.has(k))
212 // The compact notice names the tokens already: one toast, not two.
213 const shown = fresh.includes('compact') ? fresh.filter(k => k !== 'tokens') : fresh
214 for (const key of shown) $.ui.toast(past.get(key) ?? '')
215 await update($, warned, () => [...past.keys()])
216 if (fresh.includes('compact') && s.compact === 'auto') {
217 if (isRunning) isCompactDue = true
218 else compactNow($)
219 }
220
221 return next(e)
222 })
223
224 // The figures at the right of the footer, beside the engine's mode labels:
225 // dim, yellow past a warning threshold, red nearly full.
226 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
227 const m = await read($, measure)
228 const parts = m === null ? [] : fmt.statusParts(m, s)
229 if (parts.length === 0) return next(e)
230 const { Box, Text } = $.ui.resolve(e)
231 const modes = e.props.modes.join(' & ')
232
233 return (
234 <Box>
235 {modes !== '' && <Text dimColor>{modes} · </Text>}
236 {parts.flatMap((p, i) => [
237 ...(i > 0 ? [<Text dimColor> · </Text>] : []),
238 p.level === 'ok' ? <Text dimColor>{p.text}</Text> : <Text color={p.level === 'high' ? 'error' : 'warning'}>{p.text}</Text>,
239 ])}
240 </Box>
241 )
242 })
243
244 on('command.run', { command: 'gauge' }, async ($, e) => {
245 const [what = '', detail = ''] = e.args.trim().split(/\s+/)
246
247 if (what === 'context') {
248 const usage = await $.session.usage({
249 breakdown: detail === 'full' ? 'full' : 'summary',
250 columns: e.presentation.columns,
251 })
252 const b = usage.context.breakdown
253 if (b === undefined) return { text: 'No context breakdown is available in this session.' }
254
255 return { text: fmt.breakdown(b.categories, b.totalTokens, b.rawMaxTokens) }
256 }
257
258 if (what === 'history') {
259 await writing
260 const days: [string, Day][] = []
261 const today = await $.clock.now()
262 for (let i = 0; i < 14; i++) {
263 const key = fmt.dayKey(today - i * DAY_MS)
264 const day = (await $.store.get(`day:${key}`)) as Day | undefined
265 if (day !== undefined) days.push([key, day])
266 }
267
268 return { text: `spend, last 14 days\n\n${fmt.history(days)}` }
269 }
270
271 if (what !== '') return { text: 'Usage: /gauge, /gauge context [full], /gauge history' }
272
273 const m = await read($, measure)
274 const list = await read($, turns)
275
276 const now = await $.clock.now()
277
278 return { text: `this session\n\n${fmt.summary(m, now)}\n\n${fmt.turnTable(list.slice(-20), now)}` }
279 })
280}
281hooks/format.ts 217 lines1import type { ContextCategory } from 'claude-code'
2
3import type { Day, Limit, Measure, Spend, Turn } from '../types'
4
5// Token counts read short: 950, 12.3k, 142k, 1.2M.
6export function tokens(n: number): string {
7 if (n < 1_000) return String(Math.round(n))
8 if (n < 10_000) return `${trim(n / 1_000)}k`
9 if (n < 1_000_000) return `${Math.round(n / 1_000)}k`
10
11 return `${trim(n / 1_000_000)}M`
12}
13
14function trim(n: number): string {
15 return n.toFixed(1).replace(/\.0$/, '')
16}
17
18// Dollars: $1.23, and under a cent as <$0.01.
19export function usd(n: number): string {
20 if (n > 0 && n < 0.005) return '<$0.01'
21
22 return `$${n.toFixed(2)}`
23}
24
25// Durations read short: 3.2s, 48s, 2m 5s, 1h 10m.
26export function duration(ms: number): string {
27 const s = ms / 1_000
28 if (s < 10) return `${s.toFixed(1)}s`
29 if (s < 60) return `${Math.round(s)}s`
30 const m = Math.floor(s / 60)
31 if (m < 60) return `${m}m ${Math.round(s % 60)}s`
32
33 return `${Math.floor(m / 60)}h ${m % 60}m`
34}
35
36// A rate-limit window's short name: five_hour is 5h, seven_day 7d.
37export function limitName(kind: string): string {
38 const named: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
39
40 return named[kind] ?? kind.replace(/_/g, ' ')
41}
42
43export function limit(l: Limit): string {
44 return `${limitName(l.kind)} ${Math.round(l.percentUsed)}%`
45}
46
47// How close a figure is to its limit: under its warning, past it, or nearly full.
48export type Level = 'ok' | 'warn' | 'high'
49export type Part = { text: string; level: Level }
50// The warning thresholds; 0 turns one off.
51export type Lines = { warnTokens: number; warnPercent: number; limitPercent: number }
52
53const FULL = 95
54
55function past(n: number | undefined, line: number): boolean {
56 return line > 0 && n !== undefined && n >= line
57}
58
59// The footer's figures, each with its level: ctx 142k/200k 71%, $1.23, 5h 31%.
60export function statusParts(m: Measure, lines: Lines): Part[] {
61 const parts: Part[] = []
62 if (m.tokens !== undefined) {
63 const pct = m.percent === undefined ? '' : ` ${Math.round(m.percent)}%`
64 const level: Level = past(m.percent, FULL)
65 ? 'high'
66 : past(m.tokens, lines.warnTokens) || past(m.percent, lines.warnPercent)
67 ? 'warn'
68 : 'ok'
69 parts.push({ text: `ctx ${tokens(m.tokens)}/${tokens(m.window)}${pct}`, level })
70 }
71 if (m.usd !== undefined) parts.push({ text: usd(m.usd), level: 'ok' })
72 for (const l of m.limits) {
73 const level: Level = l.percentUsed >= FULL ? 'high' : past(l.percentUsed, lines.limitPercent) ? 'warn' : 'ok'
74 parts.push({ text: limit(l), level })
75 }
76
77 return parts
78}
79
80// The figures as one line: ctx 142k/200k 71% · $1.23 · 5h 31% · 7d 12%.
81export function statusLine(m: Measure): string {
82 return statusParts(m, { warnTokens: 0, warnPercent: 0, limitPercent: 0 })
83 .map(p => p.text)
84 .join(' · ')
85}
86
87// What share of a turn's input the prompt cache served, as a whole percent.
88export function hitRate(input: number, cacheRead: number, cacheWrite: number): number {
89 const all = input + cacheRead + cacheWrite
90
91 return all === 0 ? 0 : Math.round((cacheRead / all) * 100)
92}
93
94// A model id without its family prefix: claude-opus-5-5 is opus-5-5.
95export function model(id: string): string {
96 return id.replace(/^claude-/, '')
97}
98
99function table(rows: string[][], right: boolean[]): string {
100 const widths = (rows[0] ?? []).map((_, i) => Math.max(...rows.map(r => (r[i] ?? '').length)))
101
102 return rows
103 .map(r => r.map((cell, i) => (right[i] ? cell.padStart(widths[i] ?? 0) : cell.padEnd(widths[i] ?? 0))).join(' ').trimEnd())
104 .join('\n')
105}
106
107// How long until a time: 42m, 4h 10m, 6d 7h.
108export function until(at: string, now: number): string {
109 const ms = Date.parse(at) - now
110 if (!Number.isFinite(ms)) return at
111 const m = Math.max(0, Math.round(ms / 60_000))
112 if (m < 60) return `${m}m`
113 if (m < 1_440) return `${Math.floor(m / 60)}h ${m % 60}m`
114
115 return `${Math.floor(m / 1_440)}d ${Math.floor((m % 1_440) / 60)}h`
116}
117
118// The current figures, one per line.
119export function summary(m: Measure | null, now: number): string {
120 if (m === null) return 'No measurement yet: figures arrive after the first reply.'
121 const lines: string[] = []
122 if (m.tokens !== undefined) {
123 const pct = m.percent === undefined ? '' : ` (${Math.round(m.percent)}%)`
124 lines.push(`Context ${tokens(m.tokens)} of ${tokens(m.window)}${pct}`)
125 } else {
126 lines.push(`Context window ${tokens(m.window)}`)
127 }
128 if (m.usd !== undefined) lines.push(`Cost ${usd(m.usd)}`)
129 for (const l of m.limits) {
130 lines.push(`${limitName(l.kind).padEnd(10)}${Math.round(l.percentUsed)}% used${l.resetsAt ? `, resets in ${until(l.resetsAt, now)}` : ''}`)
131 }
132
133 return lines.join('\n')
134}
135
136// The last turns, newest first, with a totals row.
137export function turnTable(turns: Turn[], now: number): string {
138 if (turns.length === 0) return 'No turns yet.'
139 const head = ['model', 'in', 'cache rd', 'cache wr', 'out', 'hit', 'cost', 'time', 'ago']
140 const rows = [...turns].reverse().map(t => [
141 model(t.model),
142 tokens(t.input),
143 tokens(t.cacheRead),
144 tokens(t.cacheWrite),
145 tokens(t.output),
146 `${hitRate(t.input, t.cacheRead, t.cacheWrite)}%`,
147 t.usd === undefined ? '-' : usd(t.usd),
148 t.isAborted ? 'stopped' : duration(t.ms),
149 duration(Math.max(0, now - t.at)),
150 ])
151 const sum = (f: (t: Turn) => number) => turns.reduce((a, t) => a + f(t), 0)
152 const input = sum(t => t.input)
153 const cacheRead = sum(t => t.cacheRead)
154 const cacheWrite = sum(t => t.cacheWrite)
155 const priced = turns.filter(t => t.usd !== undefined)
156 const total = [
157 turns.length === 1 ? '1 turn' : `${turns.length} turns`,
158 tokens(input),
159 tokens(cacheRead),
160 tokens(cacheWrite),
161 tokens(sum(t => t.output)),
162 `${hitRate(input, cacheRead, cacheWrite)}%`,
163 priced.length === 0 ? '-' : usd(priced.reduce((a, t) => a + (t.usd ?? 0), 0)),
164 duration(sum(t => t.ms)),
165 '',
166 ]
167
168 return table([head, ...rows, total], [false, true, true, true, true, true, true, true, true])
169}
170
171// The context window by category, largest first, free space last.
172export function breakdown(categories: readonly ContextCategory[], total: number, max: number): string {
173 const used = categories.filter(c => c.kind === 'used' && c.tokens > 0).sort((a, b) => b.tokens - a.tokens)
174 const rest = categories.filter(c => c.kind === 'free' || c.kind === 'buffer')
175 const share = (n: number) => `${max === 0 ? 0 : Math.round((n / max) * 100)}%`
176 const rows = [...used, ...rest].map(c => [c.name, tokens(c.tokens), share(c.tokens)])
177 const deferred = categories.filter(c => c.kind === 'deferred' && c.tokens > 0)
178 const out = [
179 `context window, ${tokens(total)} of ${tokens(max)} (${share(total)})`,
180 '',
181 table(rows, [false, true, true]),
182 ]
183 if (deferred.length > 0) {
184 out.push('', `Loaded on demand, outside the window: ${deferred.map(c => `${c.name} ${tokens(c.tokens)}`).join(', ')}`)
185 }
186
187 return out.join('\n')
188}
189
190// The history: one row per day and project, newest first, with totals.
191export function history(days: [string, Day][]): string {
192 const rows: string[][] = []
193 const all: Spend = { usd: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 }
194 for (const [date, day] of days) {
195 for (const [project, s] of Object.entries(day).sort((a, b) => b[1].usd - a[1].usd)) {
196 rows.push([date, base(project), usd(s.usd), String(s.turns), tokens(s.input + s.cacheRead + s.cacheWrite), tokens(s.output)])
197 for (const k of Object.keys(all) as (keyof Spend)[]) all[k] += s[k]
198 }
199 }
200 if (rows.length === 0) return 'No history yet.'
201 const total = ['total', '', usd(all.usd), String(all.turns), tokens(all.input + all.cacheRead + all.cacheWrite), tokens(all.output)]
202
203 return table([['day', 'project', 'cost', 'turns', 'in', 'out'], ...rows, total], [false, false, true, true, true, true])
204}
205
206function base(path: string): string {
207 return path.split('/').filter(Boolean).pop() ?? path
208}
209
210// A local calendar day, YYYY-MM-DD.
211export function dayKey(ms: number): string {
212 const d = new Date(ms)
213 const pad = (n: number) => String(n).padStart(2, '0')
214
215 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
216}
217types/index.d.ts 57 lines1// One rate-limit window as the last measurement reported it.
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3
4// The session's latest measurement. `tokens` and `percent` are absent before
5// the first response; `usd` where the host keeps no cost ledger.
6export type Measure = {
7 tokens?: number
8 window: number
9 percent?: number
10 limits: Limit[]
11 usd?: number
12}
13
14// One main-loop turn: who answered, its four token counts, how long it ran
15// and what it cost. `usd` arrives with the first measurement after the turn.
16export type Turn = {
17 turnId: string
18 at: number
19 model: string
20 input: number
21 cacheRead: number
22 cacheWrite: number
23 output: number
24 ms: number
25 isAborted: boolean
26 usd?: number
27}
28
29// One project's figures for one day of history.
30export type Spend = {
31 usd: number
32 input: number
33 output: number
34 cacheRead: number
35 cacheWrite: number
36 turns: number
37}
38
39// A day of history, keyed by project root.
40export type Day = Record<string, Spend>
41
42declare module 'claude-code' {
43 interface PluginState {
44 gauge: {
45 measure: Measure | null
46 // The last turns, oldest first.
47 turns: Turn[]
48 // The thresholds past which a figure is now, so each crossing acts once.
49 warned: string[]
50 // The session's cost as the running turn started.
51 start: { turnId: string; usd: number } | null
52 // The session cost already added to the history.
53 banked: number
54 }
55 }
56}
57