SLOPSHOPPER

Usage

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

newpanecommandprocesstimer
v0.1.0MITupdated 2026-10-08jidhra/claude-mods/plugins/usage-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-pane
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ USAGE · 31% 5H │ ┃ ■ limits ■ session ■ local ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ LIMITS · account ⏺ Update(src/auth.ts) │ ┃ │ Session (5h) ▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 31 ⎿ Added 2 lines, removed 1 line │ ┃ ╰─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ──────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ╭─────────────────────────────────────────── │ ┃ │ THIS SESSION · 30m ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 5h used ▱▱▱▱▱▱ +0.0 pts of 31% │ ┃ │ tokens 0 in · 0 out · 0 cached ✻ Worked for 42s · done 4:20 PM │ ┃ │ turns 1 · prompts 0 · ctx ▰▰▱▱ 49% │ ┃ │ pts = how far each window moved since this › /usage-pane │ ┃ │ began, including any other sessions runnin │ ┃ │ same time │ ┃ ╰─────────────────────────────────────────── │ ┃ ──────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ LOCAL · all sessions │ ┃ │ local stats unavailable: scan failed │ ┃ ╰─────────────────────────────────────────── │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Usage
USAGE · 31% 5H ■ limits ■ session ■ local ╭──────────────────────────────────────────────────────╮ │ LIMITS · account resets in │ │ Session (5h) ▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 31% │ ╰──────────────────────────────────────────────────────╯ ──────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────╮ │ THIS SESSION · 30m $0.42 │ │ 5h used ▱▱▱▱▱▱ +0.0 pts of 31% │ │ tokens 0 in · 0 out · 0 cached │ │ turns 1 · prompts 0 · ctx ▰▰▱▱ 49% │ │ pts = how far each window moved since this session │ │ began, including any other sessions running at the │ │ same time │ ╰──────────────────────────────────────────────────────╯ ──────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────╮ │ LOCAL · all sessions r: refresh │ │ local stats unavailable: scan failed │ ╰──────────────────────────────────────────────────────╯
README

Usage

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.

Version Claude Code mod Claude Code 2.1.294+ License: MIT

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                       │
╰──────────────────────────────────────────────╯

The cards

  • Limits: one line for each rate-limit window your plan reports: 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.
  • This session: how long the session has run, its cost (the /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.
  • Local: every Claude Code session on this machine, read from the transcripts in ~/.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.

What "+N pts" means

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.

How it gets its numbers

  • Limits and cost come from session.measure, which fires after each main-thread turn and whenever a window moves a whole point.
  • Tokens come from turn.step, using the usage the API reported for each request.
  • Local stats come from 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.

Open and close

  • /usage-pane toggles the pane. /usage-pane open and /usage-pane close open and close it.
  • Mod Tools' Show column (/tools) toggles it too.
  • The pane never opens on its own. Flightdeck is the only mod that opens unasked.

Install

claude plugin marketplace add jidhra/claude-mods
claude plugin install usage-pane@claude-mods --scope user

Develop

claude plugin validate .
claude plugin test .
python3 -m unittest discover -s tests
Source 3 files
hooks/register.tsx 411 lines
1import { 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}
411
hooks/core.ts 147 lines
1import 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}
147
types/index.d.ts 52 lines
1/** 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