SLOPSHOPPER

usage-rings

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…

newpanebandcommandtimer
v0.5.0MITupdated 2026-10-26shiqkuangsan/claude-mod-usage-statusbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-rings
│ ┃ rings-settings ✕ › fix the failing auth test and add an audit log call │ ┃ Layout: Tiles ▾ Theme: Default ▾ Languag │ ┃ ⏺ Read(src/auth.ts) │ ┃ Show ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ ✓ Context ] [ ✓ 5h limit ] [ ✓ 7d limit ] ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Show in new sessions ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ ✓ Desktop ] [ ○ Terminal ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Colors ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ok: #22c55e ⏎ set › /rings │ ┃ ⎿ usage-rings: Usage rings shown. │ ┃ warn: #f59e0b ⏎ set │ ┃ │ ┃ danger: #ef4444 ⏎ set │ ┃ │ ┃ pace tick: #a3a3a3 ⏎ set │ ┃ │ ┃ track: #8a8a8a ⏎ set │ ┃ │ ┃ accent: #22c55e ⏎ set │ ┃ │ ┃ Empty a color field to follow the theme. ◑ 49% ctx 97k / 200k ◔ 31% ▽ 5h NaNm $0.42 session · all: today $0.42 · mo $0.42 dev ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◑ 49% ctx 97k / 200k ◔ 31% ▽ 5h NaNm $0.42 session · all: today $0.42 · mo $0.42 dev
Pane · rings-settings
Layout: Tiles ▾ Theme: Default ▾ Language: English ▾ T Show [ ✓ Context ] [ ✓ 5h limit ] [ ✓ 7d limit ] [ ✓ Other limits Show in new sessions [ ✓ Desktop ] [ ○ Terminal ] Colors ok: #22c55e ⏎ set warn: #f59e0b ⏎ set danger: #ef4444 ⏎ set pace tick: #a3a3a3 ⏎ set track: #8a8a8a ⏎ set accent: #22c55e ⏎ set Empty a color field to follow the theme. #rgb or #rrggbb. [ Reset to defaults ] [ Done ]
README

usage-rings

English | 简体中文

release changelog Claude Code license

usage-rings in the Claude desktop app

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:

  • ctx: how full the context window is, with the token count
  • 5h and 7d: plan rate-limit windows, each with the time left until it resets
  • any per-model weekly window the API reports, under the model's name
  • pace tick: a mark across each window's ring where its time has got to, so an arc past the mark means usage runs ahead of time
  • forecast: when the window's average rate so far would use it up before it resets, the tile says full ~1h57m and the ring turns amber
  • spend: this session's whole cost, marked session, 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
  • speed: output tokens per second, live while a response streams (~58 tok/s) and exact once it ends, plus optionally cache hits and time to first token

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

Layouts

LayoutWhat 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.
minimalRings and the leading spend and speed figures only.

Settings

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:

CommandDoes
/ringsHide or show the band in this session
/rings mode tiles / /rings mode minimalSwitch the layout
/rings lang en / /rings lang zhEnglish (default) or Chinese
/rings scope all / /rings scope sessionWhether 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 offWhether new sessions on that surface start with the band shown; /rings default alone shows the current choice
/rings resetBack 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.

Install

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.

Notes

  • Everything shown comes from the usage figures Claude Code reports to the mod. It makes no network calls and reads no files.
  • Day and month spend are summed by the mod itself across sessions, using Claude Code's plugin storage. A session begun today (or this month) counts from its first message for today (or this month), even when the mod loaded later; one begun earlier counts from when the mod first saw it in that day (or month). Days before the install are not counted. With several sessions open, each band picks up the others' spend within a minute.
  • The live speed is an estimate from the characters streamed (about four per token, one per CJK character); thinking the interface does not show is not counted until the response ends. The figure after a response ends uses the token count the API reports. Subagents' responses are left out.
  • The forecast uses the average rate since the window began, and appears once 5% of the window has gone by.
  • A fresh session shows the last saved rate-limit reading until its first response arrives; context shows – until then.

License

MIT

Source 2 files
hooks/register.tsx 1021 lines
1import { 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}
1021
types/index.d.ts 74 lines
1export 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