A band above the prompt with rate limits, tokens, speed, cache, cost and context fill, in light and dark themes

Mods for Claude Code, distributed as a plugin marketplace:
And the band for other agents: Grok Build (a status line script) and Codex (built-in status line settings).
A band above the prompt, in the desktop app and the CLI, that updates after every turn:
<img alt="The band: 5h and 7d limits, tokens in and out, speed, cache, cost and context" src="docs/screenshots/band-light.png" width="800">
The band stays on one line. When some pills don't fit, a +N button at the end expands it to show every pill, and Less folds it back.
Hover the 5h or 7d pill for a one-line estimate of what that window is worth at API prices. The 📈 button opens two views of both windows:
<img alt="Chart view: where each window ends at your pace, and the spend rate that lands on 100% at the reset" src="docs/screenshots/chart-light.png" width="800">
<img alt="By model view: tokens left in each window if you use only one model, and tokens each model used" src="docs/screenshots/models-light.png" width="800">
<sub>Screenshots use demo figures.</sub>
In the terminal, 📈 shows the same two views as small tables, one row per window with the columns lined up, and drops the least important columns when the window is narrow.
Installed or reloaded partway through a session (/reload-plugins), the band starts from the tokens the session has already used, read from its transcript; t/s appears after the next reply.
The band follows the app's light or dark theme; ◐ (in the 📈 panel) cycles auto, light and dark.
In Claude Code (CLI or desktop):
/plugin marketplace add EricJamie/claude-code-mods
/plugin install context-band@claude-code-mods
It loads in the next session.
/context-band auto|light|dark theme · hide / show the band · reset the token counters
python3 (the estimator, plugins/context-band/bin/api_estimate.py)~/.cache/context-band/.List prices live in PRICES in bin/api_estimate.py, from Anthropic's pricing page (https://platform.claude.com/docs/en/about-claude/pricing).
A model the table doesn't know still works: it gets its own row, priced like its family's current model (a new family is priced like Opus), and its figures are marked ≈ until its price is learned. The band learns it from Claude Code's own cost figure for each session, which always uses current prices, set against the tokens each model used in that session. The same check corrects a listed price that has changed. Updating the table when a model ships is still the quickest fix.
claude plugin validate plugins/context-band
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/context-band
Mods are an early-access Claude Code feature; the test runner needs that variable.
A countdown in the prompt footer, before the model name: cache 41:41 until the conversation's prompt cache expires. It turns orange in the last sixth of the entry's life (10 minutes of an hour), red in the last 2 minutes, and says expired after.
/plugin install cache-timer@claude-code-mods
Claude Code caches the conversation so far, so each message reads it back instead of sending it again. A cache entry lives a fixed time from the start of the last request that read or wrote it, and every message restarts that time. The main conversation uses 1-hour entries on some plans and 5-minute ones on others (subagents use 5-minute ones); the timer reads which from the session's transcript, so it needs python3.
Reading the cache is cheap but not free: on Opus 5.5 a cache read costs $0.20 per million tokens against $4 for fresh input, and writing costs 1.25× the input price for a 5-minute entry or 2× for a 1-hour one. Once an entry expires, the next message writes the whole context again: for a 180k-token Opus 5.5 conversation that is ≈ $1.44 instead of a ≈ $0.04 read.
The countdown starts from the main conversation's own requests. Requests it does not see (some background ones) can refresh the cache too, so the real expiry can be a little later than shown.
Claude Code 的插件(mod)集合,以插件市场的形式发布:
其他工具的状态栏:Grok Build(状态行脚本)和 Codex(内置状态行配置)。
显示在输入框上方(桌面端和命令行都支持),每轮对话后自动更新:
<img alt="The band: 5h and 7d limits, tokens in and out, speed, cache, cost and context" src="docs/screenshots/band-light.png" width="800">
状态栏默认只占一行。放不下的指标会收进末尾的 +N 按钮,点击即可展开显示全部,点击 Less 收起。
鼠标悬停在 5h 或 7d 上,会显示该窗口按 API 价格折算的估值。点击 📈 打开两个视图:
<img alt="Chart view: where each window ends at your pace, and the spend rate that lands on 100% at the reset" src="docs/screenshots/chart-light.png" width="800">
<img alt="By model view: tokens left in each window if you use only one model, and tokens each model used" src="docs/screenshots/models-light.png" width="800">
<sub>截图中的数字为演示数据。</sub>
在命令行里,📈 用对齐的小表格显示同样的两个视图(每个窗口一行),终端较窄时会先省略次要的列。
如果在会话中途安装或重新加载插件(/reload-plugins),状态栏会从对话记录里读出本次会话已用的 token 作为起点;t/s 会在下一次回复后出现。
状态栏会自动跟随应用的浅色/深色主题;📈 面板里的 ◐ 按钮可在自动、浅色、深色之间切换。
在 Claude Code(命令行或桌面端)中运行:
/plugin marketplace add EricJamie/claude-code-mods
/plugin install context-band@claude-code-mods
下一个会话开始生效。
/context-band auto|light|dark 切换主题 · hide / show 隐藏或显示 · reset 重置 token 计数
python3(估算脚本 plugins/context-band/bin/api_estimate.py)~/.cache/context-band/。价格表写在 bin/api_estimate.py 的 PRICES 里,来源是 Anthropic 官方价格页面(https://platform.claude.com/docs/en/about-claude/pricing)。
价格表里没有的新模型也能正常显示:它会有自己的一行,先按同系列当前模型的价格估算(全新系列按 Opus 估算),在价格学到之前数字前会标 ≈。插件会用 Claude Code 自己统计的每个会话花费(始终按当前价格计算)对照该会话里各模型用掉的 token,自动学出新模型的真实价格;已有模型如果调价,也会被同样校正。新模型发布时更新价格表仍然是最快的办法。
claude plugin validate plugins/context-band
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/context-band
插件(mod)是 Claude Code 的早期功能,运行测试需要设置这个环境变量。
在输入框下方、模型名称前面显示 cache 41:41,即离对话缓存过期还有多久。剩最后六分之一时(1 小时缓存即最后 10 分钟)变橙色,最后 2 分钟变红色,过期后显示 expired。
/plugin install cache-timer@claude-code-mods
Claude Code 会把已有的对话内容缓存起来,之后每条消息直接读缓存,不用重新发送全部内容。每条缓存有固定寿命,从最近一次读写它的请求开始时算起,每发一条消息都会重新计时。主对话的缓存,有的套餐是 1 小时,有的是 5 分钟(子代理用 5 分钟);插件会从本次会话的对话记录里读出是哪一种,所以需要 python3。
读缓存很便宜,但不免费:Opus 5.5 读缓存每百万 token $0.20,正常输入是 $4;写缓存是输入价的 1.25 倍(5 分钟缓存)或 2 倍(1 小时缓存)。缓存过期后,下一条消息要把整段上下文重新写入缓存:一段 18 万 token 的 Opus 5.5 对话,这部分费用会从约 $0.04 变成约 $1.44。
倒计时以主对话自己的请求为准。插件看不到的一些后台请求也可能刷新缓存,所以实际过期时间可能比显示的稍晚一些。
hooks/register.tsx 1368 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionContextUsage, SessionCost } from 'claude-code'
3
4import type {
5 ContextBandApi,
6 ContextBandApiWindow,
7 ContextBandAppearance,
8 ContextBandModelUse,
9 ContextBandPriceKind,
10 ContextBandStats,
11 ContextBandTheme,
12 ContextBandTurn,
13 ContextBandUsage,
14} from '../types'
15
16const EMPTY: ContextBandStats = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, requests: 0 }
17
18const stats = atom({ plugin: 'context-band', key: 'stats' } as const, EMPTY)
19const usage = atom({ plugin: 'context-band', key: 'usage' } as const, null)
20const lastTurn = atom({ plugin: 'context-band', key: 'turn' } as const, null)
21const now = atom({ plugin: 'context-band', key: 'now' } as const, 0)
22const theme = atom({ plugin: 'context-band', key: 'theme' } as const, 'auto')
23const DEFAULT_APPEARANCE: ContextBandAppearance = { isSystemDark: false, cli: 'auto', desktop: 'system' }
24const appearance = atom({ plugin: 'context-band', key: 'appearance' } as const, DEFAULT_APPEARANCE)
25const isHidden = atom({ plugin: 'context-band', key: 'isHidden' } as const, false)
26const api = atom({ plugin: 'context-band', key: 'api' } as const, null)
27const isChartOpen = atom({ plugin: 'context-band', key: 'isChartOpen' } as const, false)
28const isRowExpanded = atom({ plugin: 'context-band', key: 'isRowExpanded' } as const, false)
29const chartView = atom({ plugin: 'context-band', key: 'chartView' } as const, 'chart')
30
31const THEMES: readonly ContextBandTheme[] = ['auto', 'light', 'dark']
32
33type Tone = { bg: string; accent: string }
34type ToneKey = 'fiveHour' | 'sevenDay' | 'input' | 'output' | 'speed' | 'cache' | 'cost' | 'ctx'
35type Level = 'normal' | 'warn' | 'danger'
36type Palette = {
37 card: string
38 text: string
39 muted: string
40 divider: string
41 warn: string
42 danger: string
43 tones: Record<ToneKey, Tone>
44 models: Record<'opus' | 'sonnet' | 'fable' | 'haiku' | 'other', string>
45 // Opacities for the By model card's tinted answer column and its bar tracks.
46 tint: number
47 track: number
48 // Opacity of the area under the Chart card's spend curve.
49 area: number
50}
51
52const LIGHT: Palette = {
53 card: '#FFFFFF',
54 text: '#2B2B2B',
55 muted: '#6E6E6E',
56 divider: '#C4C4BE',
57 warn: '#B7791F',
58 danger: '#C53030',
59 tones: {
60 fiveHour: { bg: '#DCEFE6', accent: '#2E8B6A' },
61 sevenDay: { bg: '#E5E0F8', accent: '#6D4AD6' },
62 input: { bg: '#F6DCD6', accent: '#C2452F' },
63 output: { bg: '#DDEFD8', accent: '#3C8B3A' },
64 speed: { bg: '#D9EEF4', accent: '#2A8AA6' },
65 cache: { bg: '#DEE3FA', accent: '#4A5BD4' },
66 cost: { bg: '#F4EACB', accent: '#B07D12' },
67 ctx: { bg: '#E3E5EA', accent: '#4B5563' },
68 },
69 models: { opus: '#D85A30', sonnet: '#1D9E75', fable: '#7F77DD', haiku: '#888780', other: '#B4B2A9' },
70 tint: 0.1,
71 track: 0.4,
72 area: 0.14,
73}
74
75const DARK: Palette = {
76 card: '#26272B',
77 text: '#E6E6E6',
78 muted: '#9A9A9A',
79 divider: '#4A4A4A',
80 warn: '#F0B429',
81 danger: '#FF6B6B',
82 tones: {
83 fiveHour: { bg: '#183B2F', accent: '#5BD1A3' },
84 sevenDay: { bg: '#2B2550', accent: '#A996FF' },
85 input: { bg: '#46231D', accent: '#FF8C76' },
86 output: { bg: '#1F3A1E', accent: '#7FD77B' },
87 speed: { bg: '#14353F', accent: '#5BC6E4' },
88 cache: { bg: '#232A55', accent: '#8F9CFF' },
89 cost: { bg: '#3D331A', accent: '#E9B949' },
90 ctx: { bg: '#2C2F35', accent: '#A3AAB6' },
91 },
92 models: { opus: '#F0997B', sonnet: '#5DCAA5', fable: '#AFA9EC', haiku: '#B4B2A9', other: '#888780' },
93 tint: 0.16,
94 track: 0.7,
95 area: 0.22,
96}
97
98type IconName = 'gauge' | 'calendar' | 'bolt' | 'coin' | 'doc'
99
100const ICON_PATHS: Record<IconName, string> = {
101 gauge: '<path d="M4.6 17.5a8.5 8.5 0 1 1 14.8 0"/><path d="M12 14l3.6-3.6"/><circle cx="12" cy="14" r="1.2"/>',
102 calendar: '<rect x="4" y="5" width="16" height="15" rx="2.5"/><path d="M4 10h16M8.5 3v4M15.5 3v4M10 13h4l-2.2 4.5"/>',
103 bolt: '<path d="M13 2.5L4.5 13.5h6.5l-1 8 8.5-11h-6.5z"/>',
104 coin: '<circle cx="12" cy="12" r="9"/><path d="M14.8 9.4c-.5-.9-1.5-1.4-2.8-1.4-1.6 0-2.8.8-2.8 2s1.2 1.7 2.8 2 2.8.8 2.8 2-1.2 2-2.8 2c-1.3 0-2.3-.5-2.8-1.4M12 6.5V8M12 16v1.5"/>',
105 doc: '<rect x="5" y="3" width="14" height="18" rx="2.5"/><path d="M9 8.5h6M9 12h6M9 15.5h3.5"/>',
106}
107
108const GLYPHS: Record<IconName, string> = {
109 gauge: '◔',
110 calendar: '▦',
111 bolt: 'ϟ',
112 coin: '$',
113 doc: '▤',
114}
115
116const svgIcon = (name: IconName, color: string) =>
117 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICON_PATHS[name]}</svg>`
118
119const fmtTokens = (n: number) =>
120 n < 1000
121 ? `${Math.round(n)}`
122 : n < 1e6
123 ? `${Number((n / 1e3).toFixed(1))}k`
124 : n < 1e9
125 ? `${Number((n / 1e6).toFixed(n < 1e8 ? 2 : 1))}M`
126 : `${(n / 1e9).toFixed(2)}B`
127
128const fmtPercent = (p: number) => (p > 0 && p < 1 ? '<1%' : `${Math.round(p)}%`)
129
130const fmtUsd = (v: number) =>
131 v >= 1000 ? `$${String(Math.round(v)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}` : v >= 100 ? `$${Math.round(v)}` : `$${v.toFixed(2)}`
132
133const fmtLeft = (ms: number) => {
134 if (ms <= 0) return 'now'
135 const minutes = Math.ceil(ms / 60_000)
136 const days = Math.floor(minutes / 1440)
137 const hours = Math.floor((minutes % 1440) / 60)
138 const mins = minutes % 60
139 return days > 0 ? `${days}d${hours}h` : hours > 0 ? `${hours}h${mins}m` : `${mins}m`
140}
141
142const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
143
144const limitLabel = (kind: string) =>
145 kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind.replace('seven_day_', '7d ').replace(/_/g, ' ')
146
147const isFiveHour = (kind: string) => kind.startsWith('five_hour')
148
149const toUsage = (u: { context: SessionContextUsage; rateLimits: readonly SessionRateLimit[]; cost?: SessionCost }): ContextBandUsage => ({
150 ctxPercent: u.context.percent ?? null,
151 ctxTokens: u.context.tokens ?? null,
152 ctxWindow: u.context.window,
153 limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt ?? null })),
154 costUsd: u.cost?.usd ?? null,
155})
156
157async function refreshUsage($: EngineInterface) {
158 try {
159 const next = toUsage(await $.session.usage())
160 if (JSON.stringify(await read($, usage)) !== JSON.stringify(next)) await update($, usage, () => next)
161 } catch {}
162}
163
164async function refreshClock($: EngineInterface) {
165 // Countdowns show minutes, so the band needs a new time once a minute, not every tick.
166 const t = await $.clock.now()
167 const minute = t - (t % 60_000)
168 if ((await read($, now)) !== minute) await update($, now, () => minute)
169}
170
171const DESKTOP_CONFIG = 'Library/Application Support/Claude/config.json'
172
173// "auto" follows the surface: Claude Code's theme setting on the terminal, the desktop app's
174// own mode on desktop, and macOS's appearance when either of those is set to follow the system.
175async function refreshAppearance($: EngineInterface) {
176 const next: ContextBandAppearance = { ...DEFAULT_APPEARANCE }
177 try {
178 const { exitCode, stdout } = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: 3000 })
179 next.isSystemDark = exitCode === 0 && /dark/i.test(stdout)
180 } catch {}
181 try {
182 const row = (await $.config.list()).find(r => r.key === 'theme')
183 const value = String(row?.value ?? 'auto')
184 next.cli = value.startsWith('dark') ? 'dark' : value.startsWith('light') ? 'light' : 'auto'
185 } catch {}
186 try {
187 // plutil pulls the one key, so the plugin never reads the rest of that file.
188 const home = await $.env.get('HOME')
189 if (home) {
190 const { exitCode, stdout } = await $.process.run(['plutil', '-extract', 'userThemeMode', 'raw', '-o', '-', `${home}/${DESKTOP_CONFIG}`], { timeoutMs: 3000 })
191 const value = stdout.trim()
192 if (exitCode === 0 && (value === 'dark' || value === 'light')) next.desktop = value
193 }
194 } catch {}
195 if (JSON.stringify(await read($, appearance)) !== JSON.stringify(next)) await update($, appearance, () => next)
196}
197
198const resolveDark = (mode: ContextBandTheme, look: ContextBandAppearance, isTerminal: boolean) => {
199 if (mode !== 'auto') return mode === 'dark'
200 const pinned = isTerminal ? look.cli : look.desktop
201 return pinned === 'dark' ? true : pinned === 'light' ? false : look.isSystemDark
202}
203
204async function setTheme($: EngineInterface, next: ContextBandTheme) {
205 await update($, theme, () => next)
206 await $.store.set('themeMode', next)
207 if (next === 'auto') await refreshAppearance($)
208}
209
210const num = (v: unknown, fallback = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
211const nums = (v: unknown) => (Array.isArray(v) ? v.map(x => num(x)) : [])
212
213// The estimator's JSON, taken field by field so a bad line never reaches the drawing.
214function parseApi(stdout: string): ContextBandApi | null {
215 try {
216 const raw = JSON.parse(stdout) as { at?: unknown; windows?: unknown }
217 if (!Array.isArray(raw.windows)) return null
218 const windows = raw.windows.map((w: Record<string, unknown>): ContextBandApiWindow => ({
219 kind: String(w.kind ?? ''),
220 startAt: num(w.startAt),
221 endAt: num(w.endAt),
222 binMs: num(w.binMs, 1),
223 bins: nums(w.bins),
224 prevBins: nums(w.prevBins),
225 spendUsd: num(w.spendUsd),
226 pct: num(w.pct),
227 prevSpendUsd: num(w.prevSpendUsd),
228 prevPct: typeof w.prevPct === 'number' ? w.prevPct : null,
229 rateUsd: typeof w.rateUsd === 'number' && w.rateUsd > 0 ? w.rateUsd : null,
230 prevRateUsd: typeof w.prevRateUsd === 'number' && w.prevRateUsd > 0 ? w.prevRateUsd : null,
231 basis: typeof w.basis === 'string' ? w.basis : null,
232 byModel: Array.isArray(w.byModel)
233 ? w.byModel.map((m: Record<string, unknown>): ContextBandModelUse => ({
234 model: String(m.model ?? '?'),
235 usd: num(m.usd),
236 input: num(m.input),
237 output: num(m.output),
238 cacheRead: num(m.cacheRead),
239 cacheWrite: num(m.cacheWrite),
240 }))
241 : [],
242 }))
243 const prices = (raw as { usdPerToken?: unknown }).usdPerToken
244 const usdPerToken: Record<string, number> = {}
245 if (prices && typeof prices === 'object') {
246 for (const [model, value] of Object.entries(prices as Record<string, unknown>)) {
247 if (typeof value === 'number' && value > 0) usdPerToken[model] = value
248 }
249 }
250 const kinds = (raw as { prices?: unknown }).prices
251 const priceKinds: Record<string, ContextBandPriceKind> = {}
252 if (kinds && typeof kinds === 'object') {
253 for (const [model, kind] of Object.entries(kinds as Record<string, unknown>)) {
254 if (kind === 'list' || kind === 'learned' || kind === 'estimated') priceKinds[model] = kind
255 }
256 }
257 return { at: num(raw.at), windows, usdPerToken, priceKinds }
258 } catch {
259 return null
260 }
261}
262
263// The session's own tokens so far, as the estimator read them from its transcript.
264function parseSession(stdout: string): ContextBandStats | null {
265 try {
266 const raw = (JSON.parse(stdout) as { session?: Record<string, unknown> }).session
267 if (!raw || typeof raw !== 'object') return null
268 return { input: num(raw.input), output: num(raw.output), cacheRead: num(raw.cacheRead), cacheWrite: num(raw.cacheWrite), requests: num(raw.requests) }
269 } catch {
270 return null
271 }
272}
273
274// Under a few points of the window used, one point of rounding moves the estimate a lot.
275const isRough = (w: ContextBandApiWindow) => (w.basis === 'previous window' ? (w.prevPct ?? 0) : w.pct) < 5
276
277const roughMark = (w: ContextBandApiWindow) => (isRough(w) ? ' (rough)' : '')
278
279const CARD_H = 188
280// Where both views put their title and the line under it.
281const TITLE_Y = 32
282const SUBTITLE_Y = 52
283
284const BOTH = '<style>.cbD{display:none}@media (prefers-color-scheme: dark){.cbL{display:none}.cbD{display:inline}}</style>'
285
286// A desktop SVG in the palette the person pinned, or in both under "auto", where the renderer's
287// own color scheme (the app's theme) chooses between them the moment it changes: no polling.
288function themedSvg(width: number, height: number, attrs: string, mode: ContextBandTheme, draw: (p: Palette) => string) {
289 const body = mode === 'auto' ? `${BOTH}<g class="cbL">${draw(LIGHT)}</g><g class="cbD">${draw(DARK)}</g>` : draw(mode === 'dark' ? DARK : LIGHT)
290 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" ${attrs}>${body}</svg>`
291}
292
293// The strip's small chart's scale: the window's length, the time gone, and the spend so far.
294function chartGeometry(w: ContextBandApiWindow, t: number) {
295 const dur = Math.max(1, w.endAt - w.startAt)
296 const elapsed = Math.min(dur, Math.max(0, t - w.startAt))
297 const spend = w.bins.at(-1) ?? w.spendUsd
298 return { dur, elapsed, spend }
299}
300
301// The frame both views share: the card, the window's estimate as its title, and the line under it.
302function cardFrame(w: ContextBandApiWindow, pct: number, p: Palette, t: number, width: number) {
303 const label = limitLabel(w.kind)
304 const title = w.rateUsd ? `${label} window ≈ ${fmtUsd(w.rateUsd)} at API prices${roughMark(w)}` : `${label} window · not enough usage to estimate yet`
305 const tokens = w.byModel.reduce((sum, m) => sum + tokensOf(m), 0)
306 const pieces = [
307 ...(w.basis === 'previous window' ? ['from the previous window'] : []),
308 `used ${fmtPercent(pct)}`,
309 fmtUsd(w.spendUsd),
310 ...(tokens > 0 ? [`${fmtTokens(tokens)} tokens`] : []),
311 ]
312 // The reset time is in the Chart card's verdict row and on the 5h/7d pills, so not here.
313 const detail = pieces.join(' · ')
314 return (
315 `<rect x="0.5" y="0.5" width="${width - 1}" height="${CARD_H - 1}" rx="12" fill="${p.card}" stroke="${p.divider}"/>` +
316 `<text x="16" y="${TITLE_Y}" font-size="14" font-weight="600" fill="${p.text}">${esc(title)}</text>` +
317 `<text x="16" y="${SUBTITLE_Y}" font-size="11.5" fill="${p.muted}">${esc(detail)}</text>`
318 )
319}
320
321// Rates in the Chart card: one decimal below $20, whole dollars above.
322const fmtRate = (v: number) => (v < 20 ? `$${v.toFixed(1)}` : `$${String(Math.round(v)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`)
323
324// Tokens to three significant figures.
325const fmtTok3 = (n: number) => {
326 for (const [div, suffix] of [[1e9, 'B'], [1e6, 'M'], [1e3, 'k']] as const) {
327 if (n >= div) {
328 const x = n / div
329 return `${x < 10 ? x.toFixed(2) : x < 100 ? x.toFixed(1) : x.toFixed(0)}${suffix}`
330 }
331 }
332 return `${Math.round(n)}`
333}
334
335// "6d 8h", "2h 18m", "43m".
336const fmtSpan = (ms: number) => {
337 const minutes = Math.max(0, Math.round(ms / 60_000))
338 const days = Math.floor(minutes / 1440)
339 const hours = Math.floor((minutes % 1440) / 60)
340 return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes % 60}m`
341}
342
343// What a token costs on the newest Opus at your mix, for the Chart card's "≈ … Opus" figures.
344const opusPrice = (usdPerToken: Record<string, number>) => {
345 const key = Object.keys(usdPerToken).filter(m => m.startsWith('opus')).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))[0]
346 return (key && usdPerToken[key]) || 0.367e-6
347}
348
349// What the Chart view says about one window, for the desktop card, the terminal table and the hover
350// strips alike: where the window ends at this pace (or when it hits the limit), the average rate so
351// far, the rate that lands on 100% at the reset, and the previous window at this point and its end.
352function chartFacts(w: ContextBandApiWindow, t: number, usdPerToken: Record<string, number> = {}) {
353 const win = Math.max(1, w.endAt - w.startAt)
354 const el = Math.min(1, Math.max(0, (t - w.startAt) / win))
355 const rate = w.rateUsd
356 const spend = w.bins.at(-1) ?? w.spendUsd
357 const pct = rate ? (spend / rate) * 100 : w.pct
358 const isWeek = win > 24 * 3600_000
359 const perMs = isWeek ? 86_400_000 : 3_600_000
360 const per = isWeek ? '/day' : '/h'
361 const elMs = el * win
362 const leftMs = (1 - el) * win
363 const avg = elMs > 0 ? spend / (elMs / perMs) : 0
364 const budget = rate && leftMs > 0 ? Math.max(rate - spend, 0) / (leftMs / perMs) : null
365 const early = el < 0.05 || spend <= 0 || !rate
366 const rawPace = el > 0 ? pct / el : 0
367 const over = !early && rawPace >= 100
368 const tHit = over ? (el * 100) / pct : null
369 const pace = Math.min(rawPace, 100)
370 const opus = opusPrice(usdPerToken)
371 // The previous window as % of its own limit against the share of the window gone.
372 const prev: [number, number][] = []
373 let prevNow: number | null = null
374 let prevEnd: number | null = null
375 if (w.prevBins.length > 0 && w.prevRateUsd) {
376 const prevRate = w.prevRateUsd
377 prev.push([0, 0], ...w.prevBins.map((v, i): [number, number] => [((i + 1) * w.binMs) / win, (v / prevRate) * 100]))
378 prevEnd = prev.at(-1)?.[1] ?? null
379 for (let i = 1; i < prev.length; i++) {
380 const [t0, p0] = prev[i - 1]!
381 const [t1, p1] = prev[i]!
382 if (t0 <= el && el <= t1) {
383 prevNow = t1 > t0 ? p0 + ((p1 - p0) * (el - t0)) / (t1 - t0) : p1
384 break
385 }
386 }
387 }
388 return { win, el, rate, spend, pct, isWeek, per, elMs, leftMs, avg, budget, early, over, tHit, pace, opus, prev, prevNow, prevEnd }
389}
390
391type ChartFacts = ReturnType<typeof chartFacts>
392
393// The verdict in words, as the Chart card's first row says it.
394const verdictText = (f: ChartFacts) =>
395 f.early
396 ? f.spend <= 0
397 ? 'No usage yet'
398 : 'Too early to tell'
399 : f.over && f.tHit !== null
400 ? `Limit hit in ${fmtSpan((f.tHit - f.el) * f.win)}`
401 : `On pace to finish at ${Math.round(f.pace)}%`
402
403// The Chart card: what the By model table cannot show, which is time. A verdict (where the window
404// ends at this pace, or when it runs out), the average rate so far beside the rate that lands on
405// 100% at the reset, and a strip on a fixed 0–100% scale so the limit is the same top edge on every
406// card. The headroom wedge between the pace line and the limit is what the answer row quantifies.
407function chartBody(w: ContextBandApiWindow, p: Palette, t: number, width: number, usdPerToken: Record<string, number>) {
408 const acc = p.tones[windowTone(w.kind)].accent
409 const parts: string[] = [cardFrame(w, w.pct, p, t, width)]
410 const L = 16
411 const R = width - 16
412 const tw = (text: string, size: number) => text.length * size * 0.55
413 const { win, el, rate, spend, pct, isWeek, per, elMs, leftMs, avg, budget, early, over, tHit, pace, opus, prev, prevNow, prevEnd } = chartFacts(w, t, usdPerToken)
414 const text = (x: number, y: number, size: number, fill: string, body: string, extra = '') =>
415 parts.push(`<text x="${x.toFixed(1)}" y="${y}" font-size="${size}" fill="${fill}"${extra}>${body}</text>`)
416
417 // 1. The verdict, and the reset on the right; shorter phrasings when the two would touch.
418 const resetIn = `reset in ${fmtSpan(leftMs)}`
419 type Option = { spans: [string, string, boolean][]; right: string }
420 let options: Option[]
421 let rightColor = acc
422 if (early) {
423 options =
424 spend <= 0
425 ? [{ spans: [['No usage yet this window', p.text, false]], right: resetIn }, { spans: [['No usage yet', p.text, false]], right: resetIn }]
426 : [{ spans: [['Too early to call a pace', p.text, false]], right: resetIn }, { spans: [['Too early to tell', p.text, false]], right: resetIn }]
427 } else if (over && tHit !== null) {
428 rightColor = p.danger
429 const hit = fmtSpan((tHit - el) * win)
430 const before = fmtSpan((1 - tHit) * win)
431 options = [
432 { spans: [['Limit hit in ', p.danger, false], [hit, p.danger, true]], right: `${before} before reset` },
433 { spans: [['Limit in ', p.danger, false], [hit, p.danger, true]], right: `${before} before reset` },
434 { spans: [['Limit in ', p.danger, false], [hit, p.danger, true]], right: `${before} early` },
435 ]
436 } else {
437 const share = `${Math.round(pace)}%`
438 options = [
439 { spans: [['On pace to finish at ', p.text, false], [share, acc, true]], right: resetIn },
440 { spans: [['Finishing at ', p.text, false], [share, acc, true]], right: resetIn },
441 ]
442 }
443 const chosen =
444 options.find(o => L + tw(o.spans.map(s => s[0]).join(''), 12) + 12 <= R - tw(o.right, 10.5)) ?? options[options.length - 1]!
445 const spans = chosen.spans.map(([piece, color, bold]) => `<tspan fill="${color}"${bold ? ' font-weight="700"' : ''}>${esc(piece)}</tspan>`).join('')
446 text(L, 78, 12, p.text, spans, ' font-weight="600"')
447 text(R, 78, 10.5, rightColor, esc(chosen.right), ' font-weight="700" text-anchor="end"')
448 parts.push(`<line x1="${L}" y1="84" x2="${R}" y2="84" stroke="${p.divider}" stroke-opacity="0.7" stroke-width="1"/>`)
449
450 // 2. The average so far, and the answer: the rate that lands on 100% at the reset.
451 const colA = R - 82
452 parts.push(`<rect x="10" y="106" width="${width - 20}" height="20" rx="7" fill="${acc}" fill-opacity="${p.tint}"/>`)
453 const a1 = elMs > 0 && avg > 0 ? `${fmtRate(avg)}${per}` : '—'
454 const b1 = a1 !== '—' ? `≈ ${fmtTok3(avg / opus)} Opus` : ''
455 const a2 = budget !== null ? `${fmtRate(budget)}${per}` : '—'
456 const b2 = budget !== null ? `≈ ${fmtTok3(budget / opus)} Opus` : ''
457 const longLabels = ['Average so far', over ? 'Slow down to' : 'Spend up to']
458 const shortLabels = ['Average', over ? 'Slow to' : 'Up to']
459 const fits = longLabels.every((label, i) => L + tw(label, 12) + 12 <= colA - tw(i === 0 ? a1 : a2, 13))
460 const labels = fits ? longLabels : shortLabels
461 const nums = ' style="font-variant-numeric: tabular-nums"'
462 ;[
463 { label: labels[0]!, a: a1, b: b1, y: 99, isAnswer: false },
464 { label: labels[1]!, a: a2, b: b2, y: 119, isAnswer: true },
465 ].forEach(row => {
466 text(L, row.y, 12, p.text, esc(row.label))
467 text(colA, row.y, 13, row.isAnswer && over ? p.danger : p.text, esc(row.a), `${row.isAnswer ? ' font-weight="700"' : ''} text-anchor="end"${nums}`)
468 if (row.b) text(R, row.y, 10.5, p.muted, esc(row.b), ` text-anchor="end"${nums}`)
469 })
470
471 // 3. The strip: time from the last reset to the next, 0–100% of this window's limit.
472 const yT = 134
473 const yB = 161
474 const pL = L
475 const pR = R - 34
476 const X = (f: number) => pL + f * (pR - pL)
477 const Y = (v: number) => yB - (Math.min(Math.max(v, 0), 100) / 100) * (yB - yT)
478 const pts = (seq: [number, number][]) => seq.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')
479 parts.push(`<line x1="${pL}" y1="${yB}" x2="${pR}" y2="${yB}" stroke="${p.divider}" stroke-opacity="0.7" stroke-width="1"/>`)
480 parts.push(`<line x1="${pL}" y1="${yT}" x2="${pR}" y2="${yT}" stroke="${p.danger}" stroke-opacity="0.75" stroke-width="1" stroke-dasharray="2 3"/>`)
481 text(pR + 6, yT + 3.6, 10, p.danger, 'limit')
482 if (prev.length > 0) {
483 parts.push(`<polyline points="${pts(prev.map(([f, v]) => [X(f), Y(v)]))}" fill="none" stroke="${p.muted}" stroke-opacity="0.55" stroke-width="1.25" stroke-linejoin="round"/>`)
484 }
485 if (rate) {
486 const nx = X(el)
487 const ny = Y(pct)
488 if (!early) {
489 const wedge: [number, number][] = over && tHit !== null ? [[nx, ny], [X(tHit), yT], [pR, yT]] : [[nx, ny], [pR, Y(pace)], [pR, yT]]
490 parts.push(`<polygon points="${pts(wedge)}" fill="${over ? p.danger : acc}" fill-opacity="${p.tint}"/>`)
491 }
492 parts.push(`<line x1="${nx.toFixed(1)}" y1="${ny.toFixed(1)}" x2="${pR}" y2="${yT}" stroke="${acc}" stroke-opacity="0.45" stroke-width="1"/>`)
493 const cur: [number, number][] = [[X(0), Y(0)], ...w.bins.map((v, i): [number, number] => [X(Math.min((i + 1) * w.binMs, elMs) / win), Y((v / rate) * 100)])]
494 parts.push(`<polygon points="${pts([...cur, [nx, yB], [pL, yB]])}" fill="${acc}" fill-opacity="${p.area}"/>`)
495 parts.push(`<polyline points="${pts(cur)}" fill="none" stroke="${acc}" stroke-width="1.75" stroke-linejoin="round" stroke-linecap="round"/>`)
496 if (!early) {
497 if (over && tHit !== null) {
498 const hx = X(tHit)
499 parts.push(`<line x1="${nx.toFixed(1)}" y1="${ny.toFixed(1)}" x2="${hx.toFixed(1)}" y2="${yT}" stroke="${p.danger}" stroke-width="1.6" stroke-dasharray="4 3"/>`)
500 parts.push(`<circle cx="${hx.toFixed(1)}" cy="${yT}" r="3" fill="${p.danger}" stroke="${p.card}" stroke-width="1.5"/>`)
501 } else {
502 parts.push(`<line x1="${nx.toFixed(1)}" y1="${ny.toFixed(1)}" x2="${pR}" y2="${Y(pace).toFixed(1)}" stroke="${acc}" stroke-width="1.6" stroke-dasharray="4 3"/>`)
503 }
504 }
505 parts.push(`<circle cx="${nx.toFixed(1)}" cy="${ny.toFixed(1)}" r="3.25" fill="${acc}" stroke="${p.card}" stroke-width="1.5"/>`)
506 }
507
508 // 4. The previous window at this point and at its end, or a note that there is none.
509 if (prevNow !== null && prevEnd !== null) {
510 parts.push(`<line x1="${L}" y1="174.5" x2="${L + 12}" y2="174.5" stroke="${p.muted}" stroke-opacity="0.55" stroke-width="1.5" stroke-linecap="round"/>`)
511 text(L + 18, CARD_H - 10, 10.5, p.muted, esc(`Last ${isWeek ? 'week' : 'window'}: ${Math.round(prevNow)}% by now, ${Math.round(prevEnd)}% at reset`))
512 } else {
513 text(L, CARD_H - 10, 10.5, p.muted, esc(`No previous ${limitLabel(w.kind)} window to compare`))
514 }
515 return parts.join('')
516}
517
518// `opus-5-5` as `Opus 5.5`.
519const modelLabel = (model: string) => {
520 const [family = model, ...version] = model.split('-')
521 return `${family.charAt(0).toUpperCase()}${family.slice(1)}${version.length > 0 ? ` ${version.join('.')}` : ''}`
522}
523
524const modelColor = (p: Palette, model: string) =>
525 model.startsWith('opus') ? p.models.opus : model.startsWith('sonnet') ? p.models.sonnet : model.startsWith('fable') || model.startsWith('mythos') ? p.models.fable : model.startsWith('haiku') ? p.models.haiku : p.models.other
526
527const TIERS = ['fable', 'mythos', 'opus', 'sonnet', 'haiku']
528
529// Fable first, then Opus, Sonnet and Haiku; anything else after them.
530const tierRank = (model: string) => {
531 const i = TIERS.findIndex(tier => model.startsWith(tier))
532 return i === -1 ? TIERS.length : i
533}
534
535const tokensOf = (m: ContextBandModelUse) => m.input + m.output + m.cacheRead + m.cacheWrite
536
537// The dollars left in a window: its estimated total less what the % used has taken.
538const usdLeft = (w: ContextBandApiWindow, pct: number) => (w.rateUsd ? w.rateUsd * Math.max(0, 100 - pct) / 100 : null)
539
540// The tokens left in a window if every one went to `model`, at your own mix of input and cache.
541const tokensLeft = (w: ContextBandApiWindow, pct: number, usdPerToken: Record<string, number>, model: string) => {
542 const left = usdLeft(w, pct)
543 const price = usdPerToken[model]
544 return left !== null && price ? left / price : null
545}
546
547// The By model card: the frame both views share, then a labelled table. "If you use only…" heads
548// the models, "tokens left" heads the answer column, tinted in the window's color so the eye lands
549// there, and a bar per row scales those numbers against the largest. Tokens already used sit in a
550// small grey column. The foot says why the numbers differ: the same dollars at each model's price.
551function modelsBody(
552 w: ContextBandApiWindow,
553 pct: number,
554 models: string[],
555 usdPerToken: Record<string, number>,
556 priceKinds: Record<string, ContextBandPriceKind>,
557 p: Palette,
558 t: number,
559 width: number,
560) {
561 const tone = p.tones[windowTone(w.kind)]
562 const nums = 'style="font-variant-numeric: tabular-nums"'
563 const parts: string[] = [cardFrame(w, pct, p, t, width)]
564 const usedR = width - 16
565 const leftR = width - 66
566 const columnX = width - 126
567 const barX = 100
568 const barW = Math.max(20, columnX - 10 - barX)
569 // Every model gets a row: four at the usual spacing, five a little tighter, and past five the
570 // last slot names the rest rather than leaving them out.
571 const slots = Math.min(models.length, 5)
572 const first = slots <= 4 ? 99 : 97
573 const step = slots <= 4 ? 20 : 16
574 const rows = models.length > 5 ? models.slice(0, 4) : models
575 const rest = models.length > 5 ? models.slice(4) : []
576 const lefts = rows.map(model => tokensLeft(w, pct, usdPerToken, model))
577 const most = Math.max(0, ...lefts.map(v => v ?? 0))
578 if (rows.length > 0) {
579 const bottom = first + step * (rows.length - 1) + 7
580 parts.push(`<rect x="${columnX}" y="65" width="66" height="${bottom - 65}" rx="7" fill="${tone.accent}" fill-opacity="${p.tint}"/>`)
581 }
582 parts.push(`<text x="16" y="78" font-size="11" font-weight="500" fill="${p.text}">If you use only…</text>`)
583 parts.push(`<text x="${leftR}" y="78" font-size="10.5" font-weight="700" text-anchor="end" fill="${tone.accent}">tokens left</text>`)
584 parts.push(`<text x="${usedR}" y="78" font-size="10.5" text-anchor="end" fill="${p.muted}">used</text>`)
585 parts.push(`<line x1="16" y1="84" x2="${usedR}" y2="84" stroke="${p.divider}" stroke-opacity="0.7" stroke-width="1"/>`)
586 if (models.length === 0) {
587 parts.push(`<text x="16" y="102" font-size="12" fill="${p.muted}">No Claude Code usage this week yet</text>`)
588 }
589 rows.forEach((model, i) => {
590 const y = first + i * step
591 const color = modelColor(p, model)
592 const used = w.byModel.find(m => m.model === model)
593 const left = lefts[i] ?? null
594 parts.push(`<circle cx="20" cy="${y - 4.2}" r="4" fill="${color}"/>`)
595 parts.push(`<text x="30" y="${y}" font-size="12" fill="${p.text}">${esc(modelLabel(model))}</text>`)
596 parts.push(`<rect x="${barX}" y="${y - 7.2}" width="${barW}" height="6" rx="3" fill="${p.divider}" fill-opacity="${p.track}"/>`)
597 if (left !== null && most > 0) {
598 parts.push(`<rect x="${barX}" y="${y - 7.2}" width="${Math.max(3, (barW * left) / most).toFixed(1)}" height="6" rx="3" fill="${color}"/>`)
599 }
600 const mark = priceKinds[model] === 'estimated' ? '≈ ' : ''
601 parts.push(`<text x="${leftR}" y="${y}" font-size="13" font-weight="700" text-anchor="end" fill="${p.text}" ${nums}>${esc(left !== null ? `${mark}${fmtTokens(left)}` : '—')}</text>`)
602 parts.push(`<text x="${usedR}" y="${y}" font-size="10.5" text-anchor="end" fill="${p.muted}" ${nums}>${esc(used ? fmtTokens(tokensOf(used)) : 'none')}</text>`)
603 })
604 if (rest.length > 0) {
605 parts.push(`<text x="16" y="${first + 4 * step}" font-size="10.5" fill="${p.muted}">${esc(`+${rest.length} more: ${rest.map(modelLabel).join(', ')}`)}</text>`)
606 }
607 // A model whose price is borrowed from its family says so until its price is learned.
608 const estimated = models.filter(model => priceKinds[model] === 'estimated').map(modelLabel)
609 const left = usdLeft(w, pct)
610 const foot =
611 estimated.length > 0
612 ? `≈ ${estimated.join(', ')}: price${estimated.length > 1 ? 's' : ''} estimated until learned`
613 : left !== null
614 ? `Same ${fmtUsd(left)} left, spent at each model’s price`
615 : 'Not enough usage yet to estimate what is left'
616 parts.push(`<text x="16" y="${CARD_H - 10}" font-size="10.5" fill="${p.muted}">${esc(foot)}</text>`)
617 return parts.join('')
618}
619
620// The By model card's words, for its alt text.
621const modelsLine = (w: ContextBandApiWindow, pct: number, models: string[], usdPerToken: Record<string, number>, priceKinds: Record<string, ContextBandPriceKind> = {}) => {
622 const rows = models.map(model => {
623 const used = w.byModel.find(m => m.model === model)
624 const left = tokensLeft(w, pct, usdPerToken, model)
625 const mark = priceKinds[model] === 'estimated' ? '≈ ' : ''
626 return `${modelLabel(model)} ${used ? fmtTokens(tokensOf(used)) : '0'} used${left !== null ? `, ${mark}${fmtTokens(left)} left if only it` : ''}`
627 })
628 const left = usdLeft(w, pct)
629 return `${limitLabel(w.kind)} window: ${rows.length > 0 ? rows.join(' · ') : 'no Claude Code usage yet'}${left !== null ? ` · ${fmtUsd(left)} left` : ''}`
630}
631
632const SANS = `font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif"`
633
634const chartSvg = (w: ContextBandApiWindow, mode: ContextBandTheme, t: number, width: number, usdPerToken: Record<string, number>) =>
635 themedSvg(width, CARD_H, SANS, mode, p => chartBody(w, p, t, width, usdPerToken))
636
637const modelsSvg = (
638 w: ContextBandApiWindow,
639 pct: number,
640 models: string[],
641 usdPerToken: Record<string, number>,
642 priceKinds: Record<string, ContextBandPriceKind>,
643 mode: ContextBandTheme,
644 t: number,
645 width: number,
646) => themedSvg(width, CARD_H, SANS, mode, p => modelsBody(w, pct, models, usdPerToken, priceKinds, p, t, width))
647
648const MINI_W = 96
649const MINI_H = 16
650
651// The strip's small chart: this window's spend so far against the previous window's.
652function miniChartBody(w: ContextBandApiWindow, p: Palette, t: number) {
653 const tone = p.tones[windowTone(w.kind)]
654 const { dur, elapsed, spend } = chartGeometry(w, t)
655 const top = Math.max(...w.bins, ...w.prevBins, 1e-6)
656 const sx = (ms: number) => 1 + (Math.min(dur, Math.max(0, ms)) / dur) * (MINI_W - 2)
657 const sy = (usd: number) => MINI_H - 2 - (usd / top) * (MINI_H - 4)
658 const line = (bins: number[], until: number) =>
659 [`${sx(0).toFixed(1)},${sy(0).toFixed(1)}`]
660 .concat(bins.map((v, i) => `${sx(Math.min((i + 1) * w.binMs, until)).toFixed(1)},${sy(v).toFixed(1)}`))
661 .join(' ')
662 const prev = w.prevBins.length > 0 ? `<polyline points="${line(w.prevBins, dur)}" fill="none" stroke="${p.muted}" stroke-opacity="0.6" stroke-width="1.2"/>` : ''
663 return (
664 `<line x1="1" x2="${MINI_W - 1}" y1="${MINI_H - 2}" y2="${MINI_H - 2}" stroke="${p.divider}" stroke-width="1"/>${prev}` +
665 `<polyline points="${line(w.bins, elapsed)}" fill="none" stroke="${tone.accent}" stroke-width="1.8" stroke-linejoin="round"/>` +
666 `<circle cx="${sx(elapsed).toFixed(1)}" cy="${sy(spend).toFixed(1)}" r="2.4" fill="${tone.accent}"/>`
667 )
668}
669
670// One line about a window: the terminal's hover strip, and the alt text of the desktop's.
671function chartLine(w: ContextBandApiWindow, t: number, usdPerToken: Record<string, number> = {}) {
672 const f = chartFacts(w, t, usdPerToken)
673 const head = w.rateUsd ? `${limitLabel(w.kind)} window ≈ ${fmtUsd(w.rateUsd)} at API prices${roughMark(w)}` : `${limitLabel(w.kind)} window: estimating`
674 return [
675 head,
676 verdictText(f),
677 ...(!f.early && f.budget !== null ? [`spend up to ${fmtRate(f.budget)}${f.per}`] : []),
678 `reset in ${fmtSpan(f.leftMs)}`,
679 ].join(' · ')
680}
681
682// The terminal's 📈 views are small tables: a header row, then one row per window, each column as
683// wide as its widest cell, so the figures for 5h and 7d line up under one another.
684type Seg = { text: string; color?: string; isBold?: boolean }
685type Cell = Seg[]
686const seg = (text: string, color?: string, isBold = false): Seg => ({ text, color, isBold })
687const cellLen = (cell: Cell) => cell.reduce((n, s) => n + [...s.text].length, 0)
688const COL_GAP = 3
689
690function tableLines(rows: Cell[][]) {
691 const widths: number[] = []
692 rows.forEach(row => row.forEach((cell, i) => (widths[i] = Math.max(widths[i] ?? 0, cellLen(cell)))))
693 const total = widths.reduce((a, b) => a + b, 0) + COL_GAP * Math.max(0, widths.length - 1)
694 const lines = rows.map(row =>
695 row.flatMap((cell, i) => (i < row.length - 1 ? [...cell, seg(' '.repeat((widths[i] ?? 0) - cellLen(cell) + COL_GAP))] : cell)),
696 )
697 return { lines, total }
698}
699
700// The first layout that fits the width, richest first; the last one is cut at the edge if need be.
701function fitTable(layouts: Cell[][][], columns: number) {
702 const tables = layouts.map(tableLines)
703 return (tables.find(table => table.total <= columns) ?? tables[tables.length - 1]!).lines
704}
705
706const BAR = 10
707
708// The terminal Chart view: the Chart card's figures, one row per window. The bar is that card's
709// strip in ten cells: the share of the limit used, then where this pace takes it by the reset.
710function chartTable(windows: ContextBandApiWindow[], t: number, usdPerToken: Record<string, number>, p: Palette, columns: number) {
711 type Layout = { reset: boolean; spendOpus: boolean; average: boolean; averageOpus: boolean; previous: 'full' | 'short' | 'none' }
712 const facts = windows.map(w => ({ w, f: chartFacts(w, t, usdPerToken), acc: p.tones[windowTone(w.kind)].accent }))
713 const build = (o: Layout): Cell[][] => {
714 const header: Cell[] = [
715 [seg('At API prices', p.muted)],
716 [seg('At this pace', p.muted)],
717 ...(o.reset ? [[seg('Reset in', p.muted)]] : []),
718 [seg('Spend up to', p.muted)],
719 ...(o.average ? [[seg('Average so far', p.muted)]] : []),
720 ...(o.previous !== 'none' ? [[seg('Previous window', p.muted)]] : []),
721 ]
722 const rows = facts.map(({ w, f, acc }): Cell[] => {
723 const label = limitLabel(w.kind)
724 const used = Math.max(0, Math.min(BAR, Math.round((f.pct / 100) * BAR)))
725 const reach = f.early ? used : f.over ? BAR : Math.max(used, Math.min(BAR, Math.round((f.pace / 100) * BAR)))
726 const verdict = f.early
727 ? seg(f.spend <= 0 ? 'no usage yet' : 'too early to tell', p.muted)
728 : f.over && f.tHit !== null
729 ? seg(`limit in ${fmtSpan((f.tHit - f.el) * f.win)}`, p.danger, true)
730 : seg(`${Math.round(f.pace)}% at reset`, acc, true)
731 const rate = (v: number, color: string, isBold: boolean, withOpus: boolean): Cell => [
732 seg(`${fmtRate(v)}${f.per}`, color, isBold),
733 ...(withOpus ? [seg(` ≈ ${fmtTok3(v / f.opus)} Opus`, p.muted)] : []),
734 ]
735 return [
736 w.rateUsd ? [seg(`${label} ≈ ${fmtUsd(w.rateUsd)}`, acc, true), ...(isRough(w) ? [seg(' (rough)', p.muted)] : [])] : [seg(`${label} estimating`, p.muted)],
737 [seg('█'.repeat(used), acc), seg('▒'.repeat(reach - used), f.over ? p.danger : acc), seg('░'.repeat(BAR - reach), p.divider), seg(' '), verdict],
738 ...(o.reset ? [[seg(fmtSpan(f.leftMs), p.text)]] : []),
739 !f.early && f.budget !== null ? rate(f.budget, f.over ? p.danger : p.text, true, o.spendOpus) : [seg('—', p.muted)],
740 ...(o.average ? [f.avg > 0 ? rate(f.avg, p.text, false, o.averageOpus) : [seg('—', p.muted)]] : []),
741 ...(o.previous !== 'none'
742 ? [
743 f.prevNow !== null && f.prevEnd !== null
744 ? [seg(`${Math.round(f.prevNow)}% by now${o.previous === 'full' ? `, ${Math.round(f.prevEnd)}% at reset` : ''}`, p.muted)]
745 : [seg('—', p.muted)],
746 ]
747 : []),
748 ]
749 })
750 return [header, ...rows]
751 }
752 const layouts: Layout[] = [
753 { reset: true, spendOpus: true, average: true, averageOpus: true, previous: 'full' },
754 { reset: true, spendOpus: true, average: true, averageOpus: true, previous: 'short' },
755 { reset: true, spendOpus: true, average: true, averageOpus: false, previous: 'short' },
756 { reset: true, spendOpus: true, average: true, averageOpus: false, previous: 'none' },
757 { reset: true, spendOpus: true, average: false, averageOpus: false, previous: 'none' },
758 { reset: true, spendOpus: false, average: false, averageOpus: false, previous: 'none' },
759 { reset: false, spendOpus: false, average: false, averageOpus: false, previous: 'none' },
760 ]
761 return fitTable(layouts.map(build), columns)
762}
763
764// The terminal By model view: a column per model, a row per window, each cell the tokens left in
765// that window if you use only that model, then the tokens it has used there.
766function modelsTable(
767 windows: ContextBandApiWindow[],
768 pctOf: (kind: string) => number,
769 models: string[],
770 usdPerToken: Record<string, number>,
771 priceKinds: Record<string, ContextBandPriceKind>,
772 p: Palette,
773 columns: number,
774) {
775 const build = (shown: string[], withUsed: boolean): Cell[][] => {
776 const more = models.length - shown.length
777 const header: Cell[] = [
778 [seg('If you use only…', p.muted)],
779 ...shown.map(model => [seg(modelLabel(model), modelColor(p, model), true)]),
780 ...(more > 0 ? [[seg(`+${more} more`, p.muted)]] : []),
781 ]
782 const rows = windows.map((w): Cell[] => {
783 const pct = pctOf(w.kind)
784 const left = usdLeft(w, pct)
785 return [
786 [seg(limitLabel(w.kind), p.tones[windowTone(w.kind)].accent, true), seg(left !== null ? ` ${fmtUsd(left)} left` : ' estimating', p.text)],
787 ...shown.map((model): Cell => {
788 const tokens = tokensLeft(w, pct, usdPerToken, model)
789 const use = w.byModel.find(m => m.model === model)
790 const mark = priceKinds[model] === 'estimated' ? '≈ ' : ''
791 return [
792 seg(tokens !== null ? `${mark}${fmtTokens(tokens)} left` : '—', p.text, true),
793 ...(withUsed ? [seg(` · ${use ? `${fmtTokens(tokensOf(use))} used` : 'none used'}`, p.muted)] : []),
794 ]
795 }),
796 ]
797 })
798 return [header, ...rows]
799 }
800 const layouts = [build(models, true), build(models, false)]
801 for (let k = models.length - 1; k >= 1; k--) layouts.push(build(models.slice(0, k), false))
802 const lines = models.length > 0 ? fitTable(layouts, columns) : [[seg('No Claude Code usage in these windows yet', p.muted)]]
803 const estimated = models.filter(model => priceKinds[model] === 'estimated').map(modelLabel)
804 return estimated.length > 0 ? [...lines, [seg(`≈ ${estimated.join(', ')}: price estimated until learned`, p.muted)]] : lines
805}
806
807type Pill = {
808 id: string
809 tone: ToneKey
810 icon?: IconName
811 label?: string
812 labelIsAccent?: boolean
813 value: string
814 level?: Level
815 isBold?: boolean
816 sub?: string
817 // Larger numbers give way first when the line runs out of room.
818 priority: number
819 subPriority?: number
820 scope?: string
821}
822
823// Desktop pills are drawn as SVG at a fixed size: plugin Text takes the surface's own size and a
824// one-cell gap, both larger than this band wants. Monospace, so every width is exact.
825const PILL_H = 22
826const PILL_FONT = 12
827const CH = PILL_FONT * 0.6
828const PILL_PAD = 6
829const PILL_ICON = 13
830const SP = 5
831const PILL_GAP = 4
832const MONO = `font-family="ui-monospace, 'SF Mono', SFMono-Regular, Menlo, monospace" font-size="${PILL_FONT}"`
833// The desktop's cell, in CSS pixels, as offsets and widths in cells are drawn there.
834const CELL_PX = 7.7
835
836const pillPx = (pill: Pill) =>
837 PILL_PAD * 2 +
838 (pill.icon ? PILL_ICON + SP : 0) +
839 (pill.label ? pill.label.length * CH + SP : 0) +
840 pill.value.length * CH +
841 (pill.sub ? SP * 2 + 3 + pill.sub.length * CH : 0)
842
843function pillBody(pill: Pill, p: Palette) {
844 const width = Math.ceil(pillPx(pill))
845 const tone = p.tones[pill.tone]
846 const mid = PILL_H / 2
847 const parts: string[] = [`<rect width="${width}" height="${PILL_H}" rx="7" fill="${tone.bg}"/>`]
848 let x = PILL_PAD
849 if (pill.icon) {
850 parts.push(
851 `<g transform="translate(${x} ${mid - PILL_ICON / 2}) scale(${PILL_ICON / 24})" fill="none" stroke="${tone.accent}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">${ICON_PATHS[pill.icon]}</g>`,
852 )
853 x += PILL_ICON + SP
854 }
855 const text = (str: string, color: string, isBold = false) => {
856 parts.push(`<text x="${x.toFixed(1)}" y="${mid}" dominant-baseline="central" fill="${color}"${isBold ? ' font-weight="700"' : ''}>${esc(str)}</text>`)
857 x += str.length * CH
858 }
859 if (pill.label) {
860 text(pill.label, pill.labelIsAccent ? tone.accent : p.muted)
861 x += SP
862 }
863 text(pill.value, levelColor(p, pill.level), pill.isBold)
864 if (pill.sub) {
865 x += SP + 1
866 parts.push(`<rect x="${x.toFixed(1)}" y="${mid - 5.5}" width="1" height="11" fill="${p.divider}"/>`)
867 x += 2 + SP
868 text(pill.sub, p.muted)
869 }
870 return parts.join('')
871}
872
873const pillSvg = (pill: Pill, mode: ContextBandTheme, below = 0) =>
874 themedSvg(Math.ceil(pillPx(pill)) + PILL_GAP, PILL_H + below, MONO, mode, p => pillBody(pill, p))
875
876// Cells the "+N" button takes when some pills sit behind it.
877const MORE_CELLS = 4
878// Room between wrapped rows of pills.
879const ROW_SPACE = 5
880
881const pillAlt = (pill: Pill) => [pill.label, pill.value, pill.sub].filter(Boolean).join(' ')
882
883// The hover strip: one line over the pills to the right of the hovered one, the same height as
884// them. It keeps what fits, in order of importance: the estimate, the verdict, the chart, the rest.
885function stripBody(w: ContextBandApiWindow, pct: number, p: Palette, t: number, width: number) {
886 const tone = p.tones[windowTone(w.kind)]
887 const mid = PILL_H / 2
888 const f = chartFacts(w, t)
889 type Piece = { rank: number; px: number; draw: (x: number) => string }
890 const text = (rank: number, str: string, color: string, isBold = false): Piece => ({
891 rank,
892 px: str.length * CH,
893 draw: x => `<text x="${x.toFixed(1)}" y="${mid}" dominant-baseline="central" fill="${color}"${isBold ? ' font-weight="700"' : ''}>${esc(str)}</text>`,
894 })
895 const pieces: Piece[] = [
896 text(0, `${limitLabel(w.kind)}`, tone.accent),
897 text(0, w.rateUsd ? `≈ ${fmtUsd(w.rateUsd)}` : 'estimating', p.text, true),
898 text(1, `at API prices${roughMark(w)}`, p.muted),
899 ...(f.early ? [] : [text(2, verdictText(f), f.over ? p.danger : p.muted, f.over)]),
900 { rank: 3, px: MINI_W, draw: x => `<g transform="translate(${x.toFixed(1)} ${mid - MINI_H / 2})">${miniChartBody(w, p, t)}</g>` },
901 text(4, `${fmtUsd(w.spendUsd)} spent`, p.muted),
902 ...(w.prevRateUsd ? [text(5, `previous ≈ ${fmtUsd(w.prevRateUsd)}`, p.muted)] : []),
903 ]
904 const room = width - PILL_PAD * 2
905 const kept = new Set<Piece>()
906 let used = 0
907 for (const piece of [...pieces].sort((a, b) => a.rank - b.rank)) {
908 const cost = piece.px + (kept.size > 0 ? SP + 4 : 0)
909 if (used + cost > room && piece.rank > 0) continue
910 kept.add(piece)
911 used += cost
912 }
913 const parts: string[] = [`<rect x="0.5" y="0.5" width="${width - 1}" height="${PILL_H - 1}" rx="7" fill="${p.card}" stroke="${tone.accent}"/>`]
914 let x = PILL_PAD
915 for (const piece of pieces.filter(one => kept.has(one))) {
916 parts.push(piece.draw(x))
917 x += piece.px + SP + 4
918 }
919 return parts.join('')
920}
921
922const stripSvg = (w: ContextBandApiWindow, pct: number, mode: ContextBandTheme, t: number, width: number) =>
923 themedSvg(width, PILL_H, MONO, mode, p => stripBody(w, pct, p, t, width))
924
925const levelOf = (percent: number): Level => (percent >= 85 ? 'danger' : percent >= 60 ? 'warn' : 'normal')
926
927const levelColor = (p: Palette, level: Level | undefined) => (level === 'danger' ? p.danger : level === 'warn' ? p.warn : p.text)
928
929const windowTone = (kind: string): ToneKey => (isFiveHour(kind) ? 'fiveHour' : 'sevenDay')
930
931const terminalWidth = (pill: Pill) =>
932 2 + (pill.icon ? 2 : 0) + (pill.label ? pill.label.length + 1 : 0) + pill.value.length + (pill.sub ? pill.sub.length + 3 : 0)
933
934// Keeps the band on one line: drops a detail, then a whole pill, least important first.
935function fitPills(pills: Pill[], budget: number, widthOf: (pill: Pill) => number) {
936 const list = [...pills]
937 const total = () => list.reduce((sum, pill, i) => sum + widthOf(pill) + (i > 0 ? 1 : 0), 0)
938 while (list.length > 0 && total() > budget) {
939 let worst = { index: 0, isSub: false, rank: -1 }
940 list.forEach((pill, index) => {
941 if (pill.sub && pill.subPriority !== undefined && pill.subPriority > worst.rank) worst = { index, isSub: true, rank: pill.subPriority }
942 if (pill.priority > worst.rank) worst = { index, isSub: false, rank: pill.priority }
943 })
944 const target = list[worst.index]
945 if (!target) break
946 if (worst.isSub) list[worst.index] = { ...target, sub: undefined }
947 else list.splice(worst.index, 1)
948 }
949 return list
950}
951
952// Estimator runs and the band's last measured width: this module's own, started over by a reload.
953const scan = { at: 0, isRunning: false, hasSeeded: false }
954const layout = { surface: '', bodyColumns: 0, saved: '' }
955
956// Each session's cost as Claude Code itself counts it, at current prices, kept so the estimator
957// can learn the price of a model its table does not know (or has wrong): it sets that cost
958// against the tokens each model used in the session.
959const recorded = { usd: -1 }
960
961type SessionCostRecord = { usd: number; since: number; at: number }
962
963async function recordSessionCost($: EngineInterface, usd: number) {
964 if (Math.abs(usd - recorded.usd) < 0.01) return
965 recorded.usd = usd
966 const id = await $.session.id()
967 const since = (await $.session.usage()).startedAt
968 const at = await $.clock.now()
969 const stored = await $.store.get('sessionCosts')
970 const costs: Record<string, SessionCostRecord> = stored && typeof stored === 'object' ? { ...(stored as Record<string, SessionCostRecord>) } : {}
971 costs[id] = { usd, since, at }
972 const newest = Object.entries(costs).sort((a, b) => b[1].at - a[1].at).slice(0, 80)
973 await $.store.set('sessionCosts', Object.fromEntries(newest))
974}
975
976// Runs bin/api_estimate.py: the API-equivalent spend inside each rate-limit window.
977async function scanApi($: EngineInterface, minGapMs: number) {
978 const t = await $.clock.now()
979 if (scan.isRunning || t - scan.at < minGapMs) return
980 const limits = (await read($, usage))?.limits ?? []
981 if (limits.length === 0) return
982 scan.isRunning = true
983 scan.at = t
984 try {
985 const stored = await $.store.get('sessionCosts')
986 const sessionCosts =
987 stored && typeof stored === 'object'
988 ? Object.entries(stored as Record<string, SessionCostRecord>).map(([sessionId, c]) => ({ sessionId, usd: c.usd, since: c.since }))
989 : []
990 const sessionId = await $.session.id().catch(() => null)
991 const arg = JSON.stringify({ now: t, windows: limits.map(l => ({ kind: l.kind, pct: l.percentUsed, resetsAt: l.resetsAt })), sessionCosts, sessionId })
992 const { exitCode, stdout } = await $.process.run(['python3', `${$.plugin.root}/bin/api_estimate.py`, arg], { timeoutMs: 60_000 })
993 const parsed = exitCode === 0 ? parseApi(stdout) : null
994 const current = await read($, api)
995 if (parsed && JSON.stringify(current?.windows) !== JSON.stringify(parsed.windows)) await update($, api, () => parsed)
996 // Loaded partway through a session (installed or reloaded mid-session), the band has counted
997 // nothing yet: it starts from the tokens the transcript says the session has used. Only once
998 // per load, so a reset of the counters stays a reset.
999 if (parsed && !scan.hasSeeded) {
1000 scan.hasSeeded = true
1001 const seen = parseSession(stdout)
1002 if (seen && seen.requests > 0) await update($, stats, s => (s.requests === 0 ? seen : s))
1003 }
1004 } catch {
1005 } finally {
1006 scan.isRunning = false
1007 }
1008}
1009
1010// Kept so the width estimate can be checked against what the surface reports.
1011async function saveLayout($: EngineInterface) {
1012 const seen = JSON.stringify({ surface: layout.surface, bodyColumns: layout.bodyColumns })
1013 if (seen === layout.saved || layout.bodyColumns === 0) return
1014 layout.saved = seen
1015 await $.store.set('layout', { surface: layout.surface, bodyColumns: layout.bodyColumns })
1016}
1017
1018export const register: Register = on => {
1019 // Per-turn generation timing for the main loop, keyed by turnId.
1020 const speed = new Map<string, { tokens: number; ms: number }>()
1021 on('session.start', async ($, e, next) => {
1022 const started = await next(e)
1023 await $.command.register({
1024 name: 'context-band',
1025 description: 'Context band: theme auto|light|dark, hide, show, reset',
1026 argumentHint: '[auto|light|dark|hide|show|reset]',
1027 immediate: true,
1028 })
1029 // A new key, so a choice pinned by an earlier version ("theme") starts over on auto.
1030 const saved = await $.store.get('themeMode')
1031 if (typeof saved === 'string' && (THEMES as readonly string[]).includes(saved)) {
1032 await update($, theme, () => saved as ContextBandTheme)
1033 }
1034 await refreshAppearance($)
1035 await refreshClock($)
1036 await refreshUsage($)
1037 void scanApi($, 0)
1038 $.clock.every(30_000, () => {
1039 void refreshClock($)
1040 void read($, theme).then(mode => (mode === 'auto' ? refreshAppearance($) : undefined))
1041 void scanApi($, 2 * 60_000)
1042 void saveLayout($)
1043 })
1044 return started
1045 })
1046
1047 on('turn.step', async function* ($, e, next) {
1048 const stream = next(e)
1049 let firstAt = 0
1050 for await (const chunk of stream) {
1051 if (firstAt === 0) firstAt = await $.clock.now()
1052 yield chunk
1053 }
1054 const result = await stream.result
1055 try {
1056 const u = result.usage
1057 if (u) {
1058 await update($, stats, s => ({
1059 input: s.input + u.input_tokens,
1060 output: s.output + u.output_tokens,
1061 cacheRead: s.cacheRead + u.cache_read_input_tokens,
1062 cacheWrite: s.cacheWrite + u.cache_creation_input_tokens,
1063 requests: s.requests + 1,
1064 }))
1065 if (!e.agentId && firstAt > 0) {
1066 const ms = (await $.clock.now()) - firstAt
1067 const acc = speed.get(e.turnId) ?? { tokens: 0, ms: 0 }
1068 speed.set(e.turnId, { tokens: acc.tokens + u.output_tokens, ms: acc.ms + ms })
1069 }
1070 }
1071 } catch {}
1072 return result
1073 })
1074
1075 on('turn.complete', async ($, e, next) => {
1076 const done = await next(e)
1077 if (!e.agentId) {
1078 const acc = speed.get(e.turnId)
1079 speed.delete(e.turnId)
1080 const turn: ContextBandTurn = {
1081 durationMs: e.durationMs,
1082 tps: acc && acc.ms > 250 && acc.tokens > 0 ? (acc.tokens * 1000) / acc.ms : null,
1083 model: e.usage?.model ?? null,
1084 }
1085 await update($, lastTurn, prev => (turn.tps === null && prev ? { ...turn, tps: prev.tps, model: turn.model ?? prev.model } : turn))
1086 await refreshClock($)
1087 await refreshUsage($)
1088 }
1089 return done
1090 })
1091
1092 on('session.measure', async ($, e, next) => {
1093 const next_ = toUsage(e)
1094 if (JSON.stringify(await read($, usage)) !== JSON.stringify(next_)) await update($, usage, () => next_)
1095 if (e.cost) {
1096 try {
1097 await recordSessionCost($, e.cost.usd)
1098 } catch {}
1099 }
1100 // After each turn and each move of a limit: a warm scan reads only new transcript lines.
1101 void scanApi($, 15_000)
1102 return next(e)
1103 })
1104
1105 on('config.set', { key: 'theme' }, async ($, e, next) => {
1106 const done = await next(e)
1107 if ((await read($, theme)) === 'auto') await refreshAppearance($)
1108 return done
1109 })
1110
1111 on('command.run', { command: 'context-band' }, async ($, e) => {
1112 const arg = e.args.trim().toLowerCase()
1113 if ((THEMES as readonly string[]).includes(arg)) {
1114 await setTheme($, arg as ContextBandTheme)
1115 return { text: `Context band theme: ${arg}` }
1116 }
1117 if (arg === 'hide' || arg === 'show') {
1118 await update($, isHidden, () => arg === 'hide')
1119 return { text: `Context band ${arg === 'hide' ? 'hidden' : 'shown'}` }
1120 }
1121 if (arg === 'reset') {
1122 await update($, stats, () => EMPTY)
1123 return { text: 'Context band token counters reset' }
1124 }
1125 const mode = await read($, theme)
1126 return { text: `Context band — theme: ${mode}. Usage: /context-band auto|light|dark|hide|show|reset` }
1127 })
1128
1129 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1130 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
1131
1132 const s = await read($, stats)
1133 const u = await read($, usage)
1134 const turn = await read($, lastTurn)
1135 if (s.requests === 0 && !u) return next(e)
1136
1137 const mode = await read($, theme)
1138 const isTerminal = e.surface === 'terminal'
1139 const p = resolveDark(mode, await read($, appearance), isTerminal) ? DARK : LIGHT
1140 const t = (await read($, now)) || (await $.clock.now())
1141 const apiState = await read($, api)
1142 const estimates = apiState?.windows ?? []
1143 const usdPerToken = apiState?.usdPerToken ?? {}
1144 const priceKinds = apiState?.priceKinds ?? {}
1145 // The models every By model card lists: those used in any window, in tier order so rows keep
1146 // their places as costs move, the newest version first within a family.
1147 const used = new Set(estimates.flatMap(w => w.byModel.map(m => m.model)))
1148 const models = [...used].sort((a, b) => tierRank(a) - tierRank(b) || b.localeCompare(a, undefined, { numeric: true }))
1149 layout.surface = e.surface
1150 layout.bodyColumns = e.props.bodyColumns
1151
1152 const pills: Pill[] = []
1153 for (const limit of u?.limits ?? []) {
1154 const isWeek = !isFiveHour(limit.kind)
1155 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
1156 pills.push({
1157 id: `limit-${limit.kind}`,
1158 tone: windowTone(limit.kind),
1159 icon: isWeek ? 'calendar' : 'gauge',
1160 label: limitLabel(limit.kind),
1161 value: fmtPercent(limit.percentUsed),
1162 level: levelOf(limit.percentUsed),
1163 isBold: true,
1164 sub: Number.isFinite(resetsAt) ? fmtLeft(resetsAt - t) : undefined,
1165 priority: isWeek ? 4 : 3,
1166 subPriority: isWeek ? 6.5 : 6,
1167 scope: estimates.some(w => w.kind === limit.kind) ? `cb-${limit.kind}` : undefined,
1168 })
1169 }
1170 if (s.requests > 0) {
1171 pills.push({ id: 'in', tone: 'input', label: 'in', labelIsAccent: true, value: fmtTok3(s.input), priority: 10 })
1172 pills.push({ id: 'out', tone: 'output', label: 'out', labelIsAccent: true, value: fmtTok3(s.output), priority: 8 })
1173 }
1174 if (turn?.tps) {
1175 pills.push({ id: 'speed', tone: 'speed', icon: 'bolt', value: `${Math.round(turn.tps)} t/s`, priority: 5.5 })
1176 }
1177 if (s.requests > 0) {
1178 const cached = s.cacheRead + s.cacheWrite
1179 const prompt = s.input + cached
1180 pills.push({
1181 id: 'cache',
1182 tone: 'cache',
1183 label: 'cache',
1184 labelIsAccent: true,
1185 value: fmtTok3(cached),
1186 sub: prompt > 0 ? `${Math.round((s.cacheRead / prompt) * 100)}% hit` : undefined,
1187 priority: 9,
1188 subPriority: 18,
1189 })
1190 }
1191 if (u?.costUsd !== null && u?.costUsd !== undefined) {
1192 pills.push({ id: 'cost', tone: 'cost', icon: 'coin', value: `$${u.costUsd.toFixed(2)}`, priority: 5 })
1193 }
1194 if (u?.ctxPercent !== null && u?.ctxPercent !== undefined) {
1195 pills.push({
1196 id: 'ctx',
1197 tone: 'ctx',
1198 icon: 'doc',
1199 label: 'ctx',
1200 value: `${Math.round(u.ctxPercent)}%`,types/index.d.ts 86 lines1export type ContextBandStats = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6 requests: number
7}
8
9export type ContextBandLimit = { kind: string; percentUsed: number; resetsAt: string | null }
10
11export type ContextBandUsage = {
12 ctxPercent: number | null
13 ctxTokens: number | null
14 ctxWindow: number
15 limits: ContextBandLimit[]
16 costUsd: number | null
17}
18
19export type ContextBandTurn = { durationMs: number; tps: number | null; model: string | null }
20
21export type ContextBandTheme = 'auto' | 'light' | 'dark'
22
23// What "auto" resolves from: macOS, Claude Code's theme setting (terminal) and the desktop app's own mode.
24export type ContextBandAppearance = { isSystemDark: boolean; cli: 'dark' | 'light' | 'auto'; desktop: 'dark' | 'light' | 'system' }
25
26// One model's share of a window: its API cost and its tokens, as the transcripts record them.
27export type ContextBandModelUse = {
28 model: string
29 usd: number
30 input: number
31 output: number
32 cacheRead: number
33 cacheWrite: number
34}
35
36export type ContextBandChartView = 'chart' | 'models'
37
38// What a rate-limit window is worth at API list prices, from bin/api_estimate.py.
39export type ContextBandApiWindow = {
40 kind: string
41 startAt: number
42 endAt: number
43 binMs: number
44 bins: number[]
45 prevBins: number[]
46 spendUsd: number
47 pct: number
48 prevSpendUsd: number
49 prevPct: number | null
50 rateUsd: number | null
51 prevRateUsd: number | null
52 basis: string | null
53 byModel: ContextBandModelUse[]
54}
55
56// `usdPerToken`: what a token costs on each model at your own mix of input, output and cache, so
57// the dollars left in a window convert to the tokens left if you used only that model.
58// How each model's price was found: the table has it ("list"), it was learned from Claude Code's
59// own session costs ("learned"), or it is borrowed from its family until learned ("estimated").
60export type ContextBandPriceKind = 'list' | 'learned' | 'estimated'
61
62export type ContextBandApi = {
63 at: number
64 windows: ContextBandApiWindow[]
65 usdPerToken: Record<string, number>
66 priceKinds: Record<string, ContextBandPriceKind>
67}
68
69declare module 'claude-code' {
70 interface PluginState {
71 'context-band': {
72 stats: ContextBandStats
73 usage: ContextBandUsage | null
74 turn: ContextBandTurn | null
75 now: number
76 theme: ContextBandTheme
77 appearance: ContextBandAppearance
78 isHidden: boolean
79 api: ContextBandApi | null
80 isChartOpen: boolean
81 chartView: ContextBandChartView
82 isRowExpanded: boolean
83 }
84 }
85}
86