SLOPSHOPPER

usage-band

A band above the prompt showing your 5-hour and 7-day limits, context window and prompt-cache hit rate, styled for the terminal and the desktop app

newbandrowscommandtoasttimer
v2.0.2MITupdated 2026-10-05SorcererAres/CC-Usage-Band/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ usage-band │ ⏺ Read(src/auth.ts) │ usage-band is on: your 5h and 7d limits, │ ⎿ Read 6 lines │ context window and cache hit rate now show │ ⏺ Update(src/auth.ts) │ above the prompt. The limits fill in after │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /usage-band-preview USAGE_BAND_ICONS: auto → using nerd Ghostty 5h ■■■■■■■■ 31% 󰌨 97.4K/200K 󰓾 93% iTerm2 / Warp / WezTerm / kitty 5h ■■■■■■■■ 31% ≡ 97.4K/200K ● 93% macOS Terminal (256 colors) 5h ■■■■■■■■ 31% ≡ 97.4K/200K ● 93% 5h ■■■■■■■■ 31% 󰌨 97.4K/200K 󰓾 93% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ■■■■■■■■ 31% 󰌨 97.4K/200K 󰓾 93%
Command output
USAGE_BAND_ICONS: auto → using nerd Ghostty 5h ■■■■■■■■ 31% 󰌨 97.4K/200K 󰓾 93% iTerm2 / Warp / WezTerm / kitty 5h ■■■■■■■■ 31% ≡ 97.4K/200K ● 93% macOS Terminal (256 colors) 5h ■■■■■■■■ 31% ≡ 97.4K/200K ● 93% ascii fallback 5h ##------ 31% ctx 97.4K/200K hit 93%
README

usage-band

A Claude Code mod that puts a one-line band above the prompt with what you need to keep an eye on while you work:

  • 5h / 7d — how much of your 5-hour and 7-day rate-limit windows is used, and when each resets
  • Context — tokens in the context window out of its size
  • Cache hit — how much of the last turn's input the prompt cache served

The figures stay neutral and the graphics carry Claude's brand colors (blue for the limits, Claude orange for the context, olive for the cache); a metric turns red when it needs attention: by default a limit or the context at 80% or more, or a cache hit rate under 50% (both configurable, see Settings). The limit bars, and on the desktop the context dots, carry a slow shine that sweeps left to right, all in step.

What's new in 2.0.0

  • On the desktop, Claude now draws the text, so it uses the app's own font (Anthropic Sans) and follows light and dark mode.
  • Colors that match Claude: neutral figures, with the bars, dots and icons in Claude's brand colors (since 1.9.0).
  • Warning text at a lightness that reads on both light and dark bands.

Already installed? In a system terminal, run claude plugin marketplace update sorcerer-usage-band, then claude plugin update usage-band@sorcerer-usage-band, and open a new session. The full list is in the changelog.

How it looks

Terminal

5h ■■■■■■■■ 78% · 1h18m  7d ■■■■■■■■ 48% · 5d3h  󰌨 398K/1M  󰓾 100%

The line fits itself to the terminal width: on a narrow terminal it drops the bars first, then the countdowns.

Desktop app (Code tab)

