SLOPSHOPPER

usage-meter

Context, session and weekly usage on the footer row, configurable from a pane

newpanespinnercommandtimer
v0.1.0MITupdated 2026-10-14natansalvadorligabo/claude-usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
│ ┃ Usage meter ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Usage meter ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ │ General ⏺ Update(src/auth.ts) │ ┃ │ Language [ English ] [ Português ⎿ Added 2 lines, removed 1 line │ ┃ │ Labels [ Show ] [ On hover ] [ ⏺ Bash(bun test) │ ┃ │ Colors [ On ] [ Off ] ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ Context [ ✓ Visible ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Chart [ Number ] [ Bar ] [ Do │ ┃ │ Percent [ On ] [ Off ] ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ ┃ │ Session [ ✓ Visible ] › /usage-meter │ ┃ │ Chart [ Number ] [ Bar ] [ Do ⎿ usage-meter: Usage meter settings opened. │ ┃ │ Percent [ On ] [ Off ] │ ┃ │ Reset time [ On ] [ Off ] │ ┃ │ │ ┃ │ Weekly [ ✓ Visible ] │ ┃ │ Chart [ Number ] [ Bar ] [ Do │ ┃ │ Percent [ On ] [ Off ] │ ┃ │ Reset time [ On ] [ Off ] │ ┃ │ │ ┃ │ Daily budget [ ✓ Visible ] │ ┃ │ Weekends [ Count Sat and Sun ] [ │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits · open-settings-contextopen-settings-session

Draws

Pane · Usage meter
╭─────────────────────────────────────────────────────────── │ Usage meter │ │ General │ Language [ English ] [ Português ] │ Labels [ Show ] [ On hover ] [ Hide ] │ Colors [ On ] [ Off ] │ │ Context [ ✓ Visible ] │ Chart [ Number ] [ Bar ] [ Dots ] [ Level ] [ │ Percent [ On ] [ Off ] │ │ Session [ ✓ Visible ] │ Chart [ Number ] [ Bar ] [ Dots ] [ Level ] [ │ Percent [ On ] [ Off ] │ Reset time [ On ] [ Off ] │ │ Weekly [ ✓ Visible ] │ Chart [ Number ] [ Bar ] [ Dots ] [ Level ] [ │ Percent [ On ] [ Off ] │ Reset time [ On ] [ Off ] │ │ Daily budget [ ✓ Visible ] │ Weekends [ Count Sat and Sun ] [ Skip Sat and Su │ │ [ Done ] [ Restore defaults ] ╰───────────────────────────────────────────────────────────
README

Claude Usage Meter

A Claude Code mod (installed like a plugin) that shows context, 5-hour session and weekly usage on the footer row, plus a daily budget that tells you whether today's pace fits what is left of the week.

The meter on the footer: usage climbs while the agent works, hovering shows the labels, the settings pane switches charts and language, and the daily budget turns from on track to over

What it shows

MetricWhat it is
ContextHow full the model's context window is. Its colors turn orange at 50%, where compaction usually gets close.
SessionThe 5-hour usage limit, with the time it resets.
WeeklyThe 7-day usage limit, with the day and hour it resets.
Daily budgetWeekly % spent today against today's share: (100 − weekly % at the start of the day) / days until the reset.

The daily budget shows ✓ while you are on track, ◐ past 80% of today's share and ✗ over it. It can skip Saturdays and Sundays when splitting what is left; today always counts.

Settings

Click anywhere on the footer or run /usage-meter to open the settings pane:

  • Language: English or Português.
  • Labels: always shown, shown on hover, or hidden.
  • Colors: a green-to-red scale per metric, or plain text.
  • Per metric: visible or hidden; chart as a number, bar (███░░░░░), dots (●●●○○○○○), level (▃) or pie (◑); the percent; and, for session and weekly, the reset time.

Settings are kept across sessions.

Several terminals

The usage limits belong to the whole account, but each terminal only hears about them after its own responses. Every terminal writes its newest reading to ~/.claude/usage-meter/latest-limits.json (under CLAUDE_CONFIG_DIR when set), and the others pick it up within 10 seconds. No extra API calls are made.

Install

Requirements: Claude Code 2.1.29x or newer (the mod uses the function hooks API, a hooks.json module that runs inside Claude Code).

claude plugin marketplace add natansalvadorligabo/claude-usage-meter
claude plugin install usage-meter@claude-usage-meter

Start a new session and the meter appears on the footer.

To try a local clone instead: claude --plugin-dir /path/to/claude-usage-meter.

Develop

claude plugin validate .
claude plugin test .
tsc -p .            # once Claude Code has loaded the mod and written .claude-plugin/types/

Limitations

  • Session and weekly limits only appear on subscription accounts.
  • The hover background is #0c0c0c (Windows Terminal's Campbell scheme).
  • Glyphs such as ◔ ◑ ◕ depend on the terminal font.

License

MIT

Source 2 files
hooks/register.tsx 530 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, SessionContextUsage, SessionRateLimit, TextHoverProps } from 'claude-code'
3
4import type {
5  ChartStyle,
6  ColorMode,
7  DayBaseline,
8  LabelMode,
9  Language,
10  MetricId,
11  MetricSettings,
12  SharedLimits,
13  UsageMeterConfig,
14  UsageReading,
15  UsageSnapshot,
16} from '../types'
17
18const PANE = 'usage-meter-settings'
19const SETTINGS_KEY = 'settings.v2'
20const BASELINE_KEY = 'dayBaseline'
21const SHARED_LIMITS_FILE = 'usage-meter/latest-limits.json'
22// Every terminal reports the account's limits only after its own responses, so the newest reading is passed through a local file.
23const SHARED_LIMITS_SYNC_MS = 10_000
24const SEPARATOR = ' · '
25const BAR_WIDTH = 8
26const DAY_MS = 24 * 60 * 60 * 1000
27const GREEN = '#22c55e'
28const LIME = '#a3e635'
29const YELLOW = '#facc15'
30const ORANGE = '#fb923c'
31const RED = '#ef4444'
32const LIMIT_COLOR_STOPS: [number, string][] = [[0, GREEN], [25, LIME], [50, YELLOW], [65, ORANGE], [80, RED]]
33// The context is usually swapped or compacted between 50% and 60%, so its scale turns orange at 50%.
34const CONTEXT_COLOR_STOPS: [number, string][] = [[0, GREEN], [30, LIME], [40, YELLOW], [50, ORANGE], [65, RED]]
35// Pace colors stay outside the green-to-red usage scale so the daily tip is never read as a usage level.
36const PACE_STYLES = {
37  onTrack: { icon: '✓', color: '#38bdf8' },
38  close: { icon: '◐', color: '#c084fc' },
39  over: { icon: '✗', color: '#f472b6' },
40} as const
41const CLOSE_TO_BUDGET_RATIO = 0.8
42const UNCOLORED_BLOCK = '#d4d4d4'
43// Windows Terminal's Campbell scheme: a hover background in it keeps the Button's pointer inversion from showing.
44const TERMINAL_BACKGROUND = '#0c0c0c'
45const HOVER_LABEL = '#8a8a8a'
46const HOVER_SCOPE = 'usage-meter-section'
47const LEVEL_GLYPHS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
48const PIE_GLYPHS = ['○', '◔', '◑', '◕', '●']
49const METRIC_ORDER: MetricId[] = ['context', 'session', 'weekly', 'dailyBudget']
50const METRICS_WITH_RESET_TIME: MetricId[] = ['session', 'weekly']
51const CHART_STYLES: ChartStyle[] = ['none', 'bar', 'dots', 'level', 'pie']
52const LABEL_MODES: LabelMode[] = ['always', 'hover', 'never']
53const COLOR_MODES: ColorMode[] = ['on', 'off']
54const LANGUAGES: Language[] = ['en', 'pt-BR']
55const SATURDAY = 6
56const SUNDAY = 0
57
58type Messages = {
59  title: string
60  general: string
61  weekdays: string[]
62  metricLabels: Record<MetricId, string>
63  metricNames: Record<MetricId, string>
64  rows: { language: string; chart: string; percent: string; resetTime: string; labels: string; colors: string; weekends: string }
65  charts: Record<ChartStyle, string>
66  labelModes: Record<LabelMode, string>
67  colorModes: Record<ColorMode, string>
68  languages: Record<Language, string>
69  visible: string
70  hidden: string
71  on: string
72  off: string
73  countWeekends: string
74  skipWeekends: string
75  done: string
76  restoreDefaults: string
77  commandDescription: string
78  commandOpened: string
79}
80
81const MESSAGES: Record<Language, Messages> = {
82  en: {
83    title: 'Usage meter',
84    general: 'General',
85    weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
86    metricLabels: { context: 'Context:', session: 'Session:', weekly: 'Weekly:', dailyBudget: 'Daily budget:' },
87    metricNames: { context: 'Context', session: 'Session', weekly: 'Weekly', dailyBudget: 'Daily budget' },
88    rows: { language: 'Language', chart: 'Chart', percent: 'Percent', resetTime: 'Reset time', labels: 'Labels', colors: 'Colors', weekends: 'Weekends' },
89    charts: { none: 'Number', bar: 'Bar', dots: 'Dots', level: 'Level', pie: 'Pie' },
90    labelModes: { always: 'Show', hover: 'On hover', never: 'Hide' },
91    colorModes: { on: 'On', off: 'Off' },
92    languages: { en: 'English', 'pt-BR': 'Português' },
93    visible: '✓ Visible',
94    hidden: '○ Hidden',
95    on: 'On',
96    off: 'Off',
97    countWeekends: 'Count Sat and Sun',
98    skipWeekends: 'Skip Sat and Sun',
99    done: 'Done',
100    restoreDefaults: 'Restore defaults',
101    commandDescription: 'Open the usage meter settings',
102    commandOpened: 'Usage meter settings opened.',
103  },
104  'pt-BR': {
105    title: 'Medidor de uso',
106    general: 'Geral',
107    weekdays: ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'],
108    metricLabels: { context: 'Contexto:', session: 'Sessão:', weekly: 'Semanal:', dailyBudget: 'Meta diária:' },
109    metricNames: { context: 'Contexto', session: 'Sessão', weekly: 'Semanal', dailyBudget: 'Meta diária' },
110    rows: { language: 'Idioma', chart: 'Gráfico', percent: 'Percentual', resetTime: 'Horário de reset', labels: 'Rótulos', colors: 'Cores', weekends: 'Fins de semana' },
111    charts: { none: 'Número', bar: 'Barra', dots: 'Pontos', level: 'Nível', pie: 'Pizza' },
112    labelModes: { always: 'Mostrar', hover: 'Ao passar o mouse', never: 'Ocultar' },
113    colorModes: { on: 'Ligadas', off: 'Desligadas' },
114    languages: { en: 'English', 'pt-BR': 'Português' },
115    visible: '✓ Visível',
116    hidden: '○ Oculto',
117    on: 'Ligado',
118    off: 'Desligado',
119    countWeekends: 'Contar sáb e dom',
120    skipWeekends: 'Ignorar sáb e dom',
121    done: 'Concluir',
122    restoreDefaults: 'Restaurar padrão',
123    commandDescription: 'Abre as configurações do medidor de uso',
124    commandOpened: 'Configurações do medidor de uso abertas.',
125  },
126}
127
128const DEFAULT_METRIC: MetricSettings = { isVisible: true, chart: 'none', showPercent: true, showResetTime: true }
129
130const DEFAULT_CONFIG: UsageMeterConfig = {
131  language: 'en',
132  labels: 'never',
133  colors: 'on',
134  countWeekends: true,
135  metrics: {
136    context: DEFAULT_METRIC,
137    session: DEFAULT_METRIC,
138    weekly: DEFAULT_METRIC,
139    dailyBudget: DEFAULT_METRIC,
140  },
141}
142
143const EMPTY_USAGE: UsageSnapshot = { context: {}, session: {}, weekly: {}, limitsMeasuredAt: 0 }
144
145const config = atom({ plugin: 'usage-meter', key: 'config' } as const, DEFAULT_CONFIG)
146const usage = atom({ plugin: 'usage-meter', key: 'usage' } as const, EMPTY_USAGE)
147const baseline = atom({ plugin: 'usage-meter', key: 'baseline' } as const, null)
148
149type Segment = { text: string; color?: string; isDim?: boolean }
150type SegmentRun = { key: string; segments: Segment[]; isHiddenUntilHover?: boolean }
151type LineItem = { id: MetricId; label: string; segments: Segment[] }
152
153function withKnownChart(metric: MetricSettings): MetricSettings {
154  const chart = (metric.chart as string) === 'smooth' ? 'bar' : metric.chart
155  return { ...metric, chart: CHART_STYLES.includes(chart) ? chart : DEFAULT_METRIC.chart }
156}
157
158function mergeWithDefaults(stored: unknown): UsageMeterConfig {
159  if (!stored || typeof stored !== 'object') return DEFAULT_CONFIG
160  const partial = stored as Partial<UsageMeterConfig>
161  const storedMetrics: Partial<Record<MetricId, Partial<MetricSettings>>> = partial.metrics ?? {}
162  const metrics = Object.fromEntries(
163    METRIC_ORDER.map(id => [id, withKnownChart({ ...DEFAULT_METRIC, ...storedMetrics[id] })]),
164  ) as Record<MetricId, MetricSettings>
165  const labels = LABEL_MODES.includes(partial.labels as LabelMode) ? partial.labels as LabelMode : DEFAULT_CONFIG.labels
166  const colors: ColorMode = partial.colors === 'off' ? 'off' : 'on'
167  return { ...DEFAULT_CONFIG, ...partial, labels, colors, metrics }
168}
169
170function toSnapshot(context: SessionContextUsage, rateLimits: SessionRateLimit[], limitsMeasuredAt: number): UsageSnapshot {
171  const readLimit = (kind: string): UsageReading => {
172    const limit = rateLimits.find(one => one.kind === kind)
173    return limit ? { percent: limit.percentUsed, resetsAt: limit.resetsAt } : {}
174  }
175  return {
176    context: { percent: context.percent },
177    session: readLimit('five_hour'),
178    weekly: readLimit('seven_day'),
179    limitsMeasuredAt,
180  }
181}
182
183async function sharedLimitsPath($: EngineInterface): Promise<string | undefined> {
184  const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
185  if (configDir) return `${configDir}/${SHARED_LIMITS_FILE}`
186  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
187  return home ? `${home}/.claude/${SHARED_LIMITS_FILE}` : undefined
188}
189
190// Several terminals write this file: a writer never replaces a newer reading, and a torn or half-written file reads as absent.
191async function publishLimits($: EngineInterface, snapshot: UsageSnapshot) {
192  const path = await sharedLimitsPath($)
193  if (!path) return
194  const published = await readSharedLimits($)
195  if (published && published.measuredAt >= snapshot.limitsMeasuredAt) return
196  const shared: SharedLimits = { measuredAt: snapshot.limitsMeasuredAt, session: snapshot.session, weekly: snapshot.weekly }
197  await $.fs.write(path, JSON.stringify(shared)).catch(() => undefined)
198}
199
200async function readSharedLimits($: EngineInterface): Promise<SharedLimits | undefined> {
201  const path = await sharedLimitsPath($)
202  if (!path) return undefined
203  try {
204    const shared = JSON.parse(String(await $.fs.read(path))) as Partial<SharedLimits>
205    if (typeof shared.measuredAt !== 'number') return undefined
206    return { measuredAt: shared.measuredAt, session: shared.session ?? {}, weekly: shared.weekly ?? {} }
207  } catch {
208    return undefined
209  }
210}
211
212function withoutExpiredReset(reading: UsageReading, now: number): UsageReading {
213  return reading.resetsAt && Date.parse(reading.resetsAt) <= now ? {} : reading
214}
215
216async function adoptSharedLimits($: EngineInterface) {
217  const shared = await readSharedLimits($)
218  if (!shared) return
219  const now = await $.clock.now()
220  await recordUsage($, current => shared.measuredAt <= current.limitsMeasuredAt ? current : {
221    ...current,
222    session: withoutExpiredReset(shared.session, now),
223    weekly: withoutExpiredReset(shared.weekly, now),
224    limitsMeasuredAt: shared.measuredAt,
225  })
226}
227
228function pad(value: number): string {
229  return String(value).padStart(2, '0')
230}
231
232function localDayKey(date: Date): string {
233  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
234}
235
236function startOfLocalDay(date: Date): Date {
237  return new Date(date.getFullYear(), date.getMonth(), date.getDate())
238}
239
240function isWeekend(date: Date): boolean {
241  return date.getDay() === SATURDAY || date.getDay() === SUNDAY
242}
243
244function nextBaseline(current: DayBaseline | null, weekly: UsageReading, now: Date): DayBaseline | null {
245  if (typeof weekly.percent !== 'number') return current
246  const isStale = !current
247    || current.day !== localDayKey(now)
248    || current.resetsAt !== weekly.resetsAt
249    || weekly.percent < current.weeklyPercent
250  return isStale ? { day: localDayKey(now), weeklyPercent: weekly.percent, resetsAt: weekly.resetsAt } : current
251}
252
253// Today always counts, even on a weekend: the person is spending right now.
254function budgetDaysUntilReset(resetsAt: string, now: Date, countWeekends: boolean): number {
255  const reset = new Date(resetsAt)
256  if (Number.isNaN(reset.getTime())) return 1
257  const lastDay = startOfLocalDay(reset).getTime()
258  let days = 0
259  for (let day = startOfLocalDay(now); day.getTime() <= lastDay; day = new Date(day.getTime() + DAY_MS)) {
260    const isToday = localDayKey(day) === localDayKey(now)
261    if (isToday || countWeekends || !isWeekend(day)) days += 1
262  }
263  return Math.max(1, days)
264}
265
266function buildDailyBudgetSegments(weekly: UsageReading, dayBaseline: DayBaseline | null, settings: UsageMeterConfig, now: Date): Segment[] {
267  if (typeof weekly.percent !== 'number' || !weekly.resetsAt || !dayBaseline) return []
268  const days = budgetDaysUntilReset(weekly.resetsAt, now, settings.countWeekends)
269  const budget = Math.max(0, (100 - dayBaseline.weeklyPercent) / days)
270  const spentToday = Math.max(0, weekly.percent - dayBaseline.weeklyPercent)
271  const pace = spentToday > budget ? PACE_STYLES.over : spentToday > budget * CLOSE_TO_BUDGET_RATIO ? PACE_STYLES.close : PACE_STYLES.onTrack
272  const paceColor = settings.colors === 'off' ? undefined : pace.color
273  return [
274    { text: `${pace.icon} `, color: paceColor },
275    { text: `${Math.round(spentToday)}%/${Math.round(budget)}%`, color: paceColor },
276  ]
277}
278
279function pickStepColor(percent: number, colorStops: [number, string][]): string {
280  let color = colorStops[0]?.[1] ?? GREEN
281  for (const [stop, hex] of colorStops) {
282    if (stop <= percent) color = hex
283  }
284  return color
285}
286
287function colorFor(id: MetricId, percent: number, mode: ColorMode): string | undefined {
288  if (mode === 'off') return undefined
289  return pickStepColor(percent, id === 'context' ? CONTEXT_COLOR_STOPS : LIMIT_COLOR_STOPS)
290}
291
292function renderChart(percent: number, chart: ChartStyle): { filled: string; empty: string } {
293  const bounded = Math.min(Math.max(percent, 0), 100)
294  const filledCells = Math.round((bounded / 100) * BAR_WIDTH)
295  switch (chart) {
296    case 'dots':
297      return { filled: '●'.repeat(filledCells), empty: '○'.repeat(BAR_WIDTH - filledCells) }
298    case 'bar':
299      return { filled: '█'.repeat(filledCells), empty: '░'.repeat(BAR_WIDTH - filledCells) }
300    case 'level':
301      return { filled: LEVEL_GLYPHS[Math.min(7, Math.floor(bounded / 12.5))] ?? '', empty: '' }
302    case 'pie':
303      return { filled: PIE_GLYPHS[Math.round(bounded / 25)] ?? '', empty: '' }
304    default:
305      return { filled: '', empty: '' }
306  }
307}
308
309function formatResetTime(id: MetricId, resetsAt: string, messages: Messages): string {
310  const date = new Date(resetsAt)
311  if (Number.isNaN(date.getTime())) return ''
312  return id === 'weekly'
313    ? `${messages.weekdays[date.getDay()]} ${pad(date.getHours())}h`
314    : `${pad(date.getHours())}:${pad(date.getMinutes())}`
315}
316
317function buildUsageSegments(id: MetricId, reading: UsageReading, settings: UsageMeterConfig): Segment[] {
318  const { percent, resetsAt } = reading
319  if (typeof percent !== 'number') return []
320  const metric = settings.metrics[id]
321  const color = colorFor(id, percent, settings.colors)
322  const { filled, empty } = renderChart(percent, metric.chart)
323  const hasChart = filled.length + empty.length > 0
324  const segments: Segment[] = []
325  const solidBlocks = filled.match(/^█*/)?.[0] ?? ''
326  if (solidBlocks) segments.push({ text: solidBlocks, color: color ?? UNCOLORED_BLOCK })
327  if (filled.length > solidBlocks.length) segments.push({ text: filled.slice(solidBlocks.length), color })
328  if (empty) segments.push({ text: empty, isDim: true })
329  if (metric.showPercent || !hasChart) segments.push({ text: `${hasChart ? ' ' : ''}${Math.round(percent)}%`, color })
330  const resetTime = metric.showResetTime && resetsAt ? formatResetTime(id, resetsAt, MESSAGES[settings.language]) : ''
331  if (resetTime) segments.push({ text: ` ↻ ${resetTime}`, isDim: true })
332  return segments
333}
334
335function describeUsageLine(settings: UsageMeterConfig, snapshot: UsageSnapshot, dayBaseline: DayBaseline | null): LineItem[] {
336  const now = new Date()
337  return METRIC_ORDER
338    .filter(id => settings.metrics[id].isVisible)
339    .map(id => ({
340      id,
341      label: MESSAGES[settings.language].metricLabels[id],
342      segments: id === 'dailyBudget'
343        ? buildDailyBudgetSegments(snapshot.weekly, dayBaseline, settings, now)
344        : buildUsageSegments(id, snapshot[id], settings),
345    }))
346    .filter(item => item.segments.length > 0)
347}
348
349const EMPHASIS_HOVER: TextHoverProps = { bold: true, dimColor: false, inverse: false, backgroundColor: TERMINAL_BACKGROUND, scope: HOVER_SCOPE }
350const BUTTON_HOVER: TextHoverProps = { inverse: false, backgroundColor: TERMINAL_BACKGROUND, scope: HOVER_SCOPE }
351
352function lineRuns(items: LineItem[], labels: LabelMode): SegmentRun[] {
353  return items.flatMap((item, index): SegmentRun[] => {
354    const separator: Segment[] = index > 0 ? [{ text: SEPARATOR, isDim: true }] : []
355    if (labels !== 'hover') {
356      const label: Segment[] = labels === 'always' ? [{ text: `${item.label} `, isDim: true }] : []
357      return [{ key: item.id, segments: [...separator, ...label, ...item.segments] }]
358    }
359    return [
360      ...(separator.length > 0 ? [{ key: `${item.id}-separator`, segments: separator }] : []),
361      { key: `${item.id}-label`, segments: [{ text: `${item.label} `, color: HOVER_LABEL }], isHiddenUntilHover: true },
362      { key: item.id, segments: item.segments },
363    ]
364  })
365}
366
367function withMetric(current: UsageMeterConfig, id: MetricId, change: Partial<MetricSettings>): UsageMeterConfig {
368  const changed = { ...current, metrics: { ...current.metrics, [id]: { ...current.metrics[id], ...change } } }
369  const hasVisibleMetric = METRIC_ORDER.some(one => changed.metrics[one].isVisible)
370  return hasVisibleMetric ? changed : current
371}
372
373async function changeConfig($: EngineInterface, change: (current: UsageMeterConfig) => UsageMeterConfig) {
374  await update($, config, current => change(current ?? DEFAULT_CONFIG))
375  await $.store.set(SETTINGS_KEY, await read($, config))
376}
377
378async function recordUsage($: EngineInterface, change: (current: UsageSnapshot) => UsageSnapshot) {
379  const snapshot = await update($, usage, change)
380  const stored = (await $.store.get(BASELINE_KEY)) as DayBaseline | undefined
381  const updated = nextBaseline(stored ?? null, snapshot.weekly, new Date())
382  await update($, baseline, () => updated)
383  if (updated && updated !== stored) await $.store.set(BASELINE_KEY, updated)
384}
385
386async function openSettings($: EngineInterface) {
387  const { language } = await read($, config)
388  await $.ui.open({ id: PANE, title: MESSAGES[language].title, focus: true, closeOnEscape: true })
389}
390
391export const register: Register = on => {
392  let publishing: Promise<void> = Promise.resolve()
393
394  on('session.start', async ($, e, next) => {
395    await update($, config, () => DEFAULT_CONFIG)
396    const settings = mergeWithDefaults(await $.store.get(SETTINGS_KEY))
397    await update($, config, () => settings)
398    const current = await $.session.usage()
399    await recordUsage($, () => toSnapshot(current.context, current.rateLimits, 0))
400    await adoptSharedLimits($)
401    $.clock.every(SHARED_LIMITS_SYNC_MS, () => void adoptSharedLimits($))
402    await $.command.register({ name: 'usage-meter', description: MESSAGES[settings.language].commandDescription })
403    return next(e)
404  })
405
406  on('session.measure', async ($, e, next) => {
407    if (!e.changed.includes('rateLimits') || e.rateLimits.length === 0) {
408      await recordUsage($, current => ({ ...current, context: { percent: e.context.percent } }))
409      return next(e)
410    }
411    const snapshot = toSnapshot(e.context, e.rateLimits, await $.clock.now())
412    await recordUsage($, () => snapshot)
413    publishing = publishing.then(() => publishLimits($, snapshot)).catch(() => undefined)
414    await publishing
415    return next(e)
416  })
417
418  on('command.run', { command: 'usage-meter' }, async $ => {
419    await openSettings($)
420    return { text: MESSAGES[(await read($, config)).language].commandOpened }
421  })
422
423  on('ui.render', { component: 'SessionMode' }, async ($, e) => {
424    const { Box, Button, Text } = $.ui.resolve(e)
425    const settings = await read($, config)
426    const items = describeUsageLine(settings, await read($, usage), await read($, baseline))
427    const modes = e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')}{SEPARATOR}</Text>
428    const drawSegment = (segment: Segment) => (
429      <Text color={segment.color} dimColor={segment.isDim} hover={EMPHASIS_HOVER}>
430        {segment.text}
431      </Text>
432    )
433    const drawRun = (run: SegmentRun) => {
434      const button = (
435        <Button key={`open-settings-${run.key}`} plain hover={BUTTON_HOVER} onPress={() => void openSettings($)}>
436          {run.segments.map(drawSegment)}
437        </Button>
438      )
439      return run.isHiddenUntilHover ? <Box display="none" hover={{ display: 'flex', scope: HOVER_SCOPE }}>{button}</Box> : button
440    }
441
442    return (
443      <Box>
444        {modes}
445        {/* Texts nested in a Button label cannot heat their hover group, so the Box joins it and heats it for them. */}
446        <Box key={HOVER_SCOPE} hover={{ scope: HOVER_SCOPE }}>
447          {lineRuns(items, settings.labels).map(drawRun)}
448        </Box>
449      </Box>
450    )
451  })
452
453  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
454    const { Box, Button, Text } = $.ui.resolve(e)
455    const settings = await read($, config)
456    const messages = MESSAGES[settings.language]
457    const setGeneral = (change: Partial<UsageMeterConfig>) => () => changeConfig($, current => ({ ...current, ...change }))
458    const setMetric = (id: MetricId, change: Partial<MetricSettings>) => () => changeConfig($, current => withMetric(current, id, change))
459
460    const choice = (key: string, label: string, isSelected: boolean, onPress: () => void) => (
461      <Button key={key} label={label} variant={isSelected ? 'primary' : undefined} dimColor={!isSelected} onPress={onPress} />
462    )
463
464    const row = (title: string, controls: RenderChildren[]) => (
465      <Box columnGap={1}>
466        <Box width={18} flexShrink={0}>
467          <Text dimColor>{title}</Text>
468        </Box>
469        <Box flexWrap="wrap" columnGap={1}>{controls}</Box>
470      </Box>
471    )
472
473    const section = (title: string, rows: RenderChildren[], headerControl?: RenderChildren) => (
474      <Box flexDirection="column">
475        <Box columnGap={1}>
476          <Box width={18} flexShrink={0}>
477            <Text bold>{title}</Text>
478          </Box>
479          {headerControl}
480        </Box>
481        {rows}
482      </Box>
483    )
484
485    const switchRow = (key: string, title: string, isOn: boolean, onChange: (value: boolean) => () => void) =>
486      row(title, [true, false].map(value => choice(`${key}-${value}`, value ? messages.on : messages.off, isOn === value, onChange(value))))
487
488    const metricSection = (id: MetricId) => {
489      const metric = settings.metrics[id]
490      const visibility = choice(`${id}-visible`, metric.isVisible ? messages.visible : messages.hidden, metric.isVisible, setMetric(id, { isVisible: !metric.isVisible }))
491      if (!metric.isVisible) return section(messages.metricNames[id], [], visibility)
492      if (id === 'dailyBudget') {
493        return section(messages.metricNames[id], [
494          row(messages.rows.weekends, [
495            choice('weekends-true', messages.countWeekends, settings.countWeekends, setGeneral({ countWeekends: true })),
496            choice('weekends-false', messages.skipWeekends, !settings.countWeekends, setGeneral({ countWeekends: false })),
497          ]),
498        ], visibility)
499      }
500      return section(messages.metricNames[id], [
501        row(messages.rows.chart, CHART_STYLES.map(chart =>
502          choice(`${id}-chart-${chart}`, messages.charts[chart], metric.chart === chart, setMetric(id, { chart })))),
503        switchRow(`${id}-percent`, messages.rows.percent, metric.showPercent, showPercent => setMetric(id, { showPercent })),
504        ...(METRICS_WITH_RESET_TIME.includes(id)
505          ? [switchRow(`${id}-reset`, messages.rows.resetTime, metric.showResetTime, showResetTime => setMetric(id, { showResetTime }))]
506          : []),
507      ], visibility)
508    }
509
510    return (
511      <Box flexDirection="column" borderStyle="round" paddingX={1} rowGap={1}>
512        <Text bold>{messages.title}</Text>
513        {section(messages.general, [
514          row(messages.rows.language, LANGUAGES.map(language =>
515            choice(`language-${language}`, messages.languages[language], settings.language === language, setGeneral({ language })))),
516          row(messages.rows.labels, LABEL_MODES.map(labels =>
517            choice(`labels-${labels}`, messages.labelModes[labels], settings.labels === labels, setGeneral({ labels })))),
518          row(messages.rows.colors, COLOR_MODES.map(colors =>
519            choice(`colors-${colors}`, messages.colorModes[colors], settings.colors === colors, setGeneral({ colors })))),
520        ])}
521        {METRIC_ORDER.map(metricSection)}
522        <Box columnGap={1}>
523          <Button key="close" variant="primary" role="dismiss" label={messages.done} onPress={() => void $.ui.close({ id: PANE })} />
524          <Button key="restore" dimColor label={messages.restoreDefaults} onPress={() => changeConfig($, current => ({ ...DEFAULT_CONFIG, language: current.language }))} />
525        </Box>
526      </Box>
527    )
528  })
529}
530
types/index.d.ts 51 lines
1export type MetricId = 'context' | 'session' | 'weekly' | 'dailyBudget'
2export type ChartStyle = 'none' | 'bar' | 'dots' | 'level' | 'pie'
3export type LabelMode = 'always' | 'hover' | 'never'
4export type ColorMode = 'on' | 'off'
5export type Language = 'en' | 'pt-BR'
6
7export type MetricSettings = {
8  isVisible: boolean
9  chart: ChartStyle
10  showPercent: boolean
11  showResetTime: boolean
12}
13
14export type UsageMeterConfig = {
15  language: Language
16  labels: LabelMode
17  colors: ColorMode
18  countWeekends: boolean
19  metrics: Record<MetricId, MetricSettings>
20}
21
22export type UsageReading = {
23  percent?: number
24  resetsAt?: string
25}
26
27export type UsageSnapshot = {
28  context: UsageReading
29  session: UsageReading
30  weekly: UsageReading
31  limitsMeasuredAt: number
32}
33
34export type SharedLimits = {
35  measuredAt: number
36  session: UsageReading
37  weekly: UsageReading
38}
39
40export type DayBaseline = {
41  day: string
42  weeklyPercent: number
43  resetsAt?: string
44}
45
46declare module 'claude-code' {
47  interface PluginState {
48    'usage-meter': { config: UsageMeterConfig; usage: UsageSnapshot; baseline: DayBaseline | null }
49  }
50}
51