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

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.

| Metric | What it is |
|---|---|
| Context | How full the model's context window is. Its colors turn orange at 50%, where compaction usually gets close. |
| Session | The 5-hour usage limit, with the time it resets. |
| Weekly | The 7-day usage limit, with the day and hour it resets. |
| Daily budget | Weekly % 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.
Click anywhere on the footer or run /usage-meter to open the settings pane:
███░░░░░), dots (●●●○○○○○), level (▃) or pie (◑); the percent; and, for session and weekly, the reset time.Settings are kept across sessions.
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.
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.
claude plugin validate .
claude plugin test .
tsc -p . # once Claude Code has loaded the mod and written .claude-plugin/types/
#0c0c0c (Windows Terminal's Campbell scheme).◔ ◑ ◕ depend on the terminal font.hooks/register.tsx 530 lines1import { 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}
530types/index.d.ts 51 lines1export 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