Ring gauges above the prompt for context, 5h/7d limits with pace and forecast, spend and output speed; tiles or minimal layout, per-part toggles, themes…

English | 简体中文

Early version: the layout and what the band shows are still evolving.
A Claude Code mod that keeps a usage band above the prompt, drawn as ring gauges with the percentage inside:
full ~1h57m and the ring turns ambersession, then today's and this month's totals, either across every session (all:) or for this session alone (session:); optionally per turn and per hour~58 tok/s) and exact once it ends, plus optionally cache hits and time to first tokenRing colors follow the level: ok under 60%, warn under 85%, danger above. In the terminal the rings become ○ ◔ ◑ ◕ ● glyphs, and the pace tick an arrow (▲ ahead of pace, ▽ behind).
| Layout | What it looks like |
|---|---|
tiles (default) | Each ring with a two-line label: the name, then the forecast when there is one, else the reset time. Spend and speed are a tile each: the first figure on top, the rest below. Sized to sit in one row beside the desktop prompt box. |
minimal | Rings and the leading spend and speed figures only. |
Run /rings settings to open a pane where you pick the layout, theme, language and totals scope, switch each part on or off, choose where new sessions show the band, and set colors. The same can be done from the prompt:
| Command | Does |
|---|---|
/rings | Hide or show the band in this session |
/rings mode tiles / /rings mode minimal | Switch the layout |
/rings lang en / /rings lang zh | English (default) or Chinese |
/rings scope all / /rings scope session | Whether today and month total every session (default) or this session alone |
/rings show <parts> / /rings hide <parts> | Switch parts on or off, several at once, or all |
/rings theme <name> | default, ocean, grape or mono |
/rings color <slot> <#hex> | Override one color; reset in place of the color follows the theme again |
/rings default desktop on / /rings default terminal off | Whether new sessions on that surface start with the band shown; /rings default alone shows the current choice |
/rings reset | Back to the defaults, keeping the language |
Parts: ctx, 5h, 7d, models (per-model and spend limits), resets, pace, forecast, session, day, month, turn, rate ($/h), cache, speed, ttft. All are on by default except turn, rate, cache and ttft.
Color slots: ok, warn, danger (ring levels), pace (the pace tick), track (the ring background), accent (session spend).
New sessions show the band in the desktop app and hide it in the terminal; VS Code and mobile follow the desktop choice. A hidden band still counts spend, so today's and this month's totals include every session.
Settings are kept in Claude Code's plugin storage and shared by every session; a session already running picks up changes made in another one when it starts again.
At the prompt of a Claude Code terminal session:
/plugin install usage-rings --marketplace shiqkuangsan/claude-mod-usage-statusbar
From a shell:
claude plugin install usage-rings --marketplace shiqkuangsan/claude-mod-usage-statusbar
In the Claude desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with shiqkuangsan/claude-mod-usage-statusbar, then Add plugin.
Requires Claude Code 2.1.287 or later.
– until then.MIT
hooks/register.tsx 1021 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage, Timer } from 'claude-code'
3
4import type { Ledger, Limit, Mode, Part, Preset, RingSettings, Scope, Slot, Snapshot, StartSurface } from '../types'
5
6const PART_IDS: Part[] = [
7 'ctx',
8 '5h',
9 '7d',
10 'models',
11 'resets',
12 'pace',
13 'forecast',
14 'session',
15 'day',
16 'month',
17 'turn',
18 'rate',
19 'cache',
20 'speed',
21 'ttft',
22]
23const OFF_BY_DEFAULT: Part[] = ['turn', 'rate', 'cache', 'ttft']
24
25const SLOTS: Slot[] = ['ok', 'warn', 'danger', 'pace', 'track', 'accent']
26const PRESETS: Record<Preset, Record<Slot, string>> = {
27 default: { ok: '#22c55e', warn: '#f59e0b', danger: '#ef4444', pace: '#a3a3a3', track: '#8a8a8a', accent: '#22c55e' },
28 ocean: { ok: '#38bdf8', warn: '#fbbf24', danger: '#f43f5e', pace: '#94a3b8', track: '#64748b', accent: '#38bdf8' },
29 grape: { ok: '#a78bfa', warn: '#f472b6', danger: '#ef4444', pace: '#a3a3a3', track: '#8a8a8a', accent: '#a78bfa' },
30 mono: { ok: '#a3a3a3', warn: '#d97706', danger: '#dc2626', pace: '#737373', track: '#8a8a8a', accent: '#a3a3a3' },
31}
32
33const DEFAULTS: RingSettings = {
34 mode: 'tiles',
35 lang: 'en',
36 parts: Object.fromEntries(PART_IDS.map(id => [id, !OFF_BY_DEFAULT.includes(id)])) as Record<Part, boolean>,
37 preset: 'default',
38 colors: {},
39 startShown: { desktop: true, terminal: false },
40 scope: 'all',
41}
42const START_SURFACES: StartSurface[] = ['desktop', 'terminal']
43
44// Every word the mod shows, in each language it speaks.
45const TEXT = {
46 en: {
47 parts: {
48 ctx: 'Context',
49 '5h': '5h limit',
50 '7d': '7d limit',
51 models: 'Other limits',
52 resets: 'Reset countdown',
53 pace: 'Pace tick',
54 forecast: 'Forecast',
55 session: 'Session spend',
56 day: 'Today spend',
57 month: 'Month spend',
58 turn: 'Turn spend',
59 rate: 'Spend per hour',
60 cache: 'Cache hits',
61 speed: 'Tokens/s',
62 ttft: 'First token',
63 } as Record<Part, string>,
64 slots: { ok: 'ok', warn: 'warn', danger: 'danger', pace: 'pace tick', track: 'track', accent: 'accent' } as Record<
65 Slot,
66 string
67 >,
68 ctx: 'ctx',
69 spendLimit: 'spend',
70 full: (x: string) => `full ~${x}`,
71 lasts: 'lasts to reset',
72 used: (label: string, p: string) => `${label}: ${p} used`,
73 resetsIn: (x: string) => `resets in ${x}`,
74 gone: (p: number) => `${p}% of the window gone`,
75 sessionLabel: 'session',
76 totals: (scope: Scope, figures: string) => `${scope === 'all' ? 'all' : 'session'}: ${figures}`,
77 today: (v: string) => `today ${v}`,
78 month: (v: string) => `mo ${v}`,
79 turn: (v: string) => `${v}/turn`,
80 rate: (v: string) => `${v}/h`,
81 cache: (p: number) => `${p}% cache`,
82 ttft: (s: string) => `ttft ${s}s`,
83 paneTitle: 'Usage rings',
84 layout: 'Layout',
85 modes: { tiles: 'Tiles', minimal: 'Minimal' } as Record<Mode, string>,
86 theme: 'Theme',
87 language: 'Language',
88 show: 'Show',
89 colors: 'Colors',
90 colorHint: 'Empty a color field to follow the theme. #rgb or #rrggbb.',
91 scope: 'Totals',
92 scopes: { all: 'All sessions', session: 'This session' } as Record<Scope, string>,
93 startTitle: 'Show in new sessions',
94 surfaces: { desktop: 'Desktop', terminal: 'Terminal' } as Record<StartSurface, string>,
95 resetAll: 'Reset to defaults',
96 done: 'Done',
97 set: 'set',
98 hidden: 'Usage rings hidden. Run /rings to show them again.',
99 shown: 'Usage rings shown.',
100 opened: 'Usage rings settings opened.',
101 notOpened: (why: string) => `Settings pane not shown: ${why}`,
102 modeHelp: 'Modes: tiles, minimal.',
103 modeSet: (m: string) => `Layout: ${m}.`,
104 langHelp: 'Languages: en, zh.',
105 langSet: 'Language: English.',
106 scopeHelp: 'Totals: all (every session), session (this one).',
107 scopeSet: (x: Scope) => `Today and month now total ${x === 'all' ? 'every session' : 'this session'}.`,
108 partsHelp: (all: string) => `Name one or more parts: ${all}, all.`,
109 partsSet: (on: boolean, ids: string, unknown: string) =>
110 `${on ? 'Showing' : 'Hiding'} ${ids}.${unknown ? ` Unknown: ${unknown}.` : ''}`,
111 themeHelp: (all: string) => `Themes: ${all}.`,
112 themeSet: (t: string) => `Theme: ${t}.`,
113 slotHelp: (all: string) => `Color slots: ${all}.`,
114 colorBad: 'Give the color as #rgb or #rrggbb, or reset.',
115 colorReset: (slot: string) => `${slot} follows the theme again.`,
116 colorSet: (slot: string, v: string) => `${slot}: ${v}.`,
117 resetDone: 'Usage rings settings reset to defaults.',
118 defaultHelp: (now: string) => `Usage: /rings default desktop|terminal on|off. New sessions now: ${now}.`,
119 defaultNow: (surface: string, on: boolean) => `${surface} ${on ? 'shown' : 'hidden'}`,
120 defaultSet: (surface: string, on: boolean) =>
121 `New ${surface} sessions start with the band ${on ? 'shown' : 'hidden'}. This session is unchanged; /rings toggles it.`,
122 help: (parts: string, themes: string, slots: string) =>
123 [
124 'Usage: /rings [settings | mode tiles|minimal | lang en|zh | scope all|session | show <parts> | hide <parts> | theme <name> | color <slot> <#hex|reset> | default desktop|terminal on|off | reset]',
125 `Parts: ${parts}, all`,
126 `Themes: ${themes}`,
127 `Color slots: ${slots}`,
128 'Run /rings alone to hide or show the band in this session.',
129 ].join('\n'),
130 },
131 zh: {
132 parts: {
133 ctx: '上下文',
134 '5h': '5h 额度',
135 '7d': '7d 额度',
136 models: '其他额度',
137 resets: '重置倒计时',
138 pace: '时间刻度',
139 forecast: '用满预测',
140 session: '本会话花费',
141 day: '今日花费',
142 month: '本月花费',
143 turn: '本轮花费',
144 rate: '每小时花费',
145 cache: '缓存命中',
146 speed: '输出速度',
147 ttft: '首 token',
148 } as Record<Part, string>,
149 slots: { ok: '正常', warn: '警告', danger: '危险', pace: '时间刻度', track: '底环', accent: '强调' } as Record<
150 Slot,
151 string
152 >,
153 ctx: '上下文',
154 spendLimit: '花费上限',
155 full: (x: string) => `约 ${x} 用满`,
156 lasts: '撑得到重置',
157 used: (label: string, p: string) => `${label}:已用 ${p}`,
158 resetsIn: (x: string) => `${x} 后重置`,
159 gone: (p: number) => `时间已过 ${p}%`,
160 sessionLabel: '本会话',
161 totals: (scope: Scope, figures: string) => `${scope === 'all' ? '全部' : '本会话'}:${figures}`,
162 today: (v: string) => `今日 ${v}`,
163 month: (v: string) => `本月 ${v}`,
164 turn: (v: string) => `本轮 ${v}`,
165 rate: (v: string) => `${v}/小时`,
166 cache: (p: number) => `缓存 ${p}%`,
167 ttft: (s: string) => `首 token ${s}s`,
168 paneTitle: '用量圆环',
169 layout: '布局',
170 modes: { tiles: '双行小卡', minimal: '极简' } as Record<Mode, string>,
171 theme: '主题',
172 language: '语言',
173 show: '显示',
174 colors: '颜色',
175 colorHint: '清空颜色框即跟随主题。格式 #rgb 或 #rrggbb。',
176 scope: '合计范围',
177 scopes: { all: '所有会话', session: '本会话' } as Record<Scope, string>,
178 startTitle: '新会话默认显示',
179 surfaces: { desktop: '桌面端', terminal: '终端' } as Record<StartSurface, string>,
180 resetAll: '恢复默认',
181 done: '完成',
182 set: '设置',
183 hidden: '用量圆环已隐藏,再次运行 /rings 显示。',
184 shown: '用量圆环已显示。',
185 opened: '已打开用量圆环设置。',
186 notOpened: (why: string) => `设置面板未显示:${why}`,
187 modeHelp: '布局:tiles、minimal。',
188 modeSet: (m: string) => `布局:${m}。`,
189 langHelp: '语言:en、zh。',
190 langSet: '语言:中文。',
191 scopeHelp: '合计范围:all(所有会话)、session(本会话)。',
192 scopeSet: (x: Scope) => `今日和本月现在统计${x === 'all' ? '所有会话' : '本会话'}。`,
193 partsHelp: (all: string) => `请写出一个或多个组件:${all}、all。`,
194 partsSet: (on: boolean, ids: string, unknown: string) =>
195 `已${on ? '显示' : '隐藏'} ${ids}。${unknown ? `未知:${unknown}。` : ''}`,
196 themeHelp: (all: string) => `主题:${all}。`,
197 themeSet: (t: string) => `主题:${t}。`,
198 slotHelp: (all: string) => `颜色槽:${all}。`,
199 colorBad: '颜色请写成 #rgb 或 #rrggbb,或写 reset。',
200 colorReset: (slot: string) => `${slot} 已恢复跟随主题。`,
201 colorSet: (slot: string, v: string) => `${slot}:${v}。`,
202 resetDone: '用量圆环设置已恢复默认。',
203 defaultHelp: (now: string) => `用法:/rings default desktop|terminal on|off。当前新会话:${now}。`,
204 defaultNow: (surface: string, on: boolean) => `${surface}${on ? '显示' : '隐藏'}`,
205 defaultSet: (surface: string, on: boolean) =>
206 `${surface}新会话默认${on ? '显示' : '隐藏'}面板。当前会话不变,可用 /rings 切换。`,
207 help: (parts: string, themes: string, slots: string) =>
208 [
209 '用法:/rings [settings | mode tiles|minimal | lang en|zh | scope all|session | show <组件> | hide <组件> | theme <名称> | color <槽> <#hex|reset> | default desktop|terminal on|off | reset]',
210 `组件:${parts}、all`,
211 `主题:${themes}`,
212 `颜色槽:${slots}`,
213 '单独运行 /rings 在当前会话隐藏或显示面板。',
214 ].join('\n'),
215 },
216}
217type Words = (typeof TEXT)['en']
218
219const snapshot = atom({ plugin: 'usage-rings', key: 'snapshot' } as const, null)
220const ledger = atom({ plugin: 'usage-rings', key: 'ledger' } as const, null)
221const seenUsd = atom({ plugin: 'usage-rings', key: 'seenUsd' } as const, null)
222const isHidden = atom({ plugin: 'usage-rings', key: 'isHidden' } as const, null)
223const minute = atom({ plugin: 'usage-rings', key: 'minute' } as const, 0)
224const settings = atom({ plugin: 'usage-rings', key: 'settings' } as const, DEFAULTS)
225const stream = atom({ plugin: 'usage-rings', key: 'stream' } as const, { isStreaming: false })
226const turn = atom({ plugin: 'usage-rings', key: 'turn' } as const, { startUsd: null, usd: null })
227const own = atom({ plugin: 'usage-rings', key: 'own' } as const, null)
228
229// $.store keys, shared by every session. A session's spend on a day is one figure under
230// USD + `<day>:<session id>:<startedAt>`, set (never added to), so a reload or a resume
231// cannot count it twice; BASE + the same holds what it had cost when that day began.
232// LEDGER (0.3.0) and SPEND (0.3.1) are the older ledgers, read only for days before SINCE,
233// the day this scheme first ran.
234const USD = 'usd:'
235const BASE = 'base:'
236// The same for a month: MON + `<month>:<session id>:<startedAt>`, MBASE its starting cost.
237const MON = 'mon:'
238const MBASE = 'mbase:'
239const SINCE = 'since'
240const LEDGER = 'ledger'
241const SPEND = 'spend:'
242const LIMITS = 'limits'
243const SETTINGS = 'settings'
244
245const PANE = 'rings-settings'
246const MUTED = '#9a9a9a'
247const PHASES = ['○', '◔', '◑', '◕', '●']
248const HOUR = 3_600_000
249
250// An installed release loads from the plugin cache; anything else (a dev-mods folder,
251// --plugin-dir, a test) is a working copy, tagged "dev" on the band.
252const isDevRoot = (root: string) => !root.replace(/\\/g, '/').includes('/plugins/cache/')
253const WINDOWS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * 24 * HOUR }
254
255type Palette = Record<Slot, string>
256type Gauge = {
257 key: string
258 label: string
259 percent?: number
260 reset?: string
261 // Share of the window already past, 0-100; absent for context and windows of unknown length.
262 elapsed?: number
263 // Time until 100% at the window's average rate so far, when that comes before the reset.
264 fullIn?: string
265 // Whether the window has a long enough history for a forecast.
266 canForecast: boolean
267 // Context's token count, `380k / 1M`.
268 note?: string
269}
270// One line of text on a tile; `color` marks the value that leads it.
271type Figure = { key: string; text: string; color?: string; isDim?: boolean; label?: string }
272
273const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
274const pad = (n: number) => String(n).padStart(2, '0')
275const dayKey = (t: number) => {
276 const d = new Date(t)
277 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
278}
279const money = (usd: number) => `$${usd.toFixed(2)}`
280const isHex = (v: unknown): v is string => typeof v === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(v)
281
282const span = (ms: number) => {
283 const mins = Math.max(0, Math.round(ms / 60000))
284 const d = Math.floor(mins / 1440)
285 const h = Math.floor((mins % 1440) / 60)
286 const m = mins % 60
287 return d > 0 ? `${d}d${h}h` : h > 0 ? `${h}h${m}m` : `${m}m`
288}
289
290const tokens = (n: number) =>
291 n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`
292
293const isLedger = (v: unknown): v is Ledger =>
294 typeof v === 'object' &&
295 v !== null &&
296 typeof (v as Ledger).day === 'string' &&
297 typeof (v as Ledger).dayUsd === 'number' &&
298 typeof (v as Ledger).month === 'string' &&
299 typeof (v as Ledger).monthUsd === 'number'
300
301const isLimitList = (v: unknown): v is Limit[] =>
302 Array.isArray(v) && v.every(l => typeof l?.kind === 'string' && typeof l?.percentUsed === 'number')
303
304// Settings come from storage another version may have written: keep what is valid, default the rest.
305const toSettings = (v: unknown): RingSettings => {
306 const raw = (typeof v === 'object' && v !== null ? v : {}) as Partial<RingSettings>
307 const parts = { ...DEFAULTS.parts }
308 for (const id of PART_IDS) {
309 const on = raw.parts?.[id]
310 if (typeof on === 'boolean') parts[id] = on
311 }
312 const colors: RingSettings['colors'] = {}
313 for (const slot of SLOTS) {
314 const c = raw.colors?.[slot]
315 if (isHex(c)) colors[slot] = c
316 }
317 return {
318 mode: raw.mode === 'minimal' ? 'minimal' : 'tiles',
319 lang: raw.lang === 'zh' ? 'zh' : 'en',
320 parts,
321 preset: raw.preset && raw.preset in PRESETS ? raw.preset : 'default',
322 colors,
323 scope: raw.scope === 'session' ? 'session' : 'all',
324 startShown: {
325 desktop: typeof raw.startShown?.desktop === 'boolean' ? raw.startShown.desktop : DEFAULTS.startShown.desktop,
326 terminal: typeof raw.startShown?.terminal === 'boolean' ? raw.startShown.terminal : DEFAULTS.startShown.terminal,
327 },
328 }
329}
330
331const paletteOf = (s: RingSettings): Palette => ({ ...PRESETS[s.preset], ...s.colors })
332
333const toSnapshot = (u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'> & { startedAt?: number }): Snapshot => ({
334 contextPercent: u.context.percent,
335 contextTokens: u.context.tokens,
336 contextWindow: u.context.window,
337 limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
338 sessionUsd: u.cost?.usd,
339 startedAt: u.startedAt,
340})
341
342// five_hour and seven_day are the plan windows; a per-model weekly window (e.g.
343// seven_day_<model>) shows under the model's name.
344const describe = (kind: string, w: Words): { label: string; part: Part } => {
345 if (kind === 'five_hour') return { label: '5h', part: '5h' }
346 if (kind === 'seven_day') return { label: '7d', part: '7d' }
347 if (kind === 'spend_limit') return { label: w.spendLimit, part: 'models' }
348 const name = kind.replace(/^seven_day_/, '').replace(/_/g, ' ')
349 return { label: name.charAt(0).toUpperCase() + name.slice(1), part: 'models' }
350}
351const windowOf = (kind: string) => WINDOWS[kind] ?? (kind.startsWith('seven_day_') ? WINDOWS.seven_day : undefined)
352
353const toGauge = (l: Limit, t: number, w: Words): Gauge => {
354 const { label } = describe(l.kind, w)
355 const resetsAt = l.resetsAt ? Date.parse(l.resetsAt) : undefined
356 // A window whose reset time has passed starts over until fresh data arrives.
357 if (resetsAt !== undefined && resetsAt <= t) return { key: l.kind, label, percent: 0, canForecast: false }
358
359 const length = windowOf(l.kind)
360 const left = resetsAt === undefined ? undefined : resetsAt - t
361 const past = length === undefined || left === undefined ? undefined : clamp(length - left, 0, length)
362 const gauge: Gauge = {
363 key: l.kind,
364 label,
365 percent: l.percentUsed,
366 reset: left === undefined ? undefined : span(left),
367 elapsed: past === undefined || length === undefined ? undefined : (past / length) * 100,
368 canForecast: past !== undefined && length !== undefined && past >= length * 0.05,
369 }
370 if (gauge.canForecast && past && left !== undefined && l.percentUsed > 0 && l.percentUsed < 100) {
371 const toFull = ((100 - l.percentUsed) * past) / l.percentUsed
372 if (toFull < left) gauge.fullIn = span(toFull)
373 }
374 return gauge
375}
376
377// A ring gauge: faint track, an arc from 12 o'clock in the level's color, the percentage inside,
378// and an optional tick across the ring where the window's time has got to.
379// Drawn as a plain image, so the markup holds only what the ring shows: the tick moves in whole
380// percents and nothing else changes with the clock, so redraws mostly hand the same image back.
381const ring = (percent: number | undefined, color: string, palette: Palette, elapsed?: number) => {
382 const r = 14
383 const c = 2 * Math.PI * r
384 const p = clamp(percent ?? 0, 0, 100)
385 const arc =
386 p > 0
387 ? `<circle cx="18" cy="18" r="${r}" fill="none" stroke="${color}" stroke-width="3.5" stroke-linecap="round" ` +
388 `stroke-dasharray="${((c * p) / 100).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 18 18)"/>`
389 : ''
390 let tick = ''
391 if (elapsed !== undefined) {
392 const a = (Math.round(clamp(elapsed, 0, 100)) / 100) * 2 * Math.PI - Math.PI / 2
393 const x = (d: number) => (18 + d * Math.cos(a)).toFixed(2)
394 const y = (d: number) => (18 + d * Math.sin(a)).toFixed(2)
395 tick = `<line x1="${x(10.2)}" y1="${y(10.2)}" x2="${x(17.8)}" y2="${y(17.8)}" stroke="${palette.pace}" stroke-width="1.8" stroke-linecap="round"/>`
396 }
397 const label = percent === undefined ? '–' : `${Math.round(percent)}%`
398 const size = label.length >= 4 ? 8.6 : 10
399 return (
400 `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">` +
401 `<circle cx="18" cy="18" r="${r}" fill="none" stroke="${palette.track}" stroke-opacity="0.3" stroke-width="3.5"/>` +
402 arc +
403 tick +
404 `<text x="18" y="${(18 + size * 0.36).toFixed(2)}" text-anchor="middle" font-family="-apple-system,system-ui,sans-serif" ` +
405 `font-size="${size}" font-weight="650" fill="${color}">${label}</text>` +
406 `</svg>`
407 )
408}
409
410// The dev build's tag: an image, so it keeps this small size whatever the host's box spacing.
411const devTag = (color: string) =>
412 `<svg xmlns="http://www.w3.org/2000/svg" width="30" height="16" viewBox="0 0 30 16">` +
413 `<rect x="0.5" y="0.5" width="29" height="15" rx="4" fill="none" stroke="${color}"/>` +
414 `<text x="15" y="11.3" text-anchor="middle" font-family="-apple-system,system-ui,sans-serif" ` +
415 `font-size="10" font-weight="600" fill="${color}">dev</text>` +
416 `</svg>`
417
418const swatch = (color: string) =>
419 `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><rect width="14" height="14" rx="3" fill="${color}"/></svg>`
420
421async function account($: EngineInterface, usd: number | undefined) {
422 if (usd === undefined) return
423 const seen = await read($, seenUsd)
424 await update($, seenUsd, () => usd)
425
426 const day = dayKey(await $.clock.now())
427 const startedAt = (await read($, snapshot))?.startedAt
428 const began = startedAt === undefined ? undefined : dayKey(startedAt)
429 const session = `${await $.session.id()}:${startedAt ?? 0}`
430 // A session begun within the day (or month) counts from its first message, whenever the mod
431 // loaded. One begun earlier counts from what it had cost when that period began here.
432 const record = async (figure: string, baseKey: string, isWhole: boolean) => {
433 let base = 0
434 if (!isWhole) {
435 const kept = await $.store.get(baseKey)
436 if (typeof kept === 'number') base = kept
437 else {
438 base = Math.min(seen ?? usd, usd)
439 await $.store.set(baseKey, base)
440 }
441 }
442 const spent = Math.max(0, usd - base)
443 if ((await $.store.get(figure)) !== spent) await $.store.set(figure, spent)
444 }
445 await record(`${USD}${day}:${session}`, `${BASE}${day}:${session}`, began === day)
446 const month = day.slice(0, 7)
447 await record(`${MON}${month}:${session}`, `${MBASE}${month}:${session}`, began?.slice(0, 7) === month)
448 await refreshLedger($)
449}
450
451// The period a store key's figure belongs to, from `<prefix><YYYY-MM-DD>...` or `<prefix><YYYY-MM>...`.
452const dayOf = (key: string, prefix: string) => key.slice(prefix.length, prefix.length + 10)
453const monthOf = (key: string, prefix: string) => key.slice(prefix.length, prefix.length + 7)
454
455// Today's and this month's totals, for every session and for this one: each session's figures
456// summed, plus the older ledgers' figures for days before this scheme ran.
457async function refreshLedger($: EngineInterface) {
458 const day = dayKey(await $.clock.now())
459 const month = day.slice(0, 7)
460 const kept = await $.store.get(SINCE)
461 const since = typeof kept === 'string' ? kept : day
462 const mine = `${await $.session.id()}:${(await read($, snapshot))?.startedAt ?? 0}`
463 let dayUsd = 0
464 let monthUsd = 0
465 let ownDay = 0
466 let ownMonth = 0
467 // A session with no month figure yet (one 0.3.2 or 0.4.0 recorded) counts its days instead.
468 const daysOf = new Map<string, number>()
469 const withMonth = new Set<string>()
470 for (const key of await $.store.keys()) {
471 const usd = await $.store.get(key)
472 if (typeof usd !== 'number') continue
473 if (key.startsWith(USD) && monthOf(key, USD) === month) {
474 const session = key.slice(USD.length + 11)
475 daysOf.set(session, (daysOf.get(session) ?? 0) + usd)
476 if (dayOf(key, USD) === day) {
477 dayUsd += usd
478 if (session === mine) ownDay += usd
479 }
480 } else if (key.startsWith(MON) && monthOf(key, MON) === month) {
481 const session = key.slice(MON.length + 8)
482 withMonth.add(session)
483 monthUsd += usd
484 if (session === mine) ownMonth += usd
485 } else if (key.startsWith(SPEND) && monthOf(key, SPEND) === month && dayOf(key, SPEND) < since) {
486 monthUsd += usd
487 if (dayOf(key, SPEND) === day) dayUsd += usd
488 }
489 }
490 for (const [session, usd] of daysOf) {
491 if (withMonth.has(session)) continue
492 monthUsd += usd
493 if (session === mine) ownMonth += usd
494 }
495 const legacy = await $.store.get(LEDGER)
496 if (isLedger(legacy) && legacy.month === month) {
497 const isOlder = legacy.day < since
498 monthUsd += legacy.monthUsd - (isOlder ? 0 : legacy.dayUsd)
499 if (isOlder && legacy.day === day) dayUsd += legacy.dayUsd
500 }
501 // A write redraws the band: skip it when the cents shown would not change.
502 const cents = (v: number) => Math.round(v * 100)
503 const same = (l: Ledger | null, d: number, m: number) =>
504 l !== null && l.day === day && cents(l.dayUsd) === cents(d) && cents(l.monthUsd) === cents(m)
505 await update($, ledger, l => (same(l, dayUsd, monthUsd) ? l : { day, month, dayUsd, monthUsd }))
506 await update($, own, l => (same(l, ownDay, ownMonth) ? l : { day, month, dayUsd: ownDay, monthUsd: ownMonth }))
507}
508
509// Drops figures from months before this one, and marks the day this scheme first ran.
510async function pruneLedger($: EngineInterface) {
511 const day = dayKey(await $.clock.now())
512 const month = day.slice(0, 7)
513 if (typeof (await $.store.get(SINCE)) !== 'string') await $.store.set(SINCE, day)
514 for (const key of await $.store.keys()) {
515 const prefix = [USD, BASE, MON, MBASE, SPEND].find(p => key.startsWith(p))
516 if (prefix && monthOf(key, prefix) !== month) await $.store.delete(key)
517 }
518 const legacy = await $.store.get(LEDGER)
519 if (legacy !== undefined && !(isLedger(legacy) && legacy.month === month)) await $.store.delete(LEDGER)
520}
521
522async function change($: EngineInterface, edit: (s: RingSettings) => RingSettings) {
523 const next = await update($, settings, s => toSettings(edit(s)))
524 await $.store.set(SETTINGS, next)
525 return next
526}
527
528// Rough tokens for text as it streams: about four characters a token, one per CJK character.
529const roughTokens = (text: string) => {
530 const wide = (text.match(/[ -鿿가--]/g) ?? []).length
531 return wide + (text.length - wide) / 4
532}
533
534async function runCommand($: EngineInterface, args: string): Promise<string> {
535 const [verb = '', ...rest] = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
536 const w = TEXT[(await read($, settings)).lang]
537 if (verb === '') {
538 const hidden = await update($, isHidden, h => !(h ?? false))
539 return hidden ? w.hidden : w.shown
540 }
541 if (verb === 'default') {
542 const [where, value] = rest
543 const s = await read($, settings)
544 if (!START_SURFACES.includes(where as StartSurface) || (value !== 'on' && value !== 'off'))
545 return w.defaultHelp(START_SURFACES.map(x => w.defaultNow(w.surfaces[x], s.startShown[x])).join(', '))
546 const surface = where as StartSurface
547 await change($, x => ({ ...x, startShown: { ...x.startShown, [surface]: value === 'on' } }))
548 return w.defaultSet(w.surfaces[surface], value === 'on')
549 }
550 if (verb === 'settings') {
551 const opened = await $.ui.open({ id: PANE, title: w.paneTitle, focus: true })
552 return opened.isPlaced ? w.opened : w.notOpened(String(opened.reason))
553 }
554 if (verb === 'mode') {
555 const mode = rest[0]
556 if (mode !== 'tiles' && mode !== 'minimal') return w.modeHelp
557 await change($, s => ({ ...s, mode }))
558 return w.modeSet(mode)
559 }
560 if (verb === 'lang') {
561 const lang = rest[0]
562 if (lang !== 'en' && lang !== 'zh') return w.langHelp
563 await change($, s => ({ ...s, lang }))
564 return TEXT[lang].langSet
565 }
566 if (verb === 'scope') {
567 const scope = rest[0]
568 if (scope !== 'all' && scope !== 'session') return w.scopeHelp
569 await change($, s => ({ ...s, scope }))
570 return w.scopeSet(scope)
571 }
572 if (verb === 'show' || verb === 'hide') {
573 const ids = rest.includes('all') ? PART_IDS : rest
574 const known = ids.filter((id): id is Part => PART_IDS.includes(id as Part))
575 const unknown = ids.filter(id => !known.includes(id as Part))
576 if (known.length === 0) return w.partsHelp(PART_IDS.join(', '))
577 await change($, s => ({ ...s, parts: { ...s.parts, ...Object.fromEntries(known.map(id => [id, verb === 'show'])) } }))
578 return w.partsSet(verb === 'show', known.join(', '), unknown.join(', '))
579 }
580 if (verb === 'theme') {
581 const name = rest[0]
582 if (!name || !(name in PRESETS)) return w.themeHelp(Object.keys(PRESETS).join(', '))
583 await change($, s => ({ ...s, preset: name as Preset }))
584 return w.themeSet(name)
585 }
586 if (verb === 'color') {
587 const [slot, value] = rest
588 if (!SLOTS.includes(slot as Slot)) return w.slotHelp(SLOTS.join(', '))
589 if (value === 'reset') {
590 await change($, s => {
591 const colors = { ...s.colors }
592 delete colors[slot as Slot]
593 return { ...s, colors }
594 })
595 return w.colorReset(String(slot))
596 }
597 if (!isHex(value)) return w.colorBad
598 await change($, s => ({ ...s, colors: { ...s.colors, [slot as Slot]: value } }))
599 return w.colorSet(String(slot), value)
600 }
601 if (verb === 'reset') {
602 // The language stays: resetting the look should not switch what the band speaks.
603 await change($, s => ({ ...DEFAULTS, lang: s.lang }))
604 return w.resetDone
605 }
606 return w.help(PART_IDS.join(', '), Object.keys(PRESETS).join(', '), SLOTS.join(', '))
607}
608
609export const register: Register = on => {
610 let tick: Timer | undefined
611
612 on('session.start', async ($, e, next) => {
613 await $.command.register({
614 name: 'rings',
615 description: 'Hide or show the usage rings in this session; /rings settings to configure them',
616 argumentHint: '[settings | mode | lang | scope | show | hide | theme | color | default | reset]',
617 })
618
619 const usage = await $.session.usage()
620 const snap = toSnapshot(usage)
621 // A fresh session has no rate-limit reading until its first response: show the last one saved.
622 if (snap.limits.length === 0) {
623 const saved = await $.store.get(LIMITS)
624 if (isLimitList(saved)) snap.limits = saved
625 }
626 await update($, snapshot, () => snap)
627
628 // What it had cost when the mod first saw it: where a session begun on an earlier day counts from.
629 if ((await read($, seenUsd)) === null && usage.cost) {
630 const usd = usage.cost.usd
631 await update($, seenUsd, () => usd)
632 }
633 await pruneLedger($)
634 await account($, usage.cost?.usd)
635 await refreshLedger($)
636 const stored = toSettings(await $.store.get(SETTINGS))
637 await update($, settings, () => stored)
638 // Each surface has its own default; a reload keeps what this session already chose.
639 const where: StartSurface = e.surface === 'terminal' ? 'terminal' : 'desktop'
640 await update($, isHidden, h => h ?? !stored.startShown[where])
641
642 // Once a minute: redraw so the reset countdowns stay current, and pick up other sessions' spend.
643 tick?.cancel()
644 tick = $.clock.every(60_000, () => {
645 void update($, minute, n => n + 1)
646 void refreshLedger($)
647 })
648
649 return next(e)
650 })
651
652 on('session.measure', async ($, e, next) => {
653 const prev = await read($, snapshot)
654 const snap = { ...toSnapshot(e), startedAt: prev?.startedAt }
655 if (snap.limits.length > 0) await $.store.set(LIMITS, snap.limits)
656 else snap.limits = prev?.limits ?? []
657 await update($, snapshot, () => snap)
658 await account($, e.cost?.usd)
659 const usd = e.cost?.usd
660 if (usd !== undefined) await update($, turn, t => ({ startUsd: t.startUsd ?? usd, usd }))
661
662 return next(e)
663 })
664
665 on('turn.start', async ($, e, next) => {
666 const usd = (await read($, snapshot))?.sessionUsd ?? null
667 await update($, turn, () => ({ startUsd: usd, usd }))
668 return next(e)
669 })
670
671 // Watches the main loop's responses stream by, passing every piece on untouched, to time them.
672 on('turn.step', async function* ($, e, next) {
673 if (e.agentId) return yield* next(e)
674
675 const sentAt = await $.clock.now()
676 let firstAt: number | undefined
677 let count = 0
678 let shownAt = 0
679 let shown: number | undefined
680 try {
681 for await (const chunk of next(e)) {
682 yield chunk
683 const now = await $.clock.now()
684 if (firstAt === undefined) {
685 firstAt = now
686 const ttftMs = now - sentAt
687 // The last response's speed stays up until this one has a reading of its own.
688 await update($, stream, s => ({ ...s, isStreaming: true, live: undefined, ttftMs }))
689 }
690 if (chunk.kind === 'text' || chunk.kind === 'thinking') count += roughTokens(chunk.text)
691 else if (chunk.kind === 'input') count += roughTokens(chunk.json)
692 else if (chunk.kind === 'stop') {
693 const u = chunk.usage
694 const secs = (now - firstAt) / 1000
695 const cached = u ? u.cache_read_input_tokens : 0
696 const total = u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
697 await update($, stream, s => ({
698 ...s,
699 isStreaming: false,
700 live: undefined,
701 last: u && secs > 0.2 ? u.output_tokens / secs : s.last,
702 cacheHit: total > 0 ? (cached / total) * 100 : s.cacheHit,
703 }))
704 continue
705 }
706 // Once a second at most, and only when the figure drawn would change: each write redraws the band.
707 if (now - shownAt >= 1000 && now - firstAt > 200) {
708 shownAt = now
709 const live = count / ((now - firstAt) / 1000)
710 if (Math.round(live) !== shown) {
711 shown = Math.round(live)
712 await update($, stream, s => ({ ...s, live }))
713 }
714 }
715 }
716 } finally {
717 await update($, stream, s => (s.isStreaming ? { ...s, isStreaming: false, live: undefined } : s))
718 }
719 })
720
721 on('command.run', { command: 'rings' }, async ($, e) => ({ text: await runCommand($, e.args) }))
722
723 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
724 const s = await read($, settings)
725 const w = TEXT[s.lang]
726 const palette = paletteOf(s)
727 const ui = $.ui.resolve(e)
728 const { Box, Text, Button } = ui
729 const Svg = e.surface !== 'terminal' && 'Svg' in ui ? ui.Svg : undefined
730 const Select = 'Select' in ui ? ui.Select : undefined
731 const Input = 'Input' in ui ? ui.Input : undefined
732
733 return (
734 <Box flexDirection="column" gap={1}>
735 {Select ? (
736 <Box flexDirection="row" flexWrap="wrap" columnGap={3} rowGap={1}>
737 <Select
738 key="mode"
739 label={w.layout}
740 value={s.mode}
741 options={(['tiles', 'minimal'] as const).map(m => ({ value: m, label: w.modes[m] }))}
742 onSelect={v => void change($, x => ({ ...x, mode: v === 'minimal' ? 'minimal' : 'tiles' }))}
743 />
744 <Select
745 key="theme"
746 label={w.theme}
747 value={s.preset}
748 options={Object.keys(PRESETS).map(p => ({ value: p, label: p.charAt(0).toUpperCase() + p.slice(1) }))}
749 onSelect={v => void change($, x => ({ ...x, preset: v as Preset }))}
750 />
751 <Select
752 key="lang"
753 label={w.language}
754 value={s.lang}
755 options={[
756 { value: 'en', label: 'English' },
757 { value: 'zh', label: '中文' },
758 ]}
759 onSelect={v => void change($, x => ({ ...x, lang: v === 'zh' ? 'zh' : 'en' }))}
760 />
761 <Select
762 key="scope"
763 label={w.scope}
764 value={s.scope}
765 options={(['all', 'session'] as const).map(x => ({ value: x, label: w.scopes[x] }))}
766 onSelect={v => void change($, x => ({ ...x, scope: v === 'session' ? 'session' : 'all' }))}
767 />
768 </Box>
769 ) : (
770 <Text dimColor>
771 {w.layout} {w.modes[s.mode]} · {w.theme} {s.preset} · {w.language} {s.lang} · {w.scope} {w.scopes[s.scope]}
772 </Text>
773 )}
774 <Text weight="semibold">{w.show}</Text>
775 <Box flexDirection="row" flexWrap="wrap" gap={1}>
776 {PART_IDS.map(id => (
777 <Button
778 key={`part-${id}`}
779 label={`${s.parts[id] ? '✓' : '○'} ${w.parts[id]}`}
780 dimColor={!s.parts[id]}
781 onPress={() => void change($, x => ({ ...x, parts: { ...x.parts, [id]: !x.parts[id] } }))}
782 />
783 ))}
784 </Box>
785 <Text weight="semibold">{w.startTitle}</Text>
786 <Box flexDirection="row" flexWrap="wrap" gap={1}>
787 {START_SURFACES.map(x => (
788 <Button
789 key={`start-${x}`}
790 label={`${s.startShown[x] ? '✓' : '○'} ${w.surfaces[x]}`}
791 dimColor={!s.startShown[x]}
792 onPress={() => void change($, v => ({ ...v, startShown: { ...v.startShown, [x]: !v.startShown[x] } }))}
793 />
794 ))}
795 </Box>
796 <Text weight="semibold">{w.colors}</Text>
797 <Box flexDirection="column" gap={1}>
798 {SLOTS.map(slot => (
799 <Box key={`slot-${slot}`} flexDirection="row" gap={1} alignItems="center">
800 {Svg ? (
801 <Svg source={swatch(palette[slot])} alt={`${w.slots[slot]} ${palette[slot]}`} width={14} height={14} />
802 ) : null}
803 {Input ? (
804 <Input
805 key={`color-${slot}`}
806 label={w.slots[slot]}
807 value={s.colors[slot] ?? ''}
808 placeholder={PRESETS[s.preset][slot]}
809 submitLabel={w.set}
810 onSubmit={v => {
811 const value = v.trim()
812 if (value !== '' && !isHex(value)) return
813 void change($, x => {
814 const colors = { ...x.colors }
815 if (value === '') delete colors[slot]
816 else colors[slot] = value
817 return { ...x, colors }
818 })
819 }}
820 />
821 ) : (
822 <Text color={palette[slot]}>
823 {w.slots[slot]} {palette[slot]}
824 </Text>
825 )}
826 </Box>
827 ))}
828 </Box>
829 <Text dimColor>{w.colorHint}</Text>
830 <Box flexDirection="row" gap={1}>
831 <Button
832 key="reset"
833 label={w.resetAll}
834 dimColor
835 onPress={() => void change($, x => ({ ...DEFAULTS, lang: x.lang }))}
836 />
837 <Button key="close" label={w.done} variant="primary" role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
838 </Box>
839 </Box>
840 )
841 })
842
843 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
844 const snap = await read($, snapshot)
845 if (e.props.hasSurvey || snap === null || (await read($, isHidden)) === true) return next(e)
846
847 await read($, minute)
848 const t = await $.clock.now()
849 const s = await read($, settings)
850 const w = TEXT[s.lang]
851 const show = s.parts
852 const palette = paletteOf(s)
853 const isMinimal = s.mode === 'minimal'
854
855 const ctxTokens =
856 snap.contextTokens ??
857 (snap.contextPercent === undefined ? undefined : Math.round((snap.contextPercent / 100) * snap.contextWindow))
858 const gauges: Gauge[] = [
859 ...(show.ctx
860 ? [
861 {
862 key: 'ctx',
863 label: w.ctx,
864 percent: snap.contextPercent,
865 canForecast: false,
866 note: ctxTokens === undefined ? undefined : `${tokens(ctxTokens)} / ${tokens(snap.contextWindow)}`,
867 },
868 ]
869 : []),
870 ...snap.limits.filter(l => show[describe(l.kind, w).part]).map(l => toGauge(l, t, w)),
871 ]
872
873 // A window set to run out before it resets reads as a warning even at a low level.
874 const colorOf = (g: Gauge) => {
875 if (g.percent === undefined) return MUTED
876 const level = g.percent < 60 ? palette.ok : g.percent < 85 ? palette.warn : palette.danger
877 return level === palette.ok && show.forecast && g.fullIn ? palette.warn : level
878 }
879 const pctText = (g: Gauge) => (g.percent === undefined ? '–' : `${Math.round(g.percent)}%`)
880 const titleOf = (g: Gauge) =>
881 [
882 w.used(g.label, pctText(g)),
883 g.note,
884 g.reset && w.resetsIn(g.reset),
885 g.elapsed !== undefined && w.gone(Math.round(g.elapsed)),
886 g.fullIn ? w.full(g.fullIn) : g.canForecast ? w.lasts : undefined,
887 ]
888 .filter(Boolean)
889 .join(' · ')
890 // A tile's second line: the forecast when the window is set to run out, else the reset time.
891 const secondLine = (g: Gauge): Figure | undefined => {
892 if (show.forecast && g.fullIn) return { key: `${g.key}-2`, text: w.full(g.fullIn), color: palette.warn }
893 if (show.resets && g.reset) return { key: `${g.key}-2`, text: g.reset, isDim: true }
894 if (g.note) return { key: `${g.key}-2`, text: g.note, isDim: true }
895 return undefined
896 }
897
898 // Spend and speed, each a tile: its first figure leads, the rest share the second line.
899 const book = await read($, ledger)
900 const flow = await read($, stream)
901 const spent = await read($, turn)
902 const today = dayKey(t)
903 const sessionUsd = snap.sessionUsd ?? 0
904 const hours = snap.startedAt === undefined ? 0 : (t - snap.startedAt) / HOUR
905 const spend: Figure[] = []
906 if (show.session)
907 spend.push({ key: 'session', text: money(sessionUsd), color: palette.accent, label: w.sessionLabel })
908 // Today and month, for every session or this one, from the same per-session figures.
909 const totalsOf = s.scope === 'all' ? book : await read($, own)
910 const dayUsd = totalsOf?.day === today ? totalsOf.dayUsd : 0
911 const monthUsd = totalsOf?.month === today.slice(0, 7) ? totalsOf.monthUsd : 0
912 const totals = [show.day ? w.today(money(dayUsd)) : '', show.month ? w.month(money(monthUsd)) : ''].filter(Boolean)
913 if (totals.length > 0) spend.push({ key: 'totals', text: w.totals(s.scope, totals.join(' · ')) })
914 if (show.turn && spent.usd !== null && spent.startUsd !== null)
915 spend.push({ key: 'turn', text: w.turn(money(Math.max(0, spent.usd - spent.startUsd))) })
916 if (show.rate && hours > 1 / 60) spend.push({ key: 'rate', text: w.rate(money(sessionUsd / hours)) })
917
918 const speedNow = flow.isStreaming ? (flow.live ?? flow.last) : flow.last
919 const speed: Figure[] = []
920 if (show.speed && speedNow !== undefined)
921 speed.push({ key: 'speed', text: `${flow.isStreaming && flow.live !== undefined ? '~' : ''}${Math.round(speedNow)} tok/s` })
922 if (show.cache && flow.cacheHit !== undefined) speed.push({ key: 'cache', text: w.cache(Math.round(flow.cacheHit)) })
923 if (show.ttft && flow.ttftMs !== undefined) speed.push({ key: 'ttft', text: w.ttft((flow.ttftMs / 1000).toFixed(1)) })
924 const cards = [spend, speed].filter(c => c.length > 0)
925
926 if (gauges.length === 0 && cards.length === 0) return next(e)
927
928 const ui = $.ui.resolve(e)
929 const { Box, Text } = ui
930 const isDev = isDevRoot($.plugin.root)
931
932 if (e.surface !== 'terminal' && 'Svg' in ui) {
933 const { Svg } = ui
934 const size = isMinimal ? 28 : 32
935 const band = (
936 <Box flexDirection="row" flexWrap="wrap" columnGap={3} rowGap={1} alignItems="center" flexGrow={1} flexShrink={1}>
937 {gauges.map(g => {
938 const below = secondLine(g)
939 const svg = (
940 <Svg
941 source={ring(g.percent, colorOf(g), palette, show.pace ? g.elapsed : undefined)}
942 alt={titleOf(g)}
943 width={size}
944 height={size}
945 />
946 )
947 if (isMinimal) return <Box key={g.key}>{svg}</Box>
948 return (
949 <Box key={g.key} flexDirection="row" gap={1} alignItems="center">
950 {svg}
951 <Box flexDirection="column">
952 <Text weight="semibold">{g.label}</Text>
953 {below ? (
954 <Text size="footnote" color={below.color} dimColor={below.isDim} tabular>
955 {below.text}
956 </Text>
957 ) : null}
958 </Box>
959 </Box>
960 )
961 })}
962 {cards.map(([lead, ...rest]) =>
963 lead === undefined ? null : (
964 <Box key={`card-${lead.key}`} flexDirection="column">
965 {lead.label && !isMinimal ? (
966 <Box flexDirection="row" gap={1}>
967 <Text weight="semibold" color={lead.color} tabular>
968 {lead.text}
969 </Text>
970 <Text dimColor>{lead.label}</Text>
971 </Box>
972 ) : (
973 <Text weight="semibold" color={lead.color} tabular>
974 {lead.text}
975 </Text>
976 )}
977 {!isMinimal && rest.length > 0 ? (
978 <Text size="footnote" dimColor tabular>
979 {rest.map(f => f.text).join(' · ')}
980 </Text>
981 ) : null}
982 </Box>
983 ),
984 )}
985 </Box>
986 )
987 if (!isDev) return band
988 return (
989 <Box flexDirection="row" alignItems="flex-start" justifyContent="space-between" width="100%" gap={1}>
990 {band}
991 <Box key="dev" flexShrink={0} alignSelf="flex-start">
992 <Svg source={devTag(palette.warn)} alt="dev" width={30} height={16} />
993 </Box>
994 </Box>
995 )
996 }
997
998 // Terminal: no vector element, so a quarter-step circle glyph stands in for each ring,
999 // and an arrow for the pace tick: up when usage runs ahead of the time gone by.
1000 const glyph = (p: number) => PHASES[Math.min(4, Math.round(clamp(p, 0, 100) / 25))]
1001 const gaugeText = gauges.map(g => {
1002 const head = g.percent === undefined ? `○ –` : `${glyph(g.percent)} ${Math.round(g.percent)}%`
1003 const pace =
1004 show.pace && g.elapsed !== undefined && g.percent !== undefined ? (g.percent > g.elapsed ? ' ▲' : ' ▽') : ''
1005 const below = isMinimal ? undefined : secondLine(g)
1006 return `${head}${pace} ${g.label}${below ? ` ${below.text}` : ''}`
1007 })
1008 const cardText = cards.map(c =>
1009 (isMinimal ? c.slice(0, 1) : c).map(f => (f.label && !isMinimal ? `${f.text} ${f.label}` : f.text)).join(' · '),
1010 )
1011 return (
1012 <Box flexDirection="row" gap={2}>
1013 <Box flexGrow={1} flexShrink={1}>
1014 <Text dimColor>{[...gaugeText, ...cardText].join(' ')}</Text>
1015 </Box>
1016 {isDev ? <Text color={palette.warn}>dev</Text> : null}
1017 </Box>
1018 )
1019 })
1020}
1021types/index.d.ts 74 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Snapshot = {
3 contextPercent?: number
4 contextTokens?: number
5 contextWindow: number
6 limits: Limit[]
7 sessionUsd?: number
8 startedAt?: number
9}
10export type Ledger = { day: string; dayUsd: number; month: string; monthUsd: number }
11
12export type Mode = 'tiles' | 'minimal'
13export type Lang = 'en' | 'zh'
14export type Part =
15 | 'ctx'
16 | '5h'
17 | '7d'
18 | 'models'
19 | 'resets'
20 | 'pace'
21 | 'forecast'
22 | 'session'
23 | 'day'
24 | 'month'
25 | 'turn'
26 | 'rate'
27 | 'cache'
28 | 'speed'
29 | 'ttft'
30export type Slot = 'ok' | 'warn' | 'danger' | 'pace' | 'track' | 'accent'
31export type Preset = 'default' | 'ocean' | 'grape' | 'mono'
32// Whose spend the today and month figures total: every session's, or this one's.
33export type Scope = 'all' | 'session'
34// Where a session starts: the terminal, or a graphical surface (desktop, VS Code, mobile).
35export type StartSurface = 'desktop' | 'terminal'
36export type RingSettings = {
37 mode: Mode
38 lang: Lang
39 parts: Record<Part, boolean>
40 preset: Preset
41 colors: Partial<Record<Slot, string>>
42 // Whether a new session on that surface starts with the band shown.
43 startShown: Record<StartSurface, boolean>
44 scope: Scope
45}
46
47// The model's output speed for the main loop: live while a response streams, exact once it ends.
48export type RingStream = {
49 isStreaming: boolean
50 live?: number
51 last?: number
52 ttftMs?: number
53 cacheHit?: number
54}
55export type RingTurn = { startUsd: number | null; usd: number | null }
56
57declare module 'claude-code' {
58 interface PluginState {
59 'usage-rings': {
60 snapshot: Snapshot | null
61 ledger: Ledger | null
62 seenUsd: number | null
63 // null until session.start decides it from the surface and startShown.
64 isHidden: boolean | null
65 minute: number
66 settings: RingSettings
67 stream: RingStream
68 turn: RingTurn
69 // This session's own figures, from the ledger: what the session scope shows.
70 own: Ledger | null
71 }
72 }
73}
74