SLOPSHOPPER

usage-footer

Footer beside the model name: prompt cache countdown and the API-equivalent cost of your 5-hour and weekly limit windows, colored by whether the limit will…

newbandspinnercommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-footer
› 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-footer ⎿ usage-footer: No usage data yet. API equivalent cache 60 min left × 5-hour window $0.00 No requests Weekly window $0.00 No requests Scan error: JSON Parse error: Unexpected identifier "dev" At API list prices · all sessions and subagents included ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ▾ cache 60m — 5h … — 7d … auto-accept edits

Draws

Band
API equivalent cache 60 min left × 5-hour window $0.00 No requests Weekly window $0.00 No requests Scan error: JSON Parse error: Unexpected identifier "dev" At API list prices · all sessions and subagents included
README

usage-footer

A Claude Code mod that puts your usage where you look anyway: beside the model name under the prompt.

usage-footer in the terminal: the footer under the prompt and the card above it

▾ ◷42 — 5h $82 — 7d $115
  • ◷42: minutes left before this session's prompt cache expires. Green while fresh, sliding through yellow to red as it runs out.
  • 5h $82: what the requests in your current 5-hour limit window would cost at API list prices.
  • 7d $115: the same for your current weekly limit window. While the session runs Fable, this slot shows Fable's own weekly window (F $…).

The 5h and 7d values are colored by pace: red if, at the pace so far, the limit fills before the window resets; yellow if it ends the window at 85–100%; green otherwise.

Click ▾ (or run /usage-footer) for a card above the prompt with, per window:

  • when the limit fills at the current pace, or where it ends the window
  • the total cost and the cache hit rate
  • one row per model: requests, input, cache read, cache write, output, hit rate, cost

How it works

  • The windows are your account's own: each starts its length (5 hours, 7 days) before the reset time Claude Code reports for that limit, not a rolling "last 5 hours".
  • Costs come from your local transcripts (~/.claude/projects, including subagents), across every session on this machine, deduplicated per request and priced at API list prices. It is not what a subscription charges.
  • A small Node.js script scans the transcripts incrementally at the end of each turn and every 5 minutes; a scan takes tens of milliseconds. Nothing runs inside the model's request stream.

Requirements

  • Claude Code v2.1.287 or later (mods), in the terminal or the Code tab of the Desktop app.
  • Node.js on the machine. The mod looks for node on PATH, then in the usual install locations on macOS (Homebrew), Linux and Windows.
  • Works on macOS, Windows and Linux.

Install

claude plugin marketplace add omeraltinova/claude-usage-footer
claude plugin install usage-footer@claude-usage-footer

Then start a new session, or run /reload-plugins in an open one.

Notes

  • Prices are built in (plugins/usage-footer/scripts/usage-scan.mjs, PRICES) and need updating when Anthropic's prices change.
  • Usage on claude.ai (web, mobile) is not in the local transcripts, so it is not counted in the dollar figures; the limit percentages come from your account and do include it.
  • The Desktop app cuts the footer at a fixed width, so the footer stays short; the card has the details.

License

MIT

