Context and plan-limit gauges in the band above the prompt

╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Context ━━━━━━━━━━━━╋─────── 63% 630k/1M [ Compact ] you@example.com · Claude Max │
│ 5h ━━━━━━━╾─╂────────── 38% resets in 2h35m Opus 5.5 │
│ Week ━━━━━╾───╂────────── 27% resets in 3d14h xhigh effort · thinking on │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯
claude plugin marketplace add Alchez/claude-mods
claude plugin install barometer@alchez
/reload-plugins./config.| Key | Default | What it does |
|---|---|---|
redLine1M | 60 | Red line, as a context %, for 1M-token windows; 0 turns it off |
redLine200k | 80 | Red line for smaller windows; 0 turns it off |
alwaysShowCompact | false | Show the Compact button at any time, not only past the red line |
claude plugin marketplace update alchez && claude plugin update barometer@alchez
claude plugin uninstall barometer@alchez
hooks/register.tsx 471 lines1import { atom, read, update } from 'claude-code'
2import type { ConfigRow, EngineInterface, PluginOptions, Register, RenderElement, SessionCompactResult, SessionRateLimit, SessionUsage } from 'claude-code'
3
4import type { Account, Effort, RememberedLimit } from '../types'
5
6const account = atom({ plugin: 'barometer', key: 'account' } as const, null as Account)
7const isCompacting = atom({ plugin: 'barometer', key: 'isCompacting' } as const, false)
8const compactedTokens = atom({ plugin: 'barometer', key: 'compactedTokens' } as const, null as number | null)
9const effort = atom({ plugin: 'barometer', key: 'effort' } as const, null as Effort)
10const lastLimits = atom({ plugin: 'barometer', key: 'lastLimits' } as const, [] as RememberedLimit[])
11
12const MINUTE = 60_000
13const HOUR = 60 * MINUTE
14const DAY = 24 * HOUR
15
16// xterm-256 colors 46, 118, 226, 208, 196, 117 and 250, as hex
17const GREEN = '#00ff00'
18const YELLOW_GREEN = '#87ff00'
19const YELLOW = '#ffff00'
20const ORANGE = '#ff8700'
21const RED = '#ff0000'
22const ACCOUNT = '#87d7ff'
23const ELAPSED = '#bcbcbc'
24
25const FILLED = '━'
26// Heavy left half, light right half: a fill that ends mid-cell
27const HALF_FILLED = '╾'
28const EMPTY = '─'
29const MARK_ON_FILLED = '╋'
30const MARK_ON_EMPTY = '╂'
31
32const REFRESH_MS = 5_000
33const LABEL_COLUMNS = 8
34const PERCENT_COLUMNS = 5
35const COMPACT_BELOW_COLUMNS = 60
36const FOLDED_GAP_COLUMNS = 3
37const PAD_FROM_ROWS = 5
38const FRAME_FROM_ROWS = 6
39const FRAME_ROWS = 2
40const FRAME_MARGIN_LEFT = 2
41const FRAME_PADDING_X = 1
42const FRAME_COLUMNS = FRAME_MARGIN_LEFT + 2 + 2 * FRAME_PADDING_X
43const IDENTITY_COLUMN_FROM = 100
44const PROJECT_AFTER_ELAPSED_PERCENT = 5
45
46const PLAN_WINDOWS = [
47 { kind: 'five_hour', label: '5h', lengthMs: 5 * HOUR },
48 { kind: 'seven_day', label: 'Week', lengthMs: 7 * DAY },
49] as const
50
51type Style = { color?: string; dimColor?: true }
52type Cell = Style & { glyph: string }
53type Marker = { atPercent: number; color: string }
54type Pace = { resetsInMs?: number; elapsedPercent?: number; runsOutInMs?: number }
55type Plan = (typeof PLAN_WINDOWS)[number] & { limit: SessionRateLimit; pace: Pace }
56// null: the person set 0, which turns that red line off
57type RedLines = { million: number | null; standard: number | null }
58
59const GRADIENT = [GREEN, YELLOW_GREEN, YELLOW, ORANGE]
60const PLAN_LIMITS = [50, 70, 85, 94] as const
61// Each context colour ends at this share of the red line: 60 gives 30/45/52/60, 80 gives 40/60/70/80
62const CONTEXT_STEPS_OF_RED_LINE = [0.5, 0.75, 0.875, 1] as const
63
64function gradient(percent: number, limits: readonly number[]): string {
65 const step = limits.findIndex(limit => Math.round(percent) <= limit)
66 return GRADIENT[step] ?? RED
67}
68
69function redLineOption(options: PluginOptions, name: string): number | null {
70 const value = options[name]
71 if (typeof value !== 'number') throw new Error(`barometer: option ${name} should be a number, got ${JSON.stringify(value)}`)
72 return value === 0 ? null : value
73}
74
75function booleanOption(options: PluginOptions, name: string): boolean {
76 const value = options[name]
77 if (typeof value !== 'boolean') throw new Error(`barometer: option ${name} should be true or false, got ${JSON.stringify(value)}`)
78 return value
79}
80
81const planColor = (percent: number) => gradient(percent, PLAN_LIMITS)
82const redLine = (window: number, redLines: RedLines) => (window >= 1_000_000 ? redLines.million : redLines.standard)
83// undefined with the red line off: the bar and percent keep the terminal's own text colour
84function contextColor(percent: number, window: number, redLines: RedLines): string | undefined {
85 const line = redLine(window, redLines)
86 if (line === null) return undefined
87 return gradient(
88 percent,
89 CONTEXT_STEPS_OF_RED_LINE.map(share => Math.floor(line * share)),
90 )
91}
92const colorOf = (color: string | undefined) => (color === undefined ? {} : { color })
93const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value))
94
95function duration(ms: number): string {
96 const days = Math.floor(ms / DAY)
97 const hours = Math.floor((ms % DAY) / HOUR)
98 const minutes = Math.floor((ms % HOUR) / MINUTE)
99 if (days > 0) return `${days}d${hours}h`
100 if (hours > 0) return `${hours}h${String(minutes).padStart(2, '0')}m`
101 return `${minutes}m`
102}
103
104function tokenCount(count: number): string {
105 if (count >= 1_000_000) return `${Number((count / 1_000_000).toFixed(1))}M`
106 if (count >= 1_000) return `${Math.round(count / 1_000)}k`
107 return String(count)
108}
109
110function modelName(raw: string): string {
111 if (/\s/.test(raw)) return raw
112 const [family, ...version] = raw
113 .replace(/^claude-/, '')
114 .replace(/\[1m\]$/i, '')
115 .split('-')
116 .filter(part => !/^\d{8}$/.test(part))
117 if (!family) return raw
118 const name = family.charAt(0).toUpperCase() + family.slice(1)
119 return version.length > 0 ? `${name} ${version.join('.')}` : name
120}
121
122function reasoningLine(reading: Effort, isThinking: boolean | undefined): string | null {
123 const parts: string[] = []
124 if (reading === null) parts.push('? effort')
125 else if (reading.level !== null) parts.push(`${reading.level} effort`)
126 if (isThinking !== undefined) parts.push(`thinking ${isThinking ? 'on' : 'off'}`)
127 return parts.length > 0 ? parts.join(' · ') : null
128}
129
130function pace(limit: SessionRateLimit, lengthMs: number, now: number): Pace {
131 const resetsAt = limit.resetsAt === undefined ? Number.NaN : Date.parse(limit.resetsAt)
132 if (!Number.isFinite(resetsAt) || resetsAt <= now) return {}
133 const startedAt = resetsAt - lengthMs
134 const elapsedMs = Math.max(0, now - startedAt)
135 const elapsedPercent = Math.min(100, (elapsedMs / lengthMs) * 100)
136 const known = { resetsInMs: resetsAt - now, elapsedPercent }
137 const canProject = elapsedPercent >= PROJECT_AFTER_ELAPSED_PERCENT && limit.percentUsed > 0 && limit.percentUsed < 100
138 if (!canProject) return known
139 const runsOutAt = startedAt + (elapsedMs / limit.percentUsed) * 100
140 return runsOutAt < resetsAt ? { ...known, runsOutInMs: runsOutAt - now } : known
141}
142
143function meterCells(columns: number, percent: number | undefined, color: string | undefined, marker?: Marker): Cell[] {
144 const filledHalves = percent === undefined ? 0 : Math.round((clamp(percent, 0, 100) / 100) * columns * 2)
145 const markerAt = marker === undefined ? -1 : Math.min(columns - 1, Math.floor((marker.atPercent / 100) * columns))
146 return Array.from({ length: columns }, (_, index): Cell => {
147 const halvesHere = clamp(filledHalves - index * 2, 0, 2)
148 if (marker !== undefined && index === markerAt) {
149 return { glyph: halvesHere > 0 ? MARK_ON_FILLED : MARK_ON_EMPTY, color: marker.color }
150 }
151 if (halvesHere === 2) return { glyph: FILLED, ...colorOf(color) }
152 if (halvesHere === 1) return { glyph: HALF_FILLED, ...colorOf(color) }
153 return { glyph: EMPTY, dimColor: true }
154 })
155}
156
157function runs(cells: readonly Cell[]): Array<{ style: Style; glyphs: string }> {
158 const grouped: Array<{ style: Style; glyphs: string }> = []
159 for (const { glyph, ...style } of cells) {
160 const last = grouped.at(-1)
161 const isSameStyle = last !== undefined && last.style.color === style.color && last.style.dimColor === style.dimColor
162 if (isSameStyle) last.glyphs += glyph
163 else grouped.push({ style, glyphs: glyph })
164 }
165 return grouped
166}
167
168function nextReset(resetsAt: number, lengthMs: number, now: number): number {
169 let next = resetsAt
170 while (next <= now) next += lengthMs
171 return next
172}
173
174// A window past its reset is drawn as the fresh one, starting where the old one ended: what a new
175// session reads from Claude Code then. A long-running session stops reporting the window instead.
176function plansOf(usage: SessionUsage, remembered: readonly RememberedLimit[], now: number): Plan[] {
177 return PLAN_WINDOWS.flatMap(window => {
178 const reported = usage.rateLimits.find(candidate => candidate.kind === window.kind)
179 const known = reported ?? remembered.find(candidate => candidate.kind === window.kind)
180 if (known === undefined) return []
181 const resetsAt = known.resetsAt === undefined ? Number.NaN : Date.parse(known.resetsAt)
182 const hasReset = Number.isFinite(resetsAt) && resetsAt <= now
183 if (reported === undefined && !hasReset) return []
184 const limit: SessionRateLimit = hasReset
185 ? { kind: known.kind, percentUsed: 0, resetsAt: new Date(nextReset(resetsAt, window.lengthMs, now)).toISOString() }
186 : known
187 return [{ ...window, limit, pace: pace(limit, window.lengthMs, now) }]
188 })
189}
190
191async function rememberLimits($: EngineInterface, reported: readonly SessionRateLimit[]) {
192 if (reported.length === 0) return
193 const known = await read($, lastLimits)
194 const merged = [...known.filter(limit => !reported.some(fresh => fresh.kind === limit.kind)), ...reported]
195 if (JSON.stringify(merged) !== JSON.stringify(known)) await update($, lastLimits, () => merged)
196}
197
198async function rememberCompaction($: EngineInterface, result: SessionCompactResult) {
199 if (result.skip !== undefined) return
200 await update($, compactedTokens, () => result.tokensAfter ?? null)
201}
202
203async function refreshAccount($: EngineInterface) {
204 try {
205 const status = await $.process.run(['claude', 'auth', 'status', '--text'], { timeoutMs: 10_000 })
206 const email = /^Email: (.+)$/m.exec(status.stdout)?.[1]?.trim() ?? null
207 // "Login method: Claude Max account" names the plan as Claude Code's welcome banner does ("Claude Max")
208 const plan = /^Login method: (.+?)(?: account)?$/m.exec(status.stdout)?.[1]?.trim() ?? null
209 await update($, account, () => ({ email, plan }))
210 } catch {
211 // claude missing from PATH or the call timed out: the band leaves the account out
212 }
213}
214
215async function compactNow($: EngineInterface) {
216 if (await read($, isCompacting)) return
217 await update($, isCompacting, () => true)
218 try {
219 await rememberCompaction($, await $.session.compact())
220 } catch (error) {
221 $.ui.toast(`Compact failed: ${error instanceof Error ? error.message : String(error)}`)
222 } finally {
223 await update($, isCompacting, () => false)
224 }
225}
226
227export const register: Register = (on, options) => {
228 const redLines: RedLines = { million: redLineOption(options, 'redLine1M'), standard: redLineOption(options, 'redLine200k') }
229 const alwaysShowCompact = booleanOption(options, 'alwaysShowCompact')
230
231 on('session.start', async ($, e, next) => {
232 $.clock.after(0, () => void refreshAccount($))
233 $.clock.every(REFRESH_MS, async () => {
234 await rememberLimits($, (await $.session.usage()).rateLimits)
235 $.ui.invalidate('ui.render')
236 })
237 return next(e)
238 })
239
240 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
241 $.clock.after(0, () => void refreshAccount($))
242 return next(e)
243 })
244
245 on('session.measure', async ($, e, next) => {
246 if (e.context.tokens !== undefined && (await read($, compactedTokens)) !== null) {
247 await update($, compactedTokens, () => null)
248 }
249 $.ui.invalidate('ui.render')
250 return next(e)
251 })
252
253 // Effort comes off the main conversation's requests: it has no /config row, and $.process.run's env carries no CLAUDE_EFFORT
254 on('turn.step', async function* ($, e, next) {
255 if (e.agentId === undefined) {
256 const level = e.effort === undefined ? null : typeof e.effort === 'number' ? tokenCount(e.effort) : e.effort
257 const known = await read($, effort)
258 if (known === null || known.level !== level) await update($, effort, () => ({ level }))
259 }
260 return yield* next(e)
261 })
262
263 on('command.run', { command: ['effort', 'model'] }, async ($, e, next) => {
264 const result = await next(e)
265 await update($, effort, () => null)
266 return result
267 })
268
269 on('session.compact', async ($, e, next) => {
270 const result = await next(e)
271 if (e.agentId === undefined && e.trigger !== 'precompute') await rememberCompaction($, result)
272 return result
273 })
274
275 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
276 const below = await next(e)
277 if (e.props.hasSurvey) return below
278
279 const [usage, rawModel, identity, compacting, compacted, effortReading, remembered, configRows, now] = await Promise.all([
280 $.session.usage(),
281 $.session.model(),
282 read($, account),
283 read($, isCompacting),
284 read($, compactedTokens),
285 read($, effort),
286 read($, lastLimits),
287 $.config.list().catch((): ConfigRow[] => []),
288 $.clock.now(),
289 ])
290 const { Box, Button, Text } = $.ui.resolve(e)
291 const { bodyColumns, isWorking, maxRows } = e.props
292 const model = modelName(rawModel)
293 const email = identity?.email ?? null
294 const plan = identity?.plan ?? null
295 const accountLength = (email?.length ?? 0) + (plan === null ? 0 : plan.length + (email === null ? 0 : 3))
296 const accountTexts: RenderElement[] = []
297 if (email !== null) accountTexts.push(<Text color={ACCOUNT}>{email}</Text>)
298 if (plan !== null) accountTexts.push(<Text dimColor>{email === null ? plan : ` · ${plan}`}</Text>)
299 const thinkingValue = configRows.find(row => row.key === 'thinking')?.value
300 const reasoning = reasoningLine(effortReading, typeof thinkingValue === 'boolean' ? thinkingValue : undefined)
301 const contextWindow = usage.context.window
302 const contextTokens = usage.context.tokens ?? compacted ?? undefined
303 const contextPercent =
304 usage.context.percent ?? (compacted === null ? undefined : Math.round((compacted / contextWindow) * 100))
305 const plans = plansOf(usage, remembered, now)
306 const showHint = plans.length === 0 && contextPercent === undefined
307 const isFramed = maxRows >= FRAME_FROM_ROWS
308 const columns = isFramed ? bodyColumns - FRAME_COLUMNS : bodyColumns
309 const isCompact = columns < COMPACT_BELOW_COLUMNS || maxRows < 3
310 const topPadding = maxRows - (isFramed ? FRAME_ROWS : 0) >= PAD_FROM_ROWS ? 1 : 0
311 // No bottom margin: Claude Code already leaves a blank row between the band and the prompt
312 const frame = (content: RenderElement) =>
313 isFramed ? (
314 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={FRAME_PADDING_X} marginLeft={FRAME_MARGIN_LEFT}>
315 {content}
316 </Box>
317 ) : (
318 content
319 )
320
321 // flexShrink 0 on the bar and its label and percent columns: only the detail text gives way when a row runs long
322 const strip = (cells: readonly Cell[]) => (
323 <Box flexDirection="row" flexShrink={0}>
324 {runs(cells).map(run => (
325 <Text {...run.style}>{run.glyphs}</Text>
326 ))}
327 </Box>
328 )
329 const separator = <Text dimColor>{' | '}</Text>
330 const hint = <Text dimColor>Rate: populates after first API call</Text>
331 const contextRedLine = redLine(contextWindow, redLines)
332 const isPastRedLine = contextPercent !== undefined && contextRedLine !== null && contextPercent >= contextRedLine
333 const compactButton =
334 (alwaysShowCompact || isPastRedLine) && !isWorking && !compacting ? [<Button key="compact" label="Compact" onPress={() => void compactNow($)} />] : []
335
336 if (isCompact) {
337 const parts: RenderElement[][] = []
338 if (columns >= IDENTITY_COLUMN_FROM) {
339 if (accountTexts.length > 0) parts.push(accountTexts)
340 parts.push([<Text dimColor>{model}</Text>])
341 if (reasoning !== null) parts.push([<Text dimColor>{reasoning}</Text>])
342 }
343 if (contextPercent !== undefined) {
344 parts.push([
345 <Text {...colorOf(contextColor(contextPercent, contextWindow, redLines))}>{`Context: ${contextPercent}%`}</Text>,
346 ...(compactButton.length > 0 ? [<Text> </Text>, ...compactButton] : []),
347 ])
348 } else if (compactButton.length > 0) {
349 parts.push(compactButton)
350 }
351 for (const plan of plans) {
352 const used = Math.round(plan.limit.percentUsed)
353 const reset = plan.pace.resetsInMs === undefined ? [] : [<Text dimColor>{` (resets in ${duration(plan.pace.resetsInMs)})`}</Text>]
354 parts.push([<Text color={planColor(used)}>{`${plan.label}: ${used}%`}</Text>, ...reset])
355 }
356 if (showHint) parts.push([hint])
357 return (
358 <Box flexDirection="column" paddingTop={topPadding}>
359 {below}
360 {frame(
361 // Spaced, not " | "-separated: a separator ends up dangling at the end of a wrapped line
362 <Box flexDirection="row" flexWrap="wrap" columnGap={FOLDED_GAP_COLUMNS}>
363 {parts.map(part => (
364 <Box flexDirection="row" flexShrink={0}>
365 {part}
366 </Box>
367 ))}
368 </Box>,
369 )}
370 </Box>
371 )
372 }
373
374 const showIdentityColumn = columns >= IDENTITY_COLUMN_FROM
375 const identityColumns = showIdentityColumn ? Math.max(accountLength, model.length, reasoning?.length ?? 0) + 2 : 0
376 const available = columns - LABEL_COLUMNS - PERCENT_COLUMNS - 2 - identityColumns
377 const barColumns = clamp(Math.floor(available * 0.4), 10, 40)
378
379 const gauge = (label: string, cells: readonly Cell[], percent: RenderElement, detail: RenderElement[]) => (
380 <Box flexDirection="row">
381 <Box width={LABEL_COLUMNS} flexShrink={0}>
382 <Text>{label}</Text>
383 </Box>
384 {strip(cells)}
385 <Box width={PERCENT_COLUMNS} justifyContent="flex-end" flexShrink={0}>
386 {percent}
387 </Box>
388 <Box marginLeft={2} flexGrow={1} flexDirection="row" columnGap={1} overflow="hidden">
389 {detail}
390 </Box>
391 </Box>
392 )
393
394 const contextFill = contextColor(contextPercent ?? 0, contextWindow, redLines)
395 const tokensShown = contextTokens === undefined ? '?' : tokenCount(contextTokens)
396 const contextRow = gauge(
397 'Context',
398 meterCells(barColumns, contextPercent, contextFill, contextRedLine === null ? undefined : { atPercent: contextRedLine, color: RED }),
399 contextPercent === undefined ? <Text dimColor>–</Text> : <Text {...colorOf(contextFill)}>{`${contextPercent}%`}</Text>,
400 [<Text dimColor wrap="truncate-end">{`${tokensShown}/${tokenCount(contextWindow)}`}</Text>, ...compactButton],
401 )
402
403 const planRows = plans.map(plan => {
404 const used = Math.round(plan.limit.percentUsed)
405 const color = planColor(used)
406 const elapsed = plan.pace.elapsedPercent
407 const marker = elapsed === undefined ? undefined : { atPercent: elapsed, color: plan.limit.percentUsed > elapsed ? ORANGE : ELAPSED }
408 const detail: RenderElement[] = []
409 if (plan.pace.resetsInMs !== undefined) {
410 detail.push(
411 <Box flexShrink={0}>
412 <Text dimColor>{`resets in ${duration(plan.pace.resetsInMs)}`}</Text>
413 </Box>,
414 )
415 }
416 if (plan.pace.runsOutInMs !== undefined) {
417 detail.push(<Text dimColor>·</Text>)
418 detail.push(<Text color={ORANGE} wrap="truncate-end">{`out in ~${duration(plan.pace.runsOutInMs)} at this pace`}</Text>)
419 }
420 return gauge(plan.label, meterCells(barColumns, plan.limit.percentUsed, color, marker), <Text color={color}>{`${used}%`}</Text>, detail)
421 })
422
423 const gauges = (
424 <Box flexDirection="column" flexGrow={1}>
425 {contextRow}
426 {planRows}
427 {showHint ? hint : null}
428 </Box>
429 )
430 const identityColumn = (
431 <Box flexDirection="column" alignItems="flex-end" marginLeft={2} flexShrink={0}>
432 {accountTexts.length === 0 ? null : <Box flexDirection="row">{accountTexts}</Box>}
433 <Text dimColor>{model}</Text>
434 {reasoning === null ? null : <Text dimColor>{reasoning}</Text>}
435 </Box>
436 )
437 // One row, never two: the band's row budget counts it as one, so the tail truncates instead of wrapping
438 const identityRow =
439 !showIdentityColumn && maxRows >= 4 ? (
440 <Box flexDirection="row" overflow="hidden">
441 {accountTexts.length === 0 ? null : (
442 <Box flexDirection="row" flexShrink={0}>
443 {accountTexts}
444 {separator}
445 </Box>
446 )}
447 <Box flexDirection="row" flexShrink={0}>
448 <Text dimColor>{model}</Text>
449 {reasoning === null ? null : separator}
450 </Box>
451 {reasoning === null ? null : <Text dimColor wrap="truncate-end">{reasoning}</Text>}
452 </Box>
453 ) : null
454
455 return (
456 <Box flexDirection="column" paddingTop={topPadding}>
457 {below}
458 {frame(
459 <Box flexDirection="column">
460 {identityRow}
461 <Box flexDirection="row">
462 {gauges}
463 {showIdentityColumn ? identityColumn : null}
464 </Box>
465 </Box>,
466 )}
467 </Box>
468 )
469 })
470}
471types/index.d.ts 21 lines1// From `claude auth status --text`; null until it answers
2export type Account = { email: string | null; plan: string | null } | null
3
4// null until the main conversation's next model request; level null for a model that takes no effort
5export type Effort = { level: string | null } | null
6
7// The last reading of each plan window, kept because Claude Code stops reporting a window once it resets
8export type RememberedLimit = { kind: string; percentUsed: number; resetsAt?: string }
9
10declare module 'claude-code' {
11 interface PluginState {
12 barometer: {
13 account: Account
14 isCompacting: boolean
15 compactedTokens: number | null
16 effort: Effort
17 lastLimits: RememberedLimit[]
18 }
19 }
20}
21