SLOPSHOPPER

usage-bar

One band above the prompt: context fill, 5-hour and 7-day limits, prompt cache with a keep-alive button, usage credits spent, and subagents.

newbandcommandtoastmodelnetwork
v0.2.2Apache-2.0updated 2026-10-23michal-m/claude-mods/usage-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bar
› 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-bar ⎿ usage-bar: Context 97400 / 200000 tokens (49%) ⎿ usage-bar: 5h 31% (resets 1760003600000) ⎿ usage-bar: Agents 0 running, 0 seen ⎿ usage-bar: ⎿ usage-bar: Credits endpoint: (no response) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

usage-bar

One band of tinted pills above the prompt in the Claude desktop app's Code tab:

(◑ 371k ▁▃▂█ ▲ +17.6k) (◷ 5h ━━━╍╍──── 7% ↺ 2h 53m) (▦ 7d ━━━━━━━╍─ 77% ↺ 22h 53m) (⚡ 51m ↻ _Extend_) ($ €0.00) (🤖 1 agent)

At Medium and Wide chat widths everything sits on one row, and the 5h and 7d pills share the spare width, their bars stretching to fill it. When the chat column is too narrow for that (Narrow, or a split view), the band uses two rows: context, 5h and 7d on top, and on a right-aligned second row cache, agents and credits, with credits rightmost. The band switches a little before the row gets tight, and once it is on two rows it only goes back with clear room to spare, so it doesn't jump between layouts.

Point at any pill to see more detail.

  • Context: a circle that fills by quarters with the context window (○ ◔ ◑ ◕ ●; blue, then orange from 50%, red from 75%), the tokens in context, one bar per recent prompt (the tokens it added), and the last prompt's change.
  • 5h / 7d: share of your session and weekly limits used, then the time until reset. Colored as Claude's own usage bars: green under 75%, orange from 75%, red from 90%. The solid part is what you've used. The hatched part is the gap to the time elapsed: grey while you're behind the clock, in the bar's color when you're using faster than time passes. The hover card gives the reset time.
  • Cache: time left before the prompt cache expires (51m). Extend sends one tiny request over the conversation, which reads the cached context back and restarts the countdown. That costs about a tenth of the normal input price and counts against your limits. Five minutes before expiry (one minute for a 5-minute cache) a toast pops up and the pill offers Keep alive or Let it expire. No link while a turn runs, after expiry, or after a compaction: the next message writes a new cache anyway.
  • Credits: extra-usage credits this session spent ($0.00 until it spends any), or disabled when extra usage is off on your account. The hover card links to your balance on claude.ai.
  • Agents: subagents this session, and how many are running. The hover card lists the running ones.

/usage-bar refreshes now and prints the raw figures.

How the figures are read

  • Context and limits come from Claude Code after each response, with no extra requests.
  • Credits come from the account endpoint Claude's /usage screen reads (api.anthropic.com/api/oauth/usage), with your login's own token. This endpoint is unofficial and may change: if it does, the credits pill disappears. The endpoint only knows your whole account's spend, so the mod matches it to this session. Credits are billed at API prices, which is what Claude Code's session cost counts. When the account's credit spend rises, this session claims the rise only up to its own cost from the last 15 minutes not yet claimed. The figure therefore never exceeds what the session cost or what the account was charged. Turning credits on, which reveals earlier spend, adds nothing, and neither does another session spending while this one is idle. Two sessions spending credits at the same moment can split the charge unevenly. /usage-bar reset restarts the count. Readings are taken 5 seconds after each reply and after each subagent finishes (at most every 30 seconds), and otherwise every 3 minutes, shared by all open sessions. If the endpoint rate-limits, every session backs off for 5 minutes, doubling up to an hour.
  • Cache lifetime follows Claude Code's own rules (docs): 1 hour for the main conversation on a subscription within plan usage, 5 minutes once requests draw on usage credits (so Fable on Pro gets 5 minutes). The mod takes the first that applies:
  • FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL or ENABLE_PROMPT_CACHING_1H in the environment (the promptCacheTtl setting is not read).
  • The lifetime the last cache write asked for, when the response splits writes into 1-hour and 5-minute tokens.
  • What hits and misses proved: a hit more than 5 minutes after the previous request means 1 hour, and a miss within the hour means 5 minutes.
  • A guess: 5 minutes when a credits reading has just gone up or a limit is at 100%, otherwise 1 hour.

The countdown starts when the request starts, as the API counts it. The hover card says which source was used.

Requires a Claude subscription login. Desktop app only: in the terminal the mod draws nothing.

Install

/plugin marketplace add michal-m/claude-mods
/plugin install usage-bar@claude-mods
/reload-plugins

Develop

claude plugin validate ./usage-bar
claude plugin test ./usage-bar

To run the working copy in the desktop app, point CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json at the repository root. Every mod folder in it then loads, and saving a file reloads the mod:

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods",
  "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}

Credits

Licensed under Apache-2.0 (see LICENSE). Heavily inspired by Eric Cologni's Token Weather Usage (Apache-2.0). The pill styling, the icons and the hatched limit gauge are adapted from its code (see NOTICE); the rest is written from scratch.

