SLOPSHOPPER

usage-band

A band above the prompt with your usage windows, the prompt cache's warmth and age, the context to re-cache and what the next message costs per model

newbandtimer
v0.1.0MITupdated 2026-10-22cmorss/claude-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 USAGE5H●●●●●●●···69% left NO CACHE <1m· $0.78· $0.42 [ Show ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
USAGE5H●●●●●●●···69% left NO CACHE <1m· $0.78· $0.42 [ Show ]
README

usage-band

A band above the prompt that shows where your Claude usage stands, in the terminal and in the Claude desktop app.

Compact, one row:

USAGE  7D ●●●●●·····  45% left · 3d 10h   5H ●●●●●●●●●· 89% left · resetting   [COLD] 11h 32m · next $4.78   [Show]

Expanded, after Show:

  • The windows: the 5-hour and 7-day rate-limit windows (and a gateway's spend limit) as the last API response reported them, with how much is left and when each resets, plus what this session has cost so far.
  • The cache: whether the prompt cache is warm or cold, how long the session has been idle, and how long the cache holds or how long ago it went cold. The timer runs from the start of the last main-thread request, as the API's does.
  • Re-cache: the context the next message re-sends (the last response's input tokens) and its share of the model's window.
  • Next message costs: what re-sending that context costs on Fable, Opus, Sonnet and Haiku. Your model is marked YOU. While the cache is warm, your model is priced at its cache-read rate; cold, every model is priced at the cache-write rate for the session's TTL (a switch forfeits the cache, so the other models always show the write cost).

The figures come from the engine ($.session.usage() and session.measure), so they match the status line and /cost. Prices are list prices for the current generation and live in hooks/format.ts.

Options

  • Cache TTL (cacheTtl): 1h or 5m, used when neither the promptCacheTtl setting nor a model switch says which. Default 1h, the TTL of a Claude subscription.
  • Redraw every (tickSeconds): how often the idle timer redraws. Default 30.

The Show/Hide choice is remembered across sessions.

Running it

From this folder, in every session: add it to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json, or start one session with claude --plugin-dir <this folder>.

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 395 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, SessionUsage } from 'claude-code'
3
4import type { CacheMark, CacheTtl, Usage } from '../types'
5import {
6  BADGE_HEIGHT,
7  BADGE_INK,
8  BAR,
9  FAMILIES,
10  GLYPH_INK,
11  PRICES,
12  TTL_MS,
13  YOU_INK,
14  badgeSvg,
15  barDots,
16  cacheTimeline,
17  duration,
18  gridSvg,
19  familyOf,
20  filled,
21  leftColor,
22  money,
23  nextMessageCost,
24  percentLeft,
25  resetText,
26  tokens as fmtTokens,
27  warmth,
28  windowLabel,
29} from './format'
30
31const PLUGIN = 'usage-band'
32const STORE_EXPANDED = 'isExpanded'
33
34const usage = atom({ plugin: 'usage-band', key: 'usage' } as const, null)
35const mark = atom({ plugin: 'usage-band', key: 'mark' } as const, null)
36const isExpanded = atom({ plugin: 'usage-band', key: 'isExpanded' } as const, false)
37const ttl = atom({ plugin: 'usage-band', key: 'ttl' } as const, '1h')
38
39const DOTS = 10
40const DOT = '●'
41const GAP_DOT = '·'
42
43function toUsage(u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Usage {
44  return {
45    contextTokens: u.context.tokens ?? null,
46    window: u.context.window,
47    percent: u.context.percent ?? null,
48    windows: u.rateLimits.map((w) => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })),
49    costUsd: u.cost?.usd ?? null,
50  }
51}
52
53async function refreshUsage($: EngineInterface) {
54  try {
55    const u = await $.session.usage()
56    await update($, usage, () => toUsage(u))
57  } catch {
58    // Off a subscription, or before the first response: nothing to show yet.
59  }
60}
61
62async function setMark($: EngineInterface, patch: Partial<CacheMark>) {
63  await update($, mark, (m) => ({ requestAt: null, responseAt: null, model: null, ...m, ...patch }))
64}
65
66function isTtl(value: unknown): value is CacheTtl {
67  return value === '5m' || value === '1h'
68}
69
70export const register: Register = (on, options) => {
71  const configuredTtl = isTtl(options?.cacheTtl) ? options.cacheTtl : '1h'
72  const tickMs = Math.max(5, Number(options?.tickSeconds) || 30) * 1000
73
74  on('session.start', async ($, e, next) => {
75    // The TTL the engine runs under: a settings key, else the mod's own option.
76    let resolved: CacheTtl = configuredTtl
77    try {
78      const fromSettings = await $.settings.read({ key: 'promptCacheTtl' })
79      if (isTtl(fromSettings)) resolved = fromSettings
80    } catch {
81      // Settings unreadable here: keep the option.
82    }
83    await update($, ttl, () => resolved)
84
85    try {
86      const expanded = await $.store.get(STORE_EXPANDED)
87      if (typeof expanded === 'boolean') await update($, isExpanded, () => expanded)
88    } catch {
89      // No store: the band starts compact.
90    }
91
92    try {
93      await setMark($, { model: await $.session.model() })
94    } catch {
95      // No model yet.
96    }
97    await refreshUsage($)
98
99    // The idle timer and the cache's warmth move on their own; redraw as they do.
100    $.clock.every(tickMs, () => $.ui.invalidate('ui.render'))
101
102    return next(e)
103  })
104
105  on('session.measure', async ($, e, next) => {
106    await update($, usage, () => toUsage(e))
107    return next(e)
108  })
109
110  // The prompt cache's timer runs from the start of each main-thread request.
111  on('turn.step', async function* ($, e, next) {
112    const isMain = e.agentId === undefined
113    if (isMain) await setMark($, { requestAt: await $.clock.now(), model: e.model })
114    const result = yield* next(e)
115    if (isMain) await setMark($, { responseAt: await $.clock.now() })
116    return result
117  })
118
119  on('turn.complete', async ($, e, next) => {
120    if (e.agentId === undefined) {
121      await setMark($, { responseAt: await $.clock.now() })
122      await refreshUsage($)
123    }
124    return next(e)
125  })
126
127  // A switch forfeits the old model's cache: the new one is cold until its first request.
128  on('classic.PostModelSwitch', async ($, e, next) => {
129    await setMark($, { requestAt: null, model: e.to_model })
130    if (isTtl(e.cache_ttl)) await update($, ttl, () => e.cache_ttl)
131    return next(e)
132  })
133
134  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
135    // A survey holds the band while one is up (the app's next-step box is one): the band is
136    // drawn above it rather than giving way, so it stays while the person can type.
137    const below = e.props.hasSurvey ? await next(e) : null
138
139    const u = await read($, usage)
140    const m = await read($, mark)
141    const cacheTtl = await read($, ttl)
142    const expanded = await read($, isExpanded)
143    const now = await $.clock.now()
144    const isWorking = e.props.isWorking
145
146    const table = $.ui.resolve(e)
147    const { Box, Text, Button } = table
148    const wrap = (band: RenderElement): RenderElement =>
149      below === null ? (
150        band
151      ) : (
152        <Box flexDirection="column" gap="sm">
153          {band}
154          {below}
155        </Box>
156      )
157
158    const toggle = async () => {
159      const nextExpanded = !(await read($, isExpanded))
160      await update($, isExpanded, () => nextExpanded)
161      try {
162        await $.store.set(STORE_EXPANDED, nextExpanded)
163      } catch {
164        // The preference lives for the session alone.
165      }
166    }
167
168    const contextTokens = u?.contextTokens ?? 0
169    const heat = isWorking ? 'warm' : warmth(m?.requestAt ?? null, cacheTtl, now)
170    const family = familyOf(m?.model)
171    const idleMs = m?.responseAt !== null && m?.responseAt !== undefined ? now - m.responseAt : null
172    const yourCost =
173      family && contextTokens > 0 ? nextMessageCost(family, contextTokens, cacheTtl, heat === 'warm') : null
174
175    const windows = (u?.windows ?? []).map((w) => {
176      const left = percentLeft(w)
177      const color = leftColor(left)
178      const n = filled(left / 100, DOTS)
179      const reset = resetText(w, now)
180      return (
181        <Box key={`win-${w.kind}`} flexDirection="row" gap="xs" alignItems="center" flexShrink={0}>
182          <Text weight="semibold" dimColor>
183            {windowLabel(w.kind)}
184          </Text>
185          <Text color={color} size="caption">
186            {DOT.repeat(n)}
187          </Text>
188          <Text dimColor size="caption">
189            {GAP_DOT.repeat(DOTS - n)}
190          </Text>
191          <Text color={color} weight="semibold" tabular>
192            {left}% left
193          </Text>
194          {reset ? <Text dimColor>· {reset}</Text> : null}
195        </Box>
196      )
197    })
198
199    const heatLabel = isWorking ? 'BUSY' : heat === 'warm' ? 'WARM' : heat === 'cold' ? 'COLD' : 'NO CACHE'
200    const heatColor = heat === 'warm' ? 'success' : heat === 'cold' ? 'ide' : 'subtle'
201    // The terminal draws the badge as an inverse run; a graphical surface as a rounded pill.
202    const badge =
203      e.surface !== 'terminal' && 'Svg' in table ? (
204        <table.Svg source={badgeSvg(heatLabel, BADGE_INK[heat])} alt={heatLabel} height={BADGE_HEIGHT} />
205      ) : (
206        <Text inverse color={heatColor} weight="semibold">
207          {' '}
208          {heatLabel}{' '}
209        </Text>
210      )
211    const runningMs = m?.requestAt !== null && m?.requestAt !== undefined ? now - m.requestAt : null
212    const idleText = isWorking
213      ? runningMs === null
214        ? 'now'
215        : duration(runningMs)
216      : idleMs === null
217        ? '—'
218        : duration(idleMs)
219
220    const toggleButton = (
221      <Button key="toggle" label={expanded ? 'Hide' : 'Show'} onPress={() => void toggle()} />
222    )
223
224    if (!expanded) {
225      // The button keeps its place at the right end: the figures wrap, it never does.
226      return wrap(
227        <Box flexDirection="row" gap="sm" alignItems="center" justifyContent="space-between">
228          <Box flexDirection="row" gap="sm" alignItems="center" flexWrap="nowrap" flexGrow={1} flexShrink={1} overflow="hidden">
229            <Text weight="bold">USAGE</Text>
230            {windows.length > 0 ? windows : <Text dimColor>no usage windows yet</Text>}
231            <Box flexDirection="row" gap="xs" alignItems="center" flexShrink={0}>
232              {badge}
233              <Text weight="semibold" tabular>
234                {idleText}
235              </Text>
236              {yourCost !== null ? <Text dimColor>· {money(yourCost)}</Text> : null}
237              {u?.costUsd !== null && u?.costUsd !== undefined ? (
238                <Text dimColor tabular>
239                  · {money(u.costUsd)}
240                </Text>
241              ) : null}
242            </Box>
243          </Box>
244          <Box flexShrink={0}>{toggleButton}</Box>
245        </Box>
246      )
247    }
248
249    // Expanded: the header with the windows, then three columns: the cache, the re-cache, the costs.
250    const ttlMs = TTL_MS[cacheTtl]
251    const sinceRequest = m?.requestAt ? now - m.requestAt : null
252    const line = cacheTimeline(isWorking ? 0 : sinceRequest, ttlMs, BAR)
253    const timelineLeft = `warm ${cacheTtl}`
254    const timelineRight = isWorking
255      ? 'busy'
256      : heat === 'warm' && sinceRequest !== null
257        ? `cold in ${duration(ttlMs - sinceRequest)}`
258        : heat === 'cold' && sinceRequest !== null
259          ? `cold for ${duration(sinceRequest - ttlMs)}`
260          : 'no request yet'
261
262    const fillShare = u?.percent !== null && u?.percent !== undefined ? u.percent / 100 : 0
263    const fillDots = filled(fillShare, BAR)
264    const windowText = u ? `${u.percent ?? 0}% of the ${fmtTokens(u.window)} context` : 'context not measured yet'
265
266    const isGraphical = e.surface !== 'terminal' && 'Svg' in table
267    const youPill = isGraphical ? (
268      <table.Svg source={badgeSvg('YOU', YOU_INK)} alt="YOU" height={BADGE_HEIGHT} />
269    ) : (
270      <Text inverse color="ide" weight="semibold">
271        {' '}
272        YOU{' '}
273      </Text>
274    )
275
276    const priced = FAMILIES.map((f) => {
277      const isYou = f === family
278      const cost = contextTokens > 0 ? nextMessageCost(f, contextTokens, cacheTtl, isYou && heat === 'warm') : null
279      return { f, isYou, cost }
280    })
281    const dearest = Math.max(0, ...priced.map((x) => x.cost ?? 0))
282    const costs = priced.map(({ f, isYou, cost }) => {
283      const lit = barDots(cost, dearest)
284      const glyph = isGraphical ? (
285        <table.Svg
286          source={gridSvg(lit, isYou ? GLYPH_INK.you : GLYPH_INK.other)}
287          alt={`${PRICES[f].label}, ${lit} of 15 against the dearest`}
288          height={30}
289        />
290      ) : null
291      return (
292        <Box key={`cost-${f}`} flexDirection="row" gap="xs" alignItems="flex-end">
293          {glyph}
294          <Box flexDirection="column" alignItems="flex-start">
295            {isYou ? youPill : <Text size="caption"> </Text>}
296            <Text weight={isYou ? 'bold' : 'semibold'} size="heading" color={isYou ? 'text' : 'subtle'} tabular>
297              {cost === null ? '—' : money(cost)}
298            </Text>
299            <Text size="caption" dimColor>
300              {PRICES[f].label}
301              {isYou && heat === 'warm' ? ' · read' : ''}
302            </Text>
303          </Box>
304        </Box>
305      )
306    })
307
308    const caption = (text: string) => (
309      <Text size="caption" dimColor weight="semibold">
310        {text}
311      </Text>
312    )
313
314    return wrap(
315      <Box flexDirection="column" gap="md">
316        <Box flexDirection="row" gap="sm" alignItems="center" justifyContent="space-between">
317          <Box flexDirection="row" gap="sm" alignItems="center" flexWrap="nowrap" flexGrow={1} flexShrink={1} overflow="hidden">
318            <Text weight="bold">USAGE</Text>
319            {windows.length > 0 ? windows : <Text dimColor>no usage windows yet</Text>}
320          </Box>
321          <Box flexDirection="row" gap="md" alignItems="center" flexShrink={0}>
322            {u?.costUsd !== null && u?.costUsd !== undefined ? (
323              <Text dimColor tabular>
324                <Text weight="semibold">SESSION</Text> {money(u.costUsd)}
325              </Text>
326            ) : null}
327            {toggleButton}
328          </Box>
329        </Box>
330
331        <Box flexDirection="row" flexWrap="wrap" gap="xl" alignItems="flex-start">
332          <Box flexDirection="column" gap="xs">
333            <Box flexDirection="row" gap="xs" alignItems="center">
334              {badge}
335              {caption(isWorking ? 'RUNNING' : 'IDLE')}
336            </Box>
337            <Text size="title" weight="bold" tabular>
338              {idleText}
339            </Text>
340            <Box flexDirection="row" alignItems="center">
341              <Text color="warning" size="caption">
342                {DOT.repeat(line.warm)}
343              </Text>
344              <Text dimColor size="caption">
345                {GAP_DOT.repeat(line.left + Math.max(0, line.cold - 1))}
346              </Text>
347              {line.cold > 0 ? (
348                <Text color="ide" size="caption">
349                  {DOT}
350                </Text>
351              ) : null}
352            </Box>
353            <Box flexDirection="row" justifyContent="space-between" gap="md">
354              <Text size="caption" color="warning">
355                {timelineLeft}
356              </Text>
357              <Text size="caption" color={heat === 'cold' ? 'ide' : 'subtle'}>
358                {timelineRight}
359              </Text>
360            </Box>
361          </Box>
362
363          <Box flexDirection="column" gap="xs">
364            {caption('RE-CACHE')}
365            <Box flexDirection="row" gap="xs" alignItems="flex-end">
366              <Text size="title" weight="bold" tabular>
367                {contextTokens > 0 ? fmtTokens(contextTokens) : '—'}
368              </Text>
369              <Text dimColor>tokens</Text>
370            </Box>
371            <Box flexDirection="row" alignItems="center">
372              <Text color="ide" size="caption">
373                {DOT.repeat(fillDots)}
374              </Text>
375              <Text dimColor size="caption">
376                {GAP_DOT.repeat(BAR - fillDots)}
377              </Text>
378            </Box>
379            <Text size="caption" dimColor>
380              {windowText}
381            </Text>
382          </Box>
383
384          <Box flexDirection="column" gap="xs">
385            {caption('NEXT MESSAGE COSTS')}
386            <Box flexDirection="row" gap="lg" alignItems="flex-end" flexWrap="wrap">
387              {costs}
388            </Box>
389          </Box>
390        </Box>
391      </Box>
392    )
393  })
394}
395
hooks/format.ts 174 lines
1import type { CacheTtl, Window } from '../types'
2
3export const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
4
5/** A model family the band prices, with its list price in US dollars per million input tokens. */
6export type Family = 'fable' | 'opus' | 'sonnet' | 'haiku'
7
8/**
9 * List prices as of 2026-10 for the current generation: input per MTok and a cache read per
10 * MTok. A cache write is the input price times 1.25 for the 5-minute TTL and 2 for the 1-hour TTL.
11 */
12export const PRICES: Record<Family, { label: string; input: number; read: number }> = {
13  fable: { label: 'FABLE', input: 10, read: 0.25 },
14  opus: { label: 'OPUS', input: 4, read: 0.2 },
15  sonnet: { label: 'SONNET', input: 2, read: 0.2 },
16  haiku: { label: 'HAIKU', input: 0.1, read: 0.01 },
17}
18
19export const FAMILIES: Family[] = ['fable', 'opus', 'sonnet', 'haiku']
20
21/** Which family a model name belongs to, from an alias (`opus`), an id (`claude-opus-5-5`) or a label. */
22export function familyOf(model: string | null | undefined): Family | null {
23  const name = (model ?? '').toLowerCase()
24  if (name.includes('fable') || name.includes('mythos')) return 'fable'
25  if (name.includes('opus')) return 'opus'
26  if (name.includes('sonnet')) return 'sonnet'
27  if (name.includes('haiku')) return 'haiku'
28  return null
29}
30
31/** What re-sending `tokens` of context costs on a family, in dollars: a cache write when cold, a read when warm. */
32export function nextMessageCost(family: Family, tokens: number, ttl: CacheTtl, isWarm: boolean): number {
33  const price = PRICES[family]
34  const perMillion = isWarm ? price.read : price.input * (ttl === '1h' ? 2 : 1.25)
35  return (tokens / 1_000_000) * perMillion
36}
37
38export function money(usd: number): string {
39  if (usd >= 100) return `$${usd.toFixed(0)}`
40  if (usd >= 0.005 || usd === 0) return `$${usd.toFixed(2)}`
41  return '<$0.01'
42}
43
44export function tokens(n: number): string {
45  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1).replace(/\.0$/, '')}M`
46  if (n >= 1_000) return `${Math.round(n / 1_000)}k`
47  return String(n)
48}
49
50/** `3d 10h`, `11h 31m`, `42m`, `<1m`. */
51export function duration(ms: number): string {
52  const minutes = Math.floor(Math.max(0, ms) / 60_000)
53  if (minutes < 1) return '<1m'
54  const days = Math.floor(minutes / 1440)
55  const hours = Math.floor((minutes % 1440) / 60)
56  const mins = minutes % 60
57  if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`
58  if (hours > 0) return `${hours}h ${String(mins).padStart(2, '0')}m`
59  return `${mins}m`
60}
61
62export const WINDOW_LABELS: Record<string, string> = {
63  five_hour: '5H',
64  seven_day: '7D',
65  spend_limit: 'SPEND',
66}
67
68export function windowLabel(kind: string): string {
69  return WINDOW_LABELS[kind] ?? kind.replace(/_/g, ' ').toUpperCase()
70}
71
72/** Percent of a window still unused, 0 to 100. */
73export function percentLeft(window: Window): number {
74  return Math.max(0, Math.min(100, Math.round(100 - window.percentUsed)))
75}
76
77/** When a window resets, said relative to `now`: `resetting` once it is due. */
78export function resetText(window: Window, now: number): string | null {
79  if (!window.resetsAt) return null
80  const at = Date.parse(window.resetsAt)
81  if (Number.isNaN(at)) return null
82  return at - now < 60_000 ? 'resetting' : duration(at - now)
83}
84
85/** A theme colour for how much of a window is left: the text beside it says the number. */
86export function leftColor(left: number): 'success' | 'warning' | 'error' {
87  if (left >= 50) return 'success'
88  if (left >= 20) return 'warning'
89  return 'error'
90}
91
92/** How many of `of` dots to fill for a share from 0 to 1. */
93export function filled(share: number, of = 10): number {
94  return Math.max(0, Math.min(of, Math.round(share * of)))
95}
96
97export type Warmth = 'warm' | 'cold' | 'unknown'
98
99export function warmth(requestAt: number | null, ttl: CacheTtl, now: number): Warmth {
100  if (requestAt === null) return 'unknown'
101  return now - requestAt < TTL_MS[ttl] ? 'warm' : 'cold'
102}
103
104/** The badge's colours per cache state: a pale fill with dark ink reads on a light or a dark theme. */
105export const BADGE_INK: Record<Warmth, { fill: string; ink: string }> = {
106  warm: { fill: '#9fe0b4', ink: '#0d2d18' },
107  cold: { fill: '#8fd6ea', ink: '#0a2a33' },
108  unknown: { fill: '#c9ccd1', ink: '#23262b' },
109}
110
111export const BADGE_HEIGHT = 18
112
113/** A rounded pill with the label in it, as the desktop draws it. */
114export function badgeSvg(label: string, colours: { fill: string; ink: string }): string {
115  const width = Math.round(14 + label.length * 7.4)
116  const text = label.replace(/&/g, '&amp;').replace(/</g, '&lt;')
117  return (
118    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${BADGE_HEIGHT}" viewBox="0 0 ${width} ${BADGE_HEIGHT}">` +
119    `<rect width="${width}" height="${BADGE_HEIGHT}" rx="${BADGE_HEIGHT / 2}" fill="${colours.fill}"/>` +
120    `<text x="${width / 2}" y="12.5" text-anchor="middle" font-family="-apple-system, 'Inter Tight', 'Helvetica Neue', sans-serif" font-size="10.5" font-weight="700" letter-spacing="0.8" fill="${colours.ink}">${text}</text>` +
121    `</svg>`
122  )
123}
124
125/** Dots in an expanded bar. */
126export const BAR = 28
127
128/**
129 * The cache timeline the expanded view draws, split into dots: while warm, how much of the
130 * TTL has elapsed and what is left; once cold, the warm hour against the time since it ended.
131 */
132export function cacheTimeline(
133  sinceRequest: number | null,
134  ttlMs: number,
135  of = BAR,
136): { warm: number; left: number; cold: number } {
137  if (sinceRequest === null) return { warm: 0, left: of, cold: 0 }
138  if (sinceRequest < ttlMs) {
139    const warm = Math.max(1, filled(sinceRequest / ttlMs, of))
140    return { warm, left: of - warm, cold: 0 }
141  }
142  const warm = Math.max(1, Math.min(of - 1, Math.round((of * ttlMs) / sinceRequest)))
143  return { warm, left: 0, cold: of - warm }
144}
145
146/** How many of a 3×5 grid's dots a cost lights against the dearest cost shown: all for the dearest, none for nothing. */
147export function barDots(cost: number | null, max: number, of = 15): number {
148  if (cost === null || cost <= 0 || max <= 0) return 0
149  return Math.max(1, Math.min(of, Math.round((cost / max) * of)))
150}
151
152/** A 3×5 grid of dots filled from the bottom like a bar, `lit` of them in `ink`, the rest faint. */
153export function gridSvg(lit: number, ink: string): string {
154  const cols = 3
155  const rows = 5
156  const step = 6
157  const r = 1.9
158  const w = cols * step
159  const h = rows * step
160  let dots = ''
161  let n = 0
162  for (let row = rows - 1; row >= 0; row -= 1) {
163    for (let col = 0; col < cols; col += 1) {
164      n += 1
165      const isLit = n <= lit
166      dots += `<circle cx="${col * step + step / 2}" cy="${row * step + step / 2}" r="${r}" fill="${isLit ? ink : '#9aa0a6'}" opacity="${isLit ? 1 : 0.35}"/>`
167    }
168  }
169  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${dots}</svg>`
170}
171
172export const YOU_INK = { fill: '#8fd6ea', ink: '#0a2a33' }
173export const GLYPH_INK = { you: '#3b9ddd', other: '#9aa0a6' }
174
types/index.d.ts 46 lines
1/** One rate-limit window as the last API response reported it. */
2export type Window = {
3  /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
4  kind: string
5  /** 0 to 100, at most one decimal. */
6  percentUsed: number
7  /** ISO 8601; absent when the API gave none. */
8  resetsAt?: string
9}
10
11/** The status line's figures, as the engine last measured them. */
12export type Usage = {
13  /** Input tokens the last response was answered over: what the next request re-sends. */
14  contextTokens: number | null
15  /** The model's context window, in tokens. */
16  window: number
17  /** `contextTokens` over `window`, 0 to 100. */
18  percent: number | null
19  windows: Window[]
20  /** What the session has cost so far, in US dollars; null where the host keeps no ledger. */
21  costUsd: number | null
22}
23
24/** When the main thread last talked to the API, in `$.clock.now()` milliseconds. */
25export type CacheMark = {
26  /** Start of the last main-thread request; the prompt cache's timer runs from it. Null before the first, or after a model switch. */
27  requestAt: number | null
28  /** End of the last main-thread response; the idle timer runs from it. */
29  responseAt: number | null
30  /** The model the main loop runs, as the engine names it. */
31  model: string | null
32}
33
34export type CacheTtl = '5m' | '1h'
35
36declare module 'claude-code' {
37  interface PluginState {
38    'usage-band': {
39      usage: Usage | null
40      mark: CacheMark | null
41      isExpanded: boolean
42      ttl: CacheTtl
43    }
44  }
45}
46