SLOPSHOPPER

usage-band

A row of pills above the prompt: rate limits with pace, context, tokens and cost.

newbandcommandprocesstimer
v0.1.0MITupdated 2026-10-08tahabozdemir/claude-mods/mods/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /usage-pill ⎿ usage-band: 5h 31% (—) · 7d — · ctx 49% · ↑~6.4k ↓~1.5k ⧉~91.0k · ≈$0.42 5h ███░░░░░░░ 31% — 7d — ctx █████░░░░░ 49% ↑~6.4k ↓~1.5k ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ███░░░░░░░ 31% — 7d — ctx █████░░░░░ 49% ↑~6.4k ↓~1.5k
README

usage-band

A row of pills above the Claude Code prompt that shows how much of your plan you have used, and warns you before you run out.

<img alt="The usage band in a terminal, above the prompt" src="../../docs/images/hero-light.png">

Install

/plugin install usage-band --marketplace tahabozdemir/claude-mods

Type y, then press Enter twice. See the main README for the full steps.

What it shows

PillMeaning
5hHow much of the 5-hour rate limit you have used, and when it resets.
7dThe same for the 7-day limit.
ctxHow full the conversation's context window is.
↑ ↓Tokens sent and received this session, subagents included.
≈$Session cost at API list prices. Hidden on a subscription unless you turn it on.

The thin line inside a 5h or 7d bar marks how much of the window has passed. If the fill is ahead of that line, you are using the limit faster than it refills.

Colors

<img alt="Three bands in the desktop app: normal in gray, warning with the 5h pill in yellow, critical with the 5h pill in red" src="../../docs/images/states-light.png">

  • Gray: you are fine.
  • Yellow: at your current pace you will hit the limit before it resets, or you have used 75%. For ctx, the context is 70% full.
  • Red with ⚠: you have used 90%, or you will run out soon: within 30 minutes for 5h, within a day for 7d. For ctx, the context is 90% full.

The 5h and 7d pills appear only on a Claude subscription plan, because only subscriptions have these limits. When the window is narrow, the band drops tokens first, then cost, then 7d, and then draws 5h and ctx more compactly. It never wraps. The full state sheet shows every case.

Commands

CommandWhat it does
/usage-pillRefresh now and print a one-line summary
/usage-pill hideHide the band
/usage-pill showShow it again
/usage-pill cost onAlways show the cost pill
/usage-pill cost offNever show the cost pill
/usage-pill cost autoHide the cost pill on a subscription, show it otherwise (the default)

Your choices are remembered across sessions.

Options

Set them when you install, or later from a terminal:

echo '{"desktopCellPx": "8.4"}' | claude plugin configure usage-band@claude-mods --values-stdin
OptionDefaultWhat it does
Desktop cell width (px)7.2CSS pixels per column of the desktop app's code font, used to fit the band. Raise it if the desktop band drops pills it has room for.

How it works

  • Rate limits, context and cost come from Claude Code itself, refreshed after every response and every 30 seconds.
  • Token totals come from the session's transcript files in ~/.claude/projects/, summed by scripts/tokens.mjs with Node.js. The script runs only when the transcript has changed.
  • Without Node.js (node on PATH, /usr/local/bin/node or /opt/homebrew/bin/node), the band adds up each turn's usage instead and marks the numbers with ~.
  • Everything stays on your machine. The mod makes no network requests.

Troubleshooting

The band doesn't appear. It shows after the first response of a session. Run /usage-pill: if it prints a summary, the band may be hidden, and /usage-pill show brings it back. If the command is unknown, check that the mod is installed and enabled with claude plugin list.

Token numbers start with ~. Node.js was not found, so the counts are estimates. Install Node.js to get exact counts.

Something else is wrong. Start Claude Code with claude --debug. Lines beginning with usage-band: explain what failed. Include them when you open an issue.