Source 2 files
hooks/register.tsx 1023 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
3
4import type { Bar, Cache, Credits, Limit } from '../types'
5
6// One band above the prompt in the desktop app:
7//   ◑ 412k ▃▄▂█▆ ▲ +6.3k │ 5h ━━━╍── 74% · 2h 24m │ 7d ━━── 65% · 2d 20h │ cache 52 min [Extend] │ credits $1.84 │ 2 running · 7 agents
8// Each pill has a hover card with the detail.
9
10type $ = EngineInterface
11type Els = Elements['desktop']
12
13const bar = atom({ plugin: 'usage-bar', key: 'bar' } as const, { now: 0, readings: [], agentIds: [], running: [] } as Bar)
14
15const MINUTE = 60_000
16const HOUR = 60 * MINUTE
17const DAY = 24 * HOUR
18
19// ---------- Usage credits: the account endpoint the /usage screen reads ----------
20
21const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
22// The balance is only on the settings page: the endpoint does not report it.
23const BALANCE_URL = 'https://claude.ai/settings/usage'
24// The endpoint rate-limits, so all sessions share one reading through $.store:
25// a session fetches only when the shared reading is older than FRESH_MS, and
26// after a 429 every session waits out a backoff (Retry-After when given).
27const FRESH_MS = 3 * MINUTE
28const BACKOFF_MS = { first: 5 * MINUTE, max: HOUR }
29
30type ApiUsage = {
31  extra_usage?: { is_enabled?: boolean; used_credits?: number | null; currency?: string } | null
32  // The same spend as money, in the account's currency.
33  spend?: { used?: Money | null } | null
34}
35type Money = { amount_minor: number; currency: string; exponent: number }
36type Shared = { fetchedAt: number; text: string }
37type Block = { until: number; backoffMs: number }
38
39const toCents = (m: Money) => Math.round(m.amount_minor * 10 ** (2 - m.exponent))
40
41let inFlight: Promise<void> | undefined
42let lastRaw = ''
43
44// Reads the shared reading, fetching a new one once it is older than
45// `maxAgeMs`, unless the endpoint asked everyone to back off. A call made
46// while another runs waits for it, then reads again if it asked for fresher.
47async function refreshCredits($: $, maxAgeMs = FRESH_MS) {
48  if (inFlight) {
49    await inFlight
50    if (maxAgeMs >= FRESH_MS) return
51  }
52  inFlight = fetchCredits($, maxAgeMs).finally(() => (inFlight = undefined))
53  await inFlight
54}
55
56// After a reply or a subagent's last turn: read again 5 seconds later, once,
57// however many finish together, and only if the last reading is 30 s old.
58const SOON_MS = 5_000
59const SOON_MAX_AGE_MS = 30_000
60let isSoon = false
61async function refreshCreditsSoon($: $) {
62  if (isSoon) return
63  isSoon = true
64  try {
65    await $.clock.sleep(SOON_MS)
66  } finally {
67    isSoon = false
68  }
69  await refreshCredits($, SOON_MAX_AGE_MS)
70}
71
72async function fetchCredits($: $, maxAgeMs: number) {
73  try {
74    const now = await $.clock.now()
75    const shared = (await $.store.get('reading')) as Shared | undefined
76    const block = (await $.store.get('block')) as Block | undefined
77    const isFresh = shared !== undefined && now - shared.fetchedAt < maxAgeMs
78    const isBlocked = block !== undefined && now < block.until
79    if (isFresh || isBlocked) {
80      if (shared) await applyCredits($, shared)
81      return
82    }
83    const auth = await $.session.authorize()
84    if (auth?.kind !== 'bearer') return // API key or 3P provider: no credits
85    const res = await $.http.fetch(USAGE_URL, {
86      auth: auth.handle,
87      headers: { 'anthropic-beta': 'oauth-2025-04-20', 'content-type': 'application/json' },
88    })
89    lastRaw = res.text
90    if (res.status === 429) {
91      const backoffMs = Math.min(BACKOFF_MS.max, block ? block.backoffMs * 2 : BACKOFF_MS.first)
92      const retryAfterMs = Number(res.headers['retry-after']) * 1000
93      await $.store.set('block', { until: now + (retryAfterMs > 0 ? retryAfterMs : backoffMs), backoffMs } satisfies Block)
94      if (shared) await applyCredits($, shared)
95      return
96    }
97    if (!res.ok) {
98      $.ui.log(`usage-bar: ${USAGE_URL} answered ${res.status}`, { to: 'debug' })
99      return
100    }
101    const reading: Shared = { fetchedAt: now, text: res.text }
102    await $.store.set('reading', reading)
103    await $.store.delete('block')
104    await applyCredits($, reading)
105  } catch (error) {
106    $.ui.log(`usage-bar: credits refresh failed: ${String(error)}`, { to: 'debug' })
107  }
108}
109
110// Credits are billed at API prices, which is what the session's own cost
111// counts. The account says how much credit spend rose between two readings;
112// this session claims that rise only up to its own cost of the last
113// POOL_MS not yet claimed. So the figure never passes what the session cost
114// nor what the account was charged: turning credits on (the account then
115// shows earlier spend) or another session spending while this one idles adds
116// nothing. Two sessions spending at the same moment can split it unevenly.
117const POOL_MS = 15 * MINUTE
118
119async function applyCredits($: $, reading: Shared) {
120  const body = JSON.parse(reading.text) as ApiUsage
121  const x = body.extra_usage
122  if (!x) return
123  const used = body.spend?.used
124  const now = await $.clock.now()
125  let costCents = 0
126  try {
127    costCents = ((await $.session.usage()).cost?.usd ?? 0) * 100
128  } catch {
129    // No ledger: nothing to match.
130  }
131  await update($, bar, b => {
132    const usedCents = used ? toCents(used) : (x.used_credits ?? 0)
133    const isEnabled = x.is_enabled === true
134    const prev = b.credits
135    // A first reading, or one from before the reset fix: start matching now.
136    if (!prev || prev.pool === undefined) {
137      const credits: Credits = { isEnabled, usedCents, currency: used?.currency ?? x.currency ?? 'USD', sessionCents: 0, costCents, pool: [] }
138      return { ...b, credits }
139    }
140    const added = Math.max(0, costCents - prev.costCents)
141    let pool = [...prev.pool, ...(added > 0 ? [{ at: now, cents: added }] : [])].filter(p => now - p.at < POOL_MS)
142    let sessionCents = prev.sessionCents
143    const rise = prev.isEnabled && isEnabled ? usedCents - prev.usedCents : 0
144    if (rise > 0) {
145      // Claim from the oldest unmatched cost first.
146      let left = rise
147      const kept: typeof pool = []
148      for (const p of pool) {
149        const take = Math.min(left, p.cents)
150        left -= take
151        sessionCents += take
152        if (p.cents - take > 0.001) kept.push({ at: p.at, cents: p.cents - take })
153      }
154      pool = kept
155    }
156    const credits: Credits = { isEnabled, usedCents, currency: used?.currency ?? x.currency ?? 'USD', sessionCents, costCents, pool }
157    return { ...b, credits, creditsMovedAt: rise > 0 ? now : b.creditsMovedAt }
158  })
159}
160
161const money = (cents: number, currency: string) => {
162  const symbol = ({ USD: '$', EUR: '€', GBP: '£' } as Record<string, string>)[currency.toUpperCase()]
163  const amount = (cents / 100).toFixed(2)
164  return symbol ? `${symbol}${amount}` : `${amount} ${currency}`
165}
166
167const creditsSpent = (c: Credits) => Math.round(c.sessionCents)
168
169// ---------- Limits ----------
170
171const SPANS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * DAY }
172
173const fromLimit = (limits: SessionRateLimit[], kind: string): Limit | undefined => {
174  const hit = limits.find(l => l.kind === kind)
175  return hit && { percent: hit.percentUsed, resetsAt: hit.resetsAt }
176}
177
178// Share of the window's time already gone, 0-100; undefined without a reset time.
179const elapsedPercent = (l: Limit, span: number, now: number) => {
180  const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
181  if (Number.isNaN(at) || now === 0) return undefined
182  return Math.max(0, Math.min(100, (1 - (at - now) / span) * 100))
183}
184
185// As Claude's own usage bars: green under 75%, orange from 75%, red from 90%.
186type Tone = 'success' | 'warning' | 'error'
187const toneOf = (used: number): Tone => (used >= 90 ? 'error' : used >= 75 ? 'warning' : 'success')
188// Bar colors, and text colors readable on the tinted pills (the theme's yellow
189// is too bright on them: a deep amber).
190const COLOR: Record<Tone, string> = { success: '#3fa66b', warning: '#d9962b', error: '#d64545' }
191const TEXT_COLOR: Record<Tone, string | undefined> = { success: undefined, warning: '#a8690a', error: '#d64545' }
192const GREY = 'rgba(127,127,127,0.55)'
193const TRACK = 'rgba(127,127,127,0.2)'
194
195// minWidth in cells (about 32 px; a cell is about 10 px), height in CSS pixels.
196const GAUGE = { minWidth: 3, height: 9 }
197const HATCH = { back: 'rgba(127,127,127,0.16)', line: 'rgba(127,127,127,0.6)' }
198// A rounded bar, solid up to the share used; the gap to the time elapsed is
199// hatched: grey after the bar (margin left), in the bar's color when use runs
200// ahead of time. After Token Weather Usage's gauge (Apache-2.0, see NOTICE).
201// Drawn at `width` CSS pixels, the bar's estimated room in its pill (see
202// barWidthPx): the app caps an Svg with no width prop at its slot, so a
203// slightly long estimate is scaled down a little, and the stripes keep the
204// original's size instead of changing with every layout.
205function gaugeSvg(id: string, usedPercent: number, elapsed: number | undefined, tone: Tone, width: number) {
206  const { height } = GAUGE
207  const radius = height / 2
208  const used = Math.max(0, Math.min(100, usedPercent))
209  const time = elapsed === undefined ? used : Math.max(0, Math.min(100, elapsed))
210  const at = (n: number) => ((n / 100) * width).toFixed(1)
211  const color = COLOR[tone]
212  const stripes = (name: string, back: string, backOpacity: number, line: string) =>
213    `<pattern id="${name}" width="4" height="4" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">` +
214    `<rect width="4" height="4" fill="${back}" fill-opacity="${backOpacity}"/><rect width="1.6" height="4" fill="${line}"/></pattern>`
215  const defs =
216    `<defs>${stripes(id + 'm', HATCH.back, 1, HATCH.line)}${stripes(id + 'a', color, 0.35, color)}` +
217    `<clipPath id="${id}t"><rect width="${width}" height="${height}" rx="${radius}"/></clipPath>` +
218    `<clipPath id="${id}b"><rect width="${at(used)}" height="${height}" rx="${radius}"/></clipPath></defs>`
219  let body = `<rect width="${width}" height="${height}" fill="${TRACK}"/>`
220  if (time > used) body += `<rect width="${at(time)}" height="${height}" fill="url(#${id}m)"/>`
221  body += `<g clip-path="url(#${id}b)"><rect width="${at(Math.min(used, time))}" height="${height}" fill="${color}"/>`
222  if (used > time) body += `<rect x="${at(time)}" width="${at(used - time)}" height="${height}" fill="url(#${id}a)"/>`
223  body += '</g>'
224  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">${defs}<g clip-path="url(#${id}t)">${body}</g></svg>`
225}
226
227// "2h 24m", "2d 20h", "8m": time until a reset.
228function left(resetsAt: string | undefined, now: number) {
229  const at = resetsAt ? Date.parse(resetsAt) : NaN
230  if (Number.isNaN(at) || now === 0) return ''
231  const ms = Math.max(0, at - now)
232  const d = Math.floor(ms / DAY)
233  const h = Math.floor((ms % DAY) / HOUR)
234  const m = Math.floor((ms % HOUR) / MINUTE)
235  // Non-breaking spaces: a squeezed pill must not break "2h 14m" over two lines.
236  if (d > 0) return `${d}d\u00a0${h}h`
237  if (h > 0) return `${h}h\u00a0${m}m`
238  return `${m}m`
239}
240
241const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
242const clockTime = (ms: number) => {
243  const at = new Date(ms)
244  return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
245}
246const dayTime = (ms: number) => `${DAYS[new Date(ms).getDay()]} ${clockTime(ms)}`
247
248// ---------- Context ----------
249
250const HISTORY = 6 // readings kept: five deltas for the chart
251const SPARK = { height: 14, bar: 5, gap: 2 }
252
253const contextTone = (percent: number): Tone => (percent >= 75 ? 'error' : percent >= 50 ? 'warning' : 'success')
254
255const pushReading = (readings: number[], tokens: number) => {
256  // The start reading is 0 before the first answer: drop it once a real one arrives.
257  const kept = readings.filter(t => t > 0)
258  // A reopened session reads the same context again: no duplicate.
259  if (tokens > 0 && kept[kept.length - 1] === tokens) return kept
260  return [...kept, tokens].slice(-HISTORY)
261}
262
263const toContext = (c: SessionContextUsage) =>
264  c.window > 0
265    ? { tokens: c.tokens ?? 0, window: c.window, percent: c.percent ?? Math.round(((c.tokens ?? 0) / c.window) * 100) }
266    : undefined
267
268// Tokens each recent prompt added; a compaction counts as 0.
269const deltas = (readings: number[]) => readings.slice(1).map((t, i) => Math.max(0, t - (readings[i] ?? t)))
270
271function sparkSvg(readings: number[], color: string) {
272  const ds = deltas(readings)
273  if (ds.length === 0) return undefined
274  const top = Math.max(...ds, 1)
275  const { height, bar: w, gap } = SPARK
276  const width = ds.length * w + (ds.length - 1) * gap
277  const rects = ds.map((d, i) => {
278    const h = Math.max(1, (d / top) * height)
279    const fill = i === ds.length - 1 ? color : GREY
280    return `<rect x="${i * (w + gap)}" y="${(height - h).toFixed(1)}" width="${w}" height="${h.toFixed(1)}" rx="1.5" fill="${fill}"/>`
281  })
282  return { width, source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${rects.join('')}</svg>` }
283}
284
285function trend(readings: number[]) {
286  if (readings.length < 2) return ''
287  const d = (readings[readings.length - 1] ?? 0) - (readings[readings.length - 2] ?? 0)
288  return d > 0 ? `▲ +${short(d)}` : d < 0 ? `▼ −${short(-d)}` : '='
289}
290
291// 1.2M, 107k, 98.3k, 950
292function short(n: number) {
293  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
294  if (n >= 100_000) return `${Math.round(n / 1_000)}k`
295  if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
296  return String(n)
297}
298
299// ---------- Prompt cache ----------
300
301const TTL = { '5m': 5 * MINUTE, '1h': HOUR }
302
303// The lifetime, first match wins: an environment override; what the last
304// cache write asked for; what hits and misses proved. Otherwise Claude Code's
305// default: 1 hour on a plan within its limits, 5 minutes once requests run on
306// usage credits (a credits reading that just went up, or a limit at 100%).
307function ttlMs(b: Bar) {
308  const known = b.forcedTtl ?? b.writtenTtl ?? b.seenTtl
309  if (known) return TTL[known]
310  if (b.creditsMovedAt !== undefined && b.now - b.creditsMovedAt < 15 * MINUTE) return TTL['5m']
311  const limits = [b.session, b.weekly].filter(Boolean) as Limit[]
312  return limits.length > 0 && limits.every(l => l.percent < 100) ? TTL['1h'] : TTL['5m']
313}
314
315const ttlSource = (b: Bar) =>
316  b.forcedTtl ? 'set by environment' : b.writtenTtl ? 'from the last cache write' : b.seenTtl ? 'seen in traffic' : 'inferred'
317
318const isOn = (v: string | undefined) => /^(1|true|yes|on)$/i.test((v ?? '').trim())
319
320// The overrides Claude Code reads, in its own order (the promptCacheTtl
321// setting is not read here).
322async function envTtl($: $): Promise<'5m' | '1h' | undefined> {
323  try {
324    if (isOn(await $.env.get('FORCE_PROMPT_CACHING_5M'))) return '5m'
325    const ttl = (await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'))?.trim()
326    if (ttl === '5m' || ttl === '1h') return ttl
327    if (isOn(await $.env.get('ENABLE_PROMPT_CACHING_1H'))) return '1h'
328  } catch {
329    // No environment to read: Claude Code's defaults apply.
330  }
331  return undefined
332}
333
334const cacheLeftMs = (b: Bar, c: Cache) => c.lastAt + ttlMs(b) - b.now
335// The reminder comes 5 minutes before expiry, 1 minute for a 5-minute entry.
336const remindBeforeMs = (ttl: number) => Math.min(5 * MINUTE, ttl / 5)
337const isReminding = (b: Bar, c: Cache) => {
338  const ms = cacheLeftMs(b, c)
339  return !c.isDismissed && !c.isCompacted && ms > 0 && ms <= remindBeforeMs(ttlMs(b))
340}
341
342// A response below these shares of cache reads, with a real write, missed.
343const MISS_SHARE = 50
344const MISS_WRITE = 2_000
345
346type StepUsage = {
347  input_tokens?: number
348  cache_read_input_tokens?: number
349  cache_creation_input_tokens?: number
350  model?: string
351  // The API's split of the write by lifetime; not in the mod API's types, read when present.
352  cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number }
353}
354
355// A main-thread request finished: its prompt read or wrote the cache, whose
356// lifetime starts over. A hit more than 5 minutes after the previous request
357// proves the 1-hour lifetime; a miss within the hour says 5 minutes.
358function recordRequest(b: Bar, at: number, u: StepUsage, model: string): Bar {
359  const read = u.cache_read_input_tokens ?? 0
360  const write = u.cache_creation_input_tokens ?? 0
361  const tokens = read + write + (u.input_tokens ?? 0)
362  const hitPercent = tokens > 0 ? Math.round((read / tokens) * 100) : 0
363  const prev = b.cache
364  let seenTtl = b.seenTtl
365  if (prev && !prev.isCompacted) {
366    const gap = at - prev.lastAt
367    const missed = hitPercent < MISS_SHARE && write > MISS_WRITE
368    if (!missed && read > 0 && gap > TTL['5m']) seenTtl = '1h'
369    else if (missed && gap > TTL['5m'] && gap < TTL['1h'] && tokens >= prev.tokens && model === lastModel) seenTtl = '5m'
370  }
371  lastModel = model
372  const split = u.cache_creation
373  const writtenTtl =
374    (split?.ephemeral_1h_input_tokens ?? 0) > 0 ? '1h' : (split?.ephemeral_5m_input_tokens ?? 0) > 0 ? '5m' : b.writtenTtl
375  return { ...b, now: at, seenTtl, writtenTtl, cache: { lastAt: at, tokens, hitPercent } }
376}
377let lastModel = ''
378
379let remindedFor = 0
380// The last keep-alive, for /usage-bar: when, and how much of it the cache served.
381let lastKeepAlive = ''
382
383// One tiny request over the conversation: it reads the cached prefix, which
384// starts the entry's lifetime over. Costs a cache read of the whole context.
385async function keepAlive($: $) {
386  await update($, bar, b => (b.cache ? { ...b, cache: { ...b.cache, keepAlive: 'running' as const } } : b))
387  const startedAt = await $.clock.now()
388  const reply = await $.model.fork({ prompt: 'Reply with just: OK' })
389  const now = await $.clock.now()
390  lastKeepAlive = reply.isAnswered
391    ? `${clockTime(startedAt)}, read ${reply.usage?.cache_read_input_tokens ?? 0} / wrote ${reply.usage?.cache_creation_input_tokens ?? 0} / uncached ${reply.usage?.input_tokens ?? 0} tokens`
392    : `${clockTime(startedAt)}, failed: ${reply.reason}`
393  await update($, bar, b => {
394    if (!b.cache) return b
395    if (!reply.isAnswered) return { ...b, now, cache: { ...b.cache, keepAlive: 'failed' as const } }
396    return { ...b, now, cache: { ...b.cache, lastAt: startedAt, isDismissed: false, keepAlive: undefined } }
397  })
398  if (!reply.isAnswered) $.ui.toast(`Cache keep-alive failed: ${reply.reason}`)
399}
400
401async function letExpire($: $) {
402  await update($, bar, b => (b.cache ? { ...b, cache: { ...b.cache, isDismissed: true } } : b))
403}
404
405// ---------- Agents ----------
406
407async function refreshAgents($: $) {
408  let list
409  try {
410    list = await $.agent.list()
411  } catch {
412    return
413  }
414  const running = list
415    .filter(a => a.status === 'running')
416    .map(a => ({ id: a.id, type: a.type ?? '', description: a.description ?? '' }))
417  const ids = list.map(a => a.id)
418  const b = await read($, bar)
419  const isNewId = ids.some(id => !b.agentIds.includes(id))
420  const isSameRunning = running.map(a => a.id).join() === b.running.map(a => a.id).join()
421  if (!isNewId && isSameRunning) return
422  await update($, bar, b => ({
423    ...b,
424    agentIds: [...b.agentIds, ...ids.filter(id => !b.agentIds.includes(id))],
425    running,
426  }))
427}
428
429// ---------- Ticking ----------
430
431// Each minute: redraw the countdowns, remind before the cache expires, and read
432// the credits again once the shared reading is stale.
433async function tick($: $) {
434  const now = await $.clock.now()
435  const b = await update($, bar, b => ({ ...b, now }))
436  if (b.cache && isReminding(b, b.cache) && remindedFor !== b.cache.lastAt) {
437    remindedFor = b.cache.lastAt
438    $.ui.toast('Prompt cache expires soon. Keep it alive from the band above the prompt.', { timeoutMs: 15_000 })
439  }
440  await refreshCredits($)
441}
442
443const summary = (b: Bar) =>
444  [
445    b.context && `Context ${b.context.tokens} / ${b.context.window} tokens (${b.context.percent}%)`,
446    b.session && `5h ${b.session.percent}% (resets ${b.session.resetsAt ?? '?'})`,
447    b.weekly && `7d ${b.weekly.percent}% (resets ${b.weekly.resetsAt ?? '?'})`,
448    b.cache && `Cache ${Math.round(cacheLeftMs(b, b.cache) / 1000)}s left of ${ttlMs(b) / MINUTE} min (${ttlSource(b)}), ${b.cache.hitPercent}% hit`,
449    b.credits && `Credits ${b.credits.isEnabled ? money(creditsSpent(b.credits), b.credits.currency) : 'off'} this session`,
450    `Agents ${b.running.length} running, ${b.agentIds.length} seen`,
451    lastKeepAlive && `Last keep-alive ${lastKeepAlive}`,
452    lastLayout && `Band ${lastLayout}`,
453  ]
454    .filter(Boolean)
455    .join('\n')
456
457// ---------- Hooks ----------
458
459export const register: Register = on => {
460  on('session.start', async ($, e, next) => {
461    await $.command.register({
462      name: 'usage-bar',
463      description: 'Refresh the usage bar now and show its raw figures',
464    })
465    const usage = await $.session.usage()
466    const now = await $.clock.now()
467    const forcedTtl = await envTtl($)
468    await update($, bar, b => {
469      const context = toContext(usage.context) ?? b.context
470      return {
471        ...b,
472        now,
473        forcedTtl,
474        context,
475        readings: context ? pushReading(b.readings, context.tokens) : b.readings,
476        session: fromLimit(usage.rateLimits, 'five_hour') ?? b.session,
477        weekly: fromLimit(usage.rateLimits, 'seven_day') ?? b.weekly,
478      }
479    })
480    void refreshCredits($)
481    void refreshAgents($)
482    $.clock.every(MINUTE, () => void tick($))
483    $.clock.every(10_000, () => void refreshAgents($))
484    return next(e)
485  })
486
487  // Pushed by the engine after each response and whenever a window moves a point.
488  on('session.measure', async ($, e, next) => {
489    if (e.changed.includes('rateLimits') || e.changed.includes('context')) {
490      const now = await $.clock.now()
491      await update($, bar, b => ({
492        ...b,
493        now,
494        context: toContext(e.context) ?? b.context,
495        session: fromLimit(e.rateLimits, 'five_hour') ?? b.session,
496        weekly: fromLimit(e.rateLimits, 'seven_day') ?? b.weekly,
497      }))
498    }
499    return next(e)
500  })
501
502  // Each main-thread request: how much of its prompt the cache served, and
503  // when the entry's lifetime starts over. Subagents have their own caches.
504  // The lifetime counts from the start of the request that read or wrote the entry.
505  on('turn.step', async function* ($, e, next) {
506    const startedAt = await $.clock.now()
507    const result = yield* next(e)
508    if (!e.agentId && result?.usage) {
509      const usage = result.usage
510      await update($, bar, b => recordRequest(b, startedAt, usage, usage.model || e.model))
511    }
512    void refreshAgents($)
513    return result
514  })
515
516  // One context reading per main-thread prompt; credits move only through the
517  // endpoint, so read them again (if stale).
518  on('turn.complete', async ($, e, next) => {
519    const result = await next(e)
520    // A subagent finished its job: its spend reaches the account too.
521    if (e.agentId) {
522      void refreshAgents($)
523      void refreshCreditsSoon($)
524      return result
525    }
526    try {
527      const usage = await $.session.usage()
528      const context = toContext(usage.context)
529      if (context) await update($, bar, b => ({ ...b, context, readings: pushReading(b.readings, context.tokens) }))
530    } catch {
531      // No reading this turn: the band keeps the previous one.
532    }
533    void refreshCreditsSoon($)
534    return result
535  })
536
537  // A compaction: the context drops now, and the next request writes a new cache.
538  on('session.compact', async ($, e, next) => {
539    const result = await next(e)
540    if (e.agentId || e.trigger === 'precompute' || !result || 'skip' in result) return result
541    try {
542      const context = (result.tokensAfter ?? 0) > 0 ? undefined : toContext((await $.session.usage()).context)
543      await update($, bar, b => {
544        const tokens = result.tokensAfter ?? context?.tokens ?? 0
545        const window = b.context?.window ?? context?.window ?? 0
546        const dropped: Bar = { ...b, cache: b.cache && { ...b.cache, isCompacted: true, keepAlive: undefined } }
547        if (window === 0) return dropped
548        return {
549          ...dropped,
550          context: { tokens, window, percent: Math.round((tokens / window) * 100) },
551          readings: pushReading(b.readings, tokens),
552        }
553      })
554    } catch {
555      // The band catches up at the end of the next prompt.
556    }
557    return result
558  })
559
560  on('command.run', { command: 'usage-bar' }, async ($, e) => {
561    await refreshCredits($, 0)
562    // `/usage-bar reset`: count credits from now on.
563    if (e.args.trim() === 'reset') await update($, bar, b => (b.credits ? { ...b, credits: { ...b.credits, sessionCents: 0, pool: [] } } : b))
564    await refreshAgents($)
565    const now = await $.clock.now()
566    const b = await update($, bar, b => ({ ...b, now }))
567    return { text: `${summary(b)}\n\nCredits endpoint: ${lastRaw || '(no response)'}` }
568  })
569
570  // Desktop app only: the band above the prompt.
571  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
572    if (e.props.hasSurvey || e.surface !== 'desktop') return next(e)
573    const b = await read($, bar)
574    if (!b.context && !b.session && !b.weekly) return next(e)
575    const els = $.ui.resolve(e)
576    const line = drawLine($, els, b, e.props.isWorking, e.props.bodyColumns)
577    // Mods placed after us draw below our line.
578    const below = await next(e)
579    return isBlank(below) ? line : <els.Box flexDirection="column">{line}{below}</els.Box>
580  })
581}
582
583function isBlank(node: unknown) {
584  if (node === null || node === undefined || node === false) return true
585  const n = node as { props?: { children?: unknown } }
586  const children = n.props?.children
587  return Array.isArray(children) ? children.every(isBlank) : children === undefined
588}
589
590// ---------- Drawing ----------
591// Pills, icons and tints adapted from Token Weather Usage by Eric Cologni
592// (https://github.com/augiefra/claude-mods, Apache-2.0); see NOTICE.
593
594// Desktop pills: a light tint and a slightly stronger outline per pill.
595const TINTS: Record<string, [string, string]> = {
596  context: ['rgba(47,104,192,0.10)', 'rgba(47,104,192,0.28)'],
597  five_hour: ['rgba(63,166,107,0.13)', 'rgba(63,166,107,0.32)'],
598  seven_day: ['rgba(140,100,210,0.13)', 'rgba(140,100,210,0.32)'],
599  cache: ['rgba(27,161,196,0.11)', 'rgba(27,161,196,0.30)'],
600  cacheSoon: ['rgba(217,150,43,0.14)', 'rgba(217,150,43,0.36)'],
601  cacheGone: ['rgba(214,69,69,0.12)', 'rgba(214,69,69,0.36)'],
602  credits: ['rgba(184,140,40,0.13)', 'rgba(184,140,40,0.34)'],
603  agents: ['rgba(196,80,127,0.11)', 'rgba(196,80,127,0.32)'],
604}
605
606// Icon colors: deeper than the pill's tint, readable on light and dark backgrounds.
607const ICON_COLOR = {
608  context: '#2f68c0',
609  five_hour: '#3a9a62',
610  seven_day: '#8a5fd0',
611  cache: '#1b9cbe',
612  cacheSoon: '#d9962b',
613  cacheGone: '#d64545',
614  credits: '#b8892a',
615  agents: '#c4507f',
616}
617
618const ICON_SIZE = 16
619const SMALL_ICON = 14
620
621// Small outlined icons, drawn on a 24-unit grid in the pill's color.
622const ICONS = {
623  gauge: (c: string) =>
624    `<g transform="translate(0 0.5)"><path d="M3.6 18.5a9.5 9.5 0 1 1 16.8 0" fill="none" stroke="${c}" stroke-width="2.2" stroke-linecap="round"/><path d="M12 14.5l4.3-4.6" fill="none" stroke="${c}" stroke-width="2.2" stroke-linecap="round"/><circle cx="12" cy="14.5" r="1.7" fill="${c}"/></g>`,
625  calendar: (c: string) =>
626    `<rect x="3" y="4.5" width="18" height="17" rx="3" fill="none" stroke="${c}" stroke-width="2"/><path d="M3 9.5h18M8 2.5v4M16 2.5v4" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"/><text x="12" y="19.2" font-size="8.5" font-weight="700" font-family="-apple-system,Helvetica,Arial,sans-serif" text-anchor="middle" fill="${c}">7</text>`,
627  // A clock turning back: the time left before the window starts over.
628  clock: (c: string) =>
629    `<path d="M4.2 13A8 8 0 1 0 6.6 6.2" fill="none" stroke="${c}" stroke-width="2.1" stroke-linecap="round"/><path d="M3.4 3.6v4.2h4.2" fill="none" stroke="${c}" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 8v4.6l3 1.8" fill="none" stroke="${c}" stroke-width="2.1" stroke-linecap="round"/>`,
630  // A circling arrow: start the countdown over.
631  renew: (c: string) =>
632    `<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3" fill="none" stroke="${c}" stroke-width="2.1" stroke-linecap="round"/><path d="M18.4 2.8v4.4H14" fill="none" stroke="${c}" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/>`,
633  // An hourglass: let the time run out.
634  hourglass: (c: string) =>
635    `<path d="M6 3h12M6 21h12M7.5 3v2.5c0 2.4 4.5 4.3 4.5 6.5s-4.5 4.1-4.5 6.5V21M16.5 3v2.5c0 2.4-4.5 4.3-4.5 6.5s4.5 4.1 4.5 6.5V21" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
636  bolt: (c: string) =>
637    `<path d="M13.2 2 4 13.6h7.2L10.4 22l9.2-11.6h-7.2z" fill="${c}" fill-opacity="0.18" stroke="${c}" stroke-width="2" stroke-linejoin="round"/>`,
638  coin: (c: string) =>
639    `<circle cx="12" cy="12" r="9.5" fill="${c}" fill-opacity="0.16" stroke="${c}" stroke-width="2"/><path d="M15 8.8c-.5-1-1.6-1.6-3-1.6-1.7 0-3 .9-3 2.2s1.3 1.8 3 2.1 3 .9 3 2.2-1.3 2.3-3 2.3c-1.4 0-2.5-.6-3.1-1.6M12 5.6v1.6M12 16.8v1.6" fill="none" stroke="${c}" stroke-width="1.9" stroke-linecap="round"/>`,
640  agents: (c: string) =>
641    `<rect x="4" y="7.5" width="16" height="12.5" rx="3.5" fill="${c}" fill-opacity="0.14" stroke="${c}" stroke-width="2"/><path d="M12 7.5V4M2 12.5v3M22 12.5v3" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="3.2" r="1.3" fill="${c}"/><circle cx="9" cy="13" r="1.5" fill="${c}"/><circle cx="15" cy="13" r="1.5" fill="${c}"/><path d="M9.5 16.8h5" fill="none" stroke="${c}" stroke-width="1.8" stroke-linecap="round"/>`,
642}
643
644function icon({ Svg }: Els, key: string, body: string, alt: string, size = ICON_SIZE) {
645  const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">${body}</svg>`
646  return <Svg key={key} source={source} alt={alt} width={size} height={size} />
647}
648
649// The context icon: ○ ◔ ◑ ◕ ● drawn as a pie, filled by quarters.
650function fillIcon(percent: number, color: string) {
651  const quarters = percent < 12.5 ? 0 : percent < 37.5 ? 1 : percent < 62.5 ? 2 : percent < 87.5 ? 3 : 4
652  const ring = `<circle cx="12" cy="12" r="9" fill="none" stroke="${color}" stroke-width="2"/>`
653  if (quarters === 0) return ring
654  if (quarters === 4) return `${ring}<circle cx="12" cy="12" r="6.5" fill="${color}"/>`
655  // A wedge from 12 o'clock, clockwise.
656  const r = 6.5
657  const angle = (quarters / 4) * 2 * Math.PI
658  const x = 12 + r * Math.sin(angle)
659  const y = 12 - r * Math.cos(angle)
660  const wedge = `<path d="M12 12V${12 - r}A${r} ${r} 0 ${quarters > 2 ? 1 : 0} 1 ${x.toFixed(2)} ${y.toFixed(2)}z" fill="${color}"/>`
661  return `${ring}${wedge}`
662}
663
664const divider = ({ Text }: Els, key: string) => (
665  <Text key={key} dimColor>
666    │
667  </Text>
668)
669
670// A card under the pointer: hidden until the pill is hovered, drawn above the
671// band in the theme's own colors. An indented line is a detail: dim.
672// No key: a keyed Box would scope its own hover, and a hidden one is never hovered.
673function hoverCard({ Box, Text }: Els, lines: (string | false | undefined)[], extra?: unknown) {
674  const shown = lines.filter((l): l is string => typeof l === 'string')
675  return (
676    <Box
677      position="absolute"
678      bottom={1}
679      left={0}
680      display="none"
681      hover={{ display: 'flex' }}
682      flexDirection="column"
683      paddingX={1}
684      borderStyle="round"
685      borderColor="subtle"
686      backgroundColor="background"
687    >
688      {shown.map((line, i) => (
689        <Text key={`l${i}`} dimColor={line.startsWith('  ')}>
690          {line}
691        </Text>
692      ))}
693      {extra as never}
694    </Box>
695  )
696}
697
698// A tinted, outlined pill that never shrinks (squeezed, the app breaks "24 %"
699// over two lines). The app rounds a Box only through its border, whose padding
700// would make the band taller than the prompt box: paddingY takes it back.
701// A keyed pill is a hover scope: its card shows while the pointer is over it.
702// The limit pills grow (`grow`) to share the row's spare width, their bars
703// stretching with them, so the band spans the full width whichever pills show.
704// No pill shrinks: a row too narrow wraps (see drawLine) rather than squeeze.
705function pill({ Box }: Els, key: string, tint: [string, string], parts: unknown[], card: unknown, grow = false) {
706  return (
707    <Box
708      key={key}
709      flexDirection="row"
710      alignItems="center"
711      columnGap={0.5}
712      flexGrow={grow ? 1 : 0}
713      flexShrink={0}
714      paddingX={1}
715      paddingY={0}
716      borderStyle="round"
717      borderColor={tint[1]}
718      backgroundColor={tint[0]}
719    >
720      {parts.filter(Boolean) as never}
721      {card as never}
722    </Box>
723  )
724}
725
726// Whether one row fits: the band's width comes in cells of the app's code
727// font (about 10 px each); what the pills need is estimated in CSS pixels,
728// from sizes measured in the app. Kept for /usage-bar, to tune.
729let lastLayout = ''
730
731const PX = {
732  cell: 10,
733  char: 7.5, // the app's proportional text, on average (digits run wider)
734  icon: ICON_SIZE,
735  smallIcon: SMALL_ICON,
736  gap: 5, // columnGap 0.5 inside a pill
737  pill: 22, // border and padding, both sides
738  between: 10, // columnGap 1 between pills
739  band: 20, // the band's own padding
740  sparkBar: SPARK.bar + SPARK.gap,
741}
742// Spare room kept on a one-row band (split view showed the estimate runs short).
743const MARGIN_CELLS = 6
744// Once on two rows, one row comes back only with this much more room: no bouncing.
745const HYSTERESIS_CELLS = 4
746let wasTwoRows = false
747const textPx = (text: string) => text.length * PX.char
748const pillPx = (parts: number[]) => PX.pill + parts.reduce((a, n) => a + n, 0) + (parts.length - 1) * PX.gap
749
750// Each changing value counts at its widest form, so a ticking countdown or a
751// growing token count never moves the threshold. Parts not drawn count as
752// nothing: reserving them too kept a band with plenty of room on two rows.
753const widest = (text: string, template: string) => textPx(text.length > template.length ? text : template)
754
755function oneRowCells(b: Bar, isWorking: boolean) {
756  const widths: number[] = []
757  if (b.context) {
758    // The sparkline and trend appear over the first prompts, then stay.
759    const ds = deltas(b.readings).length
760    const t = trend(b.readings)
761    widths.push(pillPx([PX.icon, widest(short(b.context.tokens), '999.9k'), ...(ds > 0 ? [ds * PX.sparkBar] : []), ...(t ? [widest(t, '▲ +99.9k')] : [])]))
762  }
763  for (const l of [b.session, b.weekly]) {
764    if (!l) continue
765    widths.push(pillPx([PX.icon, textPx('5h'), GAUGE.minWidth * PX.cell, textPx('100%'), PX.smallIcon, textPx('22h\u00a059m')]))
766  }
767  if (b.cache) {
768    const v = cacheView(b, b.cache, isWorking)
769    // Extend counts even while a turn hides it, so a turn never flips the
770    // layout; the reminder's two buttons count only while they show.
771    widths.push(pillPx([PX.icon, widest(v.status, '60m'), PX.smallIcon, textPx(v.buttonLabel), ...(v.isAsking ? [textPx('│'), PX.smallIcon, textPx('Let it expire')] : [])]))
772  }
773  if (b.credits) widths.push(pillPx([PX.icon, widest(creditsText(b.credits), '€000.00')]))
774  if (b.agentIds.length > 0) widths.push(pillPx([PX.icon, widest(agentsText(b), '9 running · 99')]))
775  const px = widths.reduce((a, n) => a + n, 0) + (widths.length - 1) * PX.between + PX.band
776  return Math.ceil(px / PX.cell) + MARGIN_CELLS
777}
778
779// The room each limit bar gets, in CSS pixels: the row's width less what its
780// pills hold (current values, bars at nothing), shared by the limit pills.
781// A little long on purpose: the app scales a long bar down to its slot, while
782// a short one would leave a gap before the percentage.
783const BAR_SLACK = 1.05
784function barWidthPx(b: Bar, isWorking: boolean, columns: number, isOneRow: boolean) {
785  const widths: number[] = []
786  const limits = [b.session, b.weekly].filter(Boolean) as Limit[]
787  if (b.context) {
788    const ds = deltas(b.readings).length
789    const t = trend(b.readings)
790    widths.push(pillPx([PX.icon, textPx(short(b.context.tokens)), ...(ds > 0 ? [ds * PX.sparkBar] : []), ...(t ? [textPx(t)] : [])]))
791  }
792  for (const l of limits) {
793    const rest = left(l.resetsAt, b.now)
794    widths.push(pillPx([PX.icon, textPx('5h'), 0, textPx(`${Math.round(l.percent)}%`), ...(rest ? [PX.smallIcon, textPx(rest)] : [])]))
795  }
796  if (isOneRow) {
797    if (b.cache) {
798      const v = cacheView(b, b.cache, isWorking)
799      widths.push(pillPx([PX.icon, textPx(v.status), ...(v.canExtend ? [PX.smallIcon, textPx(v.buttonLabel)] : []), ...(v.isAsking ? [textPx('│'), PX.smallIcon, textPx('Let it expire')] : [])]))
800    }
801    if (b.credits) widths.push(pillPx([PX.icon, textPx(creditsText(b.credits))]))
802    if (b.agentIds.length > 0) widths.push(pillPx([PX.icon, textPx(agentsText(b))]))
803  }
804  const spare = columns * PX.cell - PX.band - widths.reduce((a, n) => a + n, 0) - (widths.length - 1) * PX.between
805  return Math.max(GAUGE.minWidth * PX.cell, Math.round((spare / Math.max(1, limits.length)) * BAR_SLACK))
806}
807
808function drawLine($: $, els: Els, b: Bar, isWorking: boolean, columns: number) {
809  const { Box } = els
810  const needs = oneRowCells(b, isWorking)
811  const isOneRow = columns >= needs + (wasTwoRows ? HYSTERESIS_CELLS : 0)
812  wasTwoRows = !isOneRow
813  const barPx = barWidthPx(b, isWorking, columns, isOneRow)
814  const usage = [
815    b.context && contextPill(els, b),
816    b.session && limitPill(els, 'five_hour', '5h', b.session, b.now, barPx),
817    b.weekly && limitPill(els, 'seven_day', '7d', b.weekly, b.now, barPx),
818  ].filter(Boolean)
819  const cache = b.cache && cachePill($, els, b, b.cache, isWorking)
820  const credits = b.credits && creditsPill(els, b.credits)
821  const agents = b.agentIds.length > 0 && agentsPill(els, b)
822  lastLayout = `${columns} columns, one row needs ~${needs}: ${isOneRow ? 'one row' : 'two rows'}, bars ${barPx}px`
823  // One row: the limit pills take the spare width, or give it back, their bars
824  // stretching or shrinking down to a minimum.
825  if (isOneRow)
826    return (
827      // Wrap only as a fallback, when the estimate let through a row that
828      // does not fit: pills move down rather than spill.
829      <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} rowGap={1} paddingX={1}>
830        {[...usage, cache, credits, agents].filter(Boolean) as never}
831      </Box>
832    )
833  // Too narrow (the narrow chat column): usage on the first row; cache,
834  // agents and credits on the second, right-aligned at their own size.
835  const second = [cache, agents, credits].filter(Boolean)
836  return (
837    <Box flexDirection="column" rowGap={1} paddingX={1}>
838      <Box key="usage-row" flexDirection="row" flexWrap="nowrap" alignItems="center" columnGap={1}>
839        {usage as never}
840      </Box>
841      {second.length > 0 && (
842        <Box key="extras-row" flexDirection="row" flexWrap="nowrap" alignItems="center" justifyContent="flex-end" columnGap={1}>
843          {second as never}
844        </Box>
845      )}
846    </Box>
847  )
848}
849
850function contextPill(els: Els, b: Bar) {
851  const { Text, Svg } = els
852  const c = b.context!
853  const tone = contextTone(c.percent)
854  const color = tone === 'success' ? ICON_COLOR.context : COLOR[tone]
855  const spark = sparkSvg(b.readings, color)
856  const t = trend(b.readings)
857  const parts = [
858    icon(els, 'icon', fillIcon(c.percent, color), `Context ${c.percent}% full`),
859    <Text key="tokens" bold>
860      {short(c.tokens)}
861    </Text>,
862    spark && <Svg key="spark" source={spark.source} alt="Tokens added by recent prompts" width={spark.width} height={SPARK.height} />,
863    t && (
864      <Text key="trend" dimColor>
865        {t}
866      </Text>
867    ),
868  ]
869  const card = hoverCard(els, [
870    `Context ${c.percent}% of ${short(c.window)}`,
871    `  ${c.tokens.toLocaleString('en-US')} tokens`,
872    deltas(b.readings).length > 0 && `  Last prompts: ${deltas(b.readings).map(d => `+${short(d)}`).join(', ')}`,
873  ])
874  return pill(els, 'context', TINTS.context!, parts, card)
875}
876
877function limitPill(els: Els, kind: 'five_hour' | 'seven_day', label: string, l: Limit, now: number, barPx: number) {
878  const { Box, Text, Svg } = els
879  const elapsed = elapsedPercent(l, SPANS[kind] ?? DAY, now)
880  const tone = toneOf(l.percent)
881  const rest = left(l.resetsAt, now)
882  const color = ICON_COLOR[kind]
883  const parts = [
884    icon(els, 'icon', kind === 'five_hour' ? ICONS.gauge(color) : ICONS.calendar(color), kind === 'five_hour' ? 'Session limit' : 'Weekly limit'),
885    <Text key="label">{label}</Text>,
886    // The bar starts at its minimum and grows into the pill's share of the
887    // spare width; nothing in a pill shrinks, so no pill gets narrower than
888    // its content and its reset time never spills out. No key: a keyed Box is
889    // a hover scope, and the pill's own must hold.
890    <Box width={GAUGE.minWidth} flexGrow={1} flexShrink={0}>
891      <Svg source={gaugeSvg(kind, l.percent, elapsed, tone, barPx)} alt={`${label} ${l.percent}% used`} height={GAUGE.height} />
892    </Box>,
893    <Text key="value" bold color={TEXT_COLOR[tone]}>
894      {Math.round(l.percent)}%
895    </Text>,
896    rest && icon(els, 'clock', ICONS.clock(color), 'Resets in', SMALL_ICON),
897    rest && (
898      <Text key="left" dimColor>
899        {rest}
900      </Text>
901    ),
902  ]
903  const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
904  const title = kind === 'five_hour' ? 'Session limit (5 hours)' : 'Weekly limit (7 days)'
905  const card = hoverCard(els, [
906    `${title}: ${l.percent}% used`,
907    elapsed !== undefined && `  ${Math.round(elapsed)}% of the window has passed`,
908    !Number.isNaN(at) && `  Resets ${kind === 'five_hour' ? `at ${clockTime(at)}` : dayTime(at)}`,
909  ])
910  return pill(els, kind, TINTS[kind]!, parts, card, true)
911}
912
913// A pressable link, not a Button: a desktop draws every Button as its native
914// button, which on Windows stands taller than the pill. A Markdown link is the
915// one other thing a press reaches; it draws in the app's link color, and its
916// address (a reserved .invalid host) is answered here, never opened.
917function action({ Markdown }: Els, key: string, label: string, onPress: () => void) {
918  const href = `https://usage-bar.invalid/${key}`
919  return <Markdown key={key} text={`[${label}](${href})`} pressableLinks={[href]} onLinkPress={() => onPress()} />
920}
921
922// What the cache pill shows; shared with the width estimate.
923function cacheView(b: Bar, c: Cache, isWorking: boolean) {
924  const ms = cacheLeftMs(b, c)
925  const minutes = Math.ceil(ms / MINUTE)
926  const isExpired = ms <= 0 && !c.isCompacted
927  const isAsking = !isWorking && c.keepAlive !== 'running' && isReminding(b, c)
928  const canExtend = !isWorking && !isExpired && !c.isCompacted && c.keepAlive !== 'running'
929  const status = isWorking
930    ? 'in use'
931    : c.isCompacted
932      ? 'rebuilds next message'
933      : c.keepAlive === 'running'
934        ? 'extending…'
935        : isExpired
936          ? 'expired'
937          : minutes > 1
938            ? `${minutes}m`
939            : '<\u00a01m'
940  const buttonLabel = isAsking ? 'Keep\u00a0alive' : 'Extend'
941  return { isExpired, isAsking, canExtend, status, buttonLabel }
942}
943
944function cachePill($: $, els: Els, b: Bar, c: Cache, isWorking: boolean) {
945  const { Text } = els
946  const ttl = ttlMs(b)
947  const { isExpired, isAsking, canExtend, status, buttonLabel } = cacheView(b, c, isWorking)
948  const look = isExpired ? 'cacheGone' : isAsking ? 'cacheSoon' : 'cache'
949  const tone: Tone = isExpired ? 'error' : isAsking ? 'warning' : 'success'
950  const parts = [
951    icon(els, 'icon', ICONS.bolt(ICON_COLOR[look]), 'Prompt cache'),
952    <Text key="status" bold={!c.isCompacted} dimColor={c.isCompacted} color={TEXT_COLOR[tone]}>
953      {status}
954    </Text>,
955    c.keepAlive === 'failed' && divider(els, 'sep'),
956    c.keepAlive === 'failed' && (
957      <Text key="failed" dimColor>
958        extend failed
959      </Text>
960    ),
961    // Each icon beside its link, not in it: only the words are underlined.
962    canExtend && icon(els, 'extend-icon', ICONS.renew(ICON_COLOR[look]), 'Restart the countdown', SMALL_ICON),
963    canExtend && action(els, 'extend', buttonLabel, () => keepAlive($)),
964    isAsking && divider(els, 'or'),
965    isAsking && icon(els, 'let-expire-icon', ICONS.hourglass(ICON_COLOR[look]), 'Let it run out', SMALL_ICON),
966    isAsking && action(els, 'let-expire', 'Let it expire', () => letExpire($)),
967  ]
968  const card = hoverCard(els, [
969    isExpired ? `Prompt cache expired at ${clockTime(c.lastAt + ttl)}` : `Prompt cache expires at ${clockTime(c.lastAt + ttl)}`,
970    `  Lifetime ${ttl === HOUR ? '1 hour' : '5 minutes'} (${ttlSource(b)})`,
971    `  Last request read ${c.hitPercent}% of ${short(c.tokens)} tokens from the cache`,
972    canExtend && `  Extend sends one tiny request that reads ${short(c.tokens)} tokens from the cache`,
973    canExtend && '  (about a tenth of the input price, counted against your limits)',
974  ])
975  return pill(els, 'cache', TINTS[look]!, parts, card)
976}
977
978// What the credits pill reads: the spend since the session started, or
979// "disabled" while extra usage is off on the account.
980const creditsText = (c: Credits) => (c.isEnabled ? money(creditsSpent(c), c.currency) : 'disabled')
981
982function creditsPill(els: Els, c: Credits) {
983  const { Text, Link } = els
984  const parts = [
985    icon(els, 'icon', ICONS.coin(ICON_COLOR.credits), 'Usage credits'),
986    <Text key="value" bold={c.isEnabled} dimColor={!c.isEnabled}>
987      {creditsText(c)}
988    </Text>,
989  ]
990  const lines = c.isEnabled
991    ? [
992        'Usage credits this session spent',
993        "  Your account's credit spend, matched to this session's own cost",
994        '  Read from your account 5 seconds after each reply',
995      ]
996    : ['Usage credits are turned off for your account', '  Turn them on in your usage settings']
997  const card = hoverCard(els, lines, <Link key="balance" href={BALANCE_URL} label="Balance" />)
998  return pill(els, 'credits', TINTS.credits!, parts, card)
999}
1000
1001function agentsText(b: Bar) {
1002  const total = b.agentIds.length
1003  const running = b.running.length
1004  return running > 0 ? `${running} running · ${total}` : total === 1 ? '1 agent' : `${total} agents`
1005}
1006
1007function agentsPill(els: Els, b: Bar) {
1008  const { Text } = els
1009  const total = b.agentIds.length
1010  const text = agentsText(b)
1011  const parts = [
1012    icon(els, 'icon', ICONS.agents(ICON_COLOR.agents), 'Subagents'),
1013    <Text key="value" bold>
1014      {text}
1015    </Text>,
1016  ]
1017  const card = hoverCard(els, [
1018    `${total} subagent${total === 1 ? '' : 's'} this session`,
1019    ...b.running.map(a => `  ${a.type}${a.description ? ` · ${a.description}` : ''}`),
1020  ])
1021  return pill(els, 'agents', TINTS.agents!, parts, card)
1022}
1023
types/index.d.ts 70 lines
1/** A rate-limit window: share used and when it resets (ISO 8601). */
2export type Limit = { percent: number; resetsAt?: string }
3
4/** The live context window. */
5export type Context = { tokens: number; window: number; percent: number }
6
7/** Session cost (at API prices, as /cost totals it) not yet matched to account credit spend. */
8export type CostEntry = { at: number; cents: number }
9
10/** Extra-usage credits, in the account's minor units (cents). */
11export type Credits = {
12  isEnabled: boolean
13  /** The account's credit spend at the last reading. */
14  usedCents: number
15  currency: string
16  /** This session's share: account spend increases matched to its own cost. */
17  sessionCents: number
18  /** The session's cost at the last reading, in cents. */
19  costCents: number
20  /** Cost since then not yet matched, newest last; entries expire. */
21  pool: CostEntry[]
22}
23
24/** The main thread's prompt cache, as far as the mod can tell. */
25export type Cache = {
26  /** When the last main-thread request that read or wrote it finished. */
27  lastAt: number
28  /** Prompt tokens of that request: what a keep-alive reads back. */
29  tokens: number
30  /** Share of that request's prompt read from the cache, 0-100. */
31  hitPercent: number
32  /** The person chose to let this entry (this lastAt) expire. */
33  isDismissed?: boolean
34  /** A keep-alive request is running, or the last one failed. */
35  keepAlive?: 'running' | 'failed'
36  /** A compaction dropped the cache; the next request writes a new one. */
37  isCompacted?: boolean
38}
39
40export type RunningAgent = { id: string; type: string; description: string }
41
42export type Bar = {
43  /** Clock reading the drawing counts down from; bumped every minute. */
44  now: number
45  context?: Context
46  /** Context tokens after each recent prompt, oldest first. */
47  readings: number[]
48  session?: Limit
49  weekly?: Limit
50  credits?: Credits
51  cache?: Cache
52  /** Cache lifetime set by environment (FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, ENABLE_PROMPT_CACHING_1H). */
53  forcedTtl?: '5m' | '1h'
54  /** Lifetime the last cache write asked for, when the response split it (ephemeral_1h / ephemeral_5m). */
55  writtenTtl?: '5m' | '1h'
56  /** Cache lifetime learned from hits and misses, overriding the inferred one. */
57  seenTtl?: '5m' | '1h'
58  /** When the credits reading last went up: those requests ran on credits. */
59  creditsMovedAt?: number
60  /** Every subagent id seen this session. */
61  agentIds: string[]
62  running: RunningAgent[]
63}
64
65declare module 'claude-code' {
66  interface PluginState {
67    'usage-bar': { bar: Bar }
68  }
69}
70