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.

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.
○ ◔ ◑ ◕ ●; 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.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.$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./usage-bar refreshes now and prints the raw figures.
/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.FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL or ENABLE_PROMPT_CACHING_1H in the environment (the promptCacheTtl setting is not read).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.
/plugin marketplace add michal-m/claude-mods
/plugin install usage-bar@claude-mods
/reload-plugins
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"
}
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.
hooks/register.tsx 1023 lines1import { 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}
1023types/index.d.ts 70 lines1/** 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