SLOPSHOPPER

sub-meter

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…

newbandspinnerprocesstimer
A shopper browsing a rack in a slop shop
README

Sub Meter

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).

What the fork adds

  • Click to switch. Clicking the meter (or its graph) switches between the 5-hour and the weekly window at once, and starts the switch timer over, so the window you picked stays up for a full rotateSeconds.
  • Usage graph on hover. With the pointer on the meter, a graph pops up above the prompt box, at its right end over the meter:
  • showing the 5-hour window, your usage in each of the last 24 hours;
  • showing the weekly window, your usage on each of the last 7 days.

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.

  • Hover pauses the switch. While the pointer is on the meter or on its graph, the automatic switch is paused. When the pointer leaves, it picks up with the time it had left (at least a second).

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.

Limits

  • Click and hover need fullscreen mode. Off fullscreen the terminal reports no pointer, so the meter works like upstream's and switches by itself.
  • No pointer cursor. The mod API can't change the mouse cursor, and the terminal decides its shape. Instead the meter's label lights up (bold, in the accent color) while it is hovered.
  • A terminal doesn't always report the pointer leaving its window. If it doesn't, the graph closes by itself after a minute without pointer movement.
  • The graph only sees Claude Code on this machine. Usage on claude.ai, the desktop app or another computer counts against the same limits but isn't in these transcripts. Claude Code deletes transcripts after 30 days by default (cleanupPeriodDays), which is more than the graph needs.

Install

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.

How it works

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.
  • The last reading is kept in $.store, so a new session shows it before its first response.
  • The graph's data is read with $.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.
  • A ui.render hook on SessionMode wraps what the engine draws there (its mode labels) and adds the meter after it.
  • Over the meter sits a 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.
  • The switch is a $.clock.after timer. It is cancelled on hover with its remaining time saved, rescheduled on leave, and reset by a click.
  • A 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.

Development

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

Pulling in upstream changes

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.

Source 4 files
hooks/register.tsx 350 lines
1import { 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}
350
hooks/usage.ts 174 lines
1// 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}
174
hooks/hit.tsx 38 lines
1// 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
38
types/index.d.ts 9 lines
1/** 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