SLOPSHOPPER

meter

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

newpanecommandtimer
v0.2.0MITupdated 2026-10-24mikelane/thrift/plugins/meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · meter
│ ┃ meter ✕ › fix the failing auth test and add an audit log call │ ┃ ◔ CONTEXT 97k / 200k 49% of 200k │ ┃ cache hit — ● meter: meter: could not take the summary breakdown: Error: the repl │ ┃ ⏺ Read(src/auth.ts) │ ┃ ◷ 5-HOUR 31% ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ¢ COST $0.42 this session ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ TOP CONTEXT [ ↻ [r] refresh ] ⚠ stale ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /meter │ ⎿ meter: Meter opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · meter
◔ CONTEXT 97k / 200k 49% of 200k cache hit — ◷ 5-HOUR 31% ¢ COST $0.42 this session TOP CONTEXT [ ↻ [r] refresh ] ⚠ stale
README

meter

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               │
└─────────────────────────────────────────────────┘

Opening and closing

  • /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.
  • Any other argument answers with a usage line and changes nothing.
  • The setting meterAutoOpen (default false) opens the pane at session start. An unasked pane is placed only from 144 columns; narrower, it waits for /meter.
  • In non-interactive runs (-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.

Definitions

  • Elapsed share of a window: (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: elapsed share below 0.05. At exactly 0.05 pace is computed. The row shows the percent and too early in place of pace, and is uncolored.
  • Pace: 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.
  • Projection: only when pace is above 1, which is exactly when the limit runs out before the reset: now + (100 - percentUsed) x (elapsedTime / percentUsed), drawn as hits 100% <time>.
  • Limit reached: a 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.
  • A window with no usable reset time shows its percent only: no pace, color or projection.
  • 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.
  • Context color: fill is 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.
  • Without color: every band is computed from a figure the row prints (the context percent, the pace), so the thresholds above give the band in a monochrome terminal or to a screen reader. The band name itself is not printed, and the ● after the pace carries only the band color.
  • Cache hit: 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.
  • TOP CONTEXT rows: categories with 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.
  • MCP servers line: its own line under the rows. It sums 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.
  • Age label of the breakdown: <n>s ago under 60 s, <n>m ago under 60 min, <n>h ago beyond.
  • Clock labels: 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.

Refresh policy

  • On open: one plain $.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.
  • On session.measure (while the pane is open): the headline rows redraw from the event's own context, rateLimits and cost. No $.session.usage() call.
  • Throttled summary: when the event's 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.
  • Explicit refresh: the [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.
  • A failed reading (the plain call on open, a summary or a full breakdown) goes to the debug log as 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.

Settings

SettingDefaultWhat it does
meterAutoOpenfalseOpens the pane at session start.
meterGlyphsunicodeunicode or nerd. Any other stored value is treated as unset by the engine, so the default applies.

Glyph sets

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.

Slotunicodenerd (Nerd Fonts class, codepoint)
CONTEXT label◔ U+25D4nf-fa-database U+F1C0
5-HOUR label◷ U+25F7nf-fa-clock_o U+F017
WEEKLY label⧖ U+29D6nf-fa-calendar U+F073
COST label¢ U+00A2nf-fa-dollar U+F155
Refresh button label↻ U+21BBnf-fa-refresh U+F021
MCP servers line⬡ U+2B21nf-fa-plug U+F1E6
stale marker⚠ U+26A0nf-fa-warning U+F071
limit reached✕ U+2715nf-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.

What it does not do

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.

What has been verified live

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.
  • The pane redraws after a turn.
  • r refreshes it while the pane has focus.
  • Both glyph sets draw with the labels aligned (unicode in the default font, nerd in a Nerd Font).
  • Whether the pane stays open across /clear.
Source 18 files
hooks/register.tsx 53 lines
1import 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}
53
hooks/command-registration.tsx 15 lines
1import 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}
15
hooks/engine-log.tsx 12 lines
1import 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}
12
hooks/glyphs.ts 32 lines
1export 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}`
32
hooks/meter-pane.tsx 69 lines
1import 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}
69
hooks/meter-actions.tsx 125 lines
1import 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}
125
hooks/pane-id.ts 3 lines
1export const PANE_ID = 'meter'
2export const PANE_TITLE = 'meter'
3
hooks/session-store.tsx 21 lines
1import { 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}
21
hooks/format.ts 23 lines
1const 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}
23
hooks/pane-text.ts 78 lines
1import 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}
78
hooks/reading-store.tsx 17 lines
1import { 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}
17
hooks/window-view.ts 77 lines
1import 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