Your 5-hour and weekly limits as pace bars above the prompt: how much is used against how far into the window you are.

context-meter and usage-meter status lines. This repo is a Claude Code plugin marketplace.hooks/register.tsx 216 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Reading } from '../types'
5import { bar, brief, COLORS, detail, duration, isMain, pace, summary, type Level, type Pace } from './pace'
6import { fromAppUsage } from './source'
7
8const reading = atom({ plugin: 'usage-meter', key: 'reading' } as const, null)
9const clock = atom({ plugin: 'usage-meter', key: 'now' } as const, 0)
10
11const LABEL = 5
12/** Columns left free at the row's end, so a surface that measures a little narrower still fits it. */
13const RESERVE = 2
14const GAP = 2
15/** A bar never shrinks below this; on a narrow band the row wraps instead. */
16const MIN_BAR = 6
17const POLL_MS = 5 * 60_000
18const MIN_FETCH_MS = 60_000
19const RANK: Record<Level, number> = { green: 0, yellow: 1, orange: 2, red: 3 }
20
21const alerted = new Map<string, Level>()
22let fetchedAt = 0
23
24/**
25 * The desktop app's usage card (5-hour, weekly, per-model weekly) through its
26 * own tool; none where that server is absent (terminal, cloud) or says nothing.
27 */
28async function appLimits($: EngineInterface) {
29 try {
30 const result = await $.mcp.call('ccd_session_mgmt', 'get_usage', {})
31 if (result.isError) return []
32 const text = result.content.find(one => one.type === 'text')?.text
33
34 return text === undefined ? [] : fromAppUsage(text)
35 } catch {
36 return []
37 }
38}
39
40async function record($: EngineInterface, next: Reading) {
41 const now = await $.clock.now()
42 await update($, reading, () => next)
43 await update($, clock, () => now)
44
45 const paces = next.rateLimits.map(limit => pace(limit, now))
46 // The band above the prompt draws on desktop and terminal only; the mobile app
47 // gets the row as a status line under the prompt, pinned only while it is attached.
48 const onPhone = (await $.session.surfaces()).includes('mobile')
49 $.ui.status(onPhone && paces.length > 0 ? summary(paces) : undefined)
50
51 // One toast each time a window climbs into orange or red; quiet as it falls back.
52 for (const p of paces) {
53 const before = alerted.get(p.kind) ?? 'green'
54 if (RANK[p.level] > RANK[before] && RANK[p.level] >= RANK.orange) {
55 $.ui.toast(
56 p.level === 'red'
57 ? `${p.name} limit ${Math.round(p.used)}% used${p.resetsInMs === undefined ? '' : ` — resets in ${duration(p.resetsInMs)}`}`
58 : `${p.name} usage is on pace to hit the limit before it resets`,
59 )
60 }
61 alerted.set(p.kind, p.level)
62 }
63}
64
65/** Reads the app's card (at most once a minute), else the session's own readings. */
66async function refresh($: EngineInterface, session: Reading) {
67 const now = await $.clock.now()
68 if (now - fetchedAt < MIN_FETCH_MS) {
69 const kept = await read($, reading)
70 await record($, { rateLimits: kept?.rateLimits ?? session.rateLimits, cost: session.cost })
71
72 return
73 }
74 fetchedAt = now
75 const fromApp = await appLimits($)
76 await record($, { rateLimits: fromApp.length > 0 ? fromApp : session.rateLimits, cost: session.cost })
77}
78
79export const register: Register = on => {
80 on('session.start', async ($, e, next) => {
81 const result = await next(e)
82 await $.command.register({
83 name: 'usage-meter',
84 description: 'Show your 5-hour and weekly limits against where you are in each window',
85 })
86 const usage = await $.session.usage()
87 await refresh($, { rateLimits: usage.rateLimits, cost: usage.cost })
88 // Keep the pace marks and reset countdowns moving, and re-read the card, between turns.
89 $.clock.every(60_000, () => {
90 void $.clock.now().then(now => update($, clock, () => now))
91 })
92 $.clock.every(POLL_MS, () => {
93 void $.session.usage().then(usage => refresh($, { rateLimits: usage.rateLimits, cost: usage.cost }))
94 })
95
96 return result
97 })
98
99 on('session.measure', async ($, e, next) => {
100 await refresh($, { rateLimits: e.rateLimits, cost: e.cost })
101
102 return next(e)
103 })
104
105 // A phone joining or leaving the session pins or clears its status line.
106 on('session.attach', async ($, e, next) => {
107 const result = await next(e)
108 const kept = await read($, reading)
109 if (kept !== null) await record($, kept)
110
111 return result
112 })
113
114 on('session.detach', async ($, e, next) => {
115 const result = await next(e)
116 const kept = await read($, reading)
117 if (kept !== null) await record($, kept)
118
119 return result
120 })
121
122 on('command.run', { command: 'usage-meter' }, async $ => {
123 const now = await $.clock.now()
124 const latest = await read($, reading)
125 const paces = (latest?.rateLimits ?? []).map(limit => pace(limit, now))
126 if (paces.length === 0) {
127 return { text: 'No limit readings yet: they arrive with the first reply in a session on a Claude subscription.' }
128 }
129 const cost = latest?.cost === undefined ? '' : `\nThis session: $${latest.cost.usd.toFixed(2)}`
130
131 return { text: paces.map(p => `${p.level.toUpperCase().padEnd(6)} ${p.name}: ${detail(p)}`).join('\n') + cost }
132 })
133
134 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
135 const below = await next(e)
136 if (e.props.hasSurvey) return below
137
138 const latest = await read($, reading)
139 const now = await read($, clock)
140 const paces = (latest?.rateLimits ?? [])
141 .map(limit => pace(limit, now))
142 .filter(p => isMain(p) || p.level !== 'green')
143 if (paces.length === 0) return below
144
145 // The label takes the color of the worst window, as Ctx takes its fill's.
146 const worst = paces.reduce<Level>((most, p) => (RANK[p.level] > RANK[most] ? p.level : most), 'green')
147
148 // The bars share every column the label, names and figures leave, so the row spans the band.
149 const fixed =
150 LABEL +
151 paces.reduce((sum, p) => sum + p.name.length + 1 + 1 + brief(p).length, 0) +
152 GAP * (paces.length - 1)
153 const bars = paces.filter(isMain).length
154 const width = bars === 0 ? 0 : Math.max(MIN_BAR, Math.floor((e.props.bodyColumns - fixed - RESERVE) / bars))
155
156 const { Box, Text } = $.ui.resolve(e)
157 // A window's segment: its name, a bar filled to the use with ┃ at how far into
158 // the window you are, then used→projected at reset and the time to reset.
159 // One line, never wrapped: names and figures keep their width, and each bar, in a
160 // box clipped to one row, gives up columns first where the band is narrower.
161 const segment = (p: Pace, i: number) => (
162 <Box
163 key={`usage-${p.kind}`}
164 flexDirection="row"
165 flexWrap="nowrap"
166 flexGrow={isMain(p) ? 1 : 0}
167 flexShrink={isMain(p) ? 1 : 0}
168 minWidth={0}
169 marginRight={i < paces.length - 1 ? GAP : 0}
170 >
171 <Box flexShrink={0}>
172 <Text color={COLORS[p.level]} bold>
173 {p.name}{' '}
174 </Text>
175 </Box>
176 {isMain(p) && (
177 <Box flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden">
178 <Text>
179 {bar(p, width).map(cell =>
180 cell.tone === 'empty' ? (
181 <Text dimColor>{cell.text}</Text>
182 ) : (
183 <Text color={cell.tone === 'mark' ? 'text' : COLORS[p.level]}>{cell.text}</Text>
184 ),
185 )}
186 </Text>
187 </Box>
188 )}
189 <Box flexShrink={0}>
190 <Text color={COLORS[p.level]}> {brief(p)}</Text>
191 </Box>
192 </Box>
193 )
194 const mine = (
195 <Box key="usage-meter" flexDirection="row" flexWrap="nowrap">
196 <Box flexShrink={0}>
197 <Text color={COLORS[worst]} bold>
198 {'Usg'.padEnd(LABEL)}
199 </Text>
200 </Box>
201 {paces.map(segment)}
202 </Box>
203 )
204
205 // Drawn under whatever else the band holds (the Ctx row).
206 return below ? (
207 <Box flexDirection="column">
208 {below}
209 {mine}
210 </Box>
211 ) : (
212 mine
213 )
214 })
215}
216hooks/pace.ts 142 lines1import type { Reading } from '../types'
2
3type SessionRateLimit = Reading['rateLimits'][number]
4
5export type Level = 'green' | 'yellow' | 'orange' | 'red'
6
7export type Pace = {
8 kind: string
9 name: string
10 /** Percent of the limit used, 0 to 100 (past 100 when blocked on spend). */
11 used: number
12 /** How far into the window you are, 0 to 1; absent without a reset time. */
13 elapsed?: number
14 /** Percent of the limit you will have used at reset, at the pace so far. */
15 projected?: number
16 resetsInMs?: number
17 level: Level
18}
19
20export const COLORS: Record<Level, string> = {
21 green: '#3fb950',
22 yellow: '#d29922',
23 orange: '#f0883e',
24 red: '#f85149',
25}
26
27
28const NAMES: Record<string, string> = { five_hour: '5h', seven_day: 'Wk', spend_limit: 'Spend' }
29
30/** `seven_day:<model>` is a per-model weekly window, named by its model. */
31const baseOf = (kind: string) => kind.split(':')[0] ?? kind
32const nameOf = (kind: string) => NAMES[kind] ?? (kind.includes(':') ? kind.slice(kind.indexOf(':') + 1) : kind)
33const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
34
35/** At or above this percent used, the window is red whatever the pace. */
36export const RED_FROM = 95
37/** Projected use at reset from which the pace counts as near the limit. */
38export const YELLOW_FROM = 85
39/** Projections from the first 5% of a window are too noisy; treat it as 5% in. */
40const MIN_ELAPSED = 0.05
41
42const levelOf = (used: number, projected: number | undefined): Level => {
43 if (used >= RED_FROM) return 'red'
44 // With no window to measure against, judge on use alone.
45 const measure = projected ?? used
46 if (measure > 100) return 'orange'
47 if (measure >= YELLOW_FROM) return 'yellow'
48
49 return 'green'
50}
51
52export const pace = (limit: SessionRateLimit, now: number): Pace => {
53 const used = limit.percentUsed
54 const resetsAt = limit.resetsAt === undefined ? Number.NaN : Date.parse(limit.resetsAt)
55 const length = WINDOW_MS[baseOf(limit.kind)]
56 let elapsed: number | undefined
57 let projected: number | undefined
58 let resetsInMs: number | undefined
59
60 if (!Number.isNaN(resetsAt)) {
61 resetsInMs = Math.max(0, resetsAt - now)
62 if (length !== undefined) {
63 elapsed = Math.min(1, Math.max(0, 1 - resetsInMs / length))
64 projected = used / Math.max(elapsed, MIN_ELAPSED)
65 }
66 }
67
68 return {
69 kind: limit.kind,
70 name: nameOf(limit.kind),
71 used,
72 elapsed,
73 projected,
74 resetsInMs,
75 level: levelOf(used, projected),
76 }
77}
78
79export const duration = (ms: number) => {
80 const minutes = Math.round(ms / 60_000)
81 const days = Math.floor(minutes / 1440)
82 const hours = Math.floor((minutes % 1440) / 60)
83
84 return days > 0 ? `${days}d${hours}h` : hours > 0 ? `${hours}h${minutes % 60}m` : `${minutes}m`
85}
86
87/** The windows the row always shows; any other (a model's weekly) shows once it leaves green. */
88export const isMain = (p: Pace) => p.kind === 'five_hour' || p.kind === 'seven_day'
89
90/** `used→projected` and the time to reset, as the row prints beside a bar. */
91export const brief = (p: Pace) => {
92 const projected = p.projected === undefined || p.level === 'red' ? '' : `→${Math.round(p.projected)}%`
93 const reset = p.resetsInMs === undefined ? '' : ` ↻${duration(p.resetsInMs)}`
94
95 return `${Math.round(p.used)}%${projected}${reset}`
96}
97
98export type Cell = { text: string; tone: 'fill' | 'empty' | 'mark' }
99
100/**
101 * A bar of `width` cells: filled to the percent used, with a mark at how far
102 * into the window you are. Fill left of the mark is on pace; fill past it is ahead.
103 */
104export const bar = (p: Pace, width: number): Cell[] => {
105 const filled = Math.round((Math.min(p.used, 100) / 100) * width)
106 const mark = p.elapsed === undefined ? -1 : Math.min(width - 1, Math.floor(p.elapsed * width))
107 const cells: Cell[] = []
108
109 for (let i = 0; i < width; i += 1) {
110 const tone = i === mark ? 'mark' : i < filled ? 'fill' : 'empty'
111 const text = tone === 'mark' ? '┃' : tone === 'fill' ? '█' : '░'
112 const last = cells[cells.length - 1]
113 if (last !== undefined && last.tone === tone) {
114 last.text += text
115 } else {
116 cells.push({ text, tone })
117 }
118 }
119
120 return cells
121}
122
123export const detail = (p: Pace) => {
124 const parts = [`${Math.round(p.used)}% used`]
125 if (p.elapsed !== undefined) parts.push(`${Math.round(p.elapsed * 100)}% through window`)
126 if (p.projected !== undefined) {
127 parts.push(p.projected > 100 ? `on pace to hit limit` : `→ ${Math.round(p.projected)}% at reset`)
128 }
129 if (p.resetsInMs !== undefined) parts.push(`resets in ${duration(p.resetsInMs)}`)
130
131 return parts.join(' · ')
132}
133
134export const DOTS: Record<Level, string> = { green: '🟢', yellow: '🟡', orange: '🟠', red: '🔴' }
135
136/** The row as one plain line, for the status line the mobile app draws under the prompt. */
137export const summary = (paces: Pace[]) =>
138 `Usg ${paces
139 .filter(p => isMain(p) || p.level !== 'green')
140 .map(p => `${DOTS[p.level]} ${p.name} ${brief(p)}`)
141 .join(' · ')}`
142hooks/source.ts 42 lines1import type { Reading } from '../types'
2
3type Limit = Reading['rateLimits'][number]
4
5/**
6 * The desktop app's usage card, as its `get_usage` tool returns it: windows
7 * labelled `5-hour limit`, `Weekly · all models`, `Weekly · <model>`.
8 */
9type AppPlan = {
10 status?: string
11 windows?: { label?: string; percentUsed?: number; resetsAt?: string }[]
12}
13
14const kindOf = (label: string) => {
15 if (/^5-hour/i.test(label)) return 'five_hour'
16 const weekly = /^weekly\s*·\s*(.+)$/i.exec(label)
17 if (weekly === null) return undefined
18 const scope = weekly[1]?.trim() ?? ''
19
20 return /^all models$/i.test(scope) ? 'seven_day' : `seven_day:${scope}`
21}
22
23/** The limit windows in a `get_usage` result's JSON text; none when it has no plan. */
24export const fromAppUsage = (text: string): Limit[] => {
25 let plan: AppPlan | undefined
26 try {
27 plan = (JSON.parse(text) as { plan?: AppPlan }).plan
28 } catch {
29 return []
30 }
31 if (plan?.status !== 'ok') return []
32
33 const limits: Limit[] = []
34 for (const window of plan.windows ?? []) {
35 const kind = window.label === undefined ? undefined : kindOf(window.label)
36 if (kind === undefined || typeof window.percentUsed !== 'number') continue
37 limits.push({ kind, percentUsed: window.percentUsed, resetsAt: window.resetsAt })
38 }
39
40 return limits
41}
42types/index.d.ts 11 lines1export type Reading = {
2 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
3 cost?: { usd: number }
4}
5
6declare module 'claude-code' {
7 interface PluginState {
8 'usage-meter': { reading: Reading | null; now: number }
9 }
10}
11