A side pane that shows context fill, rate-limit burn pace, cost and the largest context categories, drawn outside the model's context.

A side pane that shows what a session costs while you work: context fill, how fast you are burning the 5-hour and weekly rate-limit windows, the session cost, and the largest parts of the context. The pane is drawn outside the model's context, so it adds no tokens to any turn.
It needs Claude Code 2.1.302 or newer, whose $.session.usage(), session.measure event and $.ui.open pane it uses.
┌ meter ─────────────────────────────────────────┐
│ ◔ CONTEXT 142k / 200k 85% of 167k │ yellow
│ compacts at 167k · cache hit 96% │
│ │
│ ◷ 5-HOUR 48% · 62% of window elapsed │ green
│ pace 0.77× ● resets 4:15pm │
│ │
│ ⧖ WEEKLY 71% · 43% of window elapsed │ red
│ pace 1.66× ● hits 100% Wed 2:25pm, resets Sat 9:00am
│ │
│ ¢ COST $4.82 this session │
│ │
│ TOP CONTEXT ↻ [r] refresh · 3m ago │
│ Messages 88k ██████ │
│ MCP tools 21k █▌ │
│ Custom agents 14k █ │
│ Skills 8k ▌ │
│ Memory files 6k ▌ │
│ ⬡ mcp servers: github 9k, ha 6k │
└─────────────────────────────────────────────────┘
/meter toggles the pane. If a meter pane is listed by $.ui.panes(), placed or not, it closes. Otherwise it opens. A pane opened from /meter counts as asked, so the engine places it from 110 terminal columns. Narrower than that it waits undrawn, which is not an error and writes nothing to the debug log./meter refresh takes a full count (see Refresh policy). It works without focus. While the pane is closed it opens the pane and takes the full count in place of the open-time summary.meterAutoOpen (default false) opens the pane at session start. An unasked pane is placed only from 144 columns; narrower, it waits for /meter.-p) no command is registered and nothing opens./clear fires session.end with reason: "clear" and no session.start follows, so the plugin registers /meter again inside $.clock.after(0, ...). The cost ledger and startedAt restart at a clear, so the COST row restarts too. Whether the pane itself stays open across a clear is not stated by the declarations and has not been checked live.(windowLength - (resetsAt - now)) / windowLength, with windowLength 5 h for five_hour and 7 d for seven_day. A resetsAt in the past, missing, or not a date gives no elapsed share. The share is clamped to [0, 1], so a resetsAt more than one window ahead (clock skew) gives 0, which reads as too early.too early in place of pace, and is uncolored.percentUsed / (100 x elapsed share). Colored from the unrounded value: green below 0.9, yellow from 0.9 to 1.1 inclusive, red above 1.1. Displayed rounded to two places.now + (100 - percentUsed) x (elapsedTime / percentUsed), drawn as hits 100% <time>.five_hour or seven_day window at percentUsed 100 or more is red and reads limit reached with its reset time. No pace and no projection are shown. This wins over a missing or past resetsAt: the row is still red and reads limit reached, with no reset time.spend_limit and any other window kind have no known length, so no pace or projection. Red when percentUsed is 100 or more, otherwise uncolored.tokens / threshold when an auto-compact threshold is cached from a breakdown, else tokens / window. Green below 70%, yellow from 70% to 90% inclusive, red above 90%. The plain call's tokens is compared, never the breakdown's totalTokens. The percent is not capped, so a context over the window reads above 100%. Before the first response the row reads waiting for first response and is uncolored.● after the pace carries only the band color.cache_read_input_tokens / (input_tokens + cache_read_input_tokens + cache_creation_input_tokens), as a whole percent. Shown as cache hit — when apiUsage is null, no breakdown has been taken, or the denominator is 0.kind === "used" (never matched by name), largest first, ties by name in code-point order, at most 5. Each row shows the category's name unchanged, as /context prints it. A bar is drawn per row, scaled to the largest row, in half blocks.mcpTools[].tokens per server, counting only tools with isLoaded: true, and lists the 2 largest, ties by server name. It is drawn whether or not the MCP category is used, deferred or absent, and omitted when no loaded tool remains.<n>s ago under 60 s, <n>m ago under 60 min, <n>h ago beyond.h:mma when the time is today, ddd h:mma otherwise, in local time, rounded to the nearest minute with half a minute rounding up.$.session.usage() call for the headline figures and one breakdown: "summary" call for the cached figures. A summary is a local estimate with no network request.session.measure (while the pane is open): the headline rows redraw from the event's own context, rateLimits and cost. No $.session.usage() call.changed includes context and the last breakdown is 60 s old or older, a breakdown: "summary" call refreshes the cached figures. The threshold, the cache hit and TOP CONTEXT come only from a breakdown.[r] refresh button and /meter refresh call breakdown: "full". A full count sends one token-count API request per tool and per memory file, as /context does. The r hotkey fires only while the pane has focus (ctrl+x tab, a click, or opening with focus); typing r in the composer does nothing.meter: ... and marks the TOP CONTEXT header stale. The pane keeps its last figures, and the turn is never affected. The marker clears on the next successful breakdown.| Setting | Default | What it does |
|---|---|---|
meterAutoOpen | false | Opens the pane at session start. |
meterGlyphs | unicode | unicode or nerd. Any other stored value is treated as unset by the engine, so the default applies. |
Each slot is preceded by its glyph and one space. A plugin cannot detect the terminal's font and Nerd Font glyphs draw as boxes without a patched font, so unicode is the default and nerd is opt-in. Glyphs sit only at the start of a slot's text, never between a value and the bar or figure after it; the TOP CONTEXT category rows get none. The bars and the status dot are the same in both sets. Every glyph is a single code point.
| Slot | unicode | nerd (Nerd Fonts class, codepoint) |
|---|---|---|
| CONTEXT label | ◔ U+25D4 | nf-fa-database U+F1C0 |
| 5-HOUR label | ◷ U+25F7 | nf-fa-clock_o U+F017 |
| WEEKLY label | ⧖ U+29D6 | nf-fa-calendar U+F073 |
| COST label | ¢ U+00A2 | nf-fa-dollar U+F155 |
| Refresh button label | ↻ U+21BB | nf-fa-refresh U+F021 |
| MCP servers line | ⬡ U+2B21 | nf-fa-plug U+F1E6 |
stale marker | ⚠ U+26A0 | nf-fa-warning U+F071 |
limit reached | ✕ U+2715 | nf-fa-ban U+F05E |
All nf-fa-* codepoints are unchanged between Nerd Fonts v2 and v3. The Nerd Font codepoints and the shared █ ▌ ● are East Asian Width ambiguous, so they may take two cells in a CJK locale; the plugin does not correct for that.
It makes no decisions, so it has no off/ask/act mode and writes nothing to decisions.jsonl. It writes nothing into the model's context. It shows the live session only: no history, no charts, no cache-expiry countdown, no action such as compaction or handoff. Layout and thresholds are fixed.
Nothing yet. The unit and testing-kit tests cover the numbers, the text each row draws, the refresh policy and the hooks. These still need a check in a real terminal:
/meter opens the pane, and a second /meter closes it.r refreshes it while the pane has focus.unicode in the default font, nerd in a Nerd Font)./clear.hooks/register.tsx 53 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { registerMeterCommand } from './command-registration'
4import { debug, logFailure } from './engine-log'
5import { asGlyphSet } from './glyphs'
6import { drawMeter } from './meter-pane'
7import { openMeter, recordMeasure, reloadOpenMeter, restartMeterReading, runMeterCommand } from './meter-actions'
8import { PANE_ID } from './pane-id'
9import { isInteractiveSession, rememberInteractivity } from './session-store'
10
11async function registerAndReload($: EngineInterface): Promise<void> {
12 await registerMeterCommand($)
13 await reloadOpenMeter($)
14}
15
16export const register: Register = (on, options) => {
17 const glyphSet = asGlyphSet(options.meterGlyphs)
18 const isAutoOpen = options.meterAutoOpen === true
19
20 on('session.start', async ($, e, next) => {
21 const started = await next(e)
22 await logFailure($, rememberInteractivity($, e.isInteractive))
23 if (!e.isInteractive) return started
24 await registerMeterCommand($)
25 // Opened in a later tick so the usage readings never hold up the start of the session.
26 if (isAutoOpen) $.clock.after(0, () => void logFailure($, openMeter($, 'summary')))
27 return started
28 }).catch(($, e, next) => next(e))
29
30 // /clear fires session.end with reason 'clear' and no session.start follows, so the command is registered again.
31 on('session.end', async ($, e, next) => {
32 if (e.reason === 'clear') {
33 await logFailure($, restartMeterReading($))
34 if (await isInteractiveSession($)) $.clock.after(0, () => void logFailure($, registerAndReload($)))
35 }
36 return next(e)
37 }).catch(($, e, next) => next(e))
38
39 on('session.measure', async ($, e, next) => {
40 await recordMeasure($, e)
41 return next(e)
42 }).catch(($, e, next) => next(e))
43
44 on('command.run', { command: 'meter' }, ($, e) => runMeterCommand($, e.args)).catch(($, _e, next) => {
45 debug($, `could not run /meter: ${next.error.message ?? next.error.kind}`)
46 return { text: 'Could not run /meter.' }
47 })
48
49 on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => drawMeter($, e, glyphSet)).catch(($, e, next) =>
50 next(e),
51 )
52}
53hooks/command-registration.tsx 15 lines1import type { EngineInterface } from 'claude-code'
2
3import { debug } from './engine-log'
4
5export async function registerMeterCommand($: EngineInterface): Promise<void> {
6 try {
7 await $.command.register({
8 name: 'meter',
9 description: 'Open or close the usage meter pane. /meter refresh takes a full count',
10 })
11 } catch (error) {
12 debug($, `could not register /meter: ${String(error)}`)
13 }
14}
15hooks/engine-log.tsx 12 lines1import type { EngineInterface } from 'claude-code'
2
3// Declared as functions: the engine follows `$` past an import only into a function declaration, not a const arrow.
4export function debug($: EngineInterface, line: string): void {
5 return $.ui.log(`meter: ${line}`, { to: 'debug' })
6}
7
8// Catches what background work leaves uncaught: a refused open, pane list or store write. It never rethrows.
9export function logFailure($: EngineInterface, work: Promise<unknown>): Promise<unknown> {
10 return work.catch(error => debug($, `background work failed: ${String(error)}`))
11}
12hooks/glyphs.ts 32 lines1export const GLYPHS = {
2 unicode: {
3 context: '◔',
4 fiveHour: '◷',
5 weekly: '⧖',
6 cost: '¢',
7 refresh: '↻',
8 mcpServers: '⬡',
9 stale: '⚠',
10 limitReached: '✕',
11 },
12 nerd: {
13 context: '\u{F1C0}',
14 fiveHour: '\u{F017}',
15 weekly: '\u{F073}',
16 cost: '\u{F155}',
17 refresh: '\u{F021}',
18 mcpServers: '\u{F1E6}',
19 stale: '\u{F071}',
20 limitReached: '\u{F05E}',
21 },
22} as const
23
24export type GlyphSet = keyof typeof GLYPHS
25export type GlyphSlot = keyof (typeof GLYPHS)['unicode']
26
27export const asGlyphSet = (stored: unknown): GlyphSet => (stored === 'nerd' ? 'nerd' : 'unicode')
28
29export const glyphFor = (set: GlyphSet, slot: GlyphSlot): string => GLYPHS[set][slot]
30
31export const labelled = (set: GlyphSet, slot: GlyphSlot, text: string): string => `${glyphFor(set, slot)} ${text}`
32hooks/meter-pane.tsx 69 lines1import type { EngineInterface, RenderInput } from 'claude-code'
2
3import type { HeadlineReading } from '../types'
4import { logFailure } from './engine-log'
5import { ageLabel } from './format'
6import { labelled } from './glyphs'
7import type { GlyphSet } from './glyphs'
8import { refreshMeter } from './meter-actions'
9import {
10 contextDetailText,
11 contextRowText,
12 costRowText,
13 mcpServersRowText,
14 topContextRowTexts,
15 windowRowTexts,
16} from './pane-text'
17import { currentReading } from './reading-store'
18import { windowView } from './window-view'
19
20const NO_READING: HeadlineReading = { contextWindow: 0, rateLimits: [] }
21
22export async function drawMeter($: EngineInterface, e: RenderInput<'Pane'>, set: GlyphSet) {
23 const { Box, Text, Button } = $.ui.resolve(e)
24 const { headline, breakdown, isStale } = await currentReading($)
25 const now = await $.clock.now()
26 const utcOffsetMinutes = -new Date(now).getTimezoneOffset()
27 const figures = headline ?? NO_READING
28 const contextRow = contextRowText(figures, breakdown?.compactsAt, set)
29 const costRow = costRowText(figures, set)
30 const mcpServersRow = mcpServersRowText(breakdown?.mcpServers ?? [], set)
31
32 return (
33 <Box flexDirection="column">
34 <Text color={contextRow.color}>{contextRow.text}</Text>
35 <Box paddingLeft={2}>
36 <Text dimColor>{contextDetailText(breakdown)}</Text>
37 </Box>
38 {figures.rateLimits.map(limit => (
39 <Box flexDirection="column" marginTop={1}>
40 {windowRowTexts(windowView(limit, now, utcOffsetMinutes), set).map((row, index) => (
41 <Box paddingLeft={index === 0 ? 0 : 2}>
42 <Text color={row.color}>{row.text}</Text>
43 </Box>
44 ))}
45 </Box>
46 ))}
47 {costRow && (
48 <Box marginTop={1}>
49 <Text>{costRow}</Text>
50 </Box>
51 )}
52 <Box marginTop={1} gap={1}>
53 <Text>TOP CONTEXT</Text>
54 <Button key="refresh" hotkey="r" label={labelled(set, 'refresh', '[r] refresh')} onPress={() => void logFailure($, refreshMeter($))} />
55 {breakdown && <Text dimColor>{`· ${ageLabel(now - breakdown.takenAt)}`}</Text>}
56 {isStale && <Text color="warning">{labelled(set, 'stale', 'stale')}</Text>}
57 </Box>
58 {topContextRowTexts(breakdown?.topRows ?? []).map(row => (
59 <Text>{row}</Text>
60 ))}
61 {mcpServersRow && (
62 <Box paddingLeft={2}>
63 <Text dimColor>{mcpServersRow}</Text>
64 </Box>
65 )}
66 </Box>
67 )
68}
69hooks/meter-actions.tsx 125 lines1import type { ContextBreakdownDetail, EngineInterface, SessionMeasureInput } from 'claude-code'
2
3import { snapshotBreakdown } from './breakdown'
4import { debug, logFailure } from './engine-log'
5import { PANE_ID, PANE_TITLE } from './pane-id'
6import {
7 EMPTY_READING,
8 headlineFrom,
9 isBreakdownDue,
10 markStale,
11 withBreakdown,
12 withHeadline,
13} from './reading'
14import { changeReading, currentReading } from './reading-store'
15
16// Breakdown counts under way. A count that is already running makes a second one redundant.
17let breakdownsInFlight = 0
18
19// Counts the clears. A reading that began before a clear describes the session that was cleared, so it is dropped.
20let readingGeneration = 0
21
22async function trackingBreakdown(count: () => Promise<void>): Promise<void> {
23 breakdownsInFlight += 1
24 try {
25 await count()
26 } finally {
27 breakdownsInFlight -= 1
28 }
29}
30
31async function isMeterOpen($: EngineInterface): Promise<boolean> {
32 return (await $.ui.panes()).some(pane => pane.id === PANE_ID)
33}
34
35async function recordFailure($: EngineInterface, failedAction: string, error: unknown, generation: number): Promise<void> {
36 debug($, `could not ${failedAction}: ${String(error)}`)
37 if (generation === readingGeneration) await changeReading($, markStale)
38}
39
40async function loadHeadline($: EngineInterface): Promise<void> {
41 const generation = readingGeneration
42 try {
43 const usage = await $.session.usage()
44 if (generation !== readingGeneration) return
45 await changeReading($, reading => withHeadline(reading, headlineFrom(usage)))
46 } catch (error) {
47 await recordFailure($, 'read usage', error, generation)
48 }
49}
50
51function loadBreakdown($: EngineInterface, detail: ContextBreakdownDetail): Promise<void> {
52 return trackingBreakdown(() => takeBreakdown($, detail))
53}
54
55async function takeBreakdown($: EngineInterface, detail: ContextBreakdownDetail): Promise<void> {
56 const generation = readingGeneration
57 try {
58 const { context } = await $.session.usage({ breakdown: detail })
59 if (context.breakdown === undefined) throw new Error('the reply held no breakdown')
60 const snapshot = snapshotBreakdown(context.breakdown, await $.clock.now())
61 if (generation !== readingGeneration) return
62 await changeReading($, reading => withBreakdown(reading, snapshot))
63 } catch (error) {
64 await recordFailure($, `take the ${detail} breakdown`, error, generation)
65 }
66}
67
68export function openMeter($: EngineInterface, detail: ContextBreakdownDetail): Promise<void> {
69 return trackingBreakdown(async () => {
70 await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
71 await loadHeadline($)
72 await loadBreakdown($, detail)
73 })
74}
75
76export async function restartMeterReading($: EngineInterface): Promise<void> {
77 readingGeneration += 1
78 await changeReading($, () => EMPTY_READING)
79}
80
81export async function reloadOpenMeter($: EngineInterface): Promise<void> {
82 if (!(await isMeterOpen($))) return
83 await trackingBreakdown(async () => {
84 await loadHeadline($)
85 await loadBreakdown($, 'summary')
86 })
87}
88
89async function closeMeter($: EngineInterface): Promise<void> {
90 await $.ui.close({ id: PANE_ID })
91}
92
93export function refreshMeter($: EngineInterface): Promise<void> {
94 return loadBreakdown($, 'full')
95}
96
97async function redrawFromMeasure($: EngineInterface, e: SessionMeasureInput): Promise<void> {
98 if (!(await isMeterOpen($))) return
99 await changeReading($, reading => withHeadline(reading, headlineFrom(e)))
100 const isDue = isBreakdownDue(await currentReading($), await $.clock.now())
101 if (e.changed.includes('context') && isDue && breakdownsInFlight === 0) await loadBreakdown($, 'summary')
102}
103
104export function recordMeasure($: EngineInterface, e: SessionMeasureInput): Promise<unknown> {
105 return logFailure($, redrawFromMeasure($, e))
106}
107
108const USAGE = 'Usage: /meter to open or close the pane, /meter refresh to take a full count.'
109
110export async function runMeterCommand($: EngineInterface, args: string): Promise<{ text: string }> {
111 const argument = args.trim()
112 if (argument !== '' && argument !== 'refresh') return { text: USAGE }
113 const isOpen = await isMeterOpen($)
114 if (!isOpen) {
115 await openMeter($, argument === 'refresh' ? 'full' : 'summary')
116 return { text: 'Meter opened.' }
117 }
118 if (argument === 'refresh') {
119 await refreshMeter($)
120 return { text: 'Meter refreshed.' }
121 }
122 await closeMeter($)
123 return { text: 'Meter closed.' }
124}
125hooks/pane-id.ts 3 lines1export const PANE_ID = 'meter'
2export const PANE_TITLE = 'meter'
3hooks/session-store.tsx 21 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import { debug } from './engine-log'
5
6// Kept in the store, not a closure: the engine can start the plugin again mid-session, and no session.start follows.
7const interactivity = atom({ plugin: 'meter', key: 'isInteractive' } as const, false, { shape: 'interactive-1' })
8
9export async function rememberInteractivity($: EngineInterface, isInteractive: boolean): Promise<void> {
10 await update($, interactivity, () => isInteractive)
11}
12
13export async function isInteractiveSession($: EngineInterface): Promise<boolean> {
14 try {
15 return await read($, interactivity)
16 } catch (error) {
17 debug($, `could not read whether the session is interactive: ${String(error)}`)
18 return false
19 }
20}
21hooks/format.ts 23 lines1const SECOND_MS = 1000
2const MINUTE_MS = 60 * SECOND_MS
3const HOUR_MS = 60 * MINUTE_MS
4const BAR_WIDTH_BLOCKS = 6
5const HALVES_PER_BLOCK = 2
6
7export const formatTokens = (tokens: number): string => (tokens < 1000 ? String(tokens) : `${Math.round(tokens / 1000)}k`)
8
9export const formatPercent = (percent: number): string => String(Math.round(percent * 10) / 10)
10
11export const ageLabel = (elapsedMs: number): string => {
12 const elapsed = Math.max(0, elapsedMs)
13 if (elapsed < MINUTE_MS) return `${Math.floor(elapsed / SECOND_MS)}s ago`
14 if (elapsed < HOUR_MS) return `${Math.floor(elapsed / MINUTE_MS)}m ago`
15 return `${Math.floor(elapsed / HOUR_MS)}h ago`
16}
17
18export const barFor = (tokens: number, largest: number): string => {
19 if (tokens <= 0 || largest <= 0) return ''
20 const halves = Math.max(1, Math.round((tokens / largest) * BAR_WIDTH_BLOCKS * HALVES_PER_BLOCK))
21 return '█'.repeat(Math.floor(halves / HALVES_PER_BLOCK)) + (halves % HALVES_PER_BLOCK === 1 ? '▌' : '')
22}
23hooks/pane-text.ts 78 lines1import type { BreakdownReading, HeadlineReading, NamedTokens } from '../types'
2import { mcpServersLine } from './breakdown'
3import { contextBand, contextFill, fillPercent } from './context'
4import { barFor, formatTokens } from './format'
5import { labelled } from './glyphs'
6import type { GlyphSet } from './glyphs'
7import type { BandColor } from './pace'
8import type { WindowView } from './window-view'
9
10const SEPARATOR = ' · '
11const TOKEN_COLUMN_WIDTH = 4
12const STATUS_DOT = '●'
13
14type RowText = { text: string; color?: BandColor }
15
16export const contextRowText = (headline: HeadlineReading, compactsAt: number | undefined, set: GlyphSet): RowText => {
17 const label = labelled(set, 'context', 'CONTEXT')
18 const { contextTokens, contextWindow } = headline
19 if (!contextTokens) return { text: `${label} waiting for first response` }
20 const fill = contextFill(contextTokens, contextWindow, compactsAt)
21 const denominator = formatTokens(compactsAt ?? contextWindow)
22 return {
23 text: `${label} ${formatTokens(contextTokens)} / ${formatTokens(contextWindow)} ${fillPercent(fill)}% of ${denominator}`,
24 color: contextBand(fill),
25 }
26}
27
28export const contextDetailText = (breakdown: BreakdownReading | null): string => {
29 const cacheHit = `cache hit ${breakdown?.cacheHitPercent == null ? '—' : `${breakdown.cacheHitPercent}%`}`
30 const compactsAt = breakdown?.compactsAt
31 return compactsAt === undefined ? cacheHit : [`compacts at ${formatTokens(compactsAt)}`, cacheHit].join(SEPARATOR)
32}
33
34export const costRowText = (headline: HeadlineReading, set: GlyphSet): string | undefined =>
35 headline.costUsd === undefined ? undefined : `${labelled(set, 'cost', 'COST')} $${headline.costUsd.toFixed(2)} this session`
36
37type PacedView = Extract<WindowView, { state: 'paced' }>
38
39const pacedRowTexts = (view: PacedView, label: string): RowText[] => {
40 const projection = view.projection === undefined ? '' : `hits 100% ${view.projection}, `
41 return [
42 { text: `${label} ${view.percentText}%${SEPARATOR}${view.elapsedPercent}% of window elapsed`, color: view.color },
43 { text: `pace ${view.paceText}× ${STATUS_DOT} ${projection}resets ${view.resetsLabel}`, color: view.color },
44 ]
45}
46
47export const windowRowTexts = (view: WindowView, set: GlyphSet): RowText[] => {
48 const label = view.slot ? labelled(set, view.slot, view.label) : view.label
49 const percent = `${label} ${view.percentText}%`
50 // No default: every case returns, so tsc refuses a new state this switch does not draw.
51 switch (view.state) {
52 case 'paced':
53 return pacedRowTexts(view, label)
54 case 'too-early':
55 return [{ text: `${percent}${SEPARATOR}too early` }]
56 case 'limit-reached': {
57 const reset = view.resetsLabel === undefined ? '' : `${SEPARATOR}resets ${view.resetsLabel}`
58 return [{ text: `${percent}${SEPARATOR}${labelled(set, 'limitReached', 'limit reached')}${reset}`, color: view.color }]
59 }
60 case 'plain':
61 return [view.color ? { text: percent, color: view.color } : { text: percent }]
62 }
63}
64
65export const topContextRowTexts = (rows: readonly NamedTokens[]): string[] => {
66 const nameWidth = Math.max(0, ...rows.map(row => row.name.length))
67 const largest = Math.max(0, ...rows.map(row => row.tokens))
68 return rows.map(({ name, tokens }) => {
69 const figure = formatTokens(tokens).padStart(TOKEN_COLUMN_WIDTH)
70 return ` ${name.padEnd(nameWidth)} ${figure} ${barFor(tokens, largest)}`
71 })
72}
73
74export const mcpServersRowText = (servers: readonly NamedTokens[], set: GlyphSet): string | undefined => {
75 const line = mcpServersLine(servers)
76 return line === undefined ? undefined : labelled(set, 'mcpServers', line)
77}
78hooks/reading-store.tsx 17 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { MeterReading } from '../types'
5import { EMPTY_READING } from './reading'
6
7// Change the tag when MeterReading's fields change: it declines what an earlier version stored, which outlives a reload.
8const reading = atom({ plugin: 'meter', key: 'reading' } as const, EMPTY_READING, { shape: 'reading-1' })
9
10export function currentReading($: EngineInterface): Promise<MeterReading> {
11 return read($, reading)
12}
13
14export function changeReading($: EngineInterface, change: (current: MeterReading) => MeterReading): Promise<MeterReading> {
15 return update($, reading, change)
16}
17hooks/window-view.ts 77 lines1import type { RateLimitReading } from '../types'
2import { clockLabel } from './clock-label'
3import { formatPercent } from './format'
4import type { GlyphSlot } from './glyphs'
5import {
6 elapsedShare,
7 formatPace,
8 isTooEarly,
9 paceBand,
10 paceOf,
11 parseResetsAt,
12 projectedExhaustion,
13 windowLengthMs,
14} from './pace'
15import type { BandColor } from './pace'
16
17const LIMIT_PERCENT = 100
18
19type WindowBase = {
20 label: string
21 slot?: GlyphSlot
22 percentText: string
23}
24
25// One variant per state, so a paced view always carries its pace and reset, and no other state can claim them.
26export type WindowView =
27 | (WindowBase & {
28 state: 'paced'
29 color: BandColor
30 elapsedPercent: number
31 paceText: string
32 projection?: string
33 resetsLabel: string
34 })
35 | (WindowBase & { state: 'limit-reached'; color: 'error'; resetsLabel?: string })
36 | (WindowBase & { state: 'too-early' })
37 | (WindowBase & { state: 'plain'; color?: 'error' })
38
39const labelFor = (kind: string): string => {
40 if (kind === 'five_hour') return '5-HOUR'
41 if (kind === 'seven_day') return 'WEEKLY'
42 return kind.replace(/_/g, ' ').toUpperCase()
43}
44
45const slotFor = (kind: string): GlyphSlot | undefined => {
46 if (kind === 'five_hour') return 'fiveHour'
47 if (kind === 'seven_day') return 'weekly'
48 return undefined
49}
50
51export const windowView = (limit: RateLimitReading, now: number, utcOffsetMinutes: number): WindowView => {
52 const { kind, percentUsed } = limit
53 const base = { label: labelFor(kind), slot: slotFor(kind), percentText: formatPercent(percentUsed) }
54 const windowMs = windowLengthMs(kind)
55 const isAtLimit = percentUsed >= LIMIT_PERCENT
56 if (windowMs === undefined) {
57 return isAtLimit ? { ...base, state: 'plain', color: 'error' } : { ...base, state: 'plain' }
58 }
59 const resetsAt = parseResetsAt(limit.resetsAt)
60 const share = elapsedShare(resetsAt, now, windowMs)
61 const resetsLabel = share === undefined || resetsAt === undefined ? undefined : clockLabel(resetsAt, now, utcOffsetMinutes)
62 if (isAtLimit) return { ...base, state: 'limit-reached', color: 'error', resetsLabel }
63 if (share === undefined || resetsLabel === undefined) return { ...base, state: 'plain' }
64 if (isTooEarly(share)) return { ...base, state: 'too-early' }
65 const pace = paceOf(percentUsed, share)
66 const exhaustion = projectedExhaustion(now, percentUsed, share, windowMs)
67 return {
68 ...base,
69 state: 'paced',
70 color: paceBand(pace),
71 elapsedPercent: Math.round(share * 100),
72 paceText: formatPace(pace),
73 projection: exhaustion === undefined ? undefined : clockLabel(exhaustion, now, utcOffsetMinutes),
74 resetsLabel,
75 }
76}
77