Source 7 files
hooks/register.tsx 379 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionUsage } from 'claude-code'
3
4import type {
5  UsageBandCostMode,
6  UsageBandPlan,
7  UsageBandSnapshot,
8  UsageBandTokens,
9  UsageBandWindow,
10} from '../types'
11import { buildPills, fitPills, gapBefore, summaryLine } from './band'
12import { bandSvgs, DEFAULT_DESKTOP_CELL_PX } from './svg'
13import { GAP_BETWEEN_COLUMNS, GAP_WITHIN_COLUMNS, terminalPill, terminalWidth } from './terminal'
14import type { Tone } from './terminal'
15
16const ZERO_TOKENS: UsageBandTokens = { input: 0, cacheCreation: 0, output: 0, cacheRead: 0, requests: 0 }
17
18const snapshot = atom({ plugin: 'usage-band', key: 'snapshot' } as const, null)
19const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
20const plan = atom({ plugin: 'usage-band', key: 'plan' } as const, 'unknown')
21const isHidden = atom({ plugin: 'usage-band', key: 'isHidden' } as const, false)
22const costMode = atom({ plugin: 'usage-band', key: 'costMode' } as const, 'auto')
23const fallbackTokens = atom({ plugin: 'usage-band', key: 'fallbackTokens' } as const, ZERO_TOKENS)
24const transcript = atom({ plugin: 'usage-band', key: 'transcript' } as const, null)
25const scriptTokens = atom({ plugin: 'usage-band', key: 'scriptTokens' } as const, null)
26
27const COMMAND = 'usage-pill'
28const TICK_MS = 30_000
29const NODE_CANDIDATES = ['node', '/usr/local/bin/node', '/opt/homebrew/bin/node'] as const
30
31const HELP = [
32  'Usage: /usage-pill [hide | show | cost on | cost off | cost auto]',
33  '  /usage-pill            refresh now and print a summary',
34  '  /usage-pill hide|show  hide or show the band',
35  '  /usage-pill cost on    show the cost pill',
36  '  /usage-pill cost off   hide the cost pill',
37  '  /usage-pill cost auto  hide it on a subscription, show it otherwise (default)',
38].join('\n')
39
40type Engine = EngineInterface
41type Measured = Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>
42
43const STATUS_COLOR = { warning: 'warning', critical: 'error' } as const
44
45// --- Data ------------------------------------------------------------------
46
47function toWindow(limits: readonly SessionRateLimit[], kind: string): UsageBandWindow | null {
48  const found = limits.find(limit => limit.kind === kind)
49  if (found === undefined) return null
50  const resetsAt = found.resetsAt === undefined ? Number.NaN : Date.parse(found.resetsAt)
51
52  return { percentUsed: found.percentUsed, resetsAt: Number.isFinite(resetsAt) ? resetsAt : null }
53}
54
55async function projectsDir($: Engine): Promise<string | null> {
56  const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
57  if (configDir) return `${configDir}/projects`
58  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
59
60  return home ? `${home}/.claude/projects` : null
61}
62
63/** The main transcript: the project folder named after the cwd first, then a scan. */
64async function findTranscript($: Engine, id: string): Promise<string | null> {
65  const projects = await projectsDir($)
66  if (projects === null) return null
67
68  for (const dir of [await $.session.cwd(), await $.session.root()]) {
69    const guess = `${projects}/${dir.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
70    if (await $.fs.exists(guess)) return guess
71  }
72  const entries = await $.fs.list(projects).catch(() => [])
73  for (const entry of entries) {
74    const path = `${projects}/${entry.name}/${id}.jsonl`
75    if (entry.kind === 'dir' && (await $.fs.exists(path))) return path
76  }
77
78  return null
79}
80
81function parseTokens(stdout: string): UsageBandTokens | null {
82  try {
83    const value: unknown = JSON.parse(stdout.trim().split('\n').at(-1) ?? '')
84    if (typeof value !== 'object' || value === null) return null
85    const row = value as Record<string, unknown>
86    const tokens = { ...ZERO_TOKENS }
87    for (const key of Object.keys(ZERO_TOKENS) as (keyof UsageBandTokens)[]) {
88      const field = row[key]
89      if (typeof field !== 'number' || !Number.isFinite(field)) return null
90      tokens[key] = field
91    }
92
93    return tokens
94  } catch {
95    return null
96  }
97}
98
99/** Runs scripts/tokens.mjs with the first node that starts; null when it fails. */
100async function runTokensScript($: Engine, id: string): Promise<UsageBandTokens | null> {
101  const script = `${$.plugin.root}/scripts/tokens.mjs`
102  for (const node of NODE_CANDIDATES) {
103    let ran
104    try {
105      ran = await $.process.run([node, script, id], { timeoutMs: 60_000 })
106    } catch {
107      continue // this node could not start (or timed out): try the next
108    }
109
110    return ran.exitCode === 0 ? parseTokens(ran.stdout) : null
111  }
112
113  return null
114}
115
116/** Token totals from the transcripts, rerun only when the main one changed. */
117async function readTokens($: Engine): Promise<{ tokens: UsageBandTokens; isEstimated: boolean }> {
118  const id = await $.session.id()
119  const cached = await read($, transcript)
120  const path = cached?.path.endsWith(`/${id}.jsonl`) ? cached.path : await findTranscript($, id)
121  const stat = path === null ? null : await $.fs.stat(path).catch(() => null)
122  const lastTokens = await read($, scriptTokens)
123
124  const isUnchanged =
125    stat !== null && cached !== null && lastTokens !== null &&
126    cached.path === path && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs
127  if (isUnchanged) return { tokens: lastTokens, isEstimated: false }
128
129  const tokens = await runTokensScript($, id)
130  if (tokens !== null) {
131    await update($, scriptTokens, () => tokens)
132    if (path !== null && stat !== null) {
133      await update($, transcript, () => ({ path, size: stat.size, mtimeMs: stat.mtimeMs }))
134    }
135
136    return { tokens, isEstimated: false }
137  }
138
139  return { tokens: await read($, fallbackTokens), isEstimated: true }
140}
141
142/** Subscriptions alone report rate-limit windows; a response without any means API billing. */
143function inferPlan(current: UsageBandPlan, usage: Measured): UsageBandPlan {
144  const hasWindows = usage.rateLimits.some(l => l.kind === 'five_hour' || l.kind === 'seven_day')
145  if (hasWindows) return 'subscription'
146  if (current === 'unknown' && usage.context.tokens !== undefined) return 'api'
147
148  return current
149}
150
151async function refreshOnce($: Engine, measured?: Measured): Promise<void> {
152  const usage = measured ?? (await $.session.usage())
153  const at = await $.clock.now()
154  const { tokens, isEstimated } = await readTokens($)
155  const context = usage.context
156  const percent =
157    context.percent ??
158    (context.tokens === undefined || context.window <= 0 ? null : Math.round((context.tokens / context.window) * 100))
159
160  const next: UsageBandSnapshot = {
161    fiveHour: toWindow(usage.rateLimits, 'five_hour'),
162    sevenDay: toWindow(usage.rateLimits, 'seven_day'),
163    context: { tokens: context.tokens ?? null, window: context.window, percent },
164    costUsd: usage.cost?.usd ?? null,
165    tokens,
166    isTokensEstimated: isEstimated,
167    utcOffsetMinutes: -new Date(at).getTimezoneOffset(),
168  }
169  await update($, snapshot, () => next)
170  await update($, now, () => at)
171
172  const currentPlan = await read($, plan)
173  const nextPlan = inferPlan(currentPlan, usage)
174  if (nextPlan !== currentPlan) {
175    await update($, plan, () => nextPlan)
176    await $.store.set('plan', nextPlan)
177  }
178}
179
180// One refresh at a time; calls during one fold into a single rerun after it.
181let running: Promise<void> | null = null
182let isQueued = false
183let queuedUsage: Measured | undefined
184
185function refresh($: Engine, measured?: Measured): Promise<void> {
186  if (running !== null) {
187    isQueued = true
188    queuedUsage = measured ?? queuedUsage
189
190    return running
191  }
192  running = (async () => {
193    try {
194      await refreshOnce($, measured)
195      while (isQueued) {
196        isQueued = false
197        const usage = queuedUsage
198        queuedUsage = undefined
199        await refreshOnce($, usage)
200      }
201    } finally {
202      running = null
203    }
204  })()
205
206  return running
207}
208
209async function loadPreferences($: Engine): Promise<void> {
210  const [storedHidden, storedCost, storedPlan] = await Promise.all([
211    $.store.get('isHidden'),
212    $.store.get('costMode'),
213    $.store.get('plan'),
214  ])
215  if (typeof storedHidden === 'boolean') await update($, isHidden, () => storedHidden)
216  if (storedCost === 'auto' || storedCost === 'on' || storedCost === 'off') await update($, costMode, () => storedCost)
217  if (storedPlan === 'subscription' || storedPlan === 'api') await update($, plan, () => storedPlan)
218}
219
220async function setHidden($: Engine, value: boolean): Promise<void> {
221  await update($, isHidden, () => value)
222  await $.store.set('isHidden', value)
223}
224
225async function setCostMode($: Engine, value: UsageBandCostMode): Promise<void> {
226  await update($, costMode, () => value)
227  await $.store.set('costMode', value)
228}
229
230// --- Hooks -----------------------------------------------------------------
231
232export const register: Register = (on, options) => {
233  const configured = options.desktopCellPx
234  const cellPx = typeof configured === 'number' && configured > 0 ? configured : DEFAULT_DESKTOP_CELL_PX
235
236  on('session.start', async ($, e, next) => {
237    await $.command.register({
238      name: COMMAND,
239      description: 'Usage band: refresh and summarize rate limits, context, tokens and cost',
240      argumentHint: '[hide | show | cost on | cost off | cost auto]',
241    })
242    await loadPreferences($)
243    $.clock.every(TICK_MS, () => void refresh($))
244    void refresh($)
245
246    return next(e)
247  })
248
249  on('session.measure', ($, e, next) => {
250    void refresh($, e)
251
252    return next(e)
253  })
254
255  on('turn.complete', async ($, e, next) => {
256    const usage = e.usage
257    if (usage !== undefined) {
258      await update($, fallbackTokens, total => ({
259        input: total.input + usage.input_tokens,
260        cacheCreation: total.cacheCreation + usage.cache_creation_input_tokens,
261        output: total.output + usage.output_tokens,
262        cacheRead: total.cacheRead + usage.cache_read_input_tokens,
263        requests: total.requests + 1,
264      }))
265    }
266
267    return next(e)
268  })
269
270  // /clear starts a new session id: forget the old one's totals.
271  on('session.end', async ($, e, next) => {
272    if (e.reason === 'clear') {
273      await update($, fallbackTokens, () => ZERO_TOKENS)
274      await update($, transcript, () => null)
275      await update($, scriptTokens, () => null)
276      await update($, snapshot, () => null)
277    }
278
279    return next(e)
280  })
281
282  on('command.run', { command: COMMAND }, async ($, e) => {
283    const args = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean).join(' ')
284    switch (args) {
285      case '': {
286        await refresh($)
287        const [held, at, heldPlan, mode] = await Promise.all([
288          read($, snapshot),
289          read($, now),
290          read($, plan),
291          read($, costMode),
292        ])
293
294        return { text: summaryLine({ snapshot: held, now: at, plan: heldPlan, costMode: mode }) }
295      }
296      case 'hide':
297        await setHidden($, true)
298
299        return { text: 'Usage band hidden. /usage-pill show brings it back.' }
300      case 'show':
301        await setHidden($, false)
302
303        return { text: 'Usage band shown.' }
304      case 'cost on':
305      case 'cost off':
306      case 'cost auto': {
307        const mode = args.slice('cost '.length) as UsageBandCostMode
308        await setCostMode($, mode)
309        const said = { on: 'shown', off: 'hidden', auto: 'hidden on a subscription, shown otherwise' }[mode]
310
311        return { text: `Cost pill ${said}.` }
312      }
313      default:
314        return { text: HELP }
315    }
316  })
317
318  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
319    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
320
321    const [held, at, heldPlan, mode] = await Promise.all([
322      read($, snapshot),
323      read($, now),
324      read($, plan),
325      read($, costMode),
326    ])
327    const pills = buildPills({ snapshot: held, now: at, plan: heldPlan, costMode: mode })
328    if (pills === null) return next(e)
329
330    if (e.surface === 'terminal') {
331      const { Box, Text } = $.ui.resolve(e)
332      const { pills: shown, density } = fitPills(
333        pills,
334        e.props.bodyColumns,
335        terminalWidth,
336        GAP_WITHIN_COLUMNS,
337        GAP_BETWEEN_COLUMNS,
338      )
339
340      return (
341        <Box flexDirection="row" flexWrap="nowrap" overflow="hidden">
342          {shown.map((pill, i) => {
343            const { status, segments } = terminalPill(pill, density)
344            const colorOf = (tone: Tone) =>
345              tone === 'status' && status !== 'normal' ? STATUS_COLOR[status] : undefined
346
347            return (
348              <Box flexShrink={0} marginLeft={gapBefore(shown, i, GAP_WITHIN_COLUMNS, GAP_BETWEEN_COLUMNS)}>
349                <Text wrap="truncate">
350                  {segments.map(segment => (
351                    <Text
352                      color={colorOf(segment.tone)}
353                      dimColor={segment.tone === 'dim' || (segment.tone === 'status' && status === 'normal')}
354                      bold={segment.isBold === true}
355                    >
356                      {segment.text}
357                    </Text>
358                  ))}
359                </Text>
360              </Box>
361            )
362          })}
363        </Box>
364      )
365    }
366
367    const { Box, Svg } = $.ui.resolve(e)
368    const svgs = bandSvgs(pills, e.props.bodyColumns * cellPx)
369
370    return (
371      <Box flexDirection="row" flexWrap="nowrap" overflow="hidden">
372        {svgs.map(svg => (
373          <Svg source={svg.source} alt={svg.alt} width={svg.width} height={svg.height} isInteractive />
374        ))}
375      </Box>
376    )
377  })
378}
379
hooks/band.ts 385 lines
1// The band's view model: which pills show, what each says, and how they fit.
2// Pure: the renderers and the command read it, nothing here touches `$`.
3
4import type {
5  UsageBandCostMode,
6  UsageBandPlan,
7  UsageBandSnapshot,
8  UsageBandTokens,
9  UsageBandWindow,
10} from '../types'
11import {
12  formatClock,
13  formatCount,
14  formatDuration,
15  formatExact,
16  formatPercent,
17  formatUsd,
18} from './format'
19import { assessWindow, contextStatus } from './status'
20import type { Pace, Status, WindowKind } from './status'
21
22const DAY = 24 * 60 * 60_000
23
24export type PillId = 'fiveHour' | 'sevenDay' | 'context' | 'tokens' | 'cost'
25
26/** Groups, left to right: [5h 7d context] [tokens] [cost]. */
27export type PillGroup = 0 | 1 | 2
28
29type PillBase = { group: PillGroup; tooltip: string; alt: string }
30
31export type WindowPill = PillBase & {
32  kind: 'window'
33  id: 'fiveHour' | 'sevenDay'
34  label: '5h' | '7d'
35  status: Status
36  /** Bar fill, 0 to 1. */
37  fraction: number
38  percentText: string
39  /** Elapsed fraction of the window for the tick; null without a reset time. */
40  elapsed: number | null
41  resetText: string
42  pace: Pace
43}
44
45export type ContextPill = PillBase & {
46  kind: 'context'
47  id: 'context'
48  label: 'ctx'
49  status: Status
50  fraction: number
51  percentText: string
52}
53
54/** A bar pill with no reading yet, drawn dimmed at its final width. */
55export type PlaceholderPill = PillBase & {
56  kind: 'placeholder'
57  id: 'fiveHour' | 'sevenDay' | 'context'
58  label: '5h' | '7d' | 'ctx'
59}
60
61export type TokensPill = PillBase & {
62  kind: 'tokens'
63  id: 'tokens'
64  inText: string
65  outText: string
66  isEstimated: boolean
67}
68
69export type CostPill = PillBase & {
70  kind: 'cost'
71  id: 'cost'
72  text: string
73}
74
75export type Pill = WindowPill | ContextPill | PlaceholderPill | TokensPill | CostPill
76
77export type BandInput = {
78  snapshot: UsageBandSnapshot | null
79  now: number
80  plan: UsageBandPlan
81  costMode: UsageBandCostMode
82}
83
84const WINDOW_NAME: Record<WindowKind, string> = {
85  five_hour: '5-hour limit',
86  seven_day: '7-day limit',
87}
88
89export function paceLine(pace: Pace): string {
90  switch (pace.kind) {
91    case 'over':
92      return `At this pace you'll hit the limit in ~${formatDuration(pace.timeToLimitMs)}`
93    case 'under':
94      return 'On pace to stay under the limit'
95    case 'insufficient':
96      return 'Not enough data yet'
97  }
98}
99
100function windowPill(
101  kind: WindowKind,
102  window: UsageBandWindow,
103  now: number,
104  utcOffsetMinutes: number,
105): WindowPill {
106  const id = kind === 'five_hour' ? 'fiveHour' : 'sevenDay'
107  const label = kind === 'five_hour' ? '5h' : '7d'
108  const name = WINDOW_NAME[kind]
109  const base = { kind: 'window', id, label, group: 0 } as const
110
111  // A window whose reset time has passed has started over; the next response
112  // reports its new figure.
113  if (window.resetsAt !== null && window.resetsAt <= now) {
114    return {
115      ...base,
116      status: 'normal',
117      fraction: 0,
118      percentText: '0%',
119      elapsed: 0,
120      resetText: 'reset',
121      pace: { kind: 'insufficient' },
122      tooltip: `${name}: the window has reset\nNew figures arrive with the next response`,
123      alt: `${name}: reset`,
124    }
125  }
126
127  const remainingMs = window.resetsAt === null ? null : window.resetsAt - now
128  const { status, pace, elapsed } = assessWindow(kind, window.percentUsed, remainingMs)
129  const percentText = formatPercent(window.percentUsed)
130
131  let resetText = '—'
132  let resetLine = 'Reset time not reported'
133  if (window.resetsAt !== null && remainingMs !== null) {
134    const countdown = formatDuration(remainingMs)
135    const clock = formatClock(window.resetsAt, utcOffsetMinutes)
136    resetText = kind === 'five_hour' || remainingMs < DAY ? countdown : clock
137    resetLine = `Resets in ${countdown} · ${clock}`
138  }
139
140  const lines = [`${name}: ${percentText} used`]
141  if (elapsed !== null) lines.push(`${formatPercent(elapsed * 100)} of the window elapsed`)
142  lines.push(resetLine, paceLine(pace))
143
144  return {
145    ...base,
146    status,
147    fraction: clamp01(window.percentUsed / 100),
148    percentText,
149    elapsed,
150    resetText,
151    pace,
152    tooltip: lines.join('\n'),
153    alt: `${name}: ${percentText} used, resets ${resetText}${statusSuffix(status)}`,
154  }
155}
156
157function placeholderPill(id: PlaceholderPill['id']): PlaceholderPill {
158  const label = id === 'fiveHour' ? '5h' : id === 'sevenDay' ? '7d' : 'ctx'
159  const name = id === 'fiveHour' ? '5-hour limit' : id === 'sevenDay' ? '7-day limit' : 'Context'
160  const text = `${name}: not reported yet\nIt appears after the first response`
161
162  return { kind: 'placeholder', id, label, group: 0, tooltip: text, alt: `${name}: not reported yet` }
163}
164
165function contextPill(snapshot: UsageBandSnapshot): ContextPill | PlaceholderPill {
166  const context = snapshot.context
167  if (context === null || context.percent === null) return placeholderPill('context')
168
169  const status = contextStatus(context.percent)
170  const percentText = formatPercent(context.percent)
171  const used = context.tokens === null ? '' : `${formatExact(context.tokens)} / `
172
173  return {
174    kind: 'context',
175    id: 'context',
176    label: 'ctx',
177    group: 0,
178    status,
179    fraction: clamp01(context.percent / 100),
180    percentText,
181    tooltip: [
182      `Context: ${used}${formatExact(context.window)} tokens (${percentText})`,
183      'The session is compacted automatically as it fills up',
184    ].join('\n'),
185    alt: `Context: ${percentText} used${statusSuffix(status)}`,
186  }
187}
188
189function tokensPill(tokens: UsageBandTokens, isEstimated: boolean): TokensPill {
190  const mark = isEstimated ? '~' : ''
191  const inText = `↑${mark}${formatCount(tokens.input + tokens.cacheCreation)}`
192  const outText = `↓${mark}${formatCount(tokens.output)}`
193  const lines = [
194    `Input (uncached): ${formatExact(tokens.input)}`,
195    `Cache writes: ${formatExact(tokens.cacheCreation)}`,
196    `Output: ${formatExact(tokens.output)}`,
197    `Cache reads: ${formatExact(tokens.cacheRead)}`,
198    `Cache hit rate: ${cacheHitRate(tokens)}`,
199    isEstimated ? `Turns: ${formatExact(tokens.requests)}` : `Requests: ${formatExact(tokens.requests)}`,
200  ]
201  if (isEstimated) lines.unshift('Estimated — transcript file could not be read')
202
203  return {
204    kind: 'tokens',
205    id: 'tokens',
206    group: 1,
207    inText,
208    outText,
209    isEstimated,
210    tooltip: lines.join('\n'),
211    alt: `Tokens: ${inText} in, ${outText} out`,
212  }
213}
214
215function costPill(usd: number): CostPill {
216  const text = `≈${formatUsd(usd)}`
217
218  return {
219    kind: 'cost',
220    id: 'cost',
221    group: 2,
222    text,
223    tooltip: `${text} this session\nEstimated at API list prices. Subscription plans are not billed this amount.`,
224    alt: `Cost: about ${formatUsd(usd)} at API list prices`,
225  }
226}
227
228export function cacheHitRate(tokens: UsageBandTokens): string {
229  const input = tokens.input + tokens.cacheCreation + tokens.cacheRead
230  if (input === 0) return '—'
231
232  return `${((tokens.cacheRead / input) * 100).toFixed(1)}%`
233}
234
235function statusSuffix(status: Status): string {
236  return status === 'normal' ? '' : ` (${status})`
237}
238
239const clamp01 = (x: number): number => Math.min(1, Math.max(0, x))
240
241/** True once anything the band shows has a reading. */
242export function hasAnyData(snapshot: UsageBandSnapshot | null): snapshot is UsageBandSnapshot {
243  if (snapshot === null) return false
244  const tokens = snapshot.tokens
245  const tokenSum =
246    tokens === null ? 0 : tokens.input + tokens.cacheCreation + tokens.output + tokens.cacheRead
247
248  return (
249    snapshot.fiveHour !== null ||
250    snapshot.sevenDay !== null ||
251    snapshot.context?.percent != null ||
252    tokenSum > 0 ||
253    (snapshot.costUsd ?? 0) > 0
254  )
255}
256
257export function isCostShown(plan: UsageBandPlan, costMode: UsageBandCostMode): boolean {
258  if (costMode !== 'auto') return costMode === 'on'
259
260  return plan !== 'subscription'
261}
262
263/** The pills in band order, or null when there is nothing to show yet. */
264export function buildPills(input: BandInput): Pill[] | null {
265  const { snapshot, now, plan, costMode } = input
266  if (!hasAnyData(snapshot)) return null
267
268  const offset = snapshot.utcOffsetMinutes
269  const pills: Pill[] = []
270
271  if (plan !== 'api') {
272    pills.push(
273      snapshot.fiveHour === null
274        ? placeholderPill('fiveHour')
275        : windowPill('five_hour', snapshot.fiveHour, now, offset),
276      snapshot.sevenDay === null
277        ? placeholderPill('sevenDay')
278        : windowPill('seven_day', snapshot.sevenDay, now, offset),
279    )
280  }
281  pills.push(contextPill(snapshot))
282  if (snapshot.tokens !== null) pills.push(tokensPill(snapshot.tokens, snapshot.isTokensEstimated))
283  if (snapshot.costUsd !== null && isCostShown(plan, costMode)) pills.push(costPill(snapshot.costUsd))
284
285  return pills
286}
287
288/** Pills dropped first when the band is too narrow; 5h and context stay. */
289export const DROP_ORDER: readonly PillId[] = ['tokens', 'cost', 'sevenDay']
290
291/**
292 * How much a bar pill shows: `full`, then `compact` (no reset time), then
293 * `minimal` (label and percentage). Chosen by width alone, never by data.
294 */
295export type Density = 'full' | 'compact' | 'minimal'
296
297export const DENSITIES: readonly Density[] = ['full', 'compact', 'minimal']
298
299export type Fit = { pills: Pill[]; density: Density }
300
301/** Space before `pills[index]`: none for the first, then within or between groups. */
302export function gapBefore(pills: readonly Pill[], index: number, within: number, between: number): number {
303  const previous = pills[index - 1]
304  const pill = pills[index]
305  if (previous === undefined || pill === undefined) return 0
306
307  return previous.group === pill.group ? within : between
308}
309
310export function bandWidth(
311  pills: readonly Pill[],
312  widthOf: (pill: Pill) => number,
313  within: number,
314  between: number,
315): number {
316  return pills.reduce((sum, pill, i) => sum + gapBefore(pills, i, within, between) + widthOf(pill), 0)
317}
318
319/**
320 * Fits the band to `available`: drops pills in DROP_ORDER, then draws the
321 * pills that must stay more compactly. Never wraps; below the minimal width
322 * the row is clipped.
323 */
324export function fitPills(
325  pills: readonly Pill[],
326  available: number,
327  widthOf: (pill: Pill, density: Density) => number,
328  within: number,
329  between: number,
330): Fit {
331  const fits = (list: readonly Pill[], density: Density) =>
332    bandWidth(list, pill => widthOf(pill, density), within, between) <= available
333
334  let shown = [...pills]
335  for (const id of DROP_ORDER) {
336    if (fits(shown, 'full')) return { pills: shown, density: 'full' }
337    shown = shown.filter(pill => pill.id !== id)
338  }
339  const density = DENSITIES.find(each => fits(shown, each)) ?? 'minimal'
340
341  return { pills: shown, density }
342}
343
344/** The `/usage-pill` line: every reading, with status markers and pace. */
345export function summaryLine(input: BandInput): string {
346  const pills = buildPills({ ...input, costMode: 'on' })
347  if (pills === null) return 'No usage data yet. It appears after the first response.'
348
349  const tokens = input.snapshot?.tokens ?? null
350  const parts = pills.map(pill => {
351    switch (pill.kind) {
352      case 'placeholder':
353        return `${pill.label} —`
354      case 'window': {
355        const head = `${marker(pill.status)}${pill.label} ${pill.percentText}${criticalWord(pill.status)}`
356        if (pill.status !== 'normal' && pill.pace.kind === 'over') {
357          return `${head} — limit in ~${formatDuration(pill.pace.timeToLimitMs)} at this pace`
358        }
359
360        return `${head} (${pill.resetText})`
361      }
362      case 'context':
363        return `${marker(pill.status)}ctx ${pill.percentText}${criticalWord(pill.status)}`
364      case 'tokens': {
365        const mark = pill.isEstimated ? '~' : ''
366        const cached = tokens === null ? '' : ` ⧉${mark}${formatCount(tokens.cacheRead)}`
367
368        return `${pill.inText} ${pill.outText}${cached}`
369      }
370      case 'cost':
371        return pill.text
372    }
373  })
374
375  return parts.join(' · ')
376}
377
378function marker(status: Status): string {
379  return status === 'normal' ? '' : '⚠ '
380}
381
382function criticalWord(status: Status): string {
383  return status === 'critical' ? ' critical' : ''
384}
385
hooks/svg.ts 226 lines
1// Desktop pills: one SVG document per pill, generated as markup. Pure.
2//
3// Every width comes from the monospace advance (0.6em), so a pill is as wide
4// as its widest realistic content whatever the numbers say now. The gap after
5// a pill is drawn inside its own SVG as transparent space, so the spacing is
6// exact in CSS pixels whatever the host does between elements.
7
8import { fitPills, gapBefore } from './band'
9import type { Density, Pill } from './band'
10
11const FONT_PX = 11
12const CHAR_PX = FONT_PX * 0.6
13const HEIGHT = 22
14const PAD = 8
15const ICON = 12
16const ICON_GAP = 5
17const GAP = 6
18const BAR = 36
19const BAR_H = 4
20const BAR_Y = 9
21const CLOCK = 10
22const CLOCK_GAP = 4
23const BASELINE = 15
24
25export const GAP_WITHIN_PX = 4
26export const GAP_BETWEEN_PX = 10
27
28/**
29 * CSS pixels per cell of `bodyColumns` on the desktop. The engine reports the
30 * desktop's width only in cells of its code font, never in pixels, so this is
31 * a lower bound: a 12px monospace font (0.6em advance). A larger code font
32 * only leaves room unused; the band never overflows. `desktopCellPx` in
33 * /config overrides it.
34 */
35export const DEFAULT_DESKTOP_CELL_PX = 7.2
36
37/** Widest realistic content per slot, in characters. */
38const SLOT = {
39  label: { '5h': 2, '7d': 2, ctx: 3 },
40  percent: 4, // "100%"
41  reset: { fiveHour: 6, sevenDay: 9 }, // "4h 59m", "Sun 14:00"
42  tokens: 15, // "↑999.9k ↓999.9k"
43  cost: 8, // "≈$999.99"
44} as const
45
46const chars = (n: number): number => n * CHAR_PX
47
48const STYLE = `
49svg{--bg:#F1F1EF;--text:#2B2B2B;--muted:#6B6B6B;--track:#DDDDD9;--fill:#6B6B6B;--warn:#B26B00;--warn-text:#A06000;--warn-bg:#FFF4DE;--crit:#C2261C;--crit-bg:#FDE7E5}
50@media (prefers-color-scheme:dark){svg{--bg:#2A2A2C;--text:#E4E4E6;--muted:#9A9AA0;--track:#3D3D42;--fill:#A8A8AE;--warn:#F0B341;--warn-text:#F0B341;--warn-bg:#3A2E14;--crit:#FF6B5E;--crit-bg:#3D1A18}}
51text{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:${FONT_PX}px;fill:var(--text)}
52.bg{fill:var(--bg)}
53.warning .bg{fill:var(--warn-bg);stroke:var(--warn)}
54.critical .bg{fill:var(--crit-bg);stroke:var(--crit)}
55.muted{fill:var(--muted)}
56.warning .pct{fill:var(--warn-text);font-weight:700}
57.critical .pct{fill:var(--crit);font-weight:700}
58.icon{fill:none;stroke:var(--muted);stroke-width:1.25;stroke-linecap:round;stroke-linejoin:round}
59.dot{fill:var(--muted);stroke:none}
60.warning .icon{stroke:var(--warn)}.warning .dot{fill:var(--warn)}
61.critical .icon{stroke:var(--crit)}.critical .dot{fill:var(--crit)}
62.track{fill:var(--track)}
63.fill{fill:var(--fill)}
64.warning .fill{fill:var(--warn)}
65.critical .fill{fill:var(--crit)}
66.tick{fill:var(--text)}
67.divider{stroke:var(--muted)}
68`
69
70/** 12×12 icons, stroked in the secondary color (or the status color). */
71const ICONS = {
72  gauge:
73    '<path d="M1.5 9.5a4.5 4.5 0 0 1 9 0"/><path d="M6 9.5 8.6 6.4"/><circle class="dot" cx="6" cy="9.5" r="1"/>',
74  calendar: '<rect x="1.5" y="2.5" width="9" height="8.5" rx="1.5"/><path d="M1.5 5.5h9M4 1v3M8 1v3"/>',
75  stack: '<path d="M1.5 2.5h9M1.5 6h9M1.5 9.5h9"/>',
76  arrows: '<path d="M3.5 10.5v-9M1.3 3.7 3.5 1.5l2.2 2.2M8.5 1.5v9M6.3 8.3l2.2 2.2 2.2-2.2"/>',
77  dollar:
78    '<path d="M8.6 3.3C8.1 2.5 7.2 2 6 2 4.6 2 3.6 2.8 3.6 3.8c0 2.4 4.8 1.5 4.8 4.2 0 1.1-1 1.9-2.4 1.9-1.2 0-2.1-.5-2.6-1.3M6 .6V2M6 9.9v1.5"/>',
79  warning: '<path d="M6 1.3 11 10.5H1z"/><path d="M6 4.8v2.6"/><circle class="dot" cx="6" cy="9" r=".75"/>',
80} as const
81
82/** 10×10 countdown icon: an hourglass. */
83const HOURGLASS = '<path d="M2.5 1h5L5 5l2.5 4h-5L5 5z"/>'
84
85type IconName = keyof typeof ICONS
86
87function escapeXml(text: string): string {
88  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
89}
90
91const n = (x: number): string => String(Math.round(x * 100) / 100)
92
93function icon(name: IconName, x: number): string {
94  return `<g class="icon" transform="translate(${n(x)} ${(HEIGHT - ICON) / 2})">${ICONS[name]}</g>`
95}
96
97function text(x: number, content: string, attrs = ''): string {
98  return `<text x="${n(x)}" y="${BASELINE}"${attrs}>${escapeXml(content)}</text>`
99}
100
101function bar(x: number, fraction: number, elapsed: number | null): string {
102  const fill = fraction > 0 ? Math.max(2, fraction * BAR) : 0
103  const parts = [`<rect class="track" x="${n(x)}" y="${BAR_Y}" width="${BAR}" height="${BAR_H}" rx="2"/>`]
104  if (fill > 0) parts.push(`<rect class="fill" x="${n(x)}" y="${BAR_Y}" width="${n(fill)}" height="${BAR_H}" rx="2"/>`)
105  if (elapsed !== null) {
106    const tickX = x + Math.min(BAR - 1.5, Math.max(0, elapsed * BAR - 0.75))
107    parts.push(`<rect class="tick" x="${n(tickX)}" y="${BAR_Y - 4}" width="1.5" height="${BAR_H + 4}" rx=".5"/>`)
108  }
109
110  return parts.join('')
111}
112
113const PILL_ICON: Record<Pill['id'], IconName> = {
114  fiveHour: 'gauge',
115  sevenDay: 'calendar',
116  context: 'stack',
117  tokens: 'arrows',
118  cost: 'dollar',
119}
120
121/** A bar pill: icon, label, the bar unless minimal, percent, the reset when full. */
122function barPillWidth(labelChars: number, hasBar: boolean, resetChars: number | null): number {
123  let width = PAD + ICON + ICON_GAP + chars(labelChars) + GAP
124  if (hasBar) width += BAR + GAP
125  width += chars(SLOT.percent)
126  if (resetChars !== null) width += GAP + 1 + GAP + CLOCK + CLOCK_GAP + chars(resetChars)
127
128  return Math.ceil(width + PAD)
129}
130
131/** The pill's width in CSS pixels, from its widest realistic content. */
132export function pillWidthPx(pill: Pill, density: Density): number {
133  const lead = PAD + ICON + ICON_GAP
134  switch (pill.kind) {
135    case 'window':
136    case 'context':
137    case 'placeholder': {
138      const reset = pill.id !== 'context' && density === 'full' ? SLOT.reset[pill.id] : null
139
140      return barPillWidth(SLOT.label[pill.label], density !== 'minimal', reset)
141    }
142    case 'tokens': {
143      const length = `${pill.inText} ${pill.outText}`.length
144
145      return Math.ceil(lead + chars(Math.max(SLOT.tokens, length)) + PAD)
146    }
147    case 'cost':
148      return Math.ceil(lead + chars(Math.max(SLOT.cost, pill.text.length)) + PAD)
149  }
150}
151
152function body(pill: Pill, density: Density): string {
153  const isCritical = 'status' in pill && pill.status === 'critical'
154  const parts = [icon(isCritical ? 'warning' : PILL_ICON[pill.id], PAD)]
155  let x = PAD + ICON + ICON_GAP
156
157  switch (pill.kind) {
158    case 'placeholder':
159      parts.push(text(x, pill.label, ' class="muted"'))
160      x += chars(SLOT.label[pill.label]) + GAP
161      parts.push(text(x, '—', ' class="muted"'))
162      break
163    case 'window':
164    case 'context': {
165      parts.push(text(x, pill.label, ' class="muted"'))
166      x += chars(SLOT.label[pill.label]) + GAP
167      if (density !== 'minimal') {
168        parts.push(bar(x, pill.fraction, pill.kind === 'window' ? pill.elapsed : null))
169        x += BAR + GAP
170      }
171      x += chars(SLOT.percent)
172      parts.push(text(x, pill.percentText, ' class="pct" text-anchor="end"'))
173      if (pill.kind === 'window' && density === 'full') {
174        x += GAP
175        parts.push(`<path class="divider" d="M${n(x + 0.5)} 6v10"/>`)
176        x += 1 + GAP
177        parts.push(`<g class="icon" transform="translate(${n(x)} ${(HEIGHT - CLOCK) / 2})">${HOURGLASS}</g>`)
178        x += CLOCK + CLOCK_GAP
179        parts.push(text(x, pill.resetText, ' class="muted"'))
180      }
181      break
182    }
183    case 'tokens':
184      parts.push(text(x, `${pill.inText} ${pill.outText}`))
185      break
186    case 'cost':
187      parts.push(text(x, pill.text))
188      break
189  }
190
191  return parts.join('')
192}
193
194export type PillSvg = {
195  id: Pill['id']
196  source: string
197  alt: string
198  width: number
199  height: number
200}
201
202/** One pill as a standalone SVG, `trailing` px of transparent gap after it. */
203export function pillSvg(pill: Pill, trailing: number, density: Density): PillSvg {
204  const pillWidth = pillWidthPx(pill, density)
205  const width = pillWidth + trailing
206  const status = pill.kind === 'window' || pill.kind === 'context' ? pill.status : 'normal'
207  const source =
208    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${HEIGHT}" viewBox="0 0 ${width} ${HEIGHT}">` +
209    `<style>${STYLE}</style>` +
210    `<g class="pill ${status}"><title>${escapeXml(pill.tooltip)}</title>` +
211    `<rect class="bg" x=".5" y=".5" width="${pillWidth - 1}" height="${HEIGHT - 1}" rx="6"/>` +
212    body(pill, density) +
213    '</g></svg>'
214
215  return { id: pill.id, source, alt: pill.alt, width, height: HEIGHT }
216}
217
218/** The band for `availablePx`: pills dropped, then compacted, until it fits; gaps inside. */
219export function bandSvgs(pills: readonly Pill[], availablePx: number): PillSvg[] {
220  const { pills: shown, density } = fitPills(pills, availablePx, pillWidthPx, GAP_WITHIN_PX, GAP_BETWEEN_PX)
221
222  return shown.map((pill, i) =>
223    pillSvg(pill, gapBefore(shown, i + 1, GAP_WITHIN_PX, GAP_BETWEEN_PX), density),
224  )
225}
226
hooks/terminal.ts 114 lines
1// Terminal pills: runs of text with a tone each, padded to stable widths. Pure.
2
3import type { Density, Pill } from './band'
4import type { Status } from './status'
5
6export const GAP_WITHIN_COLUMNS = 1
7export const GAP_BETWEEN_COLUMNS = 3
8
9const BAR_CELLS = 10
10const PERCENT_COLUMNS = 4
11const RESET_COLUMNS = { fiveHour: 6, sevenDay: 9 } as const
12const TOKENS_COLUMNS = 15
13const COST_COLUMNS = 8
14
15/** `dim` for neutral text; `status` takes the pill's status color. */
16export type Tone = 'dim' | 'plain' | 'status'
17
18export type Segment = { text: string; tone: Tone; isBold?: boolean }
19
20export type TerminalPill = { pill: Pill; status: Status; segments: Segment[] }
21
22/**
23 * Ten cells of `█` and `░`. With `elapsed`, a `│` is inserted between the
24 * cells where the window's elapsed time falls, so every cell still shows fill.
25 */
26export function barSegments(fraction: number, elapsed: number | null, status: Status): Segment[] {
27  const filled = Math.round(Math.min(1, Math.max(0, fraction)) * BAR_CELLS)
28  const tickAt = elapsed === null ? -1 : Math.round(Math.min(1, Math.max(0, elapsed)) * BAR_CELLS)
29  const fillTone: Tone = status === 'normal' ? 'dim' : 'status'
30  const cells: Segment[] = []
31  for (let cell = 0; cell <= BAR_CELLS; cell += 1) {
32    if (cell === tickAt) cells.push({ text: '│', tone: status === 'normal' ? 'dim' : 'plain' })
33    if (cell < BAR_CELLS) cells.push(cell < filled ? { text: '█', tone: fillTone } : { text: '░', tone: 'dim' })
34  }
35
36  const segments: Segment[] = []
37  for (const segment of cells) {
38    const last = segments.at(-1)
39    if (last !== undefined && last.tone === segment.tone) {
40      last.text += segment.text
41    } else {
42      segments.push(segment)
43    }
44  }
45
46  return segments
47}
48
49/** A critical pill leads with `⚠`; the others keep the same two columns blank. */
50function mark(status: Status): Segment {
51  return { text: status === 'critical' ? '⚠ ' : '  ', tone: 'status' }
52}
53
54export function terminalPill(pill: Pill, density: Density): TerminalPill {
55  switch (pill.kind) {
56    case 'placeholder': {
57      const width = terminalWidth(pill, density)
58
59      return { pill, status: 'normal', segments: [{ text: `  ${pill.label} —`.padEnd(width), tone: 'dim' }] }
60    }
61    case 'window':
62    case 'context': {
63      const status = pill.status
64      const isNormal = status === 'normal'
65      const segments: Segment[] = [mark(status), { text: `${pill.label} `, tone: 'dim' }]
66      if (density !== 'minimal') {
67        segments.push(...barSegments(pill.fraction, pill.kind === 'window' ? pill.elapsed : null, status))
68        // The tick's column stays reserved when the window reported no reset time.
69        const tickRoom = pill.kind === 'window' && pill.elapsed === null ? ' ' : ''
70        segments.push({ text: `${tickRoom} `, tone: 'dim' })
71      }
72      segments.push({
73        text: pill.percentText.padStart(PERCENT_COLUMNS),
74        tone: isNormal ? 'dim' : 'status',
75        isBold: !isNormal,
76      })
77      if (pill.kind === 'window' && density === 'full') {
78        segments.push({ text: ` ${pill.resetText.padEnd(RESET_COLUMNS[pill.id])}`, tone: 'dim' })
79      }
80
81      return { pill, status, segments }
82    }
83    case 'tokens':
84      return {
85        pill,
86        status: 'normal',
87        segments: [{ text: `${pill.inText} ${pill.outText}`.padEnd(TOKENS_COLUMNS), tone: 'dim' }],
88      }
89    case 'cost':
90      return { pill, status: 'normal', segments: [{ text: pill.text.padEnd(COST_COLUMNS), tone: 'dim' }] }
91  }
92}
93
94/** Columns the pill takes, from its widest realistic content. */
95export function terminalWidth(pill: Pill, density: Density): number {
96  switch (pill.kind) {
97    case 'placeholder':
98    case 'window':
99    case 'context': {
100      // Mark, "5h " or "ctx ", the bar and its space (a window's tick too),
101      // the percent, and a window's " <reset>" when full.
102      const isWindow = pill.id !== 'context'
103      const bar = density === 'minimal' ? 0 : BAR_CELLS + (isWindow ? 1 : 0) + 1
104      const reset = pill.id !== 'context' && density === 'full' ? 1 + RESET_COLUMNS[pill.id] : 0
105
106      return 2 + pill.label.length + 1 + bar + PERCENT_COLUMNS + reset
107    }
108    case 'tokens':
109      return Math.max(TOKENS_COLUMNS, `${pill.inText} ${pill.outText}`.length)
110    case 'cost':
111      return Math.max(COST_COLUMNS, pill.text.length)
112  }
113}
114
hooks/format.ts 50 lines
1// Number and time formatting: pure functions.
2
3const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
4
5const MINUTE = 60_000
6
7/** Up to 999 as is, then `15.6k`, then `1.25M`. */
8export function formatCount(n: number): string {
9  const value = Math.max(0, Math.round(n))
10  if (value < 1000) return String(value)
11  if (value < 999_950) return `${(value / 1000).toFixed(1)}k`
12
13  return `${(value / 1_000_000).toFixed(2)}M`
14}
15
16/** Exact count with thousands separators: `1,234,567`. */
17export function formatExact(n: number): string {
18  return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
19}
20
21/** `<1m`, `45m`, `2h 40m`, `1d 7h`. */
22export function formatDuration(ms: number): string {
23  const minutes = Math.round(Math.max(0, ms) / MINUTE)
24  if (minutes < 1) return '<1m'
25  if (minutes < 60) return `${minutes}m`
26
27  const hours = Math.floor(minutes / 60)
28  if (hours < 24) return `${hours}h ${minutes % 60}m`
29
30  return `${Math.floor(hours / 24)}d ${hours % 24}h`
31}
32
33/** `Sun 14:00` in the zone `utcOffsetMinutes` east of UTC. */
34export function formatClock(epochMs: number, utcOffsetMinutes: number): string {
35  const local = new Date(epochMs + utcOffsetMinutes * MINUTE)
36  const hh = String(local.getUTCHours()).padStart(2, '0')
37  const mm = String(local.getUTCMinutes()).padStart(2, '0')
38
39  return `${WEEKDAYS[local.getUTCDay()]} ${hh}:${mm}`
40}
41
42/** `$4.32`; whole dollars from $1,000. */
43export function formatUsd(usd: number): string {
44  return usd >= 1000 ? `$${formatExact(usd)}` : `$${usd.toFixed(2)}`
45}
46
47export function formatPercent(percent: number): string {
48  return `${Math.round(percent)}%`
49}
50
hooks/status.ts 102 lines
1// Status logic: pure functions, no UI and no engine API, so they test alone.
2
3export type Status = 'normal' | 'warning' | 'critical'
4
5export type WindowKind = 'five_hour' | 'seven_day'
6
7const MINUTE = 60_000
8const HOUR = 60 * MINUTE
9
10export const WINDOW_MS: Record<WindowKind, number> = {
11  five_hour: 5 * HOUR,
12  seven_day: 7 * 24 * HOUR,
13}
14
15/** A projected run-out this close (and before reset) is critical. */
16export const CRITICAL_HORIZON_MS: Record<WindowKind, number> = {
17  five_hour: 30 * MINUTE,
18  seven_day: 24 * HOUR,
19}
20
21/** Below this elapsed fraction there is too little data to project. */
22export const MIN_ELAPSED_FOR_PACE = 0.05
23
24export const USAGE_WARNING = 0.75
25export const USAGE_CRITICAL = 0.9
26export const CONTEXT_WARNING = 70
27export const CONTEXT_CRITICAL = 90
28
29export type Pace =
30  | { kind: 'insufficient' }
31  | { kind: 'under' }
32  | { kind: 'over'; timeToLimitMs: number }
33
34export type WindowAssessment = {
35  status: Status
36  pace: Pace
37  /** Fraction of the window that has passed, 0 to 1; null without a reset time. */
38  elapsed: number | null
39}
40
41const clamp01 = (x: number): number => Math.min(1, Math.max(0, x))
42
43/** `1 − remaining / window`, clamped to 0..1. */
44export function elapsedFraction(remainingMs: number, windowMs: number): number {
45  return clamp01(1 - remainingMs / windowMs)
46}
47
48/**
49 * Projects when the window runs out at the pace so far. `u` and `e` are the
50 * used and elapsed fractions; `remainingMs` is the time until reset.
51 */
52export function projectPace(u: number, e: number, windowMs: number, remainingMs: number): Pace {
53  if (e < MIN_ELAPSED_FOR_PACE) return { kind: 'insufficient' }
54  if (u <= 0) return { kind: 'under' }
55
56  const elapsedMs = e * windowMs
57  const rate = u / elapsedMs
58  const timeToLimitMs = Math.max(0, (1 - u) / rate)
59
60  return timeToLimitMs < remainingMs ? { kind: 'over', timeToLimitMs } : { kind: 'under' }
61}
62
63/**
64 * Classifies a rate-limit window by usage and pace. `remainingMs` is null when
65 * the window reported no reset time: then only raw usage counts.
66 */
67export function assessWindow(
68  kind: WindowKind,
69  percentUsed: number,
70  remainingMs: number | null,
71): WindowAssessment {
72  const u = percentUsed / 100
73  const windowMs = WINDOW_MS[kind]
74
75  if (remainingMs === null) {
76    return { status: statusByUsage(u, false, false), pace: { kind: 'insufficient' }, elapsed: null }
77  }
78
79  const remaining = Math.max(0, remainingMs)
80  const elapsed = elapsedFraction(remaining, windowMs)
81  const pace = projectPace(u, elapsed, windowMs, remaining)
82  const isOver = pace.kind === 'over'
83  const isOverSoon = isOver && pace.timeToLimitMs <= CRITICAL_HORIZON_MS[kind]
84
85  return { status: statusByUsage(u, isOver, isOverSoon), pace, elapsed }
86}
87
88function statusByUsage(u: number, isOver: boolean, isOverSoon: boolean): Status {
89  if (u >= USAGE_CRITICAL || isOverSoon) return 'critical'
90  if (u >= USAGE_WARNING || isOver) return 'warning'
91
92  return 'normal'
93}
94
95/** Context fill, 0 to 100: warning from 70, critical from 90. */
96export function contextStatus(percent: number): Status {
97  if (percent >= CONTEXT_CRITICAL) return 'critical'
98  if (percent >= CONTEXT_WARNING) return 'warning'
99
100  return 'normal'
101}
102
types/index.d.ts 76 lines
1/** One rate-limit window as the band keeps it. */
2export type UsageBandWindow = {
3  /** 0 to 100, as the API reports it. */
4  percentUsed: number
5  /** When the window resets, in epoch milliseconds; null when unreported. */
6  resetsAt: number | null
7}
8
9/** The live context window. */
10export type UsageBandContext = {
11  /** Tokens the last response was answered over; null before the first one. */
12  tokens: number | null
13  /** The model's context window, in tokens. */
14  window: number
15  /** `tokens` over `window`, 0 to 100; null before the first response. */
16  percent: number | null
17}
18
19/** Token totals of the session, main thread and subagents together. */
20export type UsageBandTokens = {
21  /** Uncached input tokens. */
22  input: number
23  /** Input tokens written to the prompt cache. */
24  cacheCreation: number
25  output: number
26  /** Input tokens the prompt cache served. */
27  cacheRead: number
28  /** API requests counted (turns, on the fallback path). */
29  requests: number
30}
31
32/** Everything the band draws from, as of one refresh. */
33export type UsageBandSnapshot = {
34  fiveHour: UsageBandWindow | null
35  sevenDay: UsageBandWindow | null
36  context: UsageBandContext | null
37  /** Session cost in US dollars at API list prices; null when not reported. */
38  costUsd: number | null
39  tokens: UsageBandTokens | null
40  /** True when `tokens` came from turn.complete sums, not the transcripts. */
41  isTokensEstimated: boolean
42  /** The host's local time zone, minutes east of UTC, for absolute reset times. */
43  utcOffsetMinutes: number
44}
45
46/** Size and modification time of the main transcript at the last script run. */
47export type UsageBandTranscript = {
48  path: string
49  size: number
50  mtimeMs: number
51}
52
53/** `auto` hides the cost pill on a subscription and shows it otherwise. */
54export type UsageBandCostMode = 'auto' | 'on' | 'off'
55
56/** Inferred from the rate-limit windows: only subscriptions report them. */
57export type UsageBandPlan = 'subscription' | 'api' | 'unknown'
58
59declare module 'claude-code' {
60  interface PluginState {
61    'usage-band': {
62      snapshot: UsageBandSnapshot | null
63      /** The clock at the last refresh; countdowns and pace are drawn against it. */
64      now: number
65      plan: UsageBandPlan
66      isHidden: boolean
67      costMode: UsageBandCostMode
68      /** Running sum of turn.complete usage, used when the script fails. */
69      fallbackTokens: UsageBandTokens
70      /** Cache key of `scriptTokens`. */
71      transcript: UsageBandTranscript | null
72      scriptTokens: UsageBandTokens | null
73    }
74  }
75}
76