Source 2 files
hooks/register.tsx 475 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { UsageLimit, UsagePeriod, UsageScan } from '../types'
5
6const MINUTE = 60e3
7const HOUR = 60 * MINUTE
8const DAY = 24 * HOUR
9
10const DANGER = '#e05050'
11const GOOD = '#3fb950'
12const TIGHT = '#d29922'
13const OVER = '#f85149'
14
15// Where the limit lands at the window's end if use keeps its pace so far:
16// past 100% red, 85-100% yellow, below green. Uncolored without a reading.
17function paceColor(limit: UsageLimit | undefined, lengthMs: number, now: number): string | undefined {
18  const resetMs = limit?.resetsAt ? Date.parse(limit.resetsAt) : NaN
19  if (!limit || !Number.isFinite(resetMs)) return undefined
20  if (limit.percentUsed >= 100) return OVER
21  // Early in a window the pace swings wildly: count at least a tenth of it as elapsed.
22  const elapsed = Math.max(lengthMs / 10, Math.min(lengthMs, lengthMs - (resetMs - now)))
23  const projected = (limit.percentUsed * lengthMs) / elapsed
24  return projected >= 100 ? OVER : projected >= 85 ? TIGHT : GOOD
25}
26
27// Green with the cache fresh, through yellow, to red as it runs out.
28function cacheColor(leftMs: number | undefined, ttlMin: number): string | undefined {
29  if (leftMs === undefined) return undefined
30  if (leftMs <= 0) return OVER
31  const fraction = Math.min(1, leftMs / (ttlMin * MINUTE))
32  const hue = Math.round(fraction * 120)
33  return hslToHex(hue, 70, 50)
34}
35
36// When the limit fills at the pace so far, or where it ends the window if it does not.
37function forecast(limit: UsageLimit | undefined, lengthMs: number, now: number, tzOffsetMin: number): string | undefined {
38  const resetMs = limit?.resetsAt ? Date.parse(limit.resetsAt) : NaN
39  if (!limit || !Number.isFinite(resetMs)) return undefined
40  if (limit.percentUsed >= 100) return `Limit reached · resets ${clock(resetMs, tzOffsetMin, lengthMs > DAY)}`
41  const elapsed = Math.max(lengthMs / 10, Math.min(lengthMs, lengthMs - (resetMs - now)))
42  const perMs = limit.percentUsed / elapsed
43  if (perMs <= 0) return `On pace: won't fill · now ${limit.percentUsed}%`
44  const fillAt = now + (100 - limit.percentUsed) / perMs
45  if (fillAt < resetMs) {
46    return `On pace: fills ~${clock(fillAt, tzOffsetMin, lengthMs > DAY)} (in ${span(fillAt - now)}) · now ${limit.percentUsed}%`
47  }
48  const endPercent = Math.round(limit.percentUsed + perMs * (resetMs - now))
49  return `On pace: won't fill · ~${endPercent}% at window end · now ${limit.percentUsed}%`
50}
51
52const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
53
54function clock(ms: number, tzOffsetMin: number, withDay: boolean): string {
55  const local = new Date(ms + tzOffsetMin * MINUTE)
56  const time = `${String(local.getUTCHours()).padStart(2, '0')}:${String(local.getUTCMinutes()).padStart(2, '0')}`
57  return withDay ? `${DAYS[local.getUTCDay()]} ${time}` : time
58}
59
60function span(ms: number): string {
61  if (ms >= DAY) return `${Math.floor(ms / DAY)}d ${Math.floor((ms % DAY) / HOUR)}h`
62  if (ms >= HOUR) return `${Math.floor(ms / HOUR)}h ${Math.floor((ms % HOUR) / MINUTE)}m`
63  return `${Math.max(1, Math.ceil(ms / MINUTE))}m`
64}
65
66function hslToHex(hue: number, saturation: number, lightness: number): string {
67  const s = saturation / 100
68  const l = lightness / 100
69  const k = (n: number) => (n + hue / 30) % 12
70  const a = s * Math.min(l, 1 - l)
71  const channel = (n: number) =>
72    Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))
73      .toString(16)
74      .padStart(2, '0')
75  return `#${channel(0)}${channel(8)}${channel(4)}`
76}
77const FAMILY_COLORS: Record<string, string> = {
78  opus: '#d97757',
79  sonnet: '#6a9bcc',
80  haiku: '#629987',
81  fable: '#a78bfa',
82  mythos: '#a78bfa',
83}
84
85const scan = atom({ plugin: 'usage-footer', key: 'scan' } as const, null)
86const limits = atom({ plugin: 'usage-footer', key: 'limits' } as const, [])
87const lastCall = atom({ plugin: 'usage-footer', key: 'lastCall' } as const, 0)
88const error = atom({ plugin: 'usage-footer', key: 'error' } as const, null)
89const isOpen = atom({ plugin: 'usage-footer', key: 'isOpen' } as const, false)
90const isWorking = atom({ plugin: 'usage-footer', key: 'isWorking' } as const, false)
91const model = atom({ plugin: 'usage-footer', key: 'model' } as const, '')
92
93// claude-opus-5-5 -> Opus 5.5, claude-haiku-4-5-20251001 -> Haiku 4.5
94function modelName(id: string): string {
95  const [family = id, ...version] = id.replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-')
96  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version.join('.')}`.trim()
97}
98
99function modelColor(id: string): string {
100  const family = id.replace(/^claude-/, '').split('-')[0] ?? ''
101  return FAMILY_COLORS[family] ?? '#8a8a8a'
102}
103
104function usd(value: number): string {
105  return value >= 1000 ? `$${value.toFixed(0)}` : `$${value.toFixed(2)}`
106}
107
108// The desktop cuts the footer at a fixed width: whole dollars there, cents in the card.
109function usdShort(value: number): string {
110  return `$${Math.round(value)}`
111}
112
113function tokens(value: number): string {
114  if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`
115  if (value >= 1e3) return `${(value / 1e3).toFixed(value >= 1e5 ? 0 : 1)}k`
116  return String(value)
117}
118
119// Share of the prompt the cache served: read over everything sent (uncached + written + read).
120function hitRate(row: { input: number; cacheRead: number; cacheWrite: number }): string {
121  const sent = row.input + row.cacheRead + row.cacheWrite
122  return sent > 0 ? `${((row.cacheRead / sent) * 100).toFixed(1)}%` : '—'
123}
124
125function startOf(list: UsageLimit[], kind: string, lengthMs: number): number | undefined {
126  const resetsAt = list.find(limit => limit.kind === kind)?.resetsAt
127  const resetMs = resetsAt ? Date.parse(resetsAt) : NaN
128  return Number.isFinite(resetMs) ? resetMs - lengthMs : undefined
129}
130
131function weekLimit(list: UsageLimit[]): UsageLimit | undefined {
132  return list.find(limit => limit.kind === 'seven_day') ?? list.find(limit => limit.kind.startsWith('seven_day') && !isFableKind(limit.kind))
133}
134
135function isFableKind(kind: string): boolean {
136  return /fable|mythos/.test(kind)
137}
138
139function fableLimit(list: UsageLimit[]): UsageLimit | undefined {
140  return list.find(limit => isFableKind(limit.kind))
141}
142
143function isFable(id: string): boolean {
144  return /fable|mythos/.test(id)
145}
146
147function cacheLeftMs(data: UsageScan | null, last: number, now: number): number | undefined {
148  const lastTs = Math.max(last, data?.cache?.lastTs ?? 0)
149  if (lastTs === 0) return undefined
150  return lastTs + (data?.cache?.ttlMin ?? 60) * MINUTE - now
151}
152
153// Apps started from the macOS Dock (and some Windows shortcuts) do not get the shell's PATH:
154// after PATH, try where Node usually lives on macOS, Linux and Windows.
155const NODE_CANDIDATES = [
156  'node',
157  '/opt/homebrew/bin/node',
158  '/usr/local/bin/node',
159  '/usr/bin/node',
160  'C:/Program Files/nodejs/node.exe',
161]
162let nodePath: string | undefined
163
164async function runNode($: EngineInterface, args: string[]) {
165  for (const candidate of nodePath ? [nodePath] : NODE_CANDIDATES) {
166    try {
167      const ran = await $.process.run([candidate, ...args], { timeoutMs: 120e3 })
168      nodePath = candidate
169      return ran
170    } catch {
171      // not there: try the next one
172    }
173  }
174  throw new Error('Node.js not found (install it, or put node on PATH)')
175}
176
177let isScanning = false
178let isScanQueued = false
179
180async function rescan($: EngineInterface): Promise<void> {
181  if (isScanning) {
182    isScanQueued = true
183    return
184  }
185  isScanning = true
186  try {
187    const argv = ['node', `${$.plugin.root}/scripts/usage-scan.mjs`, '--session', await $.session.id()]
188    const list = await read($, limits)
189    // The account's own limit windows: each one started its length before it resets.
190    const windowStart = startOf(list, 'five_hour', 5 * HOUR)
191    const weekStart = startOf(list, weekLimit(list)?.kind ?? 'seven_day', 7 * DAY)
192    if (windowStart !== undefined) argv.push('--window-start', String(windowStart))
193    if (weekStart !== undefined) argv.push('--week-start', String(weekStart))
194    const fable = fableLimit(list)
195    const fableStart = fable ? startOf(list, fable.kind, 7 * DAY) : undefined
196    if (fableStart !== undefined) argv.push('--fable-week-start', String(fableStart))
197
198    const ran = await runNode($, argv.slice(1))
199    if (ran.exitCode !== 0) throw new Error(ran.stderr.trim().split('\n').pop() || `exit ${ran.exitCode}`)
200    const data = JSON.parse(ran.stdout) as UsageScan
201    await update($, scan, () => data)
202    await update($, error, () => null)
203  } catch (failure) {
204    const message = failure instanceof Error ? failure.message : String(failure)
205    await update($, error, () => message.slice(0, 300))
206  } finally {
207    isScanning = false
208    if (isScanQueued) {
209      isScanQueued = false
210      void rescan($)
211    }
212  }
213}
214
215function summaryText(data: UsageScan | null): string {
216  if (!data) return 'No usage data yet.'
217  const line = (title: string, period: UsagePeriod | null) =>
218    period
219      ? `${title}: ${usd(period.usd)} (${period.models.map(row => `${modelName(row.model)} ${usd(row.usd)}`).join(', ') || 'no requests'})`
220      : `${title}: no requests`
221  return [line('5-hour window', data.window), line('Weekly window', data.week), line('Weekly · Fable', data.fableWeek ?? null)].join('\n')
222}
223
224export const register: Register = on => {
225  on('session.start', async ($, e, next) => {
226    await $.command.register({
227      name: 'usage-footer',
228      description: 'Toggle the card with the API-equivalent cost of the 5-hour and weekly windows, per model',
229    })
230
231    const usage = await $.session.usage()
232    if (usage.rateLimits.length > 0) await update($, limits, () => [...usage.rateLimits])
233
234    const current = await $.session.model().catch(() => '')
235    await update($, model, () => current)
236    void rescan($)
237    // Each turn's end scans (session.measure); this catches other sessions' use meanwhile.
238    $.clock.every(5 * MINUTE, () => void rescan($))
239    // The cache countdown moves by the minute: redraw, nothing more.
240    $.clock.every(MINUTE, () => $.ui.invalidate('ui.render'))
241
242    return next(e)
243  })
244
245  // While a turn runs its requests keep the prompt cache warm; its end restarts the TTL.
246  on('turn.start', async ($, e, next) => {
247    await update($, isWorking, () => true)
248
249    return next(e)
250  })
251
252  on('turn.complete', async ($, e, next) => {
253    if (e.agentId === undefined) {
254      const now = await $.clock.now()
255      await update($, isWorking, () => false)
256      await update($, lastCall, () => now)
257      if (e.usage?.model) await update($, model, () => e.usage?.model ?? '')
258    }
259
260    return next(e)
261  })
262
263  on('session.measure', async ($, e, next) => {
264    if (e.rateLimits.length > 0) await update($, limits, () => [...e.rateLimits])
265    void rescan($)
266
267    return next(e)
268  })
269
270  on('command.run', { command: 'usage-footer' }, async $ => {
271    await update($, isOpen, open => !open)
272    await rescan($)
273
274    return { text: summaryText(await read($, scan)) }
275  })
276
277  // The footer: colored values beside the model name, kept short so the desktop does
278  // not cut them, and a ▾ that toggles the card.
279  on('ui.render', { component: 'SessionMode' }, async ($, e) => {
280    const { Box, Text, Button } = $.ui.resolve(e)
281    const data = await read($, scan)
282    const list = await read($, limits)
283    const now = await $.clock.now()
284    const isLive = await read($, isWorking)
285    const leftMs = isLive ? (data?.cache?.ttlMin ?? 60) * MINUTE : cacheLeftMs(data, await read($, lastCall), now)
286    const usesFable = isFable(await read($, model))
287    const toggle = () => void update($, isOpen, open => !open)
288
289    // The terminal has room and no ◷ in many fonts: full words and cents there;
290    // the desktop cuts its footer at a fixed width: a glyph and whole dollars.
291    const isTerminal = e.surface === 'terminal'
292    const money = isTerminal ? usd : usdShort
293    const minutes = leftMs === undefined ? undefined : Math.max(0, Math.ceil(leftMs / MINUTE))
294    const cache = isTerminal
295      ? `cache ${minutes === undefined ? '—' : `${minutes}m`}`
296      : `◷${minutes === undefined ? '—' : minutes}`
297    const window = `5h ${data?.window ? money(data.window.usd) : data ? '$0' : '…'}`
298    const week = `7d ${data ? money(data.week.usd) : '…'}`
299    const fable = `${isTerminal ? '7d Fable' : 'F'} ${data?.fableWeek ? money(data.fableWeek.usd) : '…'}`
300
301    return (
302      <Box flexDirection="row" gap={1}>
303        <Button key="toggle" plain label="▾" onPress={toggle} />
304        <Text bold color={cacheColor(leftMs, data?.cache?.ttlMin ?? 60)}>
305          {cache}
306        </Text>
307        <Text dimColor>—</Text>
308        <Text bold color={paceColor(list.find(limit => limit.kind === 'five_hour'), 5 * HOUR, now)}>
309          {window}
310        </Text>
311        <Text dimColor>—</Text>
312        {usesFable ? (
313          <Text bold color={paceColor(fableLimit(list), 7 * DAY, now)}>
314            {fable}
315          </Text>
316        ) : (
317          <Text bold color={paceColor(weekLimit(list), 7 * DAY, now)}>
318            {week}
319          </Text>
320        )}
321        {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')}</Text>}
322      </Box>
323    )
324  })
325
326  // The card: drawn in the band right above the prompt while open.
327  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
328    if (e.props.hasSurvey || !(await read($, isOpen))) return next(e)
329
330    const { Box, Text, Button } = $.ui.resolve(e)
331    const data = await read($, scan)
332    const failure = await read($, error)
333    const list = await read($, limits)
334    const now = await $.clock.now()
335    const leftMs = (await read($, isWorking)) ? (data?.cache?.ttlMin ?? 60) * MINUTE : cacheLeftMs(data, await read($, lastCall), now)
336    const full = Math.max(32, e.props.bodyColumns - 2)
337    const width = Math.min(full, 100)
338    // The terminal band has few rows: no blank line above each table there.
339    const isTerminal = e.surface === 'terminal'
340
341    const row = (left: unknown, right: unknown, rowWidth = width) => (
342      <Box flexDirection="row" justifyContent="space-between" width={rowWidth}>
343        {left}
344        {right}
345      </Box>
346    )
347    // Columns: the model, then right-aligned figures.
348    const COLUMNS: Array<[string, number]> = [
349      ['Reqs', 6],
350      ['Input', 8],
351      ['Cache read', 12],
352      ['Cache write', 13],
353      ['Output', 9],
354      ['Hit', 8],
355      ['Cost', 10],
356    ]
357    const cells = (values: string[], isHeader: boolean) =>
358      COLUMNS.map(([, size], at) => (
359        <Box width={size} justifyContent="flex-end">
360          <Text dimColor={isHeader} bold={!isHeader && at === COLUMNS.length - 1}>
361            {values[at] ?? ''}
362          </Text>
363        </Box>
364      ))
365    const section = (title: string, period: UsagePeriod | null, color?: string, note?: string, sectionWidth = width) => {
366      const models = period?.models ?? []
367      const total = models.reduce(
368        (sum, one) => ({ input: sum.input + one.input, cacheRead: sum.cacheRead + one.cacheRead, cacheWrite: sum.cacheWrite + one.cacheWrite }),
369        { input: 0, cacheRead: 0, cacheWrite: 0 },
370      )
371      return (
372        <Box flexDirection="column" width={sectionWidth}>
373          {row(
374            <Text>
375              <Text bold>{title}</Text>
376              <Text dimColor>
377                {period ? `  ${period.startLabel} – ${period.endLabel}${period.isGuessed ? ' (estimated)' : ''}` : ''}
378              </Text>
379            </Text>,
380            <Text>
381              <Text dimColor>{models.length > 0 ? `cache hit ${hitRate(total)}   ` : ''}</Text>
382              <Text bold color={color}>
383                {usd(period?.usd ?? 0)}
384              </Text>
385            </Text>,
386            sectionWidth,
387          )}
388          {note && <Text color={color}>{note}</Text>}
389          {models.length === 0 ? (
390            <Text dimColor>No requests</Text>
391          ) : (
392            <Box flexDirection="column" marginTop={isTerminal ? 0 : 1}>
393              <Box flexDirection="row">
394                <Box width={14}>
395                  <Text dimColor>Model</Text>
396                </Box>
397                {cells(COLUMNS.map(([label]) => label), true)}
398              </Box>
399              {models.map(model => (
400                <Box flexDirection="row">
401                  <Box width={14} flexDirection="row" gap={1}>
402                    <Text color={modelColor(model.model)}>●</Text>
403                    <Text>{modelName(model.model)}</Text>
404                  </Box>
405                  {cells(
406                    [
407                      String(model.reqs),
408                      tokens(model.input),
409                      tokens(model.cacheRead),
410                      tokens(model.cacheWrite),
411                      tokens(model.output),
412                      hitRate(model),
413                      model.isPriced ? usd(model.usd) : 'n/a',
414                    ],
415                    false,
416                  )}
417                </Box>
418              ))}
419            </Box>
420          )}
421        </Box>
422      )
423    }
424
425    const five = list.find(limit => limit.kind === 'five_hour')
426    const tz = data?.tzOffsetMin ?? 0
427    const windows: Array<[string, UsagePeriod | null, UsageLimit | undefined, number]> = [
428      ['5-hour window', data?.window ?? null, five, 5 * HOUR],
429      ['Weekly window', data?.week ?? null, weekLimit(list), 7 * DAY],
430    ]
431    if (fableLimit(list) || (data?.fableWeek?.usd ?? 0) > 0) {
432      windows.push(['Weekly · Fable', data?.fableWeek ?? null, fableLimit(list), 7 * DAY])
433    }
434    // A wide terminal lays the windows side by side; each needs the table's 80 columns.
435    const GAP = 4
436    const sideWidth = Math.floor((full - GAP * (windows.length - 1)) / windows.length)
437    const isSideBySide = isTerminal && sideWidth >= 82
438    const sections = windows.map(([title, period, limit, lengthMs]) =>
439      section(title, period, paceColor(limit, lengthMs, now), forecast(limit, lengthMs, now, tz), isSideBySide ? sideWidth : width),
440    )
441
442    return (
443      <Box flexDirection="column" gap={1} paddingX={1}>
444        {row(
445          <Text>
446            <Text bold>API equivalent</Text>
447            <Text dimColor>
448              {'  '}cache {leftMs === undefined ? '—' : leftMs > 0 ? `${Math.ceil(leftMs / MINUTE)} min left` : 'expired'}
449            </Text>
450          </Text>,
451          <Button
452            key="close"
453            plain
454            dimColor
455            // ✕ is ambiguous-width: terminals draw it two cells wide and the hit area covers one.
456            label={isTerminal ? '×' : '✕'}
457            onPress={() => void update($, isOpen, () => false)}
458          />,
459          // Keep clear of the band's own [-] mark in the top right corner of a wide terminal.
460          isSideBySide ? full - 6 : width,
461        )}
462        {isSideBySide ? (
463          <Box flexDirection="row" gap={GAP}>
464            {sections}
465          </Box>
466        ) : (
467          sections
468        )}
469        {failure && <Text color={DANGER}>Scan error: {failure}</Text>}
470        <Text dimColor>At API list prices · all sessions and subagents included</Text>
471      </Box>
472    )
473  })
474}
475
types/index.d.ts 47 lines
1export type UsageModelRow = {
2  model: string
3  usd: number
4  reqs: number
5  input: number
6  output: number
7  cacheRead: number
8  cacheWrite: number
9  isPriced: boolean
10}
11
12export type UsagePeriod = {
13  start: number
14  end: number
15  startLabel: string
16  endLabel: string
17  usd: number
18  models: UsageModelRow[]
19  isGuessed?: boolean
20}
21
22export type UsageScan = {
23  now: number
24  window: UsagePeriod | null
25  week: UsagePeriod
26  fableWeek?: UsagePeriod
27  cache: { lastTs: number; ttlMin: number } | null
28  tzOffsetMin?: number
29  scannedMs: number
30}
31
32export type UsageLimit = { kind: string; percentUsed: number; resetsAt?: string }
33
34declare module 'claude-code' {
35  interface PluginState {
36    'usage-footer': {
37      scan: UsageScan | null
38      limits: UsageLimit[]
39      lastCall: number
40      isWorking: boolean
41      model: string
42      error: string | null
43      isOpen: boolean
44    }
45  }
46}
47