A row of groups (SVG graphics with text in the app's own font) that spreads across the full width of the band and wraps onto a second line when the window is narrow: limit bars that stretch with the window, with the figure and reset time beside them, the context window as a two-row dot matrix that steps up from 2×10 to 2×50 as the window widens (each dot 5%, 2.5%, 2% or 1% of the window), and the cache hit rate. When the context window is the only group (no rate limits and no cache hit rate yet), its name Context sits on the left and the dots on the right. Hairlines separate the groups. It follows the app's light and dark mode.

Install

/plugin marketplace add SorcererAres/CC-Usage-Band
/plugin install usage-band@sorcerer-usage-band

The second command opens the plugin's details: choose Install for you (user scope) there to install it. Then open a new session (or run /reload-plugins). Nothing to configure. Best in Ghostty, which ships the icon font.

If /plugin isn't available (on the desktop, say), run the same commands in a system terminal with claude plugin in place of /plugin.

To update, run both of these in a system terminal (Claude Code has no /plugin update command), then open a new session:

claude plugin marketplace update sorcerer-usage-band
claude plugin update usage-band@sorcerer-usage-band

Settings

Terminal icons are picked automatically: Nerd Font icons in Ghostty, plain Unicode elsewhere. To override, set USAGE_BAND_ICONS in your shell profile to auto, nerd, unicode or ascii, e.g. export USAGE_BAND_ICONS=unicode, then open a new terminal window and start a new session there. Ghostty is recognized by TERM_PROGRAM, which tmux and SSH change or drop, so set nerd there to keep the Nerd Font icons. The VS Code extension panel always uses Unicode icons.

The warning thresholds and the colors are plugin options: limitWarn (default 80), contextWarn (80), cacheWarn (50), and colorFiveHour, colorSevenDay, colorContext, colorCache, colorWarn as #rrggbb or #rgb hex colors. Thresholds run from 0 to 100; an invalid color falls back to its default. Set them in /config or with /plugin configure usage-band@sorcerer-usage-band; in a system terminal, pipe them as JSON, every value as a string, to claude plugin configure usage-band@sorcerer-usage-band --values-stdin and restart Claude Code.

Run /usage-band-preview to see the band in every terminal style side by side, including how a 256-color terminal shows the colors.

Notes

  • The 5h / 7d figures come from your subscription's rate-limit headers, so they appear after the first response of a session and only on a subscription.
  • The cache hit rate is the last turn's cache reads over all its input (uncached + cache reads + cache writes), summed over the turn's requests. It appears once the first turn completes; an interrupted or failed turn has no usage figures, so the previous rate stays.
  • Limit countdowns read 42m under an hour, 3h14m under a day and 5d3h beyond.
  • On the desktop the text is drawn by Claude itself, so it uses the app's own font (Anthropic Sans) and text color in both light and dark mode; only the bars, dots and icons are SVG.

What it can reach

Mods run with the same access as Claude Code itself; they are not sandboxed. This one only:

  • reads the session's usage figures ($.session.usage, session.measure, turn.complete)
  • reads the TERM_PROGRAM and USAGE_BAND_ICONS environment variables to pick terminal icons, and its own plugin options for thresholds and colors
  • registers the /usage-band-preview command, draws the band and shows one welcome toast after install
  • keeps one flag in the plugin's own storage ($.store) so the welcome shows only once

The turn.complete event also carries Claude's reply; the mod takes only its token counts and never reads or keeps the reply. It reads and writes no files itself, runs no processes and makes no network requests. To list every event and interface it uses, clone the repository and run claude plugin validate usage-band from its root.

Development

claude plugin validate .
claude plugin test .

Changelog

See CHANGELOG.md for every version.

License

MIT

Source 2 files
hooks/register.tsx 773 lines
1// usage-band:在输入框上方显示 5h / 7d 额度、上下文窗口和缓存命中率。
2// 整个模组只有这一个文件:前面是配色、读数的格式化、终端与桌面端的排版,
3// 最后的 register 把它们接到引擎的事件上。
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, PluginOptions, Register } from 'claude-code'
6
7import type { Limit, Measure, Style, TurnTokens } from '../types'
8
9// 模组保存在会话里的状态,类型契约见 ../types/index.d.ts
10const measure = atom({ plugin: 'usage-band', key: 'measure' } as const, null)
11const turn = atom({ plugin: 'usage-band', key: 'turn' } as const, null)
12const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
13const phase = atom({ plugin: 'usage-band', key: 'phase' } as const, 0)
14const style = atom({ plugin: 'usage-band', key: 'style' } as const, 'unicode')
15
16// ---- 配色与阈值
17
18// 每项指标一种颜色;只有指标需要注意时才换成警示色。
19// 默认配色取自 Claude 的品牌色:额度用蓝(7d 稍深)、上下文用 Claude 橙、缓存用橄榄绿,
20// 警示用更深的红,与橙色拉开距离。数字与标签保持中性,只有图形带颜色
21// 颜色与阈值:默认值如下,可在 /config 里按插件的 userConfig 字段逐项改
22export type Theme = {
23  hue: { five: string; seven: string; ctx: string; cache: string }
24  warn: string
25  // 额度与上下文达到该百分比变成警示色;缓存命中率低于该百分比变成警示色
26  limitWarn: number
27  contextWarn: number
28  cacheWarn: number
29}
30export const DEFAULT_THEME: Theme = {
31  hue: { five: '#6a9bcc', seven: '#4f7aa6', ctx: '#d97757', cache: '#788c5d' },
32  warn: '#b8433b',
33  limitWarn: 80,
34  contextWarn: 80,
35  cacheWarn: 50,
36}
37// 终端进度条的底轨:暖灰
38const TRACK = '#57534c'
39
40// 把 #rgb / #rrggbb 规整成小写 #rrggbb;不合法时用默认值,免得一个手误让整行颜色算错
41export const parseColor = (v: unknown, fallback: string): string => {
42  const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
43  if (/^#[0-9a-f]{6}$/.test(s)) return s
44  if (/^#[0-9a-f]{3}$/.test(s)) return `#${[...s.slice(1)].map(c => c + c).join('')}`
45  return fallback
46}
47
48// 阈值限制在 0–100;空值、非数字时用默认值
49export const parsePercent = (v: unknown, fallback: number): number => {
50  const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
51  return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : fallback
52}
53
54// 从插件选项(plugin.json 的 userConfig)得到这次加载用的配色与阈值
55export const themeFrom = (o: PluginOptions = {}): Theme => {
56  const d = DEFAULT_THEME
57  return {
58    hue: {
59      five: parseColor(o.colorFiveHour, d.hue.five),
60      seven: parseColor(o.colorSevenDay, d.hue.seven),
61      ctx: parseColor(o.colorContext, d.hue.ctx),
62      cache: parseColor(o.colorCache, d.hue.cache),
63    },
64    warn: parseColor(o.colorWarn, d.warn),
65    limitWarn: parsePercent(o.limitWarn, d.limitWarn),
66    contextWarn: parsePercent(o.contextWarn, d.contextWarn),
67    cacheWarn: parsePercent(o.cacheWarn, d.cacheWarn),
68  }
69}
70
71// ---- 终端图标
72
73// 三套图标与进度条字符:Nerd Font、普通 Unicode、纯 ASCII
74export const GLYPHS: Record<Style, { ctx: string; hit: string; fill: string; track: string }> = {
75  nerd: { ctx: '\u{F0328} ', hit: '\u{F04FE} ', fill: '■', track: '■' },
76  unicode: { ctx: '≡ ', hit: '● ', fill: '■', track: '■' },
77  ascii: { ctx: 'ctx ', hit: 'hit ', fill: '#', track: '-' },
78}
79
80// 自带 Nerd Font 符号、不用用户另装字体的终端
81const NERD_BUILTIN = new Set(['ghostty'])
82
83// 图标样式:手动设置优先;auto 时只有自带 Nerd Font 的终端用 nerd,其余用 unicode
84export const detectStyle = (setting: string, termProgram: string | undefined): Style => {
85  if (setting === 'nerd' || setting === 'unicode' || setting === 'ascii') return setting
86  return NERD_BUILTIN.has((termProgram ?? '').toLowerCase()) ? 'nerd' : 'unicode'
87}
88
89// ---- 读数
90
91// token 数的短写法:950、15.6K、100K、1M
92export const fmtTokens = (n: number): string => {
93  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
94  if (n >= 1_000) return `${+(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}K`
95  return String(n)
96}
97
98// 距重置还有多久:42m、3h14m、5d3h
99export const fmtLeft = (ms: number): string => {
100  const mins = Math.max(0, Math.round(ms / 60_000))
101  const d = Math.floor(mins / 1440)
102  const h = Math.floor((mins % 1440) / 60)
103  const m = mins % 60
104  if (d > 0) return `${d}d${h}h`
105  if (h > 0) return `${h}h${m}m`
106  return `${m}m`
107}
108
109// 缓存命中率:上一轮的缓存读取量占全部输入(未缓存 + 缓存读取 + 缓存写入)的百分比
110export const hitRate = (t: TurnTokens): number | null => {
111  const total = t.input + t.cacheRead + t.cacheWrite
112  // 总量为 0 或有字段缺失(NaN)时都不显示,避免出现 NaN%
113  if (!(total > 0) || !Number.isFinite(t.cacheRead)) return null
114  return Math.round((t.cacheRead / total) * 100)
115}
116
117// 某个额度窗口此刻该显示的读数。重置时间已过而会话里还没有新的响应时,旧读数已经失效:
118// 新窗口要等下一次请求才开始计,所以按 0% 显示,也不再显示停在 0m 的倒计时
119export const limitNow = (l: Limit, at: number): { pct: number; left: string } => {
120  const ms = l.resetsAt ? Date.parse(l.resetsAt) - at : NaN
121  if (ms <= 0) return { pct: 0, left: '' }
122  return { pct: Math.round(l.percentUsed), left: Number.isFinite(ms) ? fmtLeft(ms) : '' }
123}
124
125// 上下文占比;宿主没给 percent 时用 tokens / window 兜底,否则上下文永远不会变红
126export const ctxPercent = (c: Measure['context']): number =>
127  c.percent ?? (c.window > 0 ? ((c.tokens ?? 0) / c.window) * 100 : 0)
128
129// ---- 颜色运算
130
131// 把 #rrggbb 颜色按 |t| 的比例混向白色(t > 0)或黑色(t < 0)
132export const lighten = (hex: string, t: number): string => {
133  const n = parseInt(hex.slice(1), 16)
134  const target = t >= 0 ? 255 : 0
135  const ch = (v: number) => Math.round(v + (target - v) * Math.abs(t)).toString(16).padStart(2, '0')
136  return `#${ch((n >> 16) & 255)}${ch((n >> 8) & 255)}${ch(n & 255)}`
137}
138
139// 最接近的 xterm 256 色,用来预览这一行在 256 色终端里的样子
140export const to256 = (hex: string): string => {
141  const n = parseInt(hex.slice(1), 16)
142  const rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255] as const
143  const levels = [0, 95, 135, 175, 215, 255]
144  const nearest = (v: number) => levels.reduce((a, b) => (Math.abs(b - v) < Math.abs(a - v) ? b : a))
145  const cube = rgb.map(nearest)
146  const avg = (rgb[0] + rgb[1] + rgb[2]) / 3
147  const g = Math.min(238, Math.max(8, 8 + Math.round((avg - 8) / 10) * 10))
148  const dist = (c: readonly number[]) => c.reduce((s, v, i) => s + (v - rgb[i]!) ** 2, 0)
149  const pick = dist(cube) <= dist([g, g, g]) ? cube : [g, g, g]
150  return `#${pick.map(v => v.toString(16).padStart(2, '0')).join('')}`
151}
152
153// ---- 终端:一行字符
154
155// 终端动画每帧的间隔(毫秒)
156const FRAME_MS = 200
157
158// 一段文字:color 缺省时用终端前景色;dim 为次要文字(倒计时、/1M)
159export type Span = { text: string; color?: string; dim?: boolean }
160// 这一行用哪套图标、按哪种色深来画
161export type Look = { style: Style; colors: 'true' | '256' }
162
163// 组与组之间的间隔
164const SEP: Span = { text: '  ' }
165
166// 这一行有多少个字符
167export const width = (spans: Span[]) => spans.reduce((n, s) => n + [...s.text].length, 0)
168
169// 东亚「宽度不确定」(Ambiguous)字符:这一行用到的 · ≡ ■ ●,以及 Nerd Font 图标所在的私用区。
170// 在 CJK 区域设置或开启了「Ambiguous characters are double-width」的终端里,它们占两列。
171// 终端的实际设置读不到,估宽时一律按两列算:宁可早一步降级,也不让这一行溢出折行
172const AMBIGUOUS: readonly (readonly [number, number])[] = [
173  [0x00b7, 0x00b7],
174  [0x2190, 0x26ff],
175  [0xe000, 0xf8ff],
176  [0xf0000, 0x10fffd],
177]
178const cellsOf = (ch: string) => {
179  const cp = ch.codePointAt(0) ?? 0
180  return AMBIGUOUS.some(([lo, hi]) => cp >= lo && cp <= hi) ? 2 : 1
181}
182
183// 这一行在终端里最多占多少列(宽度不确定的字符按两列计)
184export const columns = (spans: Span[]) =>
185  spans.reduce((n, s) => n + [...s.text].reduce((w, c) => w + cellsOf(c), 0), 0)
186
187// 进度条:已填充的部分从深到浅渐变,上面叠一道从左向右移动的柔和扫光
188export const bar = (pct: number, cells: number, color: string, frame: number, s: Style = 'unicode'): Span[] => {
189  const g = GLYPHS[s]
190  const filled = Math.max(pct > 0 ? 1 : 0, Math.min(cells, Math.round((pct / 100) * cells)))
191  // 所有进度条用同一个周期,扫光同步移动
192  const pos = frame % (cells + 5)
193  const spans: Span[] = []
194  for (let i = 0; i < filled; i++) {
195    const shade = filled === 1 ? 0 : -0.15 + (0.35 * i) / (filled - 1)
196    const glow = i === pos ? 0.55 : i === pos - 1 || i === pos + 1 ? 0.25 : 0
197    spans.push({ text: g.fill, color: lighten(lighten(color, shade), glow) })
198  }
199  if (cells > filled) spans.push({ text: g.track.repeat(cells - filled), color: TRACK })
200  return spans
201}
202
203// detail 2:进度条加倒计时;1:去掉进度条;0:只剩数字
204export const layout = (
205  m: Measure | null,
206  t: TurnTokens | null,
207  at: number,
208  detail: 0 | 1 | 2,
209  frame = 0,
210  look: Look = { style: 'unicode', colors: 'true' },
211  theme: Theme = DEFAULT_THEME,
212): Span[] => {
213  const g = GLYPHS[look.style]
214  const groups: Span[][] = []
215
216  const limit = (label: string, l: Limit | undefined, hue: string) => {
217    if (!l) return
218    const { pct, left } = limitNow(l, at)
219    // 标签与数字用终端前景色,超过阈值才变成警示色;进度条带各自的颜色
220    const warn = pct >= theme.limitWarn ? theme.warn : undefined
221    const out: Span[] = [{ text: `${label} `, color: warn }]
222    if (detail === 2) out.push(...bar(pct, 8, warn ?? hue, frame, look.style), { text: ' ' })
223    out.push({ text: `${pct}%`, color: warn })
224    if (detail >= 1 && left) out.push({ text: ` · ${left}`, dim: true })
225    groups.push(out)
226  }
227  limit('5h', m?.rateLimits.find(l => l.kind === 'five_hour'), theme.hue.five)
228  limit('7d', m?.rateLimits.find(l => l.kind === 'seven_day'), theme.hue.seven)
229
230  if (m) {
231    const pct = ctxPercent(m.context)
232    const warn = pct >= theme.contextWarn ? theme.warn : undefined
233    groups.push([
234      { text: g.ctx, color: warn ?? theme.hue.ctx },
235      { text: fmtTokens(m.context.tokens ?? 0), color: warn },
236      { text: `/${fmtTokens(m.context.window)}`, dim: true },
237    ])
238  }
239
240  const hit = t ? hitRate(t) : null
241  if (hit !== null) {
242    const warn = hit < theme.cacheWarn ? theme.warn : undefined
243    groups.push([
244      { text: g.hit, color: warn ?? theme.hue.cache },
245      { text: `${hit}%`, color: warn },
246    ])
247  }
248
249  const spans = groups.flatMap((grp, i) => (i === 0 ? grp : [SEP, ...grp]))
250  return look.colors === '256' ? spans.map(s => (s.color ? { ...s, color: to256(s.color) } : s)) : spans
251}
252
253// 从最详细的样式往下试,取第一个放得下的;连 detail 0 都放不下时也只能用它
254export const fit = (
255  m: Measure | null,
256  t: TurnTokens | null,
257  at: number,
258  cols: number,
259  frame: number,
260  look: Look,
261  theme: Theme = DEFAULT_THEME,
262) => {
263  for (const detail of [2, 1] as const) {
264    const spans = layout(m, t, at, detail, frame, look, theme)
265    if (columns(spans) <= cols) return { spans, detail }
266  }
267  return { spans: layout(m, t, at, 0, frame, look, theme), detail: 0 as const }
268}
269
270// 终端这一行有没有正在扫光的进度条:只有 detail 2 才画进度条,扫光也只落在已填充的格子上
271const hasShine = (m: Measure | null, at: number, detail: 0 | 1 | 2) =>
272  detail === 2 &&
273  (m?.rateLimits ?? []).some(l => (l.kind === 'five_hour' || l.kind === 'seven_day') && limitNow(l, at).pct > 0)
274
275// ---- 桌面端
276
277// 文字交给 Claude 自己画(Text 元素),自动用上界面的 Anthropic Sans 和正文颜色;
278// 图形(进度条、点阵、图标、分隔线)仍是 SVG 图片。SVG 以图片形式插入,拿不到应用加载的网页字体,
279// 所以文字不能放在 SVG 里。图片原地重绘,不会像交互式(框架内)SVG 那样每次重绘都闪一下。
280
281// 每张图形图片高 D.h;纵向位置都相对中线 MID。gap 是组与组之间的最小间距(估宽用),
282// inner 是组内文字与图形的间距估计(对应外层 columnGap 1 列,约 8px)
283const D = { h: 20, gap: 30, inner: 8, barW: 76, barH: 6 }
284const MID = D.h / 2
285// 图标、点阵按大写字母高度(约 10px)居中排布
286const CAP = { top: MID - 5, h: 10 }
287
288// 估宽用的字宽(em,按 13px 的界面字体估算,Anthropic Sans 与 SF Pro 相差不大)。
289// 只用于分配伸缩宽度,误差由外层 space-between 的间距吸收
290const ADVANCE: Record<string, number> = {
291  h: 0.58,
292  d: 0.6,
293  m: 0.9,
294  K: 0.64,
295  M: 0.84,
296  '%': 0.84,
297  '/': 0.36,
298  '.': 0.27,
299  ' ': 0.26,
300}
301const FONT_PX = 13
302const textW = (v: string) =>
303  [...v].reduce((w, c) => w + (c >= '0' && c <= '9' ? 0.62 : (ADVANCE[c] ?? 0.6)), 0) * FONT_PX
304
305// 扫光动画的起点占位符:去重比较、测试里换成 0s,真正绘制时换成按时钟对齐的相位
306const BEGIN = '{{shine-begin}}'
307export const SHINE_MS = 2600
308
309// 图形的颜色:浅色模式略压暗、深色模式略调亮,两种底色上都看得清。元素用 class="gf"(填充)或 "gs"(描边)取 --g
310const paint = (color: string) => `style="--gl:${lighten(color, -0.12)};--gd:${lighten(color, 0.12)}"`
311
312// 图片的配色方案取自应用;同时声明浅色和深色两种方案,图片才会跟随应用并保持透明
313// (方案与应用不一致的框架,背后会垫上一层不透明的画布)。
314const SVG_HEAD =
315  `<style>` +
316  `:root{color-scheme:light dark;background:transparent}` +
317  // 底轨统一用品牌暖灰;图形色经 --g 在两种模式间切换
318  `.track{fill:#b0aea5;fill-opacity:.35}` +
319  `.g{--g:var(--gl)}.gf{fill:var(--g)}.gs{stroke:var(--g)}.rule{fill:#000;fill-opacity:.1}.sep{fill:#000;fill-opacity:.2}` +
320  `@media (prefers-color-scheme:dark){.track{fill-opacity:.22}.g{--g:var(--gd)}.rule{fill:#fff;fill-opacity:.13}.sep{fill:#fff;fill-opacity:.22}}` +
321  `</style>` +
322  `<defs><linearGradient id="shine" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/>` +
323  `<stop offset=".5" stop-color="#fff" stop-opacity=".8"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs>`
324
325// 把图形包成一张完整的 SVG 图片
326const wrap = (width: number, body: string) =>
327  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${D.h}" viewBox="0 0 ${width} ${D.h}" style="color-scheme:light dark;background:transparent">${SVG_HEAD}${body}</svg>`
328
329// 文字的警示色。Text 只能给一个颜色、不能按深浅模式各给一套,所以把警示色调到中间亮度
330// (相对亮度约 0.19),在浅色和深色底上对比度都约 3.7,两边都看得清
331export const warnText = (warn: string): string => {
332  const lum = (hex: string) => {
333    const n = parseInt(hex.slice(1), 16)
334    const f = (v: number) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)
335    return 0.2126 * f((n >> 16) & 255) + 0.7152 * f((n >> 8) & 255) + 0.0722 * f(n & 255)
336  }
337  let lo = -1
338  let hi = 1
339  for (let i = 0; i < 24; i++) {
340    const mid = (lo + hi) / 2
341    if (lum(lighten(warn, mid)) < 0.19) lo = mid
342    else hi = mid
343  }
344  return lighten(warn, (lo + hi) / 2)
345}
346
347// 一段文字:color 缺省时用 Claude 的正文颜色;dim 为次要文字(倒计时、/1M)
348export type DeskSpan = { text: string; color?: string; dim?: boolean }
349// 组内的一项:一段或几段紧挨着的文字,或一张图形
350export type DeskItem = { kind: 'text'; spans: DeskSpan[] } | { kind: 'svg'; svg: string; width: number }
351// 一组:key 区分 5h / 7d / label / ctx / hit;width 是估计宽度(px),只用于分配伸缩宽度
352export type DeskGroup = { key: string; alt: string; width: number; items: DeskItem[] }
353
354const txt = (...spans: DeskSpan[]): DeskItem => ({ kind: 'text', spans })
355const pic = (width: number, body: string): DeskItem => ({ kind: 'svg', svg: wrap(width, body), width })
356const itemW = (it: DeskItem) => (it.kind === 'svg' ? it.width : it.spans.reduce((w, s) => w + textW(s.text), 0))
357const group = (key: string, alt: string, items: DeskItem[]): DeskGroup => ({
358  key,
359  alt,
360  items,
361  width: Math.ceil(items.reduce((w, it) => w + itemW(it), 0) + D.inner * Math.max(0, items.length - 1)),
362})
363
364// 5h ▬▬▬▬▬▬──── 69% │ 1h31m:标签、进度条、读数,再是重置倒计时
365const limitGroup = (
366  key: string,
367  label: string,
368  l: Limit,
369  hue: string,
370  at: number,
371  theme: Theme,
372  barW: number,
373): DeskGroup => {
374  const { pct, left } = limitNow(l, at)
375  const warn = pct >= theme.limitWarn ? theme.warn : undefined
376  const color = warn ?? hue
377  const y = (D.h - D.barH) / 2
378  const r = D.barH / 2
379  const fillW = Math.max(pct > 0 ? D.barH : 0, Math.min(barW, (barW * pct) / 100))
380  const bar = pic(
381    barW,
382    `<defs><clipPath id="c">${`<rect x="0" y="${y}" width="${fillW}" height="${D.barH}" rx="${r}"/>`}</clipPath></defs>` +
383      `<rect x="0" y="${y}" width="${barW}" height="${D.barH}" rx="${r}" class="track"/>` +
384      `<rect x="0" y="${y}" width="${fillW}" height="${D.barH}" rx="${r}" class="g gf" ${paint(color)}/>` +
385      // 扫光走完整条进度条、只在已填充处可见;所有扫光共用同一个时钟,任何时刻都在同一位置
386      `<g clip-path="url(#c)"><rect y="${y}" width="18" height="${D.barH}" fill="url(#shine)">` +
387      `<animate attributeName="x" values="-18;${barW};${barW}" keyTimes="0;0.62;1" dur="2.6s" begin="${BEGIN}" repeatCount="indefinite"/></rect></g>`,
388  )
389  const tc = warn && warnText(warn)
390  const items = [txt({ text: label, color: tc }), bar, txt({ text: `${pct}%`, color: tc })]
391  if (left) {
392    const rule = pic(1, `<rect x="0" y="${CAP.top}" width="1" height="${CAP.h}" class="rule"/>`)
393    items.push(rule, txt({ text: left, dim: true }))
394  }
395  return group(key, `${key === '5h' ? '5-hour' : '7-day'} limit ${pct}% used`, items)
396}
397
398// 三层叠放的薄片:上下文窗口
399const layersIcon = () =>
400  `<g fill="none" class="gs" stroke-width="1.2" stroke-linejoin="round">` +
401  `<path d="M5 0.6 L9.4 2.8 L5 5 L0.6 2.8 Z" class="gf" fill-opacity="0.25"/>` +
402  `<path d="M0.6 5.2 L5 7.4 L9.4 5.2"/><path d="M0.6 7.2 L5 9.4 L9.4 7.2"/></g>`
403
404// 靶心:提示词有多少命中了缓存
405const targetIcon = () =>
406  `<g fill="none" class="gs" stroke-width="1.2">` +
407  `<circle cx="5" cy="5" r="4.4"/><circle cx="5" cy="5" r="2.1"/><circle cx="5" cy="5" r="0.7" class="gf"/></g>`
408
409// 图标画在边长 CAP.h 的方格里,描边的外沿也算在内
410const ICON = CAP.h
411const icon = (draw: () => string, color: string, x = 0) =>
412  `<g transform="translate(${x} ${CAP.top})" class="g" ${paint(color)}>${draw()}</g>`
413
414// 只剩上下文一组时,左侧显示它的名称:和右侧的点阵同属一个模块,上下文超过阈值时一起变成警示色
415export const CTX_LABEL = 'Context'
416
417// 上下文画成两行点阵(默认 2×10,每个点代表窗口的 1/20):先从左到右填满上排,再填下排,
418// 点亮的点上叠着同一道扫光。
419// 桌面端变宽时列数分档增加(点的大小和间距不变),每个点代表的比例相应变小
420// 两排的位置让点的外沿正好贴齐 CAP 区域的上下边:CAP.top + r 与 CAP.top + CAP.h - r
421const DOTS = { cols: 10, pitch: 5, r: 1.6, rows: [CAP.top + 1.6, CAP.top + CAP.h - 1.6] }
422const ctxGroup = (hue: string, tokens: number, window: number, pct: number, cols: number, warn?: string): DeskGroup => {
423  const color = warn ?? hue
424  const lit = Math.min(cols * 2, Math.round((pct / 100) * cols * 2))
425  const mx = ICON + 6
426  const matrixW = cols * DOTS.pitch
427  const dot = (i: number) =>
428    `<circle cx="${mx + DOTS.pitch / 2 + (i % cols) * DOTS.pitch}" cy="${DOTS.rows[Math.floor(i / cols)]}" r="${DOTS.r}"/>`
429  const on = Array.from({ length: lit }, (_, i) => dot(i)).join('')
430  const off = Array.from({ length: cols * 2 - lit }, (_, i) => dot(lit + i)).join('')
431  // 图标和点阵画在同一张图里
432  const graphic = pic(
433    mx + matrixW,
434    icon(layersIcon, color) +
435      `<defs><clipPath id="c">${on}</clipPath></defs>` +
436      `<g class="track">${off}</g>` +
437      `<g class="g gf" ${paint(color)}>${on}</g>` +
438      `<g clip-path="url(#c)"><rect y="${MID - 7}" width="18" height="14" fill="url(#shine)">` +
439      `<animate attributeName="x" values="${mx - 18};${mx + matrixW};${mx + matrixW}" keyTimes="0;0.62;1" dur="2.6s" begin="${BEGIN}" repeatCount="indefinite"/></rect></g>`,
440  )
441  const num = fmtTokens(tokens)
442  const suffix = `/${fmtTokens(window)}`
443  // 读数和 /窗口大小 紧挨在一起,是同一项里的两段文字
444  return group('ctx', `context ${num} of ${fmtTokens(window)}`, [
445    graphic,
446    txt({ text: num, color: warn && warnText(warn) }, { text: suffix, dim: true }),
447  ])
448}
449
450// 桌面端可伸缩部分的尺寸:额度进度条长度(px)与上下文点阵列数
451export type Stretch = { barW: number; dotCols: number }
452export const DEFAULT_STRETCH: Stretch = { barW: D.barW, dotCols: DOTS.cols }
453
454// 桌面端这一行的所有分组,按显示顺序。begin 是扫光起点:比较与测试用 0s,绘制时用按时钟对齐的相位
455export const desktopGroups = (
456  m: Measure | null,
457  t: TurnTokens | null,
458  at: number,
459  theme: Theme = DEFAULT_THEME,
460  stretch: Stretch = DEFAULT_STRETCH,
461  begin = '0s',
462): DeskGroup[] => {
463  const groups: DeskGroup[] = []
464  const five = m?.rateLimits.find(l => l.kind === 'five_hour')
465  const seven = m?.rateLimits.find(l => l.kind === 'seven_day')
466  if (five) groups.push(limitGroup('5h', '5h', five, theme.hue.five, at, theme, stretch.barW))
467  if (seven) groups.push(limitGroup('7d', '7d', seven, theme.hue.seven, at, theme, stretch.barW))
468  const hit = t ? hitRate(t) : null
469  if (m) {
470    const pct = ctxPercent(m.context)
471    const warn = pct >= theme.contextWarn ? theme.warn : undefined
472    // 没有额度、也还没有缓存命中率时(非订阅账号,或会话第一次回复之前)只剩上下文一组:左侧补上它的名称
473    if (!five && !seven && hit === null) {
474      groups.push(group('label', CTX_LABEL, [txt({ text: CTX_LABEL, color: warn && warnText(warn) })]))
475    }
476    groups.push(ctxGroup(theme.hue.ctx, m.context.tokens ?? 0, m.context.window, pct, stretch.dotCols, warn))
477  }
478  if (hit !== null) {
479    const warn = hit < theme.cacheWarn ? theme.warn : undefined
480    groups.push(
481      group('hit', `cache hit ${hit}%`, [
482        pic(ICON, icon(targetIcon, warn ?? theme.hue.cache)),
483        txt({ text: `${hit}%`, color: warn && warnText(warn) }),
484      ]),
485    )
486  }
487  return groups.map(g => ({
488    ...g,
489    items: g.items.map(it => (it.kind === 'svg' ? { ...it, svg: it.svg.split(BEGIN).join(begin) } : it)),
490  }))
491}
492
493// 图形图片的高度(绘制时用)
494export const DESK_H = D.h
495
496// 组与组之间的分隔线(名称和点阵是同一个模块,中间不画)
497export const SEP_SVG = wrap(1, `<rect x="0" y="${MID - 8}" width="1" height="16" class="sep"/>`)
498export const needsSep = (groups: DeskGroup[], i: number) => i > 0 && groups[i - 1]?.key !== 'label'
499
500// 每张图片的 SMIL 时钟从图片载入时起算。把扫光的起点设成「此刻在周期里的相位」取负,
501// 同一次绘制载入的图片就都对齐到同一个时钟上,拆成多张图后扫光依旧同步
502export const shineBegin = (now: number) => `-${((((now % SHINE_MS) + SHINE_MS) % SHINE_MS) / 1000).toFixed(2)}s`
503
504// 桌面端一列约合多少 CSS 像素。宿主只上报列数:实测这一行 95 列、宽约 768px,约 8.1px/列,取 8 偏保守
505export const PX_PER_COL = 8
506// 伸缩部分的上下限(进度条长度 px);估算误差留出的余量。文字改由宿主绘制后字宽只能估算,余量放宽到 48px,
507// 剩下的空隙由外层 space-between 吸收,不会挤到换行
508export const BAR = { min: 40, max: 360, slack: 48 }
509// 点阵列数只取这几档,每个点分别代表 5%、2.5%、2%、1%,点亮几个点始终对应整齐的刻度
510export const DOT_STEPS = [10, 20, 25, 50] as const
511
512// 进度条和上下文点阵随这一行的宽度伸缩:先按伸缩部分为 0 算出其余内容和组间距占多少,
513// 剩下的平分给每条进度条和点阵;点阵取放得下的最大一档列数。没有上报宽度时用默认尺寸
514export const fitStretch = (
515  m: Measure | null,
516  t: TurnTokens | null,
517  at: number,
518  theme: Theme,
519  bodyColumns: number,
520): Stretch => {
521  if (!(bodyColumns > 0)) return DEFAULT_STRETCH
522  const groups = desktopGroups(m, t, at, theme, { barW: 0, dotCols: 0 })
523  const stretchy = groups.filter(g => g.key === '5h' || g.key === '7d' || g.key === 'ctx').length
524  if (stretchy === 0) return DEFAULT_STRETCH
525  // 外层 Box 左右各留 1 列内边距
526  const room = (bodyColumns - 2) * PX_PER_COL - BAR.slack
527  const used = groups.reduce((w, g) => w + g.width, 0) + (groups.length - 1) * D.gap
528  const share = (room - used) / stretchy
529  return {
530    barW: Math.round(Math.min(BAR.max, Math.max(BAR.min, share))),
531    dotCols: [...DOT_STEPS].reverse().find(c => c * DOTS.pitch <= share) ?? DOT_STEPS[0],
532  }
533}
534
535// ---- 状态写入
536
537// 绘制读取的值每写入一次,桌面端就重绘这一行,外框随之闪一下。所以读数只在会改变显示内容时
538// 才写入:新的 token 数取整后还是同一个数字,或者时钟走了一格而所有倒计时都没变,就什么也不写。
539// 比较时要用这次加载的配色与阈值:自定义阈值下跨过阈值只改颜色,用默认值比较会漏掉这次变化
540const shown = (m: Measure | null, t: TurnTokens | null, at: number, theme: Theme) =>
541  JSON.stringify(desktopGroups(m, t, at, theme))
542
543const tick = async ($: EngineInterface, theme: Theme) => {
544  const at = await $.clock.now()
545  const [m, t, was] = [await read($, measure), await read($, turn), await read($, now)]
546  if (was && shown(m, t, was, theme) === shown(m, t, at, theme)) return
547  await update($, now, () => at)
548}
549
550const setMeasure = async ($: EngineInterface, next: Measure, theme: Theme) => {
551  const [m, t, at] = [await read($, measure), await read($, turn), await read($, now)]
552  if (m && shown(m, t, at, theme) === shown(next, t, at, theme)) return
553  await update($, measure, () => next)
554}
555
556const setTurn = async ($: EngineInterface, next: TurnTokens, theme: Theme) => {
557  const [m, t, at] = [await read($, measure), await read($, turn), await read($, now)]
558  if (t && shown(m, t, at, theme) === shown(m, next, at, theme)) return
559  await update($, turn, () => next)
560}
561
562// ---- 预览命令与欢迎提示
563
564const PREVIEW = 'usage-band-preview'
565
566// 会话里还没有读数时,预览用这组示例数据
567const SAMPLE_MEASURE: Measure = {
568  context: { tokens: 176_000, window: 1_000_000, percent: 18 },
569  rateLimits: [
570    { kind: 'five_hour', percentUsed: 42 },
571    { kind: 'seven_day', percentUsed: 43 },
572  ],
573}
574const SAMPLE_TURN: TurnTokens = { input: 900, output: 2_000, cacheRead: 170_000, cacheWrite: 1_000 }
575
576// 预览里逐个画出的终端样式
577const PROFILES: { name: string; look: Look }[] = [
578  { name: 'Ghostty', look: { style: 'nerd', colors: 'true' } },
579  { name: 'iTerm2 / Warp / WezTerm / kitty', look: { style: 'unicode', colors: 'true' } },
580  { name: 'macOS Terminal (256 colors)', look: { style: 'unicode', colors: '256' } },
581  { name: 'ascii fallback', look: { style: 'ascii', colors: '256' } },
582]
583
584// 图标样式的手动设置,读取自 USAGE_BAND_ICONS。用环境变量而不是插件选项,
585// 这样新装的用户没有任何需要配置的东西。
586const ICONS_ENV = 'USAGE_BAND_ICONS'
587
588// 安装后第一次加载时显示的欢迎提示
589export const WELCOME =
590  'usage-band is on: your 5h and 7d limits, context window and cache hit rate now show above the prompt. ' +
591  'The limits fill in after Claude’s first reply.'
592
593// ---- 注册
594
595export const register: Register = (on, options) => {
596  // 选项在 /config 里改动时,模组会带着新选项整体重新加载,所以这里读一次即可
597  const theme = themeFrom(options)
598  let setting = 'auto'
599  // 终端靠重绘来播放动画;计时器由第一次绘制启动,所以只用桌面端的会话不会运行它。
600  // 重新加载时计时器和这个标记一起丢弃。
601  let isAnimating = false
602  // 上一次终端绘制里是否有正在扫光的进度条。帧计数只在它为真时推进一格,并由下一次绘制重新置位:
603  // 进度条不在屏上(问卷占位、窄屏去掉了进度条、没有额度数据)时帧计数就停下,不再每秒重绘 5 次;
604  // 回到屏上的那次绘制会让它继续
605  let isShining = false
606  // 桌面端上一次画出的分组。显示内容不变时原样复用,图片不会重新载入;
607  // 内容一变就整组按新相位重建,所有图片一起重新载入,扫光仍然对齐
608  let lastBand = ''
609  let lastGroups: DeskGroup[] = []
610
611  on('session.start', async ($, e, next) => {
612    const result = await next(e)
613    // 必须写字面量而不能用 ICONS_ENV:引擎靠静态分析列出模组读取的环境变量,传变量会拒绝加载
614    setting = ((await $.env.get('USAGE_BAND_ICONS')) ?? 'auto').trim().toLowerCase() || 'auto'
615    const term = await $.env.get('TERM_PROGRAM')
616    await update($, style, () => detectStyle(setting, term))
617    await $.command.register({
618      name: PREVIEW,
619      description: 'Preview how the usage band looks in different terminals',
620    })
621    // 安装后只欢迎一次,让新用户知道输入框上方多出来的是什么
622    if ((await $.store.get('welcomed')) !== true) {
623      await $.store.set('welcomed', true)
624      $.ui.toast(WELCOME, { timeoutMs: 12_000 })
625    }
626    const usage = await $.session.usage()
627    await setMeasure($, { context: usage.context, rateLimits: usage.rateLimits }, theme)
628    await tick($, theme)
629    $.clock.every(60_000, () => {
630      void tick($, theme)
631    })
632    return result
633  })
634
635  on('session.measure', async ($, e, next) => {
636    const m: Measure = {
637      context: { tokens: e.context.tokens, window: e.context.window, percent: e.context.percent },
638      rateLimits: e.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
639    }
640    await setMeasure($, m, theme)
641    await tick($, theme)
642    return next(e)
643  })
644
645  on('turn.complete', async ($, e, next) => {
646    // 只统计主循环;子代理运行时会各自触发 turn.complete
647    if (e.agentId === undefined && e.usage) {
648      const u = e.usage
649      await setTurn($, {
650        input: u.input_tokens,
651        output: u.output_tokens,
652        cacheRead: u.cache_read_input_tokens,
653        cacheWrite: u.cache_creation_input_tokens,
654      }, theme)
655    }
656    return next(e)
657  })
658
659  on('command.run', { command: PREVIEW }, async () => ({
660    text: `usage-band style preview (${ICONS_ENV}: ${setting})`,
661  }))
662
663  on('ui.render', { component: 'CommandOutput', props: { command: PREVIEW } }, async ($, e) => {
664    const m = (await read($, measure)) ?? SAMPLE_MEASURE
665    const t = (await read($, turn)) ?? SAMPLE_TURN
666    const at = await read($, now)
667    const current = await read($, style)
668    const { Box, Text } = $.ui.resolve(e)
669    return (
670      <Box flexDirection="column">
671        <Text dimColor>
672          {ICONS_ENV}: {setting} → using {current}
673        </Text>
674        {PROFILES.map(p => (
675          <Box key={p.name} flexDirection="column" marginTop={1}>
676            <Text dimColor>{p.name}</Text>
677            <Box flexDirection="row">
678              {layout(m, t, at, 2, 3, p.look, theme).map((s, i) => (
679                <Text key={`s${i}`} color={s.color} dimColor={s.dim}>
680                  {s.text}
681                </Text>
682              ))}
683            </Box>
684          </Box>
685        ))}
686      </Box>
687    )
688  })
689
690  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
691    const m = await read($, measure)
692    const t = await read($, turn)
693    const at = await read($, now)
694    if (e.props.hasSurvey || (m === null && t === null)) return next(e)
695
696    // 桌面端和移动端的动画在 SVG 内部完成,所以不读取帧计数
697    if (e.surface === 'desktop' || e.surface === 'mobile') {
698      const { Box, Svg, Text } = $.ui.resolve(e)
699      const stretch = fitStretch(m, t, at, theme, e.props.bodyColumns)
700      // 宽度变了(进度条长度、点阵列数变了)也要整组重建
701      const band = `${stretch.barW}|${stretch.dotCols}|${shown(m, t, at, theme)}`
702      if (band !== lastBand) {
703        lastBand = band
704        lastGroups = desktopGroups(m, t, at, theme, stretch, shineBegin(await $.clock.now()))
705      }
706      const groups = lastGroups
707      // 文字由 Claude 绘制:不指定颜色时就是界面的正文颜色,深浅模式自动切换
708      const span = (sp: DeskSpan, key: string) => (
709        <Text key={key} {...(sp.color ? { color: sp.color } : {})} dimColor={sp.dim === true}>
710          {sp.text}
711        </Text>
712      )
713      // 每组的第一张图带上这一组的说明,供读屏软件使用;其余图形是装饰
714      const item = (it: DeskItem, key: string, alt: string) =>
715        it.kind === 'svg' ? (
716          <Svg key={key} source={it.svg} alt={alt} width={it.width} height={DESK_H} />
717        ) : it.spans.length === 1 ? (
718          span(it.spans[0]!, key)
719        ) : (
720          <Box key={key} flexDirection="row">
721            {it.spans.map((sp, i) => span(sp, `${key}-${i}`))}
722          </Box>
723        )
724      // 两组及以上时组间距平分剩余空间;只有一组时居中,免得整行偏向左边
725      return (
726        <Box
727          flexDirection="row"
728          flexWrap="wrap"
729          justifyContent={groups.length > 1 ? 'space-between' : 'center'}
730          alignItems="center"
731          flexGrow={1}
732          paddingX={1}
733        >
734          {groups.flatMap((g, i) => [
735            ...(needsSep(groups, i)
736              ? [<Svg key={`sep-${g.key}`} source={SEP_SVG} alt="" width={1} height={DESK_H} />]
737              : []),
738            <Box key={g.key} flexDirection="row" alignItems="center" columnGap={1}>
739              {g.items.map((it, j) =>
740                item(it, `${g.key}-${j}`, j === g.items.findIndex(x => x.kind === 'svg') ? g.alt : ''),
741              )}
742            </Box>,
743          ])}
744        </Box>
745      )
746    }
747
748    if (!isAnimating) {
749      isAnimating = true
750      $.clock.every(FRAME_MS, () => {
751        if (!isShining) return
752        isShining = false
753        void update($, phase, f => (f ?? 0) + 1)
754      })
755    }
756    const frame = await read($, phase)
757    // 图标取决于终端的字体;其他界面(vscode)一律用普通字符
758    const s = e.surface === 'terminal' ? await read($, style) : 'unicode'
759    const { spans, detail } = fit(m, t, at, e.props.bodyColumns - 2, frame, { style: s, colors: 'true' }, theme)
760    isShining = hasShine(m, at, detail)
761    const { Box, Text } = $.ui.resolve(e)
762    return (
763      <Box flexDirection="row" flexWrap="wrap" paddingX={1}>
764        {spans.map((sp, i) => (
765          <Text key={`s${i}`} color={sp.color} dimColor={sp.dim}>
766            {sp.text}
767          </Text>
768        ))}
769      </Box>
770    )
771  })
772}
773
types/index.d.ts 30 lines
1// 一个额度窗口:kind 如 five_hour、seven_day,percentUsed 是已用的百分比,resetsAt 是重置时间(ISO 8601)
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3// 一次用量读数:上下文窗口的占用,和各个额度窗口
4export type Measure = {
5  context: { tokens?: number; window: number; percent?: number }
6  rateLimits: Limit[]
7}
8// 上一轮对话的 token 用量,用来算缓存命中率
9export type TurnTokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
10// 终端图标样式
11export type Style = 'nerd' | 'unicode' | 'ascii'
12
13// 模组保存在会话里的状态,各个键与 hooks/register.tsx 里的 atom 一一对应
14declare module 'claude-code' {
15  interface PluginState {
16    'usage-band': {
17      // 最近一次用量读数
18      measure: Measure | null
19      // 上一轮的 token 用量
20      turn: TurnTokens | null
21      // 计算倒计时用的时刻(毫秒),只在显示内容会变时才更新
22      now: number
23      // 终端扫光的帧计数
24      phase: number
25      // 终端图标样式
26      style: Style
27    }
28  }
29}
30