SLOPSHOPPER

context-band

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

newbandcommandprocesstimer
★ 1v0.8.1no licenseupdated 2026-10-03EricJamie/claude-code-mods/plugins/context-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /context-band ⎿ context-band: Context band — theme: auto. Usage: /context-band auto|light|dark|hide|show|reset ◔ 5h 31% $ $0.42 ▤ ctx 49% │ 97.4k/200k ◐ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◔ 5h 31% $ $0.42 ▤ ctx 49% │ 97.4k/200k ◐
README

claude-code-mods

Mods for Claude Code, distributed as a plugin marketplace:

  • context-band: a band above the prompt with rate limits, tokens, speed, cache, cost and context
  • cache-timer: a countdown to when the conversation's prompt cache expires, before the model name

And the band for other agents: Grok Build (a status line script) and Codex (built-in status line settings).

中文说明

context-band

A band above the prompt, in the desktop app and the CLI, that updates after every turn:

  • 5h / 7d rate-limit usage and the time until each resets
  • in / out tokens, t/s output speed, cache tokens and hit rate
  • $ the session's cost at API prices, ctx how full the context window is

<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:

  • Chart: where the window ends at your pace (or when you hit the limit), your average spend rate beside the rate that lands on 100% at the reset, and a strip chart against the limit and last week
  • By model: for each model, the tokens left if you use only that model (with bars to compare), and the tokens it has used

<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.

Install

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.

Commands

/context-band auto|light|dark theme · hide / show the band · reset the token counters

How the estimates work

  • A window runs from its reset time back 5 hours or 7 days.
  • $ at 100% = what Claude Code spent in the window at API list prices ÷ the % of the window used. Under 5% used it is marked "(rough)".
  • Tokens left if one model does it all = the dollars left in the window ÷ what a token costs on that model at your own mix of input, output and cache (your last 7 days, priced as if they had all gone to that model).
  • Spend comes from the Claude Code transcripts on this machine; usage elsewhere (claude.ai, other machines) counts toward the % but not the $, so the estimates read low if you use those a lot. The estimates assume the limits weigh models by API price.

Requirements and privacy

  • python3 (the estimator, plugins/context-band/bin/api_estimate.py)
  • macOS for the automatic theme and the desktop usage history; elsewhere those fall back quietly
  • Everything stays on your machine: the mod reads local files and makes no network calls. Its cache is ~/.cache/context-band/.

Prices and new models

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.

Development

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.

cache-timer

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)集合,以插件市场的形式发布:

  • context-band:输入框上方的状态栏,显示用量限额、token、速度、缓存、花费和上下文
  • cache-timer:在模型名称前面显示对话缓存到期的倒计时

其他工具的状态栏:Grok Build(状态行脚本)和 Codex(内置状态行配置)。

context-band 状态栏

显示在输入框上方(桌面端和命令行都支持),每轮对话后自动更新:

  • 5h / 7d:5 小时和 7 天用量限额的使用比例,以及距离重置的时间
  • in / out:输入、输出 token;t/s:输出速度;cache:缓存 token 和命中率
  • $:本次会话按 API 价格计算的花费;ctx:上下文窗口的使用比例

<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 价格折算的估值。点击 📈 打开两个视图:

  • Chart(图表):按当前速度到重置时会用到多少(或何时触顶)、目前的平均花费速度和刚好在重置时用满的速度,以及与额度线和上周对比的小图
  • By model(按模型):如果只用某个模型还能用多少 token(带对比条),以及该模型已用的 token

<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 计数

估算方法

  • 窗口从重置时间往前推 5 小时或 7 天。
  • 100% 估值 = 窗口内 Claude Code 按 API 价格的花费 ÷ 已用比例。已用不足 5% 时标注"(rough)",表示还不准确。
  • 只用某个模型还能用多少 token = 窗口剩余金额 ÷ 该模型在你的使用习惯下每个 token 的价格(把你最近 7 天的请求全部按该模型重新计价)。
  • 花费来自本机的 Claude Code 对话记录。在其他地方的使用(claude.ai、其他电脑)会占用额度,但不会计入金额,所以如果你经常用这些,估值会偏低。估算假设额度按 API 价格对不同模型加权。

依赖与隐私

  • 需要 python3(估算脚本 plugins/context-band/bin/api_estimate.py)
  • 自动主题和桌面端用量历史仅支持 macOS,其他系统会自动跳过
  • 所有数据都留在本机:插件只读取本地文件,不联网。缓存位于 ~/.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-timer 缓存倒计时

在输入框下方、模型名称前面显示 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。

倒计时以主对话自己的请求为准。插件看不到的一些后台请求也可能刷新缓存,所以实际过期时间可能比显示的稍晚一些。

Source 2 files
hooks/register.tsx 1368 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 lines
1export 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