Your subscription usage at the bottom right of Claude Code: the 5-hour and weekly windows, as a bar that takes turns. In fullscreen, click it to switch and…

A Claude Code mod: your subscription usage, always at the bottom right, on the same line as the permission mode.
A fork of midudev/better-claude's Sub Meter, which adds a click to switch windows and a usage graph on hover.
╭──────────────────────────────────────────────────╮
│ Claude Code usage per hour, last 24h │
│ ▃ █ █ ▃ │
│ ▁ █ █ █ █ ▁ │
│ █ █ █ █ █ █ │
│ ▃ █ █ █ █ █ █ ▃ │
│ █ █ █ █ █ █ █ █ │
│ ▃ █ █ █ █ █ █ █ █ ▄ │
│ 00 06 12 18 │
│ busiest: 10:00 │
╰──────────────────────────────────────────────────╯
⏵⏵ auto mode on 5h ━━━━━━──── 58% · 2h 10m
Every 8 seconds it switches between the 5-hour window and the weekly one (change it in /config, rotateSeconds). The bar turns yellow from 75% and red from 90%. After it the time until that window resets.
There is no monthly window: Claude Code only reports the 5-hour and the weekly ones. Off a subscription (API key billing) there is nothing to show, so the footer stays as it is.
While it is on, it also drops the (shift+tab to cycle) hint after the permission mode, to leave room for the meter. Turn that off in /config (hideCycleHint).
rotateSeconds.The bars are relative, so the busiest hour or day is full height, and the busiest is named underneath. It's there to show when usage was highest, not how much. Click while the graph is up and it follows the window you switched to.
The graph comes from Claude Code's own session transcripts (~/.claude/projects/**/*.jsonl, or under $CLAUDE_CONFIG_DIR), the same files tools like ccusage and codeburn read. Every model call in them has its time, model and token counts, so the graph covers the whole week from the first time it opens, including usage from before the mod was installed. Subagent transcripts are included.
Each call is weighted by the API's relative list prices: output tokens count about 5× input, cache writes a little more than input, cache reads about a tenth, and bigger models more than smaller ones. That follows how fast you use up your limits much better than raw token counts, which are mostly cheap cache reads. No numbers are shown, since the weights are only an approximation of what counts against the limits.
The transcripts are read when the session starts and again when the graph opens, at most every 30 seconds, and only files that changed are re-read.
cleanupPeriodDays), which is more than the graph needs.From Claude Code:
/plugin marketplace add matt-winfield/skills
/plugin install sub-meter@matt-winfield-skills
Or from the shell:
claude plugin marketplace add matt-winfield/skills
claude plugin install sub-meter@matt-winfield-skills
If midudev's sub-meter@better-claude is installed, uninstall it first (claude plugin uninstall sub-meter@better-claude). Both draw in the same spot.
Get updates with claude plugin marketplace update matt-winfield-skills, then claude plugin update sub-meter@matt-winfield-skills.
The hooks module is hooks/register.tsx. Reading and weighting transcripts and drawing the chart is in hooks/usage.ts, and the pointer targets are in hooks/hit.tsx.
session.measure hands it the rate-limit windows after each response (five_hour, seven_day), and $.session.usage() gives the first reading at start.$.store, so a new session shows it before its first response.$.fs.list and $.fs.read from the transcripts changed in the last week. A file of 4 MiB or more, past what one read can take, is streamed through cat (type on Windows) with $.process.spawn. A message streamed as several lines, or copied into a resumed session's transcript, counts once by its message id.ui.render hook on SessionMode wraps what the engine draws there (its mode labels) and adds the meter after it.Client (hit.tsx), an unpainted region that posts pointer enter, leave and click to the hooks module through ui.message. While hovered, a ui.render hook on AbovePrompt draws the graph in the band above the prompt box, with its own Client on top, so the pointer on the graph counts as hovering too. It isn't drawn from the footer's own slot because that slot clips anything above its row. The graph stays up for 0.7 seconds after the pointer leaves the meter, long enough to cross the prompt box onto it. On a short terminal it uses fewer rows of bars, and it gives way to a survey in the band.$.clock.after timer. It is cancelled on hover with its remaining time saved, rescheduled on leave, and reset by a click.ui.render hook on PromptHint rewrites the hint line without (shift+tab to cycle). The engine still draws it, and the mode label before it.claude plugin validate ./claude-code-mods/sub-meter # from the repository root
claude plugin test ./claude-code-mods/sub-meter # runs hooks/*.test.ts(x) against the engine
claude --plugin-dir ./claude-code-mods/sub-meter # try it in a session; edits hot-reload
This folder was imported with git subtree, keeping upstream's history of plugins/sub-meter. To merge newer upstream commits:
git clone https://github.com/midudev/better-claude /tmp/better-claude
git -C /tmp/better-claude subtree split --prefix=plugins/sub-meter -b sub-meter-split
git subtree pull --prefix=claude-code-mods/sub-meter /tmp/better-claude sub-meter-split
MIT © Miguel Ángel Durán (midudev), fork changes © Matthew Winfield. See LICENSE.
hooks/register.tsx 350 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderNode, SessionRateLimit, Timer } from 'claude-code'
3
4import { bucketsFor, callsIn, chartOf, sinceFor } from './usage'
5import type { Call } from './usage'
6import type { Limit } from '../types'
7
8const limits = atom({ plugin: 'sub-meter', key: 'limits' } as const, [] as Limit[])
9const view = atom({ plugin: 'sub-meter', key: 'view' } as const, 0)
10const isHovered = atom({ plugin: 'sub-meter', key: 'isHovered' } as const, false)
11const scannedAt = atom({ plugin: 'sub-meter', key: 'scannedAt' } as const, 0)
12
13const WINDOWS = ['five_hour', 'seven_day']
14const LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'week' }
15const ROTATE_SECONDS = 8
16const CELLS = 10
17const CYCLE_HINT = /\s*\([^()]+ to cycle\)/
18// The pointer going from the meter up onto its graph, over the prompt box between them, leaves one
19// well before it enters the other: the graph stays up this long after a leave. Off both for good, the switch timer resumes, with at
20// least a second to go. A pointer that left the terminal says nothing: after a minute with no
21// word from it, the meter is no longer hovered.
22const LEAVE_MS = 700
23const MIN_RESUME_MS = 1000
24const HOVER_TIMEOUT_MS = 60_000
25// Under this many columns the graph packs its bars.
26const COMPACT_COLUMNS = 64
27const CHART_ROWS = 6
28// The transcripts are looked at again when the graph opens, at most this often; a file over what
29// one read takes is streamed instead.
30const RESCAN_MS = 30_000
31const MAX_READ_BYTES = 4 * 1024 * 1024
32
33/** The subscription windows the meter shows, in order, from what the engine measured. */
34const windowsOf = (rateLimits: readonly SessionRateLimit[]): Limit[] =>
35 WINDOWS.flatMap(kind => {
36 const found = rateLimits.find(limit => limit.kind === kind)
37 return found ? [{ kind, percentUsed: found.percentUsed, resetsAt: found.resetsAt }] : []
38 })
39
40/** Whether a drawing beneath shows nothing: no text, no control (the engine's own node is empty). */
41function isBlank (node: RenderNode | undefined): boolean {
42 if (node === undefined || node === null) return true
43 if (typeof node === 'string') return node.trim() === ''
44 return (node.type === 'Box' || node.type === 'Text') && (node.children ?? []).every(isBlank)
45}
46
47const colorFor = (percent: number) => (percent >= 90 ? 'error' : percent >= 75 ? 'warning' : 'claude')
48
49/** "42m", "3h 5m", "2d 4h": how long until the window resets. */
50function until (ms: number) {
51 const minutes = Math.max(1, Math.round(ms / 60000))
52 if (minutes < 60) return `${minutes}m`
53 const hours = Math.floor(minutes / 60)
54 if (hours < 24) return `${hours}h ${minutes % 60}m`
55 return `${Math.floor(hours / 24)}d ${hours % 24}h`
56}
57
58/** Keeps the latest reading for the meter, and for the next session until a response reports again. */
59async function remember ($: EngineInterface, found: Limit[]) {
60 if (found.length === 0) return
61 await update($, limits, () => found)
62 await $.store.set('limits', found)
63}
64
65// The model calls of every transcript of the last week, by path, as last read; and the scan running.
66const transcripts = new Map<string, { mtimeMs: number; size: number; calls: Call[] }>()
67let scanning: Promise<void> | undefined
68let lastScan = -Infinity
69
70/** Where Claude Code keeps its session transcripts: one folder per project, a `.jsonl` per session. */
71async function projectsDir ($: EngineInterface) {
72 const config = await $.env.get('CLAUDE_CONFIG_DIR')
73 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
74 const root = config ?? (home === undefined ? undefined : `${home}/.claude`)
75 return root === undefined ? undefined : `${root.replace(/[\\/]+$/, '')}/projects`
76}
77
78/** The model calls in one transcript: read at once, or streamed where it is bigger than a read takes. */
79async function callsOf ($: EngineInterface, path: string, size: number, since: number): Promise<Call[]> {
80 if (size < MAX_READ_BYTES) return callsIn(await $.fs.read(path), since)
81 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
82 const calls: Call[] = []
83 let rest = ''
84 for await (const piece of $.process.spawn({ argv: isWindows ? ['cmd', '/c', 'type', path] : ['cat', path] })) {
85 if (piece.stream !== 'stdout') continue
86 const text = rest + piece.text
87 const end = text.lastIndexOf('\n')
88 rest = text.slice(end + 1)
89 if (end >= 0) calls.push(...callsIn(text.slice(0, end), since))
90 }
91 calls.push(...callsIn(rest, since))
92 return calls
93}
94
95/**
96 * Reads again the transcripts written to in the last week (each session's, and its subagents'),
97 * skipping those unchanged since the last look. Unreadable ones are left out.
98 */
99async function scan ($: EngineInterface) {
100 const now = await $.clock.now()
101 if (scanning || now - lastScan < RESCAN_MS) return scanning
102 lastScan = now
103 scanning = (async () => {
104 const since = sinceFor('seven_day', now)
105 const root = await projectsDir($)
106 const seen = new Set<string>()
107 const look = async (path: string, mtimeMs: number, size: number) => {
108 seen.add(path)
109 const known = transcripts.get(path)
110 if (known && known.mtimeMs === mtimeMs && known.size === size) return
111 const calls = await callsOf($, path, size, since).catch(() => [])
112 transcripts.set(path, { mtimeMs, size, calls })
113 }
114 const list = (path: string) => $.fs.list(path).catch(() => [])
115 for (const project of root === undefined ? [] : await list(root)) {
116 if (project.kind !== 'dir') continue
117 const folder = `${root}/${project.name}`
118 for (const entry of await list(folder)) {
119 if (entry.kind !== 'file' || !entry.name.endsWith('.jsonl') || entry.mtimeMs < since) continue
120 await look(`${folder}/${entry.name}`, entry.mtimeMs, entry.size)
121 // A session's subagents write transcripts of their own, beside it.
122 const subagents = `${folder}/${entry.name.slice(0, -'.jsonl'.length)}/subagents`
123 for (const agent of await list(subagents)) {
124 if (agent.kind === 'file' && agent.name.endsWith('.jsonl') && agent.mtimeMs >= since) await look(`${subagents}/${agent.name}`, agent.mtimeMs, agent.size)
125 }
126 }
127 }
128 for (const path of transcripts.keys()) if (!seen.has(path)) transcripts.delete(path)
129 await update($, scannedAt, () => now)
130 })().finally(() => {
131 scanning = undefined
132 })
133 return scanning
134}
135
136function * allCalls () {
137 for (const { calls } of transcripts.values()) yield * calls
138}
139
140// The switch timer: when it is due, and, while it is paused, how long it had left.
141let timer: Timer | undefined
142let dueAt = 0
143let pausedWith: number | undefined
144let rotateMs = ROTATE_SECONDS * 1000
145// Which of the pointer targets the pointer is on, and the timers that end a hover.
146const over = { meter: false, graph: false }
147let leaving: Timer | undefined
148let silence: Timer | undefined
149
150async function schedule ($: EngineInterface, ms: number) {
151 timer?.cancel()
152 dueAt = (await $.clock.now()) + ms
153 timer = $.clock.after(ms, () => {
154 timer = undefined
155 void rotate($)
156 })
157}
158
159/** Shows the other window, and starts its time over. */
160async function rotate ($: EngineInterface) {
161 await update($, view, n => n + 1)
162 if (pausedWith !== undefined) pausedWith = rotateMs
163 else await schedule($, rotateMs)
164}
165
166async function pause ($: EngineInterface) {
167 if (pausedWith !== undefined) return
168 pausedWith = timer ? Math.max(0, dueAt - (await $.clock.now())) : rotateMs
169 timer?.cancel()
170 timer = undefined
171}
172
173async function resume ($: EngineInterface) {
174 if (pausedWith === undefined) return
175 const left = pausedWith
176 pausedWith = undefined
177 await schedule($, Math.max(left, MIN_RESUME_MS))
178}
179
180/** The pointer came onto a target (or moved on it) or left it: the graph shows while it is on either. */
181async function point ($: EngineInterface, target: keyof typeof over, isOver: boolean) {
182 over[target] = isOver
183 silence?.cancel()
184 if (over.meter || over.graph) {
185 leaving?.cancel()
186 leaving = undefined
187 silence = $.clock.after(HOVER_TIMEOUT_MS, () => { void unhover($) })
188 await pause($)
189 if (!(await read($, isHovered))) {
190 await update($, isHovered, () => true)
191 void scan($).catch(() => undefined)
192 }
193 return
194 }
195 leaving ??= $.clock.after(LEAVE_MS, () => {
196 leaving = undefined
197 if (!over.meter && !over.graph) void unhover($)
198 })
199}
200
201async function unhover ($: EngineInterface) {
202 over.meter = false
203 over.graph = false
204 await update($, isHovered, () => false)
205 await resume($)
206}
207
208export const register: Register = (on, options) => {
209 const hideCycleHint = options.hideCycleHint !== false
210 const rotateSeconds = Number(options.rotateSeconds)
211 rotateMs = (rotateSeconds > 0 ? rotateSeconds : ROTATE_SECONDS) * 1000
212 timer = undefined
213 pausedWith = undefined
214 over.meter = false
215 over.graph = false
216 transcripts.clear()
217 lastScan = -Infinity
218
219 on('session.start', async ($, e, next) => {
220 const saved = await $.store.get('limits')
221 if (Array.isArray(saved)) await update($, limits, () => saved as Limit[])
222
223 const usage = await $.session.usage()
224 await remember($, windowsOf(usage.rateLimits))
225
226 await update($, isHovered, () => false)
227 await schedule($, rotateMs)
228 // The graph's data, read ahead so it shows at once; the session does not wait on it.
229 if (e.isInteractive) void scan($).catch(() => undefined)
230 return next(e)
231 })
232
233 on('session.measure', async ($, e, next) => {
234 await remember($, windowsOf(e.rateLimits))
235 return next(e)
236 })
237
238 // The meter and its graph say where the pointer is; a click on either shows the other window.
239 on('ui.message', async ($, e, next) => {
240 const result = await next(e)
241 const target = e.element === 'meter-hit' ? 'meter' : e.element === 'graph-hit' ? 'graph' : undefined
242 if (target === undefined) return result
243 const data = e.data as { isOver?: unknown; isClicked?: unknown } | null
244 await point($, target, data?.isOver === true)
245 if (data?.isClicked === true) await rotate($)
246 return result
247 })
248
249 // Bottom right of the prompt footer: the engine's mode labels, then the meter.
250 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
251 const all = await read($, limits)
252 if (all.length === 0) return next(e)
253
254 const now = await $.clock.now()
255 const shown = all[(await read($, view)) % all.length]!
256 const resetsIn = shown.resetsAt === undefined ? undefined : Date.parse(shown.resetsAt) - now
257 const hasReset = resetsIn !== undefined && resetsIn <= 0
258 const percent = hasReset ? 0 : shown.percentUsed
259 const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * CELLS)
260 // The engine's own labels, and what any other plugin draws there (a switch), stay as drawn, before the meter.
261 const beneath = await next(e)
262 const labels = e.props.modes.length > 0 || !isBlank(beneath) ? beneath : null
263 const ui = $.ui.resolve(e)
264 const { Box, Text } = ui
265 // The pointer reaches a `Client` on the terminal, and there only in fullscreen.
266 const Client = 'Client' in ui && e.viewport?.isFullscreen !== false ? ui.Client : undefined
267 const hovered = Client !== undefined && (await read($, isHovered))
268
269 const label = `${LABELS[shown.kind] ?? shown.kind} `
270 const tail = resetsIn !== undefined && !hasReset ? ` · ${until(resetsIn)}` : ''
271 const width = label.length + CELLS + ` ${Math.round(percent)}%`.length + tail.length
272
273 return (
274 <Box flexDirection="row">
275 {labels}
276 {labels ? <Text dimColor> · </Text> : null}
277 <Box key="meter" flexDirection="row">
278 {hovered ? <Text bold color="claude">{label}</Text> : <Text dimColor>{label}</Text>}
279 <Text color={colorFor(percent)}>{'━'.repeat(filled)}</Text>
280 <Text dimColor>{'─'.repeat(CELLS - filled)}</Text>
281 <Text> {Math.round(percent)}%</Text>
282 {tail ? <Text dimColor>{tail}</Text> : null}
283 {Client
284 ? (
285 <Box position="absolute" top={0} left={0}>
286 <Client key="meter-hit" module="./hit.tsx" width={width} height={1} />
287 </Box>
288 )
289 : null}
290 </Box>
291 </Box>
292 )
293 })
294
295 // The graph, while the meter or the graph itself is hovered: in the band above the prompt, at its
296 // right end over the meter. The footer's own slot clips anything drawn above its row.
297 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
298 const ui = $.ui.resolve(e)
299 if (!('Client' in ui) || e.viewport?.isFullscreen === false || e.props.hasSurvey || !(await read($, isHovered))) return next(e)
300 const all = await read($, limits)
301 if (all.length === 0) return next(e)
302
303 const shown = all[(await read($, view)) % all.length]!
304 const now = await $.clock.now()
305 await read($, scannedAt)
306 // The border and the title, axis and footer lines take 5 of the band's rows; with none left
307 // for a bar, there is no graph.
308 const rows = Math.min(CHART_ROWS, e.props.maxRows - 5)
309 if (rows < 1) return next(e)
310 const isCompact = e.props.bodyColumns < COMPACT_COLUMNS
311 const chart = chartOf(bucketsFor(allCalls(), shown.kind, now), shown.kind, isCompact, rows)
312 const line = (text: string) => ` ${text} `
313 const { Box, Client, Text } = ui
314 const below: RenderElement = await next(e)
315 return (
316 <Box flexDirection="column">
317 {below}
318 <Box key="graph" flexDirection="row" justifyContent="flex-end">
319 <Box flexShrink={0}>
320 <Box flexDirection="column" borderStyle="round" borderColor="claude" flexShrink={0} width={chart.width + 4}>
321 <Text bold>{line(chart.title)}</Text>
322 {chart.rows.map((row, i) => <Text key={`row-${i}`} color="claude">{line(row)}</Text>)}
323 <Text dimColor>{line(chart.axis)}</Text>
324 <Text dimColor>{line(chart.footer)}</Text>
325 </Box>
326 <Box position="absolute" top={0} left={0}>
327 <Client key="graph-hit" module="./hit.tsx" width={chart.width + 4} height={chart.rows.length + 5} />
328 </Box>
329 </Box>
330 </Box>
331 </Box>
332 )
333 })
334
335 // The engine draws the mode label itself; the hint line after it starts with "(shift+tab to cycle)".
336 if (hideCycleHint) {
337 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
338 if (!CYCLE_HINT.test(e.props.hint)) return next(e)
339
340 const hint = e.props.hint
341 .replace(CYCLE_HINT, ' · ')
342 .split('·')
343 .map(part => part.trim())
344 .filter(Boolean)
345 .join(' · ')
346 return next({ ...e, props: { ...e.props, hint } })
347 })
348 }
349}
350hooks/usage.ts 174 lines1// The usage behind the graph: Claude Code's own session transcripts, every model call in them with
2// its time and tokens, weighed by what the call costs relative to others, then summed per hour or
3// day. Plain functions, no `$`: the hooks module reads the files and draws.
4
5/** One model call from a transcript: its message id, when it was made, and its relative weight. */
6export type Call = { id: string; t: number; weight: number }
7
8type Usage = {
9 input_tokens?: number
10 output_tokens?: number
11 cache_creation_input_tokens?: number
12 cache_read_input_tokens?: number
13 cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
14}
15
16// What a model's input token costs relative to the others, and its cache reads relative to its
17// input: the API's list prices, which follow what a call takes from the limits closely enough to
18// tell a busy hour from a quiet one. The first pattern a model id matches wins.
19const MODELS: Array<{ match: RegExp; input: number; read: number }> = [
20 { match: /fable|mythos/, input: 10, read: 0.025 },
21 { match: /opus-5-5/, input: 4, read: 0.05 },
22 { match: /opus/, input: 5, read: 0.1 },
23 { match: /sonnet-5/, input: 2, read: 0.1 },
24 { match: /sonnet/, input: 3, read: 0.1 },
25 { match: /haiku-5/, input: 0.1, read: 0.1 },
26 { match: /haiku/, input: 1, read: 0.1 }
27]
28const DEFAULT_MODEL = { input: 4, read: 0.1 }
29// Output, and the two cache writes, relative to input: the same for every model.
30const OUTPUT = 5
31const WRITE_5M = 1.25
32const WRITE_1H = 2
33
34/** A call's weight: its tokens of each kind, priced relative to one input token of Claude Haiku 5.5. */
35export function weightOf (model: string, usage: Usage): number {
36 const { input, read } = MODELS.find(entry => entry.match.test(model)) ?? DEFAULT_MODEL
37 const written = usage.cache_creation_input_tokens ?? 0
38 const hour = usage.cache_creation?.ephemeral_1h_input_tokens ?? 0
39 const minutes = usage.cache_creation?.ephemeral_5m_input_tokens ?? Math.max(0, written - hour)
40 const tokens =
41 (usage.input_tokens ?? 0) +
42 (usage.output_tokens ?? 0) * OUTPUT +
43 minutes * WRITE_5M +
44 hour * WRITE_1H +
45 (usage.cache_read_input_tokens ?? 0) * read
46 return tokens * input
47}
48
49/**
50 * The model calls in a transcript's text since `since`. A streamed response is written as a line
51 * per content block, each with the message's usage: the last line of a message id stands for it.
52 */
53export function callsIn (text: string, since: number): Call[] {
54 const byId = new Map<string, Call>()
55 for (const line of text.split('\n')) {
56 // Most lines are no model call: skip them before parsing.
57 if (!line.includes('"usage"') || !line.includes('"assistant"')) continue
58 let entry: { type?: unknown; timestamp?: unknown; message?: { id?: unknown; model?: unknown; usage?: unknown } }
59 try {
60 entry = JSON.parse(line)
61 } catch {
62 continue
63 }
64 const message = entry.message
65 if (entry.type !== 'assistant' || typeof entry.timestamp !== 'string' || !message || typeof message.usage !== 'object' || !message.usage) continue
66 const t = Date.parse(entry.timestamp)
67 if (!(t >= since)) continue
68 const model = typeof message.model === 'string' ? message.model : ''
69 // A turn the engine wrote itself (an error, an interruption) names no real model and costs nothing.
70 if (model === '<synthetic>') continue
71 const id = typeof message.id === 'string' ? message.id : `${entry.timestamp}:${byId.size}`
72 byId.set(id, { id, t, weight: weightOf(model, message.usage as Usage) })
73 }
74 return [...byId.values()]
75}
76
77export type Bucket = { start: number; label: string; weight: number }
78
79const HOUR = 3_600_000
80const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
81
82/** Where the hour (or the local calendar day) holding `t` starts. */
83function startOf (t: number, unit: 'hour' | 'day') {
84 const date = new Date(t)
85 if (unit === 'day') date.setHours(0, 0, 0, 0)
86 else date.setMinutes(0, 0, 0)
87 return date.getTime()
88}
89
90/** How far back the graph of a window reaches: 24 hours for the 5-hour one, 7 days for the weekly. */
91export function sinceFor (kind: string, now: number) {
92 return kind === 'five_hour' ? startOf(now, 'hour') - 23 * HOUR : startOf(startOf(now, 'day') - 6 * 24 * HOUR + 12 * HOUR, 'day')
93}
94
95/**
96 * The graph's bars: for the 5-hour window, each of the last 24 hours; for the weekly one, each of
97 * the last 7 days. The current hour or day is last. A call made twice (a resumed session copies
98 * its history into the new transcript) counts once.
99 */
100export function bucketsFor (calls: Iterable<Call>, kind: string, now: number): Bucket[] {
101 const unit = kind === 'five_hour' ? 'hour' : 'day'
102 const count = unit === 'hour' ? 24 : 7
103 const buckets: Bucket[] = []
104 let start = startOf(now, unit)
105 for (let i = 0; i < count; i++) {
106 const date = new Date(start)
107 const label = unit === 'hour' ? String(date.getHours()).padStart(2, '0') : DAY_NAMES[date.getDay()]!
108 buckets.unshift({ start, label, weight: 0 })
109 // A day before midnight is a calendar day back, whatever the clocks did that day.
110 start = unit === 'hour' ? start - HOUR : startOf(start - HOUR, 'day')
111 }
112 const seen = new Set<string>()
113 for (const call of calls) {
114 if (seen.has(call.id)) continue
115 seen.add(call.id)
116 for (let i = buckets.length - 1; i >= 0; i--) {
117 if (call.t >= buckets[i]!.start) {
118 if (call.t < (buckets[i + 1]?.start ?? Infinity)) buckets[i]!.weight += call.weight
119 break
120 }
121 }
122 }
123 return buckets
124}
125
126export type Chart = { title: string; rows: string[]; axis: string; footer: string; width: number }
127
128const LEVELS = ' ▁▂▃▄▅▆▇█'
129
130/**
131 * The bar chart of the buckets, as rows of text the popover draws, every row as wide as the
132 * chart so it paints over what is under it. The bars are relative: the busiest is full height.
133 * `isCompact` packs them for a narrow terminal; `height` is how many rows the bars take.
134 */
135export function chartOf (buckets: readonly Bucket[], kind: string, isCompact = false, height = 6): Chart {
136 const isHours = kind === 'five_hour'
137 const barWidth = isHours ? 1 : isCompact ? 3 : 5
138 const gap = isHours ? (isCompact ? 0 : 1) : isCompact ? 1 : 2
139 const width = buckets.length * barWidth + (buckets.length - 1) * gap
140 const max = Math.max(...buckets.map(bucket => bucket.weight))
141 const rows: string[] = []
142 for (let row = height - 1; row >= 0; row--) {
143 const cells = buckets.map(bucket => {
144 // Eighths of a row: a bar with any use shows at least the lowest one.
145 const eighths = bucket.weight > 0 ? Math.max(1, Math.round((bucket.weight / max) * height * 8)) : 0
146 return LEVELS[Math.min(8, Math.max(0, eighths - row * 8))]!.repeat(barWidth)
147 })
148 rows.push(cells.join(' '.repeat(gap)))
149 }
150 // Under the bars: the hours 00, 06, 12 and 18 from their bar on, or every day centered under its bar.
151 const axis = Array.from({ length: width }, () => ' ')
152 buckets.forEach((bucket, i) => {
153 if (isHours && Number(bucket.label) % 6 !== 0) return
154 const label = isHours ? bucket.label : bucket.label.slice(0, barWidth)
155 const centered = i * (barWidth + gap) + (isHours ? 0 : Math.floor((barWidth - label.length) / 2))
156 const at = Math.min(Math.max(0, centered), width - label.length)
157 for (let c = 0; c < label.length; c++) axis[at + c] = label[c]!
158 })
159 const busiest = buckets.reduce((top, bucket) => (bucket.weight > top.weight ? bucket : top), buckets[0]!)
160 const title = isCompact
161 ? (isHours ? 'Usage per hour, 24h' : 'Usage per day, 7 days')
162 : (isHours ? 'Claude Code usage per hour, last 24h' : 'Claude Code usage per day, last 7 days')
163 const footer = max === 0
164 ? (isHours ? 'no use in the last 24 hours' : 'no use in the last 7 days')
165 : `busiest: ${isHours ? `${busiest.label}:00` : busiest.label}`
166 return {
167 title: title.slice(0, width).padEnd(width),
168 rows,
169 axis: axis.join(''),
170 footer: footer.slice(0, width).padEnd(width),
171 width
172 }
173}
174hooks/hit.tsx 38 lines1// The surface module of the meter's pointer targets: an unpainted region over the meter (and over
2// its graph) that tells the hooks module when the pointer comes onto it, leaves it, or clicks it.
3import type { ClientModule } from 'claude-code'
4
5const REPEAT_MS = 50
6
7const Hit: ClientModule<null, { isOver: boolean; isPressed?: boolean }> = (props, surface) => {
8 surface.onPointer(event => {
9 // A click is the left button let go over the region it went down on.
10 if (event.type === 'down') {
11 if (event.button === 'left') surface.setState({ isOver: true, isPressed: true })
12 return
13 }
14 if (event.type === 'up') {
15 // A region not laid out yet (0 by 0) says nothing of where the pointer is.
16 const isInside = surface.columns === 0 || (event.x >= 0 && event.y >= 0 && event.x < surface.columns && event.y < surface.rows)
17 if (surface.state?.isPressed && isInside) surface.post({ isOver: true, isClicked: true })
18 surface.setState({ isOver: isInside })
19 return
20 }
21 const isOver = event.type !== 'leave'
22 surface.post({ isOver })
23 if (isOver !== (surface.state?.isOver ?? false)) surface.setState({ isOver, isPressed: surface.state?.isPressed })
24 // Going straight from the meter onto its graph, the leave and the enter post in one frame, and
25 // the later replaces the other: the enter says so again a frame on.
26 if (event.type === 'enter') {
27 const stop = surface.every(REPEAT_MS, () => {
28 stop()
29 if (surface.state?.isOver) surface.post({ isOver: true })
30 })
31 }
32 })
33 const { Box } = surface.elements
34 return <Box />
35}
36
37export default Hit
38types/index.d.ts 9 lines1/** One rate-limit window of the subscription, as the last API response reported it. */
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'sub-meter': { limits: Limit[]; view: number; isHovered: boolean; scannedAt: number }
7 }
8}
9