A Usage pane beside the transcript: 5-hour and weekly limits with reset times, this session's share, cost and tokens, and today/week stats across every session

How much of your Claude plan is left, and how much this session spent.<br> A pane beside the conversation with the 5-hour and weekly limits, this session's share of them, and token stats across every session on the machine.
Usage looks like its siblings Pinboard, Flightdeck and Buffer pane. Its layout follows the Agents panel in Omarchy: one line per limit window, with a meter and the time until that window resets.
USAGE · 42% 5H · 18% WEEK
■ limits ■ session ■ local
╭──────────────────────────────────────────────╮
│ LIMITS · account resets in │
│ Session (5h) ▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ 42% 2h 10m │
│ Weekly ▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 18% 4d 3h │
╰──────────────────────────────────────────────╯
╭──────────────────────────────────────────────╮
│ THIS SESSION · 1h 12m $3.41 │
│ 5h used ▰▰▱▱▱▱ +7.5 pts of 42% │
│ 7d used ▱▱▱▱▱▱ +1.2 pts of 18% │
│ tokens 1.2M in · 48k out · 980k cached│
│ turns 14 · prompts 9 · ctx ▰▱▱▱ 31% │
│ models Opus 5.5 82% · Haiku 4.5 18% │
╰──────────────────────────────────────────────╯
╭──────────────────────────────────────────────╮
│ LOCAL · all sessions 2m ago refresh │
│ today 1.8M tok · 33 prompts · 15 ses │
│ this week 21.8M tok · 158 prompts · 65 s │
│ by day █▂▁▁▇▁▂ busiest Fri │
│ mostly Opus 5.5 │
╰──────────────────────────────────────────────╯
Session (5h), Weekly, and any model-scoped weekly window such as Fable weekly. Each line shows a meter, the percentage, and the time until that window resets. The meter turns to the warning colour at 70% and to the error colour at 90%. These are the same figures /status shows in its Usage tab. The engine passes them to mods through $.session.usage() and the session.measure event, so the pane makes no API calls of its own./cost total), and how far each window has moved since the session began, in percentage points. A window that resets partway through the session carries its points forward. Below that are the tokens from every model request, both the main thread's and each subagent's (in covers uncached input plus cache writes, and cached is cache reads), then turns, prompts, the context fill, and each model's share of the tokens.~/.claude/projects. It shows today's and this week's tokens, prompts and sessions, a 7-day sparkline, the busiest day, and the model used most. Here, tokens means input plus cache writes plus output. Cache reads are left out because they would swamp the rest.Rate limits apply to the whole account, so the engine only reports the account's total. The session's share is the points each window moved after the session started. Any other Claude session running on the same account at the same time is included. With one session running, the figure is exact.
session.measure, which fires after each main-thread turn and whenever a window moves a whole point.turn.step, using the usage the API reported for each request.hooks/scan.py, which needs only the Python standard library. It runs when the pane opens, every 5 minutes while the pane is open, and whenever you press r. It reads only the transcripts touched in the last 8 days, and it reads each file from where the last scan stopped. Its byte offsets and per-day totals are cached in ~/.cache/usage-pane/scan.json. The first scan of about 300 MB takes around 1.3 s; later scans take about 0.1 s./usage-pane toggles the pane. /usage-pane open and /usage-pane close open and close it./tools) toggles it too.claude plugin marketplace add jidhra/claude-mods
claude plugin install usage-pane@claude-mods --scope user
claude plugin validate .
claude plugin test .
python3 -m unittest discover -s testshooks/register.tsx 411 lines1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Baseline, Limit, Local, SessionTotals } from '../types'
5import {
6 advanceBaselines,
7 EMPTY_SESSION,
8 fmtSpan,
9 fmtTokens,
10 fmtUsd,
11 gauge,
12 levelColor,
13 limitLabel,
14 limitTitle,
15 modelMix,
16 modelName,
17 parseScan,
18 parseVerb,
19 sessionShare,
20 sortLimits,
21 untilReset,
22 weekday,
23} from './core'
24
25const PANE = 'usage-pane'
26const TITLE = 'Usage'
27// Docked width; matches Flightdeck's and Pinboard's so the dock doesn't jump between panes
28const PANE_COLUMNS = 66
29const TICK_MS = 60_000
30// The LOCAL card rescans every this many ticks while the pane is open
31const SCAN_EVERY_TICKS = 5
32const SCAN_TIMEOUT_MS = 20_000
33
34const limits = atom({ plugin: 'usage-pane', key: 'limits' } as const, [] as Limit[])
35const baselines = atom({ plugin: 'usage-pane', key: 'baselines' } as const, {} as Record<string, Baseline>)
36const session = atom({ plugin: 'usage-pane', key: 'session' } as const, EMPTY_SESSION)
37const local = atom({ plugin: 'usage-pane', key: 'local' } as const, { stats: null, scannedAt: null, error: null } as Local)
38const paneOpen = atom({ plugin: 'usage-pane', key: 'paneOpen' } as const, false)
39const tick = atom({ plugin: 'usage-pane', key: 'tick' } as const, 0)
40
41// Theme colour names (Flightdeck's palette), so light, dark and colour-blind themes all work
42const C = {
43 limits: 'claude',
44 session: 'suggestion',
45 local: 'success',
46 dim: 'inactive',
47 faint: 'subtle',
48} as const
49
50type Reading = {
51 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
52 cost?: { usd: number }
53 context: { percent?: number }
54}
55
56/** Takes one measurement: the windows, their baselines, the cost and the context fill. */
57async function take($: EngineInterface, r: Reading) {
58 const next: Limit[] = r.rateLimits.map(l => ({ kind: l.kind, pct: l.percentUsed, resetsAt: l.resetsAt ?? null }))
59 if (next.length > 0) {
60 await update($, limits, () => next)
61 await update($, baselines, old => advanceBaselines(old, next))
62 }
63 await update($, session, s => ({ ...s, costUsd: r.cost?.usd ?? s.costUsd, ctxPct: r.context.percent ?? s.ctxPct }))
64}
65
66/** Runs scan.py for the LOCAL card; a failure keeps the last figures and says why. */
67async function rescan($: EngineInterface) {
68 const script = `${$.plugin.root}/hooks/scan.py`
69 try {
70 const ran = await $.process.run(['python3', '-I', script], { timeoutMs: SCAN_TIMEOUT_MS })
71 const stats = ran.exitCode === 0 ? parseScan(ran.stdout) : null
72 const now = await $.clock.now()
73 if (stats) await update($, local, () => ({ stats, scannedAt: now, error: null }))
74 else await update($, local, l => ({ ...l, error: (ran.stderr || 'scan failed').trim().split('\n').at(-1) ?? 'scan failed' }))
75 } catch (err) {
76 await update($, local, l => ({ ...l, error: String(err) }))
77 }
78}
79
80async function openPane($: EngineInterface) {
81 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
82 void rescan($)
83 return opened
84}
85
86export const register: Register = on => {
87 on('session.start', async ($, e, next) => {
88 await $.command.register({
89 name: 'usage-pane',
90 description: 'Show or hide the Usage pane: rate limits, this session’s share and local token stats',
91 argumentHint: '[open|close]',
92 immediate: true,
93 })
94 const u = await $.session.usage().catch(() => null)
95 if (u) {
96 await update($, session, s => ({ ...s, startedAt: s.startedAt ?? u.startedAt }))
97 await take($, u)
98 }
99 // A reload keeps the pane up but starts the module over: re-read whether it is open
100 const isUp = (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
101 await update($, paneOpen, () => isUp)
102 // No open on launch: only Flightdeck opens unasked
103 let ticks = 0
104 $.clock.every(TICK_MS, () => {
105 void (async () => {
106 if (!(await read($, paneOpen))) return
107 await update($, tick, n => n + 1)
108 if (++ticks % SCAN_EVERY_TICKS === 0) await rescan($)
109 })()
110 })
111 return next(e)
112 })
113
114 on('session.end', async ($, e, next) => {
115 if (e.reason === 'clear') {
116 const now = await $.clock.now()
117 await update($, session, () => ({ ...EMPTY_SESSION, startedAt: now }))
118 // Fresh baselines from the current figures: the cleared session starts at zero
119 const current = await read($, limits)
120 await update($, baselines, () => advanceBaselines({}, current))
121 }
122 return next(e)
123 })
124
125 on('command.run', { command: 'usage-pane' }, async ($, e) => {
126 const verb = parseVerb(e.args)
127 if (verb === null) return { text: 'Usage: /usage-pane [open|close]' }
128 const isUp = (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
129 if (verb === 'close' || (verb === 'toggle' && isUp)) {
130 await $.ui.close({ id: PANE })
131 return {}
132 }
133 const opened = await openPane($)
134 return opened.isPlaced ? {} : { text: `The Usage pane is not shown yet: ${opened.reason}` }
135 })
136
137 on('ui.open', { id: PANE }, async ($, e, next) => {
138 const opened = await next(e)
139 await update($, paneOpen, () => true)
140 return opened
141 }).catch(($, e, next) => next(e))
142
143 on('ui.close', { id: PANE }, async ($, e, next) => {
144 const closed = await next(e)
145 await update($, paneOpen, () => false)
146 return closed
147 }).catch(($, e, next) => next(e))
148
149 on('session.measure', async ($, e, next) => {
150 await take($, e)
151 return next(e)
152 })
153
154 on('turn.start', async ($, e, next) => {
155 if (e.text.trim() !== '') await update($, session, s => ({ ...s, prompts: s.prompts + 1 }))
156 return next(e)
157 })
158
159 on('turn.complete', async ($, e, next) => {
160 await update($, session, s => ({ ...s, turns: s.turns + 1 }))
161 return next(e)
162 })
163
164 // Every model request, the main thread's and each subagent's, adds what the API said it cost
165 on('turn.step', async function* ($, e, next) {
166 const result = yield* next(e)
167 const u = result.usage
168 if (u) {
169 await update($, session, s => ({
170 ...s,
171 inTok: s.inTok + u.input_tokens,
172 outTok: s.outTok + u.output_tokens,
173 cacheRead: s.cacheRead + u.cache_read_input_tokens,
174 cacheWrite: s.cacheWrite + u.cache_creation_input_tokens,
175 byModel: { ...s.byModel, [u.model]: (s.byModel[u.model] ?? 0) + u.input_tokens + u.cache_creation_input_tokens + u.output_tokens },
176 }))
177 }
178 return result
179 })
180
181 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
182 const { Box, Text, Button } = $.ui.resolve(e)
183 const W = Math.max(40, e.props.bodyColumns)
184 // A card's text area: the width less its border and one cell of padding each side
185 const inner = W - 4
186 await read($, tick)
187 const now = await $.clock.now()
188 const allLimits = sortLimits(await read($, limits))
189 const base = await read($, baselines)
190 const s: SessionTotals = await read($, session)
191 const loc = await read($, local)
192
193 // A card's first row: the upper-case label and subtitle in its accent, a dim state on the right
194 const header = (accent: Color, label: string, subtitle: string, right: RenderChildren) => (
195 <Box justifyContent="space-between" width={inner}>
196 <Text color={accent} bold wrap="truncate">
197 {`${label.toUpperCase()} · ${subtitle}`}
198 </Text>
199 {right}
200 </Box>
201 )
202 // A label column, then the row's content
203 const LABEL = 14
204 const row = (label: string, content: RenderChildren) => (
205 <Box flexDirection="row" width={inner}>
206 <Box width={LABEL} flexShrink={0}>
207 <Text dimColor wrap="truncate">
208 {label}
209 </Text>
210 </Box>
211 <Box flexShrink={1} flexGrow={1}>
212 {content}
213 </Box>
214 </Box>
215 )
216 const meter = (pct: number, cells: number, color: Color) => {
217 const g = gauge(pct, cells)
218 return (
219 <Text>
220 <Text color={color}>{g.on}</Text>
221 <Text color={C.faint}>{g.off}</Text>
222 </Text>
223 )
224 }
225 const rule = <Text color={C.faint}>{'─'.repeat(W)}</Text>
226
227 // ---- LIMITS: one line per window, like Omarchy's agents panel
228 const meterCells = Math.max(6, inner - LABEL - 14)
229 const limitsCard = (
230 <Box flexDirection="column" borderStyle="round" borderColor={C.limits} paddingX={1} width={W}>
231 {header(C.limits, 'limits', 'account', <Text dimColor>resets in</Text>)}
232 {allLimits.length === 0 ? (
233 <Text color={C.faint}>no reading yet: one comes with the next response</Text>
234 ) : (
235 allLimits.map(l =>
236 row(
237 limitTitle(l.kind),
238 <Box flexDirection="row" justifyContent="space-between" width={inner - LABEL}>
239 <Text>
240 {meter(l.pct, meterCells, levelColor(l.pct))}
241 <Text color={levelColor(l.pct)} bold>{` ${String(Math.round(l.pct)).padStart(3)}%`}</Text>
242 </Text>
243 <Text dimColor>{untilReset(l.resetsAt, now)}</Text>
244 </Box>,
245 ),
246 )
247 )}
248 </Box>
249 )
250
251 // ---- THIS SESSION
252 const elapsed = s.startedAt ? fmtSpan(Math.max(0, now - s.startedAt)) : '—'
253 const mix = modelMix(s.byModel)
254 const shareCells = 6
255 const sessionCard = (
256 <Box flexDirection="column" borderStyle="round" borderColor={C.session} paddingX={1} width={W}>
257 {header(C.session, 'this session', elapsed, <Text bold>{s.costUsd === null ? '' : fmtUsd(s.costUsd)}</Text>)}
258 {allLimits.map(l => {
259 const pts = sessionShare(base[l.kind])
260 return row(
261 `${limitLabel(l.kind)} used`,
262 <Text>
263 {meter(pts, shareCells, C.session)}
264 <Text color={C.session} bold>{` +${pts.toFixed(1)} pts`}</Text>
265 <Text dimColor>{` of ${Math.round(l.pct)}%`}</Text>
266 </Text>,
267 )
268 })}
269 {row(
270 'tokens',
271 <Text wrap="truncate">
272 <Text bold>{fmtTokens(s.inTok + s.cacheWrite)}</Text>
273 <Text dimColor> in · </Text>
274 <Text bold>{fmtTokens(s.outTok)}</Text>
275 <Text dimColor> out · </Text>
276 <Text>{fmtTokens(s.cacheRead)}</Text>
277 <Text dimColor> cached</Text>
278 </Text>,
279 )}
280 {row(
281 'turns',
282 <Text wrap="truncate">
283 <Text bold>{String(s.turns)}</Text>
284 <Text dimColor> · prompts </Text>
285 <Text bold>{String(s.prompts)}</Text>
286 {s.ctxPct !== null && <Text dimColor> · ctx </Text>}
287 {s.ctxPct !== null && meter(s.ctxPct, 4, levelColor(s.ctxPct))}
288 {s.ctxPct !== null && <Text>{` ${Math.round(s.ctxPct)}%`}</Text>}
289 </Text>,
290 )}
291 {mix.length > 0 &&
292 row(
293 'models',
294 <Text wrap="truncate">
295 {mix.slice(0, 3).map((m, i) => (
296 <Text>
297 {i > 0 && <Text dimColor> · </Text>}
298 <Text>{m.name}</Text>
299 <Text dimColor>{` ${m.pct}%`}</Text>
300 </Text>
301 ))}
302 </Text>,
303 )}
304 {allLimits.length > 0 && (
305 <Text color={C.faint} wrap="wrap">
306 pts = how far each window moved since this session began, including any other sessions running at the same time
307 </Text>
308 )}
309 </Box>
310 )
311
312 // ---- LOCAL: every session on this machine, from the transcripts
313 const st = loc.stats
314 const ago = loc.scannedAt ? fmtSpan(Math.max(0, now - loc.scannedAt)) : null
315 const localCard = (
316 <Box flexDirection="column" borderStyle="round" borderColor={C.local} paddingX={1} width={W}>
317 {header(
318 C.local,
319 'local',
320 'all sessions',
321 <Box columnGap={1}>
322 <Text dimColor>{ago ? `${ago} ago` : ''}</Text>
323 <Button key="rescan" label="refresh" hotkey="r" plain dimColor onPress={() => rescan($)} />
324 </Box>,
325 )}
326 {st ? (
327 <Box flexDirection="column">
328 {row(
329 'today',
330 <Text wrap="truncate">
331 <Text bold>{fmtTokens(st.today.tokens)}</Text>
332 <Text dimColor> tok · </Text>
333 <Text>{String(st.today.prompts)}</Text>
334 <Text dimColor> prompts · </Text>
335 <Text>{String(st.today.sessions)}</Text>
336 <Text dimColor> sessions</Text>
337 </Text>,
338 )}
339 {row(
340 'this week',
341 <Text wrap="truncate">
342 <Text bold>{fmtTokens(st.week.tokens)}</Text>
343 <Text dimColor> tok · </Text>
344 <Text>{String(st.week.prompts)}</Text>
345 <Text dimColor> prompts · </Text>
346 <Text>{String(st.week.sessions)}</Text>
347 <Text dimColor> sessions</Text>
348 </Text>,
349 )}
350 {row(
351 'by day',
352 <Text wrap="truncate">
353 {(() => {
354 const max = Math.max(1, ...st.byDay.map(d => d.tokens))
355 const bars = '▁▂▃▄▅▆▇█'
356 return st.byDay.map(d => (
357 <Text color={C.local}>{d.tokens > 0 ? bars[Math.min(7, Math.floor((d.tokens / max) * 7.999))]! : ' '}</Text>
358 ))
359 })()}
360 {st.busiestDay && <Text dimColor>{` busiest ${weekday(st.busiestDay)}`}</Text>}
361 </Text>,
362 )}
363 {st.topModel && row('mostly', <Text>{modelName(st.topModel)}</Text>)}
364 </Box>
365 ) : (
366 <Text color={C.faint}>{loc.error ? `local stats unavailable: ${loc.error}` : 'scanning transcripts…'}</Text>
367 )}
368 {st && loc.error && <Text color={C.faint}>{`last scan failed: ${loc.error}`}</Text>}
369 </Box>
370 )
371
372 const five = allLimits.find(l => /five/i.test(l.kind))
373 const seven = allLimits.find(l => /^seven[_ -]?days?$/i.test(l.kind))
374 const legend = [
375 { label: 'limits', color: C.limits },
376 { label: 'session', color: C.session },
377 { label: 'local', color: C.local },
378 ]
379
380 return (
381 <Box flexDirection="column" width={W}>
382 <Box justifyContent="center">
383 <Text bold wrap="truncate">
384 <Text>USAGE</Text>
385 {five && <Text color={C.dim}> · </Text>}
386 {five && <Text color={levelColor(five.pct)}>{`${Math.round(five.pct)}%`}</Text>}
387 {five && <Text> 5H</Text>}
388 {seven && <Text color={C.dim}> · </Text>}
389 {seven && <Text color={levelColor(seven.pct)}>{`${Math.round(seven.pct)}%`}</Text>}
390 {seven && <Text> WEEK</Text>}
391 </Text>
392 </Box>
393 <Box justifyContent="center" columnGap={2}>
394 {legend.map(l => (
395 <Text>
396 <Text color={l.color}>■</Text>
397 <Text dimColor>{` ${l.label}`}</Text>
398 </Text>
399 ))}
400 </Box>
401 {[limitsCard, sessionCard, localCard].map((card, i) => (
402 <Box flexDirection="column">
403 {i > 0 && rule}
404 {card}
405 </Box>
406 ))}
407 </Box>
408 )
409 })
410}
411hooks/core.ts 147 lines1import type { Baseline, Limit, LocalStats, SessionTotals } from '../types'
2
3export const EMPTY_SESSION: SessionTotals = {
4 startedAt: null,
5 costUsd: null,
6 inTok: 0,
7 outTok: 0,
8 cacheRead: 0,
9 cacheWrite: 0,
10 turns: 0,
11 prompts: 0,
12 byModel: {},
13 ctxPct: null,
14}
15
16/**
17 * Moves each window's baseline to the new reading. A window seen for the first
18 * time starts at its current figure; one whose reset time changed folds what
19 * the session used of the old period into `carried` and starts again from 0.
20 */
21export function advanceBaselines(old: Record<string, Baseline>, limits: readonly Limit[]): Record<string, Baseline> {
22 const out: Record<string, Baseline> = { ...old }
23 for (const l of limits) {
24 const b = out[l.kind]
25 if (!b) {
26 out[l.kind] = { startPct: l.pct, resetsAt: l.resetsAt, lastPct: l.pct, carried: 0 }
27 } else if (l.resetsAt && b.resetsAt && l.resetsAt !== b.resetsAt) {
28 out[l.kind] = { startPct: 0, resetsAt: l.resetsAt, lastPct: l.pct, carried: b.carried + Math.max(0, b.lastPct - b.startPct) }
29 } else {
30 out[l.kind] = { ...b, resetsAt: b.resetsAt ?? l.resetsAt, lastPct: l.pct }
31 }
32 }
33 return out
34}
35
36/** How many points of a window moved since this session began, one decimal. */
37export function sessionShare(b: Baseline | undefined): number {
38 if (!b) return 0
39 return Math.round((b.carried + Math.max(0, b.lastPct - b.startPct)) * 10) / 10
40}
41
42/** The windows in display order: 5-hour, then 7-day, then anything model-scoped. */
43export function sortLimits(limits: readonly Limit[]): Limit[] {
44 const rank = (k: string) => (/five|5h/i.test(k) ? 0 : /^seven_day$|^7d$/i.test(k) ? 1 : 2)
45 return [...limits].sort((a, b) => rank(a.kind) - rank(b.kind) || a.kind.localeCompare(b.kind))
46}
47
48/** `five_hour` → `Session (5h)`, `seven_day` → `Weekly`, `seven_day_opus` → `Opus weekly`, as /status names them. */
49export function limitTitle(kind: string): string {
50 if (/^five[_ -]?hours?$/i.test(kind)) return 'Session (5h)'
51 if (/^seven[_ -]?days?$/i.test(kind)) return 'Weekly'
52 const scoped = /^seven[_ -]?days?[_ -](.+)$/i.exec(kind)
53 if (scoped) return `${cap(scoped[1]!.replace(/[_-]+/g, ' '))} weekly`
54 return cap(kind.replace(/[_-]+/g, ' '))
55}
56
57/** A window's short name for the title line: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */
58export const limitLabel = (kind: string) =>
59 kind
60 .replace(/five[_ -]?hours?/i, '5h')
61 .replace(/seven[_ -]?days?/i, '7d')
62 .replace(/[_-]+/g, ' ')
63 .trim()
64
65const cap = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s)
66
67/** A gauge of `width` cells: ▰ filled, ▱ empty. */
68export const gauge = (pct: number, width: number) => {
69 const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
70 return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
71}
72
73/** The theme colour a percentage reads in: calm, then warning from 70, error from 90. */
74export const levelColor = (pct: number) => (pct >= 90 ? 'error' : pct >= 70 ? 'warning' : 'claude')
75
76export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
77
78/** 950 → `950`, 48_200 → `48k`, 1_234_567 → `1.2M`. */
79export function fmtTokens(n: number): string {
80 if (n < 1000) return String(Math.round(n))
81 if (n < 1_000_000) return `${n < 10_000 ? (n / 1000).toFixed(1) : Math.round(n / 1000)}k`
82 if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(1)}M`
83 return `${(n / 1_000_000_000).toFixed(1)}B`
84}
85
86/** Milliseconds as `2h 10m`, `4d 3h`, `12m`, `<1m`. */
87export function fmtSpan(ms: number): string {
88 if (ms < 60_000) return '<1m'
89 const m = Math.floor(ms / 60_000)
90 const d = Math.floor(m / 1440)
91 const h = Math.floor((m % 1440) / 60)
92 if (d > 0) return `${d}d ${h}h`
93 if (h > 0) return `${h}h ${m % 60}m`
94 return `${m}m`
95}
96
97/** The time until a window resets, or '' without a reset time. */
98export function untilReset(resetsAt: string | null, now: number): string {
99 if (!resetsAt) return ''
100 const at = Date.parse(resetsAt)
101 return Number.isFinite(at) ? fmtSpan(Math.max(0, at - now)) : ''
102}
103
104/** `claude-opus-5-5` → `Opus 5.5`, `claude-haiku-4-5-20251001` → `Haiku 4.5`. */
105export function modelName(id: string): string {
106 const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?/i.exec(id)
107 if (!m) return id
108 return `${cap(m[1]!)} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
109}
110
111/** Each model's share of the session's tokens, largest first, as whole percentages. */
112export function modelMix(byModel: Record<string, number>): { name: string; pct: number }[] {
113 const total = Object.values(byModel).reduce((a, b) => a + b, 0)
114 if (total <= 0) return []
115 const merged: Record<string, number> = {}
116 for (const [id, n] of Object.entries(byModel)) merged[modelName(id)] = (merged[modelName(id)] ?? 0) + n
117 return Object.entries(merged)
118 .map(([name, n]) => ({ name, pct: Math.round((n / total) * 100) }))
119 .sort((a, b) => b.pct - a.pct)
120}
121
122/** `2026-10-06` → `Tue`, read as a local calendar day. */
123export function weekday(day: string): string {
124 const [y, mo, d] = day.split('-').map(Number)
125 if (!y || !mo || !d) return day
126 return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][new Date(y, mo - 1, d).getDay()]!
127}
128
129/** Parses scan.py's stdout; null when it isn't the shape the card draws. */
130export function parseScan(stdout: string): LocalStats | null {
131 try {
132 const v = JSON.parse(stdout) as LocalStats
133 return v && typeof v.today?.tokens === 'number' && typeof v.week?.tokens === 'number' ? v : null
134 } catch {
135 return null
136 }
137}
138
139/** `/usage-pane [open|close]`: no argument toggles. */
140export function parseVerb(args: string): 'open' | 'close' | 'toggle' | null {
141 const verb = args.trim().toLowerCase()
142 if (verb === '') return 'toggle'
143 if (verb === 'open' || verb === 'show') return 'open'
144 if (verb === 'close' || verb === 'hide') return 'close'
145 return null
146}
147types/index.d.ts 52 lines1/** One rate-limit window as last measured: `five_hour`, `seven_day`, a model-scoped weekly. */
2export type Limit = { kind: string; pct: number; resetsAt: string | null }
3
4/**
5 * Where a window stood when this session first saw it. `carried` is what the
6 * session used of windows that have since reset, in percentage points.
7 */
8export type Baseline = { startPct: number; resetsAt: string | null; lastPct: number; carried: number }
9
10/** This session's own totals. */
11export type SessionTotals = {
12 startedAt: number | null
13 costUsd: number | null
14 inTok: number
15 outTok: number
16 cacheRead: number
17 cacheWrite: number
18 turns: number
19 prompts: number
20 /** Output + input tokens by the model that answered. */
21 byModel: Record<string, number>
22 ctxPct: number | null
23}
24
25export type LocalDay = { tokens: number; prompts: number; sessions: number }
26
27/** What hooks/scan.py prints: every session on this machine, today and over 7 days. */
28export type LocalStats = {
29 today: LocalDay
30 week: LocalDay
31 topModel: string | null
32 busiestDay: string | null
33 byDay: { day: string; tokens: number }[]
34}
35
36export type Local = { stats: LocalStats | null; scannedAt: number | null; error: string | null }
37
38declare module 'claude-code' {
39 interface PluginState {
40 'usage-pane': {
41 limits: Limit[]
42 baselines: Record<string, Baseline>
43 session: SessionTotals
44 local: Local
45 /** Whether the Usage pane is open; Mod Tools reads it for its Show column. */
46 paneOpen: boolean
47 /** Bumped once a minute while the pane is open, so the reset countdowns redraw. */
48 tick: number
49 }
50 }
51}
52