SLOPSHOPPER

WeekToken

Rate-limit pace for Claude Code's 5-hour and weekly windows: usage against elapsed time, plain-language forecast, per-model quotas and burn-up history. A band…

newpanebandcommandtoastprocess
v0.2.20MITupdated 2026-10-063dnow/claude-mods/weektoken
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · weektoken
│ ┃ WeekToken ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ │ weektoken │ │ ┃ No data yet ⏺ Read(src/auth.ts) │ WeekToken is on: the band above the prompt │ │ ┃ Waiting for this session's first reply, or ⎿ Read 6 lines │ shows your usage pace; 5-hour and 7-day │ │ ┃ press Refresh ⏺ Update(src/auth.ts) │ figures appear after the first reply. Type │ │ ┃ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ ⏺ Bash(bun test) │ ┃ [ ⊖ Hide band ] [ ↻ Refresh ] ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ @mj0011sec │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /weektoken │ ┃ ⎿ weektoken: WeekToken pace pane opened │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · WeekToken
No data yet Waiting for this session's first reply, or press Refresh [ ⊖ Hide band ] [ ↻ Refresh ] @mj0011sec
README

WeekToken

Will you run out before the reset? WeekToken puts what you have used of Claude Code's rate limits next to how much of the window has passed, and tells you whether you are ahead of the clock or behind it. It covers the 5-hour window, the 7-day window and per-model weekly quotas such as Fable.

The WeekToken band above the prompt

  • Pace, not just a percentage. 64% used means little on its own; 64% used with 71% of the week gone means you are fine. The band and the pane show both, colored by pace.
  • Per-model quotas. Fable and other models with a weekly limit of their own get their own pace, next to the 5-hour and 7-day windows.
  • A forecast and a history. "At this rate, runs out ~Sat 20:12", plus a burn-up chart that lays past windows under this one, so you can see how this week compares.
  • Nothing to set up. No settings; English or Chinese, following Claude Code's language; desktop app and terminal.
/weektoken paceBurn-up history
Pace rings, forecast and readingsBurn-up chart with past windows

In the terminal, the band sits above the prompt and /weektoken docks beside the conversation:

WeekToken in the terminal: the pane docked on the right, the band above the prompt

Features

A band above the prompt: ❮ 7-day ❯ · 64% used · 71% elapsed, a progress bar that stretches with the band, plus Details and Hide (in the desktop app, Hide is the close button at the band's right end).

  • The bar stays in the quota's own color, like Apple's Activity rings. Using slower than time: light stripes of that color after the bar are the margin you have left. Using faster: the part ahead of the clock turns a deeper shade of the same color, striped. Only when a quota is used up does a warm tone appear, harmonized toward the quota's color.
  • Details opens the pane; pressed again, it closes it (the button reads Close while the pane is open).
  • Shows the tightest quota by default. The arrows around the name switch to another quota, and the choice is remembered across sessions. The name sits in a fixed-width slot, so the arrows stay put as you switch. The band and the /weektoken pane always show the same quota: switching in either one switches both.
  • On a narrow band the line never wraps: the elapsed figure is dropped first, as a whole, and in the terminal the bar after it. Whether it fits is judged from the band's width alone, so every quota behaves the same at a given width.
  • When a quota's window has passed its reset time and no reading of the new window has arrived yet, the band says reset instead of showing the old window's usage.
  • Hide asks first and tells you how to get the band back: /weektoken show, or ⊕ Show band at the bottom of the pane.

The /weektoken pane:

  • Pace rings, drawn the way the band is: the outer ring is usage in the quota's own color, followed by light stripes for the margin you have left, or turning a deeper, striped shade where usage is ahead of the clock; the thin gray inner ring is time; the center is the burn rate R (1.0 means you run out exactly at reset). Used, elapsed and time to reset sit beside the rings.
  • The arrows on either side of the pane's title switch between the 5-hour window, the 7-day window and per-model quotas such as Fable, on both tabs. As in the band, the name sits in a fixed-width slot, so the arrows stay put.
  • Narration and forecast, e.g. "Usage 12h 28m behind the clock", "At this rate, runs out ~Sat 20:12"; an extra line when you have been speeding up or slowing down lately.
  • Burn-up chart: this window, the last month or everything, with older windows a step away; an even-pace line, the projection at the recent rate, when you would run out, and stretches without data, with the legend drawn inside the chart. When a range holds more than 60 windows, 60 evenly spaced ones are drawn and the caption says so. In Last month and All, the chart stays a plain image; point at it and ⤢ Inspect lines appears at its top-left. Press it and the chart becomes hoverable: point at a line to see its dates and peak, with that line bold and the others faded. ✓ Done, or changing the range, quota or tab, turns it back into an image (a plain image never flashes when the band redraws; the hoverable chart can). When the hoverable chart would be too large to draw for a range, the button does not appear.
  • Footer: "Last used Fable 3h ago" when a quota has not moved for half an hour, ⊖ Hide band / ⊕ Show band and ↻ Refresh. A refresh takes about half a second; while it runs, the button reads Refreshing…, and a notice appears only if fresh figures could not be fetched.

Colors follow pace, not the raw percentage: the over-pace thresholds are Claude Code's own rate-limit warning calibration (five_hour: 0.9/0.72; seven_day: 0.25/0.15, 0.5/0.35, 0.75/0.6) and tighten as the window goes on.

Where the data comes from

SourceContent
The session's own rate limitsThe 5-hour and 7-day windows, updated after every reply
cachedUsageUtilization in ~/.claude.jsonClaude Code's own usage cache, including per-model quotas (Fable and others)
Anthropic's usage endpoint, through Claude Code (only when you press ↻ Refresh); local claude -p --no-session-persistence /usage if that isn't availableFresh per-model quotas; also the 5-hour and 7-day windows when there is no local reading from the last 10 minutes (a new session before its first reply)
~/.weektoken/samples.jsonl (optional)History from the WeekToken macOS app, imported read-only

Samples are kept in the mod's own cross-session store, up to 8000; the oldest go first. Sessions open at the same time merge their samples and keep one copy of each reading. Without history to import from the WeekToken macOS app, history starts when you install the mod, since the API only reports current values.

A quota you are not using does not change, so old data still gives a valid pace.

Commands

CommandDoes
/weektokenOpens the pace pane
/weektoken hideHides the band (kept across sessions)
/weektoken showShows it again

Configuration

Nothing to configure. Two environment variables override the defaults; set them in your shell profile (e.g. ~/.zshrc) and start a new session:

VariableEffect
WEEKTOKEN_LANGzh or en forces the interface language. By default it follows Claude Code's language setting, then the system language
WEEKTOKEN_HISTORYPath of the WeekToken macOS app's history file; an empty string turns the import off
CLAUDE_MODS_DISABLEall, or a comma list containing weektoken, turns the mod off entirely

Privacy and permissions

Mods run with the same access as Claude Code itself and are not sandboxed. This is everything WeekToken does besides drawing its own band and pane.

What it sends, and where

Nothing WeekToken reads is sent anywhere. It makes one kind of request, only when you press ↻ Refresh: a GET to Anthropic's usage endpoint (https://api.anthropic.com/api/oauth/usage), the same one Claude Code's own /usage reads. The request goes through Claude Code, which adds the login it already has; the mod never sees the credential, only an opaque handle that Claude Code honors for Anthropic's own hosts alone. No body is sent, no model is called and no quota is used.

Without a subscription login (an API key, another provider), or if that request fails, Refresh runs Claude Code's /usage command instead (below), and Claude Code makes the same request itself.

Programs it runs, and why

Every command is fixed text, and none goes through a shell.

CommandWhenWhy
claude -p --no-session-persistence /usageOnly when you press ↻ Refresh and the usage endpoint above can't be usedA fallback source for per-model quotas such as Fable. The mod looks for claude on your PATH, then in ~/.local/bin, ~/.claude/local, /opt/homebrew/bin and /usr/local/bin. It saves no session.
defaults read -g AppleLanguagesAt session start, and when you change Claude Code's languageReads the macOS language when Claude Code's own language setting is not set (macOS only).
tail -n 8000 ~/.weektoken/samples.jsonlAt session start and every 10 minutes, only if the file existsImports history from the WeekToken macOS app.
perl, with one fixed pattern and no shellOnly when ~/.claude.json is over 4 MiB and can't be read directlyExtracts just the cachedUsageUtilization entry from that file and prints it back to the mod; nothing else is read or sent.

What it reads on your machine

  • ~/.claude.json, Claude Code's own file. Only its cachedUsageUtilization entry is used: Claude Code's usage cache, which includes per-model quotas. The file also holds account details; those are not used or kept. It is read at session start, every 5 minutes when the file has changed, and on Refresh.
  • ~/.weektoken/samples.jsonl, or the file named by WEEKTOKEN_HISTORY, if it exists.
  • Its own plugin.json, at session start, for the version shown after the author credit in the pane.
  • Environment variables: HOME and PATH (to find the files and claude above), LANG, LC_ALL and LC_MESSAGES (the language), WEEKTOKEN_LANG, WEEKTOKEN_HISTORY and CLAUDE_MODS_DISABLE. It reads no credentials.
  • From Claude Code: the session's rate-limit figures, the time and model of each reply, and the language setting.

What it stores

In the mod's own store on your machine: samples (up to 8000) and their version stamp, when each model was last used, whether the band is shown and which quota it shows, the quota selected in the pane, the modification time of the imported history file, and whether the welcome notice was shown. The interface language is detected each session, not stored.

Hooks

HookWhat it does
session.startRegisters /weektoken, loads the stored samples, reads the sources above and starts the 1-, 5- and 10-minute timers
session.measureRecords the session's 5-hour and 7-day figures when they change
turn.completeRecords the time and model of each reply, to tell when a quota was last used
command.run, /weektoken onlyAnswers its own command: opens the pane, or shows or hides the band
ui.render, AbovePromptDraws the band, then whatever other plugins or Claude Code draw there, below it
ui.render, the weektoken pane onlyDraws the /weektoken pane
ui.pressNotes presses on its own pane and passes every press on unchanged
ui.focusPasses the event on unchanged. On its own pane in the desktop app, when a click only moved the focus to a button (the first click on an unfocused pane), it runs that button's action
config.set, language onlyPasses the change on unchanged, then detects the interface language again

It changes no settings or permissions, and it leaves other plugins' events as they are.

Install

From the Claude plugin directory: run /plugin directory in Claude Code, or add it under Customize > Plugins > Discover on claude.ai, which brings it to Claude Code at the next session start. Or from this repository, inside Claude Code: /plugin marketplace add 3dnow/claude-mods, then /plugin install weektoken@mj0111-mods. Then open a new session, or run /reload-plugins.

It runs wherever Claude Code does: the terminal, the IDE extensions and the desktop app's Code tab. Like every mod, it does not run in chat or Cowork.

Requirements

  • Claude Code 2.1.287 or newer, where mods are on by default. On an earlier build with mods in early access, add "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } to ~/.claude/settings.json first
  • A Claude subscription; sessions billed by API key have no rate limits

Development

claude plugin validate .
claude plugin test

Author

mj0111 · @mj0011sec on X

Source 7 files
hooks/register.tsx 1134 lines
1// WeekToken mod
2//
3// Pace: whether usage is running fast or slow relative to elapsed time.
4//   · A band above the prompt: ‹ name › · used · elapsed + a bar that scales with width. Shows the tightest quota by default;
5//     the prev/next arrows beside the name switch quota and remember it; once hidden it takes no row, restore from the pane.
6//   · /weektoken opens the pane: quota arrows beside the title, pace rings with used/elapsed/reset, commentary, burn-up
7//   Data: the session's own rateLimits (5-hour / 7-day, updated on every reply) + Claude Code's local usage cache
8//   ~/.claude.json (incl. per-model quotas such as Fable) + history samples from WeekToken for macOS (read-only import).
9
10import { atom, read, update } from 'claude-code'
11import type { Register } from 'claude-code'
12
13import type { Activity, BandMode, BandShow, Note, PaneView, Sample } from '../types'
14import { L, langFromAppleLanguages, langFromEnv, langFromSetting, setLang, type Lang } from './i18n.ts'
15import * as P from './pace.ts'
16import { identity, identityText, statusText, STATUS } from './theme.ts'
17import { bandBarSvg, burnUpSvg, creditSvg, ringsSvg } from './svg.ts'
18
19const PANE = 'weektoken'
20const DEFAULT_VIEW: PaneView = { key: null, tab: 'pace', range: 'current', offset: 0 }
21
22const samplesA = atom({ plugin: 'weektoken', key: 'samples' } as const, [] as Sample[])
23const viewA = atom({ plugin: 'weektoken', key: 'view' } as const, DEFAULT_VIEW)
24const noteA = atom({ plugin: 'weektoken', key: 'note' } as const, null as Note)
25const bandA = atom({ plugin: 'weektoken', key: 'band' } as const, 'open' as BandMode)
26const confirmHideA = atom({ plugin: 'weektoken', key: 'confirmHide' } as const, false)
27// Quota being viewed (shared by band and pane; written together with view.key on switch, see selectQuota)
28const bandKeyA = atom({ plugin: 'weektoken', key: 'bandKey' } as const, null as string | null)
29const bandShowA = atom({ plugin: 'weektoken', key: 'bandShow' } as const, null as BandShow | null)
30const activityA = atom({ plugin: 'weektoken', key: 'activity' } as const, { byModel: {} } as Activity)
31// Whether the pane is open: the band button reads "Close" or "Details" from this
32const paneOpenA = atom({ plugin: 'weektoken', key: 'paneOpen' } as const, false)
33/** Loaded version: read from our own plugin.json at session start, drawn after the credit */
34const versionA = atom({ plugin: 'weektoken', key: 'version' } as const, '')
35const AUTHOR = { x: 'mj0011sec' }
36
37// CLAUDE_MODS_DISABLE=all, or weektoken in the comma list: all hooks pass through and no command is registered
38let disabled = false
39async function readDisabled($: any): Promise<boolean> {
40  const raw = ((await $.env.get('CLAUDE_MODS_DISABLE')) ?? '').toLowerCase()
41  disabled = raw.split(',').map((v: string) => v.trim()).some((v: string) => v === 'all' || v === 'weektoken')
42  return disabled
43}
44const langA = atom({ plugin: 'weektoken', key: 'lang' } as const, 'en' as Lang)
45
46// ======================================================================
47// Model: derive each quota's display state, the bottleneck and the pane's selected quota from samples
48// ======================================================================
49
50type QuotaRow = {
51  key: string
52  obs?: P.Obs
53  at?: number
54  len: P.WindowLength
55  d: P.Display
56  name: string
57  full: string
58}
59
60type Model = { keys: string[]; rows: Record<string, QuotaRow>; band: string | null; selected: string | null }
61
62function buildModel(samples: readonly Sample[], view: PaneView, now: number, bandKey: string | null = null): Model {
63  const keys = P.discoveredKeys(samples)
64  const rows: Record<string, QuotaRow> = {}
65  for (const k of keys) {
66    const lo = P.latestObs(samples, k)
67    const len = P.inferWindow(k, samples)
68    rows[k] = {
69      key: k,
70      obs: lo?.obs,
71      at: lo?.at,
72      len,
73      // No downgrade by data age: unused means unchanged, so old data still yields a pace (the footer says how long it's been idle)
74      d: P.display(lo?.obs, len.seconds, now),
75      name: P.displayName(k, lo?.obs.n),
76      full: P.fullName(k, lo?.obs.n),
77    }
78  }
79  const bottleneck = P.pickBottleneck(keys.map(k => ({ key: k, pace: rows[k].d.kind === 'pace' ? rows[k].d.pace : null })))
80  // Band: show the quota picked with the band arrows, otherwise the tightest one
81  const band = bandKey && rows[bandKey] ? bandKey : bottleneck
82  // Pane and band show the same quota (both written on switch); if none was picked, both show the tightest
83  const selected = view.key && rows[view.key] ? view.key : (band ?? keys[0] ?? null)
84  return { keys, rows, band, selected }
85}
86
87/** What the band draws, rounded to display precision; null if it can't be computed (band falls back to native rendering) */
88function bandShowOf(samples: readonly Sample[], bandKey: string | null, now: number): BandShow | null {
89  if (!samples.length) return null
90  const m = buildModel(samples, DEFAULT_VIEW, now, bandKey)
91  const row = m.band ? m.rows[m.band] : undefined
92  if (!row) return null
93  // Last observed window is past its reset time: that usage is void, so the band says "reset" until the next reply brings a new window
94  const ended = row.d.kind === 'usageOnly' && row.d.reason.kind === 'windowEnded'
95  const used = ended ? null : P.displayUsed(row.d)
96  return {
97    key: row.key,
98    ...(row.obs?.n ? { n: row.obs.n } : {}),
99    ...(ended ? { ended: true } : {}),
100    keys: m.keys,
101    used: used == null ? null : Math.round(used * 100),
102    elapsed: row.d.kind === 'pace' ? Math.round(row.d.pace.elapsed * 100) : null,
103    status: P.displayStatus(row.d),
104  }
105}
106
107// ======================================================================
108// Data collection
109// ======================================================================
110
111type RateLimitLike = { kind: string; percentUsed: number; resetsAt?: string }
112
113async function recordRateLimits($: any, list: readonly RateLimitLike[] | undefined, t: number): Promise<boolean> {
114  const w: Record<string, P.Obs> = {}
115  for (const rl of list ?? []) {
116    if (rl.kind !== 'five_hour' && rl.kind !== 'seven_day') continue
117    const r = rl.resetsAt ? Date.parse(rl.resetsAt) : NaN
118    w[rl.kind] = { u: Math.min(Math.max(rl.percentUsed, 0), 100), ...(Number.isFinite(r) ? { r } : {}) }
119  }
120  if (!Object.keys(w).length) return false
121  return addSample($, { t, w })
122}
123
124async function addSample($: any, s: Sample): Promise<boolean> {
125  let changed = false
126  await update($, samplesA, list => {
127    const next = P.appendSample(list ?? [], s)
128    changed = next !== list
129    return next
130  })
131  if (changed) {
132    await persistSamples($)
133    await refreshBand($)
134  }
135  return changed
136}
137
138/**
139 * Merge with what's stored before writing back: with several sessions open, each writing its full list would clobber samples another session just recorded.
140 * Other sessions' samples are merged into this one too. Read-merge-write isn't atomic; an occasional collision just lands one write later, since each session merges again on its next write.
141 */
142async function persistSamples($: any): Promise<void> {
143  const stored = await $.store.get('samples')
144  const mine = (await read($, samplesA)) ?? []
145  let merged = P.mergeSamples(Array.isArray(stored) ? (stored as Sample[]) : [], mine)
146  try {
147    await $.store.set('samples', merged)
148  } catch {
149    // Storage is size-capped (4 MiB per plugin): if the write fails, drop the oldest quarter and retry once
150    merged = merged.slice(Math.floor(merged.length / 4))
151    await $.store.set('samples', merged)
152  }
153  // Small revision tag: other sessions check it every minute and skip reading the full samples if unchanged
154  lastSeenRev = `${merged.length}:${merged[merged.length - 1]?.t ?? 0}`
155  await $.store.set('samplesRev', lastSeenRev)
156  if (merged.length !== mine.length) await update($, samplesA, () => merged)
157}
158
159let lastSeenRev: string | null = null
160
161/** Merge samples other sessions stored into this session (no write-back); touch state only if something is new */
162async function adoptStoredSamples($: any): Promise<boolean> {
163  const rev = await $.store.get('samplesRev')
164  if (typeof rev === 'string' && rev === lastSeenRev) return false
165  const stored = await $.store.get('samples')
166  if (!Array.isArray(stored)) return false
167  lastSeenRev = typeof rev === 'string' ? rev : null
168  const mine = (await read($, samplesA)) ?? []
169  const merged = P.mergeSamples(stored as Sample[], mine)
170  if (merged.length === mine.length) return false
171  await update($, samplesA, () => merged)
172  return true
173}
174
175const mergeActivity = (a: Activity, b: Activity): Activity => {
176  const byModel: Record<string, number> = { ...(a.byModel ?? {}) }
177  for (const [k, t] of Object.entries(b.byModel ?? {})) byModel[k] = Math.max(byModel[k] ?? 0, t)
178  return { any: Math.max(a.any ?? 0, b.any ?? 0) || undefined, byModel }
179}
180
181/** Record a reply (incl. subagents): any model counts toward 5-hour / 7-day; a model also counts toward its family's per-model quota */
182async function recordActivity($: any, model: string | undefined): Promise<void> {
183  const now = await $.clock.now()
184  const fam = P.modelFamily(model)
185  await update($, activityA, a => mergeActivity(a ?? { byModel: {} }, { any: now, byModel: fam ? { [fam]: now } : {} }))
186  // Other sessions write too: merge with the stored value before saving, keeping the later time
187  const stored = ((await $.store.get('activity')) as Activity | undefined) ?? { byModel: {} }
188  await $.store.set('activity', mergeActivity(stored, await read($, activityA)))
189}
190
191/** A tree that draws nothing: null, empty string, or only empty Box/Text */
192function isBlank(node: any): boolean {
193  if (node == null || node === false || node === '') return true
194  if (Array.isArray(node)) return node.every(isBlank)
195  if (typeof node === 'string') return node.trim() === ''
196  if (typeof node === 'object' && (node.type === 'Box' || node.type === 'Text')) return isBlank(node.children ?? node.props?.children)
197  return false
198}
199
200/** Recompute what the band draws; skip the write if unchanged, so no redraw */
201async function refreshBand($: any): Promise<void> {
202  const next = bandShowOf((await read($, samplesA)) ?? [], (await read($, bandKeyA)) ?? null, await $.clock.now())
203  const cur = (await read($, bandShowA)) ?? null
204  if (JSON.stringify(next) === JSON.stringify(cur)) return
205  await update($, bandShowA, () => next)
206}
207
208async function sampleSession($: any): Promise<boolean> {
209  const u = await $.session.usage()
210  return recordRateLimits($, u.rateLimits, await $.clock.now())
211}
212
213let cacheMtime = -1
214
215async function expandHome($: any, p: string): Promise<string> {
216  if (!p.startsWith('~/')) return p
217  const home = await $.env.get('HOME')
218  return home ? home + p.slice(1) : p
219}
220
221/** Claude Code's local usage cache (incl. per-model quotas); not re-parsed if the file is unchanged */
222async function sampleCache($: any, force = false): Promise<boolean> {
223  const path = await expandHome($, '~/.claude.json')
224  if (!(await $.fs.exists(path))) return false
225  const st = await $.fs.stat(path)
226  if (!force && st.mtimeMs === cacheMtime) return false
227  cacheMtime = st.mtimeMs
228  let text: string | null = null
229  try {
230    text = await $.fs.read(path)
231  } catch {
232    text = await extractCacheWithPerl($, path) // Read fails over 4 MiB: extract just the cache section
233  }
234  if (!text) return false
235  const s = P.parseClaudeJsonCache(text, await $.clock.now())
236  return s ? addSample($, s) : false
237}
238
239async function extractCacheWithPerl($: any, path: string): Promise<string | null> {
240  // -0777: slurp the whole file; program and regex are fixed text, the path is passed to perl as an argument, no shell
241  try {
242    const r = await $.process.run(['perl', '-0777', '-ne', 'print $1 if /"cachedUsageUtilization"\\s*:\\s*(\\{(?:[^{}"]++|"(?:\\\\.|[^"\\\\])*+"|(?1))*\\})/', path], { timeoutMs: 15_000 })
243    if (r.exitCode === 0 && r.stdout.trim().startsWith('{')) return `{"cachedUsageUtilization":${r.stdout.trim()}}`
244  } catch {}
245  return null
246}
247
248/** History from WeekToken for macOS (read-only); default location, override with WEEKTOKEN_HISTORY, empty string disables import. Skipped if the file is unchanged; returns the count newly imported */
249async function importHistory($: any): Promise<number> {
250  const file = ((await $.env.get('WEEKTOKEN_HISTORY')) ?? '~/.weektoken/samples.jsonl').trim()
251  if (!file) return 0
252  const path = await expandHome($, file)
253  if (!(await $.fs.exists(path))) return 0
254  const st = await $.fs.stat(path)
255  if ((await $.store.get('historyMtime')) === st.mtimeMs) return 0
256  const r = await $.process.run(['tail', '-n', '8000', path], { timeoutMs: 15_000 })
257  if (r.exitCode !== 0) return 0
258  const imported = P.parseSamplesJsonl(r.stdout, await $.clock.now())
259  let added = 0
260  await update($, samplesA, list => {
261    const merged = P.mergeSamples(list ?? [], imported)
262    added = merged.length - (list ?? []).length
263    return merged
264  })
265  await persistSamples($)
266  await $.store.set('historyMtime', st.mtimeMs)
267  if (added) await refreshBand($)
268  return added
269}
270
271async function loadState($: any): Promise<void> {
272  const stored = await $.store.get('samples')
273  const list = Array.isArray(stored) ? (stored as Sample[]) : []
274  await update($, samplesA, cur => P.mergeSamples(list, cur ?? []))
275  const v = (await $.store.get('view')) as { key?: string | null; bandKey?: string | null; pinned?: string | null } | undefined
276  // Band and pane show the same quota. Older versions stored them separately (the band's earlier in view.bandKey / view.pinned); on mismatch the band's wins
277  const bk = (await $.store.get('bandKey')) ?? v?.bandKey ?? v?.pinned ?? null
278  const k = typeof bk === 'string' ? bk : typeof v?.key === 'string' ? v.key : null
279  if (k) {
280    await update($, bandKeyA, () => k)
281    await update($, viewA, cur => ({ ...(cur ?? DEFAULT_VIEW), key: k }))
282  }
283  const act = (await $.store.get('activity')) as Activity | undefined
284  if (act && typeof act === 'object') await update($, activityA, cur => mergeActivity(cur ?? { byModel: {} }, act))
285  const band = await $.store.get('band')
286  // Older versions also had "collapsed" (mini); now always treated as shown
287  if (band === 'hidden' || band === 'open' || band === 'mini') await update($, bandA, () => (band === 'hidden' ? 'hidden' : 'open'))
288}
289
290/**
291 * Local claude's /usage: the only fresh source of per-model quotas (Fable etc.). A local command: no model call, no quota spent,
292 * no session record; it scans local sessions for the usage breakdown, about 10 s, so it only runs on manual refresh.
293 * The command is fixed text; claude is found by name. Processes started by the desktop app may not have it on PATH,
294 * so a few common install locations are appended to the existing PATH (env.PATH is used for program lookup, verified).
295 */
296async function usagePath($: any): Promise<string> {
297  const home = (await $.env.get('HOME')) ?? ''
298  const extra = home ? [`${home}/.local/bin`, `${home}/.claude/local`] : []
299  return [(await $.env.get('PATH')) ?? '/usr/bin:/bin', ...extra, '/opt/homebrew/bin', '/usr/local/bin'].join(':')
300}
301
302/** Run /usage and record a sample; ok = output received and recognized */
303async function sampleUsageCommand($: any): Promise<{ ok: boolean; changed: boolean }> {
304  const r = await $.process.run(['claude', '-p', '--no-session-persistence', '/usage'], { timeoutMs: 60_000, env: { PATH: await usagePath($) } })
305  if (r.exitCode !== 0) return { ok: false, changed: false }
306  const s = P.parseUsageCommand(r.stdout, await $.clock.now())
307  if (!s) return { ok: false, changed: false }
308  return { ok: true, changed: await addFresh($, s) }
309}
310
311const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
312
313/**
314 * The same figures /usage shows, straight from Anthropic's usage endpoint and through the host:
315 * $.session.authorize() answers a handle, and the engine attaches the session's own login to the request
316 * (first-party hosts only), so the mod never sees the credential. About half a second, where starting a whole
317 * `claude -p /usage` takes 7-9 s. Null without a subscription login or when the answer can't be read;
318 * refresh then falls back to the command.
319 */
320async function sampleUsageApi($: any): Promise<{ ok: boolean; changed: boolean } | null> {
321  const auth = await $.session.authorize()
322  if (!auth?.handle || auth.kind !== 'bearer') return null
323  const r = await $.http.fetch(USAGE_URL, { auth: auth.handle, headers: { 'anthropic-beta': 'oauth-2025-04-20' } })
324  if (!r.ok) return null
325  let w: Record<string, P.Obs>
326  try { w = P.parseUtilization(JSON.parse(r.text)) } catch { return null }
327  if (!Object.keys(w).length) return null
328  return { ok: true, changed: await addFresh($, { t: await $.clock.now(), w }) }
329}
330
331/** Records a fresh account-wide reading. For 5-hour / 7-day the session's own values (second precision) win; use these only if there's no local reading within 10 minutes (new session, no reply yet) */
332async function addFresh($: any, s: Sample): Promise<boolean> {
333  const samples = (await read($, samplesA)) ?? []
334  const w: Record<string, P.Obs> = {}
335  for (const [k, o] of Object.entries(s.w)) {
336    if (P.isModelScoped(k)) w[k] = o
337    else if (k === 'five_hour' || k === 'seven_day') {
338      const lo = P.latestObs(samples, k)
339      if (!lo || s.t - lo.at > 10 * 60_000) w[k] = o
340    }
341  }
342  return Object.keys(w).length ? addSample($, { t: s.t, w }) : false
343}
344
345/** Manual refresh "in progress": kept in state so the button shows "Refreshing…"; treated as done after 90 s (subprocess timeout is 60 s) */
346const isBusy = (note: Note, now: number) => !!note?.busy && now - note.at < 90_000
347
348async function refreshAll($: any, manual: boolean): Promise<void> {
349  if (manual) {
350    const now = await $.clock.now()
351    if (isBusy(await read($, noteA), now)) return
352    await update($, noteA, () => ({ busy: true, at: now }))
353  }
354  try {
355    try { await sampleSession($) } catch {}
356    try { await sampleCache($, true) } catch {}
357    try { await importHistory($) } catch {}
358    if (!manual) return
359    // The usage endpoint first (about half a second); starting `claude -p /usage` only if that isn't available
360    let ok = false
361    try { ok = (await sampleUsageApi($))?.ok ?? false } catch {}
362    if (!ok) try { ok = (await sampleUsageCommand($)).ok } catch {}
363    // Nothing on success (footer and chart update themselves); notify only when /usage fails
364    if (!ok) $.ui.toast(L('没能运行 claude /usage:Fable 等分模型配额这次没更新', "Couldn't run claude /usage: per-model quotas such as Fable weren't updated"), { timeoutMs: 8_000 })
365  } finally {
366    if (manual) await update($, noteA, () => null)
367  }
368}
369
370// ======================================================================
371// UI language: WEEKTOKEN_LANG (zh / en) → Claude Code's language setting → system language (macOS preferred languages, then LC_ALL/LC_MESSAGES/LANG) → English
372// ======================================================================
373
374async function detectLang($: any): Promise<Lang> {
375  const forced = ((await $.env.get('WEEKTOKEN_LANG')) ?? '').trim().toLowerCase()
376  if (forced === 'zh' || forced === 'en') return forced
377  try {
378    const row = (await $.config.list()).find((r: { key: string }) => r.key === 'language')
379    const l = langFromSetting(row?.value)
380    if (l) return l
381  } catch {}
382  // macOS "system language" is the preferred-languages list; LANG is only the terminal env and may differ in processes started by the desktop app. Linux has no defaults, falls through to LANG
383  try {
384    const r = await $.process.run(['defaults', 'read', '-g', 'AppleLanguages'], { timeoutMs: 5_000 })
385    const l = r.exitCode === 0 ? langFromAppleLanguages(r.stdout) : null
386    if (l) return l
387  } catch {}
388  return langFromEnv(await $.env.get('LC_ALL')) ?? langFromEnv(await $.env.get('LC_MESSAGES')) ?? langFromEnv(await $.env.get('LANG')) ?? 'en'
389}
390
391/** Detect language; skip the write if unchanged (a write redraws band and pane). Returns whether it changed */
392async function refreshLang($: any): Promise<boolean> {
393  const l = await detectLang($)
394  setLang(l)
395  if ((await read($, langA)) === l) return false
396  await update($, langA, () => l)
397  return true
398}
399
400/** Sync language before drawing: after a module reload the variable resets to default, but the value in $.state remains */
401async function useLang($: any): Promise<void> {
402  setLang((await read($, langA)) ?? 'en')
403}
404
405// ======================================================================
406// Pane and band actions
407// ======================================================================
408
409async function openPane($: any): Promise<void> {
410  await $.ui.open({ id: PANE, title: 'WeekToken' })
411  await update($, paneOpenA, () => true)
412  await update($, viewA, v => ({ ...(v ?? DEFAULT_VIEW), explore: false }))
413}
414
415/** Band "Details": close the pane if it's open and in front, otherwise open it (or bring it to front). Asks the engine on press, not stored state */
416async function togglePane($: any): Promise<void> {
417  const pane = (await $.ui.panes()).find((p: { id: string; isShown: boolean }) => p.id === PANE)
418  if (pane?.isShown) {
419    await $.ui.close({ id: PANE })
420    await update($, paneOpenA, () => false)
421  } else await openPane($)
422}
423
424/** Sync whether the pane is actually open into state (session start, after reload) */
425async function syncPaneOpen($: any): Promise<void> {
426  const open = (await $.ui.panes()).some((p: { id: string }) => p.id === PANE)
427  if ((await read($, paneOpenA)) !== open) await update($, paneOpenA, () => open)
428}
429
430async function setView($: any, fn: (v: PaneView) => PaneView): Promise<void> {
431  await update($, viewA, v => fn(v ?? DEFAULT_VIEW))
432  const v = await read($, viewA)
433  await $.store.set('view', { key: v.key })
434}
435
436/**
437 * Switch quota: band and pane show the same one. Band arrows, pane side buttons and the burn-up dropdown all go through here; both switch and it's remembered.
438 * (Stored separately earlier because switching on the band made the pane's frame flicker; with the pace ring and bar now plain images it no longer does, so they're unified)
439 */
440async function selectQuota($: any, k: string): Promise<void> {
441  await update($, bandKeyA, () => k)
442  await update($, viewA, v => ({ ...(v ?? DEFAULT_VIEW), key: k, offset: 0, explore: false }))
443  await $.store.set('bandKey', k)
444  await $.store.set('view', { key: k })
445  await refreshBand($)
446}
447
448async function stepKey($: any, keys: readonly string[], sel: string, offset: number): Promise<void> {
449  const k = P.neighbor(sel, keys, offset)
450  if (k) await selectQuota($, k)
451}
452
453async function setBand($: any, mode: BandMode): Promise<void> {
454  await update($, bandA, () => mode)
455  await $.store.set('band', mode)
456}
457
458/** Show/hide in the pane: read current state on press and flip it, not the render-time snapshot */
459async function toggleBandHidden($: any): Promise<void> {
460  const cur = (await read($, bandA)) ?? 'open'
461  await setBand($, cur === 'hidden' ? 'open' : 'hidden')
462}
463
464/** Band "Hide" asks first: once hidden the band has no entry point, so tell the user to come back via /weektoken; dropped after 8 s without an answer */
465let confirmTimer: { cancel: () => void } | null = null
466
467async function askHideBand($: any): Promise<void> {
468  confirmTimer?.cancel()
469  await update($, confirmHideA, () => true)
470  confirmTimer = $.clock.after(8000, () => { void update($, confirmHideA, () => false).catch(() => {}) })
471}
472
473async function answerHideBand($: any, hide: boolean): Promise<void> {
474  confirmTimer?.cancel()
475  confirmTimer = null
476  await update($, confirmHideA, () => false)
477  if (hide) await setBand($, 'hidden')
478}
479
480/** Band prev/next arrows: switch quota, the pane follows */
481async function stepBand($: any, keys: readonly string[], cur: string, offset: number): Promise<void> {
482  await stepKey($, keys, cur, offset)
483}
484
485// When the desktop pane doesn't have the keyboard (focus in the prompt), the first click only hands focus to the pane: the engine sends ui.focus, no ui.press.
486// Measured: with focus, a click is "ui.press → ui.focus ~70ms later"; without focus, only ui.focus.
487// So when drawing the pane, record each button's action by key; if ui.focus lands on a button with no ui.press before or after, press it once on its behalf.
488// Events can't tell a click from Tab: if the pane already had the keyboard (only then do Tab / arrow keys move focus), don't press.
489// "Already had" comes from the last render's e.props.isFocused: the pane redraws with false when focus leaves (confirmed in device logs);
490// $.ui.panes() already reports "focused" inside ui.focus, even on the first click after blur, so it's unusable. Desktop only.
491const paneActions = new Map<string, () => unknown>()
492let paneSurface = 'desktop'
493let paneFocusedAtRender = false
494let lastPanePress: { el: string; at: number } | null = null
495
496/** Register a pane button's action (for the substitute press); errors are swallowed, no unhandled rejections */
497function paneAct(key: string, fn: () => unknown): () => Promise<void> {
498  const run = () => Promise.resolve().then(fn).then(() => {}, () => {})
499  paneActions.set(key, run)
500  return run
501}
502
503// ======================================================================
504// Rendering: the band above the prompt
505// ======================================================================
506
507const STATUS_DIM = new Set(['early', 'unknown'])
508const SVG_MAX = 120_000
509
510/** Async action fired by a click: errors are swallowed, no unhandled rejections */
511const quiet = (p: Promise<unknown>) => { void p.catch(() => {}) }
512
513/**
514 * Draw an SVG. Small and interactive images get explicit width/height (interactive ones sit in a sandboxed iframe and otherwise stretch to the full row);
515 * wide charts are fluid: no width/height, natural width, shrunk to pane width at most (a size wider than the pane gets scaled down as a bitmap and blurs)
516 */
517function pic($: any, e: any, source: string, alt: string, opts: { isInteractive?: boolean; fluid?: boolean; fillHeight?: number } = {}) {
518  const { Svg } = $.ui.resolve(e)
519  if (opts.fluid) return <Svg source={source} alt={alt} />
520  // Plain image with height only: the SVG declares no width (width="100%") and uses percentage geometry, so width follows the cell up to the image default 300px,
521  // stretching without distortion. No interactive frame: the host reloads it, flickering, whenever the same plugin redraws elsewhere (the pane) (confirmed on device)
522  if (opts.fillHeight) return <Svg source={source} alt={alt} height={opts.fillHeight} />
523  const m = /^<svg[^>]*?\swidth="([\d.]+)"\s+height="([\d.]+)"/.exec(source)
524  const size = m ? { width: Number(m[1]), height: Number(m[2]) } : {}
525  return opts.isInteractive
526    ? <Svg source={source} alt={alt} {...size} isInteractive />
527    : <Svg source={source} alt={alt} {...size} />
528}
529
530/** Text bullet for the terminal: usage blocks + time marker */
531function termBar($: any, e: any, key: string, used: number | null, elapsed: number | null, st: P.Status, width: number) {
532  const { Text } = $.ui.resolve(e)
533  const nU = Math.round((used ?? 0) * width)
534  const mark = elapsed == null ? -1 : Math.min(width - 1, Math.floor(elapsed * width))
535  const runs: { text: string; color?: string; dim?: boolean }[] = []
536  const push = (ch: string, color?: string, dim?: boolean) => {
537    const last = runs[runs.length - 1]
538    if (last && last.color === color && last.dim === dim) last.text += ch
539    else runs.push({ text: ch, color, dim })
540  }
541  for (let i = 0; i < width; i++) {
542    if (i === mark) push('│', STATUS[st].solid)
543    else if (i < nU) push('█', identityText(key))
544    else push('░', undefined, true)
545  }
546  return (
547    <Text>
548      {runs.map(r => <Text color={r.color} dimColor={r.dim}>{r.text}</Text>)}
549    </Text>
550  )
551}
552
553const termBarOf = ($: any, e: any, row: QuotaRow, width: number) =>
554  termBar($, e, row.key, P.displayUsed(row.d), row.d.kind === 'pace' ? row.d.pace.elapsed : null, P.displayStatus(row.d), width)
555
556/**
557 * The band's text line: ‹ name › · used · elapsed. One non-wrapping Text per segment (nested Text wraps on desktop);
558 * name and used don't shrink, elapsed is truncated first. Pace status is shown by the bar color, not text.
559 * With multiple quotas, prev/next arrows flank the name and on hover take the color of the quota they switch to.
560 */
561const bandTexts = (show: BandShow) => ({
562  name: P.displayName(show.key, show.n),
563  used: show.ended ? L('· 已重置', '· reset') : show.used != null ? L(`· 已用 ${show.used}%`, `· ${show.used}% used`) : null,
564  elapsed: show.elapsed != null ? L(`· 已过 ${show.elapsed}%`, `· ${show.elapsed}% elapsed`) : null,
565  details: L('详情', 'Details'),
566  closeDetails: L('收起', 'Close'),
567  hide: L('隐藏', 'Hide'),
568})
569
570/** Terminal cell width: CJK and full-width characters take two cells */
571const cells = (s: string) => [...s].reduce((n, ch) => n + (/[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch) ? 2 : 1), 0)
572
573/**
574 * Width of the band's name cell: sized to the longest quota name, name centered. Switching quotas moves neither the arrows nor the text after them.
575 * Counted in character cells; desktop fonts are proportional but a cell is slightly wider than a typical glyph, so no extra margin (with it the title looks too wide)
576 */
577const bandNameWidth = (show: BandShow, _term: boolean) =>
578  Math.max(...show.keys.map(k => cells(P.displayName(k, k === show.key ? show.n : undefined))), cells(P.displayName(show.key, show.n)))
579
580/**
581 * Terminal band layout: text at its actual width, the bar takes the remaining cells (max 48).
582 * Under 8 cells, drop the "elapsed" segment first; still short, skip the bar. Never let the line wrap to two.
583 */
584/**
585 * Band layout: the bar takes the remaining cells; when short, drop the "elapsed" segment first, then skip the bar (terminal).
586 * "Does it fit" assumes the worst case: longest quota name, widest reading such as "100%", the longer of the two button labels.
587 * Depends only on band width, not the current quota's numbers, so all quotas behave the same at a given width; none shows in full while another is cut to "elap…".
588 * Terminal counts exact character cells; on desktop fonts are proportional and native buttons have padding, so arrows and buttons use estimated cells and the bar keeps at least BAR_MIN.
589 */
590const BAR_MIN = 6
591function bandLayout(show: BandShow, cols: number, term: boolean): { bar: number; withElapsed: boolean } {
592  const t = bandTexts(show)
593  const used = cells(L('· 已用 100%', '· 100% used'))
594  const elapsed = cells(L('· 已过 100%', '· 100% elapsed'))
595  const buttons = Math.max(cells(t.details), cells(t.closeDetails)) + cells(t.hide)
596  const width = (withElapsed: boolean) => {
597    // Name plus both arrows form one group: terminal "h: ❮" / "l: ❯" are 4 cells each, one space apart; desktop native buttons have padding, 3 cells each, no spaces
598    const arrows = show.keys.length > 1 ? (term ? 4 + 4 + 2 : 3 + 3) : 0
599    const parts = [bandNameWidth(show, term) + arrows, used, ...(withElapsed ? [elapsed] : [])]
600    const line = parts.reduce((a, b) => a + b, 0) + parts.length - 1
601    // Terminal: one space between line, bar, spacer, Details and Hide; the engine draws " [-]" last, plus one more cell;
602    // Desktop: 2 cells of padding per button, one space between line, bar and the two buttons
603    return line + buttons + (term ? 4 + 5 : 4 + 3)
604  }
605  if (!term) return { bar: 1, withElapsed: !!t.elapsed && width(true) + BAR_MIN <= cols }
606  for (const withElapsed of [true, false]) {
607    const bar = Math.min(48, cols - width(withElapsed) - 1)
608    if (bar >= 8 || (!withElapsed && bar >= 4)) return { bar, withElapsed: withElapsed && t.elapsed != null }
609  }
610  return { bar: 0, withElapsed: false }
611}
612
613function bandLine($: any, e: any, show: BandShow, withElapsed = true) {
614  const { Box, Text, Button } = $.ui.resolve(e)
615  const term = e.surface === 'terminal'
616  const t = bandTexts(show)
617  const canSwitch = show.keys.length > 1
618  const arrow = (offset: -1 | 1) => (
619    <Box key={offset < 0 ? 'band-sw-prev' : 'band-sw-next'} flexShrink={0}>
620      <Button
621        key={offset < 0 ? 'band-prev' : 'band-next'}
622        label={offset < 0 ? '❮' : '❯'}
623        plain
624        {...(term ? { hotkey: offset < 0 ? 'h' : 'l' } : {})}
625        hover={{ color: identityText(P.neighbor(show.key, show.keys, offset) as string), bold: true }}
626        onPress={() => quiet(stepBand($, show.keys, show.key, offset))}
627      />
628    </Box>
629  )
630  return (
631    <Box flexDirection="row" alignItems="center" gap={1} flexShrink={1} minWidth={0} overflow="hidden">
632      {/* Arrows and name as one group; desktop native buttons have their own padding, so no gap inside, keeping the title from getting too wide */}
633      <Box flexDirection="row" alignItems="center" gap={term ? 1 : 0} flexShrink={0}>
634        {canSwitch ? arrow(-1) : null}
635        <Box flexShrink={0} width={bandNameWidth(show, term)} justifyContent="center"><Text color={identityText(show.key)} wrap="truncate-end">{t.name}</Text></Box>
636        {canSwitch ? arrow(1) : null}
637      </Box>
638      {t.used ? (
639        <Box flexShrink={0}><Text color={show.ended ? undefined : identityText(show.key)} dimColor={!!show.ended} bold={!show.ended} wrap="truncate-end">{t.used}</Text></Box>
640      ) : null}
641      {t.elapsed && withElapsed ? (
642        <Box flexShrink={100} minWidth={0}><Text dimColor wrap="truncate-end">{t.elapsed}</Text></Box>
643      ) : null}
644    </Box>
645  )
646}
647
648function drawConfirmHide($: any, e: any) {
649  const { Box, Text, Button } = $.ui.resolve(e)
650  return (
651    <Box flexDirection="row" alignItems="center" gap={1}>
652      <Box flexGrow={1} flexShrink={1} minWidth={0}>
653        <Text wrap="truncate-end">{L('隐藏横条?之后输入 /weektoken show 可恢复', 'Hide the band? Type /weektoken show to bring it back')}</Text>
654      </Box>
655      <Button key="band-hide-yes" label={L('隐藏', 'Hide')} variant="primary" onPress={() => quiet(answerHideBand($, true))} />
656      <Button key="band-hide-no" label={L('取消', 'Cancel')} onPress={() => quiet(answerHideBand($, false))} />
657    </Box>
658  )
659}
660
661function drawBand($: any, e: any, show: BandShow, paneOpen = false) {
662  const { Box, Button } = $.ui.resolve(e)
663  const term = e.surface === 'terminal'
664  const used = show.used == null ? null : show.used / 100
665  const elapsed = show.elapsed == null ? null : show.elapsed / 100
666  const full = P.fullName(show.key, show.n)
667  const label = show.ended ? L(`${full}:窗口已重置`, `${full}: window has reset`)
668    : show.used == null ? full
669    : show.elapsed == null ? L(`${full}:已用 ${show.used}%`, `${full}: ${show.used}% used`)
670    : L(`${full}:已用 ${show.used}%,已过 ${show.elapsed}%`, `${full}: ${show.used}% used, ${show.elapsed}% elapsed`)
671  const t = bandTexts(show)
672  // One text line + bar; details go in the pane, restore from the pane after hiding.
673  // Desktop: text is laid out at its own width first, the bar grows from 0 into the remaining space (at least BAR_MIN cells); if "elapsed" doesn't fit, the whole segment is hidden (see bandLayout).
674  // Terminal: the bar is fixed-width text, sized from bodyColumns (the conversation column's width when the pane is docked beside it)
675  const lay = bandLayout(show, e.props?.bodyColumns ?? (term ? 80 : 120), term)
676  const pace = used != null && elapsed != null ? ({ used, elapsed } as P.Pace) : null
677  return (
678    <Box flexDirection="row" alignItems="center" gap={1}>
679      {bandLine($, e, show, lay.withElapsed)}
680      {term ? (
681        lay.bar > 0 ? <Box flexShrink={0}>{termBar($, e, show.key, used, elapsed, show.status, lay.bar)}</Box> : null
682      ) : (
683        <Box width={0} flexGrow={1} flexShrink={1} minWidth={BAR_MIN}>
684          {pic($, e, bandBarSvg(pace, used, show.status, identity(show.key), label, 'fluid'), label, { fillHeight: 14 })}
685        </Box>
686      )}
687      {term ? <Box flexGrow={1} /> : null}
688      <Button key="open" label={paneOpen ? t.closeDetails : t.details} plain dimColor onPress={() => quiet(togglePane($))} />
689      <Button key="band-hide" label={t.hide} plain dimColor role="dismiss" onPress={() => quiet(askHideBand($))} />
690    </Box>
691  )
692}
693
694// ======================================================================
695// Rendering: pane
696// ======================================================================
697
698/** Pane footer note: when this quota has been unused for over half an hour, say how long ago it was last used (refresh progress shows on the Refresh button, not this line) */
699function noteLine(row: QuotaRow | undefined, samples: readonly Sample[], activity: Activity, now: number): string | null {
700  if (!row) return null
701  const lu = P.lastUsed(samples, row.key, activity)
702  if (!lu) return null
703  const age = (now - lu.at) / 1000
704  if (age < 1800) return null
705  const d = P.formatDuration(age)
706  const who = P.isModelScoped(row.key) ? row.name : null
707  if (lu.isLowerBound) return who ? L(`至少 ${d}没用过 ${who}`, `${who} unused for ${d}+`) : L(`至少 ${d}没有用量`, `No usage for ${d}+`)
708  return who ? L(`上次使用 ${who}:${d}前`, `Last used ${who} ${d} ago`) : L(`上次使用:${d}前`, `Last used ${d} ago`)
709}
710
711function trendOf(samples: readonly Sample[], row: QuotaRow): P.Trend | null {
712  if (row.d.kind !== 'pace' || row.obs?.r == null) return null
713  return P.buildSeries(row.key, row.obs.r, samples, row.len.seconds)?.trend ?? null
714}
715
716const trendText = (t: P.Trend): string =>
717  L({ accelerating: '↗ 最近在加速', easing: '↘ 最近在缓下来', steady: '→ 速度稳定' }, { accelerating: '↗ Speeding up lately', easing: '↘ Slowing down lately', steady: '→ Steady pace' })[t]
718
719function drawEmpty($: any, e: any) {
720  const { Box, Text } = $.ui.resolve(e)
721  return (
722    <Box flexDirection="column" alignItems="center" paddingY={1}>
723      <Text bold>{L('还没有数据', 'No data yet')}</Text>
724      <Text dimColor wrap="wrap">{L('等这个会话收到第一条回复,或点「刷新」', "Waiting for this session's first reply, or press Refresh")}</Text>
725    </Box>
726  )
727}
728
729function drawPaceTab($: any, e: any, samples: readonly Sample[], m: Model, row: QuotaRow) {
730  const { Box, Text, Button } = $.ui.resolve(e)
731  const term = e.surface === 'terminal'
732  const d = row.d
733  const st = P.displayStatus(d)
734  const pace = d.kind === 'pace' ? d.pace : null
735  const used = P.displayUsed(d)
736  const id = identity(row.key)
737  const label = P.accessibilityLabel(d, row.full)
738  const second = pace ? P.forecast(pace) : P.displayDetail(d)
739  const trend = trendOf(samples, row)
740  // Trend is only supplementary, no extra color (amber and the quota blue are near-complementary and look cheapest together)
741  const trendLine = trend && trend !== 'steady' ? <Text dimColor>{trendText(trend)}</Text> : null
742  const showBadge = !P.isVerifiedByData(row.len.source)
743  const canSwitch = m.keys.length > 1
744  const target = (offset: number) => P.neighbor(row.key, m.keys, offset) as string
745  // Readings rounded, matching the band
746  const metrics = pace
747    ? [
748        { v: `${Math.round(pace.used * 100)}%`, l: L('已用', 'Used'), accent: true },
749        { v: `${Math.round(pace.elapsed * 100)}%`, l: L('已过', 'Elapsed') },
750        { v: P.formatDuration(pace.timeToReset), l: L('距重置', 'Resets in') },
751      ]
752    : d.kind === 'usageOnly' ? [{ v: `${Math.round(d.obs.u)}%`, l: L('已用', 'Used'), accent: true }] : []
753
754  if (term) {
755    return (
756      <Box flexDirection="column" gap={1}>
757        <Box flexDirection="row" gap={1}>
758          {termBarOf($, e, row, 30)}
759          <Text color={STATUS_DIM.has(st) ? undefined : statusText(st)} bold>{pace?.burnRate != null ? `${pace.burnRate.toFixed(2)}×` : P.menuBarText(d).trim()}</Text>
760          <Text dimColor>{P.displayLabel(d)}</Text>
761        </Box>
762        <Text bold>{P.displayHeadline(d)}</Text>
763        {second ? <Text dimColor wrap="wrap">{second}</Text> : null}
764        {trendLine}
765        <Text>{metrics.map(x => `${x.l} ${x.v}`).join('   ')}</Text>
766        {showBadge ? <Text dimColor>?{P.windowNote(row.len)}</Text> : null}
767        {canSwitch ? (
768          <Box flexDirection="row" gap={2}>
769            <Button key="prev" label={`‹ ${m.rows[target(-1)]?.full ?? ''}`} hotkey="h" onPress={paneAct('prev', () => stepKey($, m.keys, row.key, -1))} />
770            <Button key="next" label={`${m.rows[target(1)]?.full ?? ''} ›`} hotkey="l" onPress={paneAct('next', () => stepKey($, m.keys, row.key, 1))} />
771          </Box>
772        ) : null}
773      </Box>
774    )
775  }
776
777  const center = pace && pace.burnRate != null
778    ? { kind: 'rate' as const, text: pace.burnRate.toFixed(2) }
779    : d.kind === 'usageOnly' ? { kind: 'pct' as const, text: `${Math.round(d.obs.u)}%` } : { kind: 'none' as const }
780  return (
781    <Box flexDirection="column" alignItems="center" gap={2}>
782      {/* Quota-switch arrows flank the title (see drawPane); this row holds only the image, centered */}
783      <Box flexDirection="row" justifyContent="center" width="100%">
784        {pic($, e, ringsSvg({ used, elapsed: pace?.elapsed ?? null, status: st, id, center, label: P.displayLabel(d), title: label, readings: pace ? metrics.map(x => ({ label: x.l.toUpperCase(), value: x.v, accent: !!x.accent })) : [] }), label)}
785      </Box>
786      <Box flexDirection="column" alignItems="center">
787        <Text bold>{P.displayHeadline(d)}</Text>
788        {second ? <Text dimColor wrap="wrap">{second}</Text> : null}
789        {trendLine}
790      </Box>
791      {showBadge ? <Text dimColor>?{P.windowNote(row.len)}</Text> : null}
792    </Box>
793  )
794}
795
796function drawBurnUpTab($: any, e: any, samples: readonly Sample[], m: Model, row: QuotaRow, view: PaneView, now: number) {
797  const { Box, Text, Button, Select } = $.ui.resolve(e)
798  const o = P.buildOverlay(row.key, samples, row.len.seconds, view.range, view.offset, now)
799  const ranges: P.Range[] = ['current', 'month', 'all']
800  // Desktop already has arrows beside the title, so none here; terminal uses a dropdown; surfaces without Select (mobile) fall back to prev/next buttons
801  const quotaPick = m.keys.length < 2 || e.surface !== 'terminal' ? null : Select ? (
802    <Select key="quota" options={m.keys.map(k => ({ value: k, label: P.pickName(k, m.rows[k].obs?.n) }))} value={row.key} onSelect={(k: string) => quiet(selectQuota($, k))} />
803  ) : (
804    <Box flexDirection="row" gap={1}>
805      <Button key="prev" label="‹" onPress={paneAct('prev', () => stepKey($, m.keys, row.key, -1))} />
806      <Button key="next" label="›" onPress={paneAct('next', () => stepKey($, m.keys, row.key, 1))} />
807    </Box>
808  )
809  const rangeRow = (
810    <Box flexDirection="row" alignItems="center" gap={1} flexWrap="wrap">
811      {quotaPick}
812      {ranges.map(r => (
813        <Button key={`range-${r}`} label={P.rangeLabel(r)} variant={view.range === r ? 'primary' : 'secondary'} onPress={paneAct(`range-${r}`, () => setView($, v => ({ ...v, range: r, offset: 0, explore: false })))} />
814      ))}
815    </Box>
816  )
817  const focused = o.focused
818  const canOlder = view.range === 'current' && view.offset < o.totalWindows - 1
819  const canNewer = view.range === 'current' && view.offset > 0
820  const nav = view.range === 'current' && focused ? (
821    <Box flexDirection="row" alignItems="center" gap={1}>
822      {canOlder ? <Button key="older" label={L('‹ 更早', '‹ Older')} onPress={paneAct('older', () => setView($, v => ({ ...v, offset: v.offset + 1 })))} /> : null}
823      <Text dimColor>{P.windowLabel(focused.series.reset, row.len.seconds, focused.isCurrent)}</Text>
824      {canNewer ? <Button key="newer" label={L('更近 ›', 'Newer ›')} onPress={paneAct('newer', () => setView($, v => ({ ...v, offset: v.offset - 1 })))} /> : null}
825    </Box>
826  ) : null
827  if (!o.entries.length) {
828    return (
829      <Box flexDirection="column" gap={1}>
830        {rangeRow}
831        <Text dimColor wrap="wrap">{L('这个窗口的采样还不够画趋势', 'Not enough samples in this window yet')}</Text>
832      </Box>
833    )
834  }
835  // "Last month / All" is normally a plain image (no flicker on band or pane redraws); hovering the chart reveals "Inspect lines" at top left,
836  // and only clicking it swaps in the hoverable frame: the line under the pointer goes bold with its date and peak, the rest fade
837  const W = row.len.seconds
838  const caption = P.overlayCaption(o)
839  if (e.surface === 'terminal') {
840    const s = focused!.series
841    return (
842      <Box flexDirection="column" gap={1}>
843        {rangeRow}
844        {nav}
845        <Text>{L(`峰值 ${Math.round(s.peak * 100)}%`, `Peak ${Math.round(s.peak * 100)}%`)}</Text>
846        {caption ? <Text dimColor wrap="wrap">{caption}</Text> : null}
847        <Text dimColor>{L('(曲线图在桌面端显示)', '(The chart shows in the desktop app)')}</Text>
848      </Box>
849    )
850  }
851  const title = L(`${row.full} 用量轨迹`, `${row.full} burn-up`)
852  const input = { width: 360, overlay: o, W, status: P.displayStatus(row.d), id: identity(row.key), title }
853  const plain = burnUpSvg(input)
854  // The hoverable version adds hit lines and labels; if it exceeds the cap, no inspect mode, the plain chart draws as usual
855  const live = view.range !== 'current' && o.entries.length > 1 ? burnUpSvg({ ...input, interactive: true }) : null
856  const canExplore = live != null && live.length <= SVG_MAX
857  const exploring = canExplore && !!view.explore
858  const svg = exploring && live ? live : plain
859  const alt = caption ? `${title}. ${caption}` : title
860  // Svg source is capped at 131072 chars; over that the engine rejects the whole tree. Overlays are already limited, this is a final backstop
861  if (svg.length > SVG_MAX) {
862    return (
863      <Box flexDirection="column" gap={1}>
864        {rangeRow}
865        {nav}
866        <Text dimColor wrap="wrap">{L('这个范围的数据太多,画不下:换「本窗口」或「近一月」看', 'Too much data to draw for this range: try This window or Last month')}</Text>
867      </Box>
868    )
869  }
870  const toggle = (
871    <Button
872      key="explore"
873      label={exploring ? L('✓ 完成', '✓ Done') : L('⤢ 逐条查看', '⤢ Inspect lines')}
874      variant={exploring ? 'primary' : 'secondary'}
875      onPress={paneAct('explore', () => setView($, v => ({ ...v, explore: !exploring })))}
876    />
877  )
878  const chart = canExplore ? (
879    <Box key="chart" flexDirection="column">
880      {exploring ? pic($, e, svg, alt, { isInteractive: true }) : pic($, e, svg, alt, { fluid: true })}
881      <Box position="absolute" top={0} left={4} {...(exploring ? {} : { display: 'none', hover: { display: 'flex' } })}>{toggle}</Box>
882    </Box>
883  ) : pic($, e, svg, alt, { fluid: true })
884  return (
885    <Box flexDirection="column" gap={1}>
886      {rangeRow}
887      {nav}
888      {chart}
889      {exploring ? <Text dimColor wrap="wrap">{L('鼠标指到一条线上,看它是哪个窗口、峰值多少', 'Point at a line to see its window and peak')}</Text> : null}
890      {caption ? <Text dimColor wrap="wrap">{caption}</Text> : null}
891    </Box>
892  )
893}
894
895/** Credit: bottom-right of the pane, the X handle, one line of 10px translucent text (drawn as an image; not clickable) */
896// Version read at pane render (reading versionA also redraws the pane once it's written)
897let paneVersion = ''
898
899function credit($: any, e: any) {
900  const { Box, Text } = $.ui.resolve(e)
901  const handle = `@${AUTHOR.x}${paneVersion ? ` · v${paneVersion}` : ''}`
902  return (
903    <Box flexDirection="row" justifyContent="flex-end">
904      {e.surface === 'terminal' ? <Text dimColor>{handle}</Text> : pic($, e, creditSvg(handle), handle)}
905    </Box>
906  )
907}
908
909function drawPane($: any, e: any, samples: readonly Sample[], activity: Activity, view: PaneView, note: Note, band: BandMode, m: Model, now: number) {
910  const { Box, Text, Button } = $.ui.resolve(e)
911  // At least as tall as the pane body (row count from the host; Box sizes are in rows on every surface); the spacer before the credit pushes it to the bottom
912  const fill = e.props?.scroll?.bodyRows ? Math.max(1, e.props.scroll.bodyRows) : '100%'
913  paneActions.clear()
914  paneSurface = e.surface
915  paneFocusedAtRender = !!e.props?.isFocused
916  const term = e.surface === 'terminal'
917  const sel = m.selected
918  const row = sel ? m.rows[sel] : undefined
919  const nl = noteLine(row, samples, activity, now)
920  const busy = isBusy(note, now)
921  const footer = (
922    <Box flexDirection="row" alignItems="center" gap={1}>
923      {nl ? <Text dimColor wrap="truncate-end">{nl}</Text> : null}
924      <Box flexGrow={1} />
925      <Button key="band-toggle" label={band === 'hidden' ? L('⊕ 显示横条', '⊕ Show band') : L('⊖ 隐藏横条', '⊖ Hide band')} onPress={paneAct('band-toggle', () => toggleBandHidden($))} />
926      <Button key="refresh" label={busy ? L('↻ 刷新中…', '↻ Refreshing…') : L('↻ 刷新', '↻ Refresh')} {...(term ? { hotkey: 'r' } : {})} onPress={paneAct('refresh', () => refreshAll($, true))} />
927    </Box>
928  )
929  if (!row) {
930    return (
931      <Box flexDirection="column" gap={term ? 1 : 2} paddingX={1} minHeight={fill}>
932        {drawEmpty($, e)}
933        {footer}
934        <Box flexGrow={1} />
935        {credit($, e)}
936      </Box>
937    )
938  }
939  // Desktop: quota-switch arrows flank the title, as on the band; the name cell is sized to the longest quota name so arrows don't move on switch.
940  // (Arrows used to sit at both ends of the ring row; once readings moved into the image it grew 50% wider and squeezed the arrows against the ring)
941  // The terminal pace tab has its own named buttons (h / l), so no title arrows
942  const headArrows = !term && m.keys.length > 1
943  const headArrow = (offset: -1 | 1) => {
944    const name = offset < 0 ? 'prev' : 'next'
945    return (
946      <Box key={`sw-${name}`} flexShrink={0}>
947        <Button
948          key={name}
949          label={offset < 0 ? '❮' : '❯'}
950          plain
951          hover={{ color: identityText(P.neighbor(row.key, m.keys, offset) as string), bold: true }}
952          onPress={paneAct(name, () => stepKey($, m.keys, row.key, offset))}
953        />
954      </Box>
955    )
956  }
957  const title = <Text color={identityText(row.key)} bold wrap="truncate-end">{row.full}</Text>
958  const header = headArrows ? (
959    <Box flexDirection="row" alignItems="center">
960      {headArrow(-1)}
961      <Box flexShrink={0} width={Math.max(...m.keys.map(k => cells(m.rows[k].full)))} justifyContent="center">{title}</Box>
962      {headArrow(1)}
963    </Box>
964  ) : (
965    <Box flexDirection="row" alignItems="center" gap={1}>{title}</Box>
966  )
967  const tabs = (
968    <Box flexDirection="row" gap={1}>
969      <Button key="tab-pace" label={L('配速', 'Pace')} {...(term ? { hotkey: '1' } : {})} variant={view.tab === 'pace' ? 'primary' : 'secondary'} onPress={paneAct('tab-pace', () => setView($, v => ({ ...v, tab: 'pace', explore: false })))} />
970      <Button key="tab-burnup" label={L('用量轨迹', 'Burn-up')} {...(term ? { hotkey: '2' } : {})} variant={view.tab === 'burnup' ? 'primary' : 'secondary'} onPress={paneAct('tab-burnup', () => setView($, v => ({ ...v, tab: 'burnup', explore: false })))} />
971    </Box>
972  )
973  return (
974    <Box flexDirection="column" gap={term ? 1 : 2} paddingX={1} minHeight={fill}>
975      {header}
976      {tabs}
977      {view.tab === 'burnup' ? drawBurnUpTab($, e, samples, m, row, view, now) : drawPaceTab($, e, samples, m, row)}
978      {footer}
979      <Box flexGrow={1} />
980      {credit($, e)}
981    </Box>
982  )
983}
984
985// ======================================================================
986// Hooks
987// ======================================================================
988
989
990const commandSpec = () => ({
991  name: 'weektoken',
992  description: L('打开 WeekToken 配速面板;show / hide 显示或隐藏横条', 'Open the WeekToken pace pane; show / hide the band'),
993  argumentHint: '[show | hide]',
994  immediate: true,
995})
996
997// Timer handles: on another session.start in the same environment (enable, process restart), cancel the old ones first so they don't stack
998let timers: { cancel: () => void }[] = []
999
1000export const register: Register = on => {
1001  on('session.start', async ($, e, next) => {
1002    if (await readDisabled($)) return next(e)
1003    try {
1004      const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }
1005      if (typeof manifest.version === 'string') await update($, versionA, () => manifest.version as string)
1006    } catch {}
1007    try { await refreshLang($) } catch {}
1008    await $.command.register(commandSpec())
1009    // "Refreshing" / "confirm hide" left from before the reload will never be cleared otherwise
1010    try { await update($, noteA, () => null) } catch {}
1011    try { await update($, confirmHideA, () => false) } catch {}
1012    try { await loadState($) } catch {}
1013    try { await importHistory($) } catch {}
1014    try { await sampleSession($) } catch {}
1015    try { await sampleCache($) } catch {}
1016    try { await refreshBand($) } catch {}
1017    try { await syncPaneOpen($) } catch {}
1018    // First run after install: say what the band is, when data shows up, and where the entry point is
1019    if ((await $.store.get('welcomed')) !== true) {
1020      await $.store.set('welcomed', true)
1021      $.ui.toast(L(
1022        'WeekToken 已启用:输入框上方显示用量配速,5 小时 / 7 天的数据在第一条回复后出现。输入 /weektoken 看详情和历史,/weektoken hide 隐藏横条',
1023        'WeekToken is on: the band above the prompt shows your usage pace; 5-hour and 7-day figures appear after the first reply. Type /weektoken for details and history, /weektoken hide to hide the band',
1024      ), { timeoutMs: 15_000 })
1025    }
1026    for (const t of timers) t.cancel()
1027    timers = [
1028      $.clock.every(5 * 60_000, () => { void sampleCache($).catch(() => {}) }),
1029      $.clock.every(10 * 60_000, () => { void importHistory($).catch(() => {}) }),
1030      // Every minute: merge samples recorded by other sessions; "elapsed %" moves with time, so recompute the band, writing nothing if the display is unchanged
1031      $.clock.every(60_000, () => { void adoptStoredSamples($).then(() => refreshBand($)).catch(() => {}) }),
1032    ]
1033    return next(e)
1034  })
1035
1036  // Every reply uses quota (subagents too): record time and model to determine "last used"
1037  on('turn.complete', async ($, e, next) => {
1038    if (disabled) return next(e)
1039    try { if (e.usage) await recordActivity($, e.usage.model) } catch {}
1040    return next(e)
1041  })
1042
1043  // After each reply, record a sample whenever the officially reported limit windows change (≥ 1 percentage point)
1044  on('session.measure', async ($, e, next) => {
1045    if (disabled) return next(e)
1046    try { await recordRateLimits($, e.rateLimits, await $.clock.now()) } catch {}
1047    return next(e)
1048  })
1049
1050  on('command.run', { command: 'weektoken' }, async ($, e) => {
1051    await useLang($)
1052    const arg = (e.args ?? '').trim().toLowerCase()
1053    if (arg === 'show') {
1054      await setBand($, 'open')
1055      await refreshBand($)
1056      return { text: L('横条已显示', 'Band shown') }
1057    }
1058    if (arg === 'hide') {
1059      await setBand($, 'hidden')
1060      return { text: L('横条已隐藏,输入 /weektoken show 恢复', 'Band hidden; type /weektoken show to bring it back') }
1061    }
1062    if (arg) return { text: L(`不认识的参数「${arg}」:可以用 show、hide,不带参数则打开面板`, `Unknown argument "${arg}": use show or hide, or nothing to open the pane`) }
1063    await refreshAll($, false)
1064    await openPane($)
1065    return { text: L('WeekToken 配速面板已打开', 'WeekToken pace pane opened') }
1066  })
1067
1068  // Workaround for the pane's first click only taking focus (see paneActions): record every press on the pane…
1069  on('ui.press', async ($, e, next) => {
1070    if (disabled || e.plugin !== 'weektoken') return next(e)
1071    const at = await $.clock.now()
1072    if (e.component === 'Pane') lastPanePress = { el: e.element, at }
1073    return next(e)
1074  })
1075  // …if focus lands on a button with no press before or after, and the pane didn't have the keyboard, press it once on its behalf
1076  on('ui.focus', async ($, e, next) => {
1077    const el = e.element
1078    if (disabled || e.plugin !== 'weektoken' || e.component !== 'Pane' || !el || e.origin.kind !== 'person' || paneSurface === 'terminal') return next(e)
1079    const hadKeyboard = paneFocusedAtRender
1080    const r = await next(e)
1081    const at = await $.clock.now()
1082    const pressed = (since: number) => lastPanePress != null && lastPanePress.el === el && lastPanePress.at >= since
1083    // Pane already had the keyboard: Tab / arrow keys moving focus, or a click that brings its own ui.press. Don't press in either case
1084    if (hadKeyboard || pressed(at - 300)) return r
1085    $.clock.after(250, () => {
1086      if (pressed(at)) return // arrived on its own afterwards
1087      const act = paneActions.get(el)
1088      if (!act) return
1089      void act()
1090    })
1091    return r
1092  })
1093
1094  // Claude Code's language changed in /config: pass the setting to the engine unchanged, re-detect shortly after (once the new value applies);
1095  // band and pane redraw accordingly, and the command description switches language too
1096  on('config.set', { key: 'language' }, async ($, e, next) => {
1097    if (!disabled) $.clock.after(300, () => { void refreshLang($).then(changed => (changed ? $.command.register(commandSpec()) : undefined)).catch(() => {}) })
1098    return next(e)
1099  })
1100
1101  // The band reads only these: shown/hidden, confirm prompt, language, content to draw (already rounded). Not samples or pane state.
1102  // After drawing itself it appends what later mods and the engine draw below, without blocking them (same wrapping Box whether or not anything is below)
1103  // When the pane closes (band "Close", the pane's ×, unload) the band button reverts to "Details"; the close passes through to the engine unchanged
1104  on('ui.close', { id: PANE }, async ($, e, next) => {
1105    if (!disabled) { try { await update($, paneOpenA, () => false) } catch {} }
1106    return next(e)
1107  })
1108
1109  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1110    if (disabled || e.props.hasSurvey) return next(e)
1111    if (((await read($, bandA)) ?? 'open') === 'hidden') return next(e)
1112    await useLang($)
1113    const show = (await read($, confirmHideA)) ? 'confirm' : await read($, bandShowA)
1114    if (!show) return next(e)
1115    const ours = show === 'confirm' ? drawConfirmHide($, e) : drawBand($, e, show, !!(await read($, paneOpenA)))
1116    const below = await next(e)
1117    const { Box } = $.ui.resolve(e)
1118    return <Box flexDirection="column">{ours}{isBlank(below) ? null : below}</Box>
1119  })
1120
1121  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1122    const samples = (await read($, samplesA)) ?? []
1123    const view = (await read($, viewA)) ?? DEFAULT_VIEW
1124    const note = await read($, noteA)
1125    const band = (await read($, bandA)) ?? 'open'
1126    await useLang($)
1127    const now = await $.clock.now()
1128    const activity = (await read($, activityA)) ?? { byModel: {} }
1129    const bandKey = (await read($, bandKeyA)) ?? null
1130    paneVersion = (await read($, versionA)) ?? ''
1131    return drawPane($, e, samples, activity, view, note, band, buildModel(samples, view, now, bandKey), now)
1132  })
1133}
1134
hooks/i18n.ts 38 lines
1// UI language: Chinese / English; any other language falls back to English.
2// register.tsx detects it at session start (mod setting → Claude Code language setting → system language), then calls setLang;
3// pure functions use L(zh, en) to pick text for the current language.
4
5export type Lang = 'zh' | 'en'
6
7let current: Lang = 'en'
8
9export const setLang = (l: Lang): void => { current = l }
10export const getLang = (): Lang => current
11
12/** Pick one of the two by the current language */
13export const L = <T>(zh: T, en: T): T => (current === 'zh' ? zh : en)
14
15const ZH = /^zh([-_.\s]|$)|chinese|mandarin|cantonese|中文|汉语|漢語|简体|繁體|繁体|普通话|国语|國語|粤语/i
16
17/** Claude Code language setting: unset (empty, or shown as "Default (English)") → null; Chinese → zh; any other language → en */
18export function langFromSetting(v: unknown): Lang | null {
19  if (typeof v !== 'string') return null
20  const s = v.trim()
21  if (!s || /^default\b/i.test(s)) return null
22  return ZH.test(s) ? 'zh' : 'en'
23}
24
25/** LANG / LC_ALL / LC_MESSAGES: C and POSIX don't count as a language */
26export function langFromEnv(v: string | undefined): Lang | null {
27  const s = (v ?? '').trim()
28  if (!s || /^(c|posix)([._]|$)/i.test(s)) return null
29  return /^zh([-_.]|$)/i.test(s) ? 'zh' : 'en'
30}
31
32/** Output of `defaults read -g AppleLanguages`: take the first (preferred) entry */
33export function langFromAppleLanguages(out: string): Lang | null {
34  const first = out.replace(/[()]/g, '').split(/[,\n]/).map(x => x.trim().replace(/^"|"$/g, '')).find(Boolean)
35  if (!first) return null
36  return /^zh([-_]|$)/i.test(first) ? 'zh' : 'en'
37}
38
hooks/pace.ts 885 lines
1// WeekToken pace model: a port of WeekTokenCore from the WeekToken macOS app.
2// Algorithms, thresholds and copy match the original (PaceModel / QuotaDisplay / PaceNarrator / QuotaCatalog /
3// Bottleneck / WindowInference / BurnUpSeries / BurnUpOverlay). Pure functions; never touches `$`.
4// Time: samples and reset times are ms timestamps; window length W is in seconds. Copy is bilingual zh/en, picked by the current language in i18n.ts.
5
6import { L } from './i18n.ts'
7
8export const FIVE_HOUR = 18000
9export const SEVEN_DAY = 604800
10
11/** One quota in an observation: usage 0–100, reset time (ms), display name from the server */
12export type Obs = { u: number; r?: number; n?: string }
13/** One sample: time (ms) + per-quota observations */
14export type Sample = { t: number; w: Record<string, Obs> }
15
16// ---------- Window length and source ----------
17
18export type LengthSource =
19  | { kind: 'declared' }
20  | { kind: 'measured'; n: number }
21  | { kind: 'corroborated'; n: number }
22  | { kind: 'clientDeclared' }
23  | { kind: 'assumed' }
24
25export type WindowLength = { seconds: number; source: LengthSource }
26
27export function sourceBadge(s: LengthSource): string {
28  switch (s.kind) {
29    case 'declared': return L('厂商提供', 'vendor-declared')
30    case 'measured': return L(`实测 n=${s.n}`, `measured n=${s.n}`)
31    case 'corroborated': return L(`已验证 n=${s.n}`, `verified n=${s.n}`)
32    case 'clientDeclared': return L('客户端标定', 'client default')
33    case 'assumed': return L('假定', 'assumed')
34  }
35}
36
37/** The UI shows the source only when it is not verified by local data */
38export const isVerifiedByData = (s: LengthSource): boolean =>
39  (s.kind === 'measured' || s.kind === 'corroborated') && s.n >= 1
40
41export function prior(key: string): WindowLength {
42  const k = key.toLowerCase()
43  if (k.includes('five_hour') || k.includes('5h') || k.includes('session') || k === 'primary') {
44    return { seconds: FIVE_HOUR, source: { kind: 'clientDeclared' } }
45  }
46  if (k.includes('seven_day') || k.includes('week') || k.includes('7d')) {
47    return { seconds: SEVEN_DAY, source: { kind: 'clientDeclared' } }
48  }
49  return { seconds: SEVEN_DAY, source: { kind: 'assumed' } }
50}
51
52const MIN_JUMP = 60
53const BOUND_TOLERANCE = 60
54const FIXED_PERIOD_TOLERANCE = 60
55
56function robustUpperBound(jumps: number[]): number {
57  const sorted = [...jumps].sort((a, b) => a - b)
58  if (sorted.length < 10) return sorted[0]
59  return sorted[Math.max(1, Math.floor(sorted.length / 20))]
60}
61
62/** Infer window length from resets_at jumps: each jump gives an interval [jump − sample gap, jump]; intersect them */
63export function inferWindow(key: string, samples: readonly Sample[]): WindowLength {
64  const p = prior(key)
65  const bounds: { lo: number; hi: number; jump: number }[] = []
66  let prevR: number | undefined
67  let prevT: number | undefined
68  for (const s of samples) {
69    const o = s.w[key]
70    if (!o || o.r == null) continue
71    if (prevR != null && prevT != null) {
72      const jump = (o.r - prevR) / 1000
73      const gap = (s.t - prevT) / 1000
74      if (jump > MIN_JUMP && jump >= 3600 && jump <= 30 * 86400) {
75        bounds.push({ lo: Math.max(0, jump - gap), hi: jump, jump })
76      }
77    }
78    prevR = o.r
79    prevT = s.t
80  }
81  if (bounds.length === 0) return p
82  const n = bounds.length
83  const jumps = bounds.map(b => b.jump)
84  const jMin = Math.min(...jumps)
85  const jMax = Math.max(...jumps)
86  // Fixed period: if the jumps are uniform, that is the exact W.
87  // But not when jumps exceed the prior: a rolling window's jump = W + idle gap from reset to first use; when that gap is constant (scheduled jobs)
88  // the jumps are uniform too, but that is the gap, not W — leave it to the interval inference below
89  const mean = jumps.reduce((a, b) => a + b, 0) / n
90  if (n >= 2 && jMax - jMin <= FIXED_PERIOD_TOLERANCE && mean <= p.seconds + FIXED_PERIOD_TOLERANCE) {
91    if (Math.abs(mean - p.seconds) <= FIXED_PERIOD_TOLERANCE) return { seconds: p.seconds, source: { kind: 'corroborated', n } }
92    return { seconds: mean, source: { kind: 'measured', n } }
93  }
94  // Rolling window: lower bound = max of the interval lower bounds, upper bound = robust minimum jump
95  const lo = Math.max(...bounds.map(b => b.lo))
96  const hi = robustUpperBound(jumps)
97  if (lo - hi > BOUND_TOLERANCE) {
98    // Observations contradict each other: fall back to the upper bound, which always holds
99    if (Math.abs(hi - p.seconds) <= BOUND_TOLERANCE) return { seconds: p.seconds, source: { kind: 'corroborated', n } }
100    return { seconds: hi, source: { kind: 'measured', n } }
101  }
102  if (p.seconds >= lo - BOUND_TOLERANCE && p.seconds <= hi + BOUND_TOLERANCE) {
103    return { seconds: p.seconds, source: { kind: 'corroborated', n } }
104  }
105  return { seconds: Math.min(Math.max(p.seconds, lo), hi), source: { kind: 'measured', n } }
106}
107
108// ---------- Pace ----------
109
110export type Status = 'unknown' | 'early' | 'comfortable' | 'onPace' | 'overPace' | 'exhausted'
111
112export type Pace = {
113  /** U: fraction used 0–1 */
114  used: number
115  /** T: fraction of time elapsed 0–1 */
116  elapsed: number
117  /** Window length (seconds) */
118  W: number
119  /** Computation time (ms) */
120  now: number
121  delta: number
122  burnRate: number | null
123  timeToReset: number
124  runway: number | null
125  exhaustionAt: number | null
126  leadTime: number
127  overPaceThreshold: number
128  status: Status
129}
130
131const clamp = (x: number, lo: number, hi: number) => Math.min(Math.max(x, lo), hi)
132
133export const earlyCutoff = (W: number) => Math.max(0.001, 300 / Math.max(W, 1))
134
135/** Official limit-warning calibration (from the claude binary): (utilization, timePct) */
136export function officialThresholds(W: number): [number, number][] {
137  return W <= 6 * 3600 ? [[0.9, 0.72]] : [[0.75, 0.6], [0.5, 0.35], [0.25, 0.15]]
138}
139
140/** Over-pace ratio threshold: tightens as the window progresses (1.67 → 1.43 → 1.25 → 1.1) */
141export function overPaceThreshold(elapsed: number, W: number): number {
142  const sorted = officialThresholds(W).sort((a, b) => a[1] - b[1])
143  for (const [util, timePct] of sorted) if (elapsed <= timePct) return util / timePct
144  return 1.1
145}
146
147export function paceFromValues(used: number, elapsed: number, W: number, now: number): Pace {
148  const cutoff = earlyCutoff(W)
149  const delta = used - elapsed
150  const burnRate = elapsed > cutoff ? used / elapsed : null
151  const timeToReset = Math.max(0, W * (1 - elapsed))
152  let runway: number | null
153  if (used <= 0) runway = null
154  else if (used >= 1) runway = 0
155  else if (elapsed <= cutoff) runway = null
156  else runway = (W * elapsed * (1 - used)) / used
157  const exhaustionAt = runway != null && runway < timeToReset ? now + runway * 1000 : null
158  const over = overPaceThreshold(elapsed, W)
159  let status: Status
160  if (used >= 1) status = 'exhausted'
161  else if (elapsed <= cutoff) status = 'early'
162  else if (burnRate == null) status = 'unknown'
163  else if (burnRate > over) status = 'overPace'
164  else if (burnRate < 1 / over) status = 'comfortable'
165  else status = 'onPace'
166  return { used, elapsed, W, now, delta, burnRate, timeToReset, runway, exhaustionAt, leadTime: delta * W, overPaceThreshold: over, status }
167}
168
169/** Build pace from one observation; no reset time, or reset already passed → null (no fabricated clamp to 100%) */
170export function paceFromObs(o: Obs, W: number, now: number): Pace | null {
171  if (o.r == null) return null
172  const remaining = (o.r - now) / 1000
173  if (remaining <= 0) return null
174  const used = clamp(o.u / 100, 0, 1)
175  const elapsed = 1 - Math.min(remaining, W) / W
176  return paceFromValues(used, elapsed, W, now)
177}
178
179const STATUS_ZH: Record<Status, string> = {
180  comfortable: '富余',
181  onPace: '贴合配速',
182  overPace: '超速',
183  exhausted: '已用尽',
184  early: '窗口初期',
185  unknown: '数据不足',
186}
187const STATUS_EN: Record<Status, string> = {
188  comfortable: 'Under pace',
189  onPace: 'On pace',
190  overPace: 'Over pace',
191  exhausted: 'Used up',
192  early: 'Too early',
193  unknown: 'No data yet',
194}
195export const statusLabel = (s: Status): string => L(STATUS_ZH, STATUS_EN)[s]
196
197// ---------- Three display states: pace / usage only / empty ----------
198
199export type UsageOnlyReason =
200  | { kind: 'notStarted' }
201  | { kind: 'windowEnded' }
202  | { kind: 'noResetTime' }
203  | { kind: 'stale'; age: number }
204
205export type Display =
206  | { kind: 'pace'; pace: Pace; obs: Obs }
207  | { kind: 'usageOnly'; obs: Obs; reason: UsageOnlyReason }
208  | { kind: 'empty' }
209
210export const staleThreshold = (W: number) => Math.max(15 * 60, 0.05 * W)
211
212export function display(o: Obs | undefined, W: number, now: number, observedAt?: number): Display {
213  if (!o) return { kind: 'empty' }
214  if (observedAt != null) {
215    const age = (now - observedAt) / 1000
216    if (age > staleThreshold(W)) return { kind: 'usageOnly', obs: o, reason: { kind: 'stale', age } }
217  }
218  const pace = paceFromObs(o, W, now)
219  if (pace) return { kind: 'pace', pace, obs: o }
220  if (o.r != null) return { kind: 'usageOnly', obs: o, reason: { kind: o.r <= now ? 'windowEnded' : 'noResetTime' } }
221  return { kind: 'usageOnly', obs: o, reason: { kind: o.u <= 0 ? 'notStarted' : 'noResetTime' } }
222}
223
224export function displayUsed(d: Display): number | null {
225  if (d.kind === 'pace') return d.pace.used
226  if (d.kind === 'usageOnly') return clamp(d.obs.u / 100, 0, 1)
227  return null
228}
229
230export function displayStatus(d: Display): Status {
231  return d.kind === 'pace' ? d.pace.status : 'unknown'
232}
233
234export function displayLabel(d: Display): string {
235  if (d.kind === 'pace') return statusLabel(d.pace.status)
236  if (d.kind === 'empty') return L('数据不足', 'No data yet')
237  switch (d.reason.kind) {
238    case 'notStarted': return L('窗口未开始', 'Not started')
239    case 'windowEnded': return L('窗口已结束', 'Window ended')
240    case 'noResetTime': return L('无重置时刻', 'No reset time')
241    case 'stale': return L('数据已过期', 'Stale data')
242  }
243}
244
245export function displayHeadline(d: Display): string {
246  if (d.kind === 'pace') return headline(d.pace)
247  if (d.kind === 'empty') return L('无法计算配速', "Can't compute pace")
248  switch (d.reason.kind) {
249    case 'notStarted': return L('额度已重置', 'Quota has reset')
250    case 'windowEnded': return L('窗口已重置', 'Window has reset')
251    case 'noResetTime': return L('只能显示用量', 'Usage only')
252    case 'stale': return L('拿不到当前数据', 'No current data')
253  }
254}
255
256export function displayDetail(d: Display): string | null {
257  if (d.kind !== 'usageOnly') return null
258  switch (d.reason.kind) {
259    case 'notStarted': return L('还没开始用。没有活跃窗口,接口就不返回重置时刻,配速无从谈起', 'Not used yet. With no active window the API returns no reset time, so there is no pace to compute')
260    case 'windowEnded': return L('重置时刻已过,这份数据描述的是上一个窗口。等下次取数刷新', 'The reset time has passed, so this data describes the previous window. Waiting for the next refresh')
261    case 'noResetTime': return L('接口没给重置时刻,定位不了窗口相位', 'The API gave no reset time, so the position in the window is unknown')
262    case 'stale': {
263      const mins = Math.floor(d.reason.age / 60)
264      const ago = mins >= 60 ? L(`${Math.floor(mins / 60)} 小时 ${mins % 60} 分`, `${Math.floor(mins / 60)}h ${mins % 60}m`) : L(`${mins} 分`, `${mins}m`)
265      return L(`这份数据是 ${ago}前的。拿旧用量配当前时间算出的配速会偏乐观,所以不给结论`, `This data is ${ago} old. Pairing old usage with the current time would look too optimistic, so no verdict`)
266    }
267  }
268}
269
270// ---------- Narration ----------
271
272/** Chinese: 1 day 2 hours / 1 hour 30 min / 5 min; English: 1d 2h / 1h 30m / 5m (truncated, not rounded) */
273export function formatDuration(seconds: number): string {
274  const s = Math.trunc(Math.abs(seconds))
275  const days = Math.floor(s / 86400)
276  const hours = Math.floor((s % 86400) / 3600)
277  const minutes = Math.floor((s % 3600) / 60)
278  if (days > 0) return hours > 0 ? L(`${days} 天 ${hours} 小时`, `${days}d ${hours}h`) : L(`${days} 天`, `${days}d`)
279  if (hours > 0) return minutes > 0 ? L(`${hours} 小时 ${minutes} 分`, `${hours}h ${minutes}m`) : L(`${hours} 小时`, `${hours}h`)
280  return L(`${minutes} 分`, `${minutes}m`)
281}
282
283export function headline(p: Pace): string {
284  if (p.status === 'exhausted') return L('额度已用尽', 'Quota used up')
285  if (p.status === 'early') return L('窗口刚开始,还看不出配速', 'Window just started, too early to tell')
286  if (p.status === 'unknown') return L('数据不足', 'Not enough data')
287  if (Math.abs(p.leadTime) < 3600) return L('与时间进度基本同步', 'In step with the clock')
288  const d = formatDuration(p.leadTime)
289  return p.leadTime > 0 ? L(`用量超前 ${d}`, `Usage ${d} ahead of the clock`) : L(`用量落后 ${d}`, `Usage ${d} behind the clock`)
290}
291
292const WEEKDAYS_ZH = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
293const WEEKDAYS_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
294const pad2 = (n: number) => String(n).padStart(2, '0')
295
296function clockText(at: number, withWeekday: boolean): string {
297  const d = new Date(at)
298  const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
299  return withWeekday ? `${L(WEEKDAYS_ZH, WEEKDAYS_EN)[d.getDay()]} ${hm}` : hm
300}
301
302export function forecast(p: Pace): string | null {
303  if (p.status === 'exhausted') return L(`${formatDuration(p.timeToReset)}后重置`, `Resets in ${formatDuration(p.timeToReset)}`)
304  if (p.status === 'early' || p.burnRate == null) return null
305  if (p.exhaustionAt != null && p.runway != null) {
306    const at = clockText(p.exhaustionAt, p.runway >= 86400)
307    return L(`照这个速度,约 ${at} 用完`, `At this rate, runs out ~${at}`)
308  }
309  const end = Math.round(p.burnRate * 100)
310  return L(`照这个速度,重置时约用到 ${end}%`, `At this rate, ~${end}% by reset`)
311}
312
313const fmt4g = (x: number) => String(Number(x.toPrecision(4)))
314
315export function windowNote(len: WindowLength): string {
316  const days = len.seconds / 86400
317  const span = days >= 1 ? L(`${fmt4g(days)} 天`, `${fmt4g(days)}d`) : L(`${fmt4g(len.seconds / 3600)} 小时`, `${fmt4g(len.seconds / 3600)}h`)
318  return L(`窗口 ${span} · ${sourceBadge(len.source)}`, `Window ${span} · ${sourceBadge(len.source)}`)
319}
320
321/** Short menu-bar text: pace delta Δ; the leading space is intentional, as in the original */
322export function menuBarText(d: Display): string {
323  if (d.kind === 'empty') return ' —'
324  if (d.kind === 'usageOnly') return ` ${Math.round(d.obs.u)}%`
325  const p = d.pace
326  if (p.status === 'early') return ' ·'
327  if (p.status === 'exhausted') return L(' 用尽', ' out')
328  if (p.status === 'unknown') return ' —'
329  const x = p.delta * 100
330  return ` ${x < 0 ? '-' : '+'}${Math.abs(Math.round(x))}%`
331}
332
333export function pct(x: number): string {
334  const v = x * 100
335  return v < 10 ? `${v.toFixed(1)}%` : `${Math.round(v)}%`
336}
337
338/** Full description for screen readers and Svg alt */
339export function accessibilityLabel(d: Display, name: string): string {
340  if (d.kind === 'empty') return L(`${name},暂无数据`, `${name}, no data yet`)
341  if (d.kind === 'usageOnly') {
342    const why = L(
343      { notStarted: '窗口尚未开始', windowEnded: '窗口已结束', noResetTime: '缺少重置时刻,算不出配速', stale: '数据过旧,不再据此判断配速' },
344      { notStarted: 'window not started yet', windowEnded: 'window ended', noResetTime: 'no reset time, so no pace', stale: 'data too old to judge pace' },
345    )[d.reason.kind]
346    const u = pct(clamp(d.obs.u / 100, 0, 1))
347    return L(`${name},已用 ${u},${why}`, `${name}, ${u} used, ${why}`)
348  }
349  const p = d.pace
350  const parts = [name, L(`已用 ${pct(p.used)}`, `${pct(p.used)} used`), L(`时间过了 ${pct(p.elapsed)}`, `${pct(p.elapsed)} of the time elapsed`)]
351  const gap = pct(Math.abs(p.delta))
352  if (Math.abs(p.delta) >= 0.005) parts.push(p.delta > 0 ? L(`超出时间进度 ${gap}`, `${gap} ahead of the clock`) : L(`落后时间进度 ${gap}`, `${gap} behind the clock`))
353  else parts.push(L('与时间进度基本同步', 'in step with the clock'))
354  parts.push(statusLabel(p.status), L(`${formatDuration(p.timeToReset)}后重置`, `resets in ${formatDuration(p.timeToReset)}`))
355  return parts.join(L(',', ', '))
356}
357
358// ---------- Quota catalog ----------
359
360export const isWeekly = (key: string) => key.startsWith('seven_day') || key.startsWith('weekly_')
361
362const capitalize = (s: string) => s.replace(/\b([a-z])([a-z]*)/gi, (_, a: string, b: string) => a.toUpperCase() + b.toLowerCase())
363
364export function displayName(key: string, serverName?: string): string {
365  if (serverName) return serverName
366  if (key === 'five_hour') return L('5 小时', '5-hour')
367  if (key === 'seven_day') return L('7 天', '7-day')
368  if (key === 'spend') return L('额度包', 'Credits')
369  if (key === 'weekly_scoped') return L('分模型', 'Per-model')
370  if (key.startsWith('weekly_')) return capitalize(key.slice(7).replace(/_/g, ' '))
371  return key.replace(/_/g, ' ')
372}
373
374/** Short name for the quota picker dropdown: Fable / All models / 5 hours */
375export function pickName(key: string, serverName?: string): string {
376  if (key === 'five_hour') return L('5 小时', '5 hours')
377  if (key === 'seven_day') return L('全部模型', 'All models')
378  return displayName(key, serverName)
379}
380
381export function fullName(key: string, serverName?: string): string {
382  const week = L('7 天', '7-day')
383  if (serverName) return isWeekly(key) ? `${week} · ${serverName}` : serverName
384  if (key === 'five_hour') return L('5 小时会话额度', '5-hour session')
385  if (key === 'seven_day') return L('7 天 · 全部模型', '7-day · All models')
386  if (isWeekly(key)) return `${week} · ${displayName(key)}`
387  return displayName(key)
388}
389
390const order = (key: string) => (key === 'five_hour' ? 0 : key === 'seven_day' ? 1 : isWeekly(key) ? 2 : 9)
391
392export function sortKeys(keys: readonly string[]): string[] {
393  return [...keys].sort((a, b) => order(a) - order(b) || (a < b ? -1 : a > b ? 1 : 0))
394}
395
396export function isKnown(key: string): boolean {
397  const k = key.toLowerCase()
398  return k.includes('five_hour') || k.includes('seven_day') || k.includes('week') || k.includes('session') || k === 'primary'
399}
400
401/** Keys that had a reset time in history and were used or are known quotas (filters out spend / extra_usage / experimental zero-usage keys) */
402export function discoveredKeys(samples: readonly Sample[]): string[] {
403  const hadReset = new Set<string>()
404  const hadUse = new Set<string>()
405  for (const s of samples) {
406    for (const [k, o] of Object.entries(s.w)) {
407      if (o.r != null) hadReset.add(k)
408      if (o.u > 0) hadUse.add(k)
409    }
410  }
411  return sortKeys([...hadReset].filter(k => hadUse.has(k) || isKnown(k)))
412}
413
414export function neighbor(key: string, keys: readonly string[], offset: number): string | null {
415  if (keys.length < 2) return null
416  const i = keys.indexOf(key)
417  if (i < 0) return null
418  return keys[(((i + offset) % keys.length) + keys.length) % keys.length]
419}
420
421/** Latest observation for a key, with its observation time */
422export function latestObs(samples: readonly Sample[], key: string): { obs: Obs; at: number } | null {
423  for (let i = samples.length - 1; i >= 0; i--) {
424    const o = samples[i].w[key]
425    if (o) return { obs: o, at: samples[i].t }
426  }
427  return null
428}
429
430// ---------- Bottleneck: which quota the band above the input box shows ----------
431
432export function pickBottleneck(cands: readonly { key: string; pace: Pace | null }[]): string | null {
433  const tier = (p: Pace | null) => (!p ? 3 : p.used >= 1 ? 0 : p.runway != null && p.runway < p.timeToReset ? 1 : 2)
434  let best: { key: string; pace: Pace | null } | null = null
435  for (const c of cands) {
436    if (!best) { best = c; continue }
437    const a = tier(c.pace)
438    const b = tier(best.pace)
439    if (a !== b) { if (a < b) best = c; continue }
440    const cp = c.pace
441    const bp = best.pace
442    let better = false
443    if (a === 0 && cp && bp) better = cp.timeToReset > bp.timeToReset
444    else if (a === 1 && cp && bp) better = (cp.runway ?? Infinity) < (bp.runway ?? Infinity)
445    else if (a === 2 && cp && bp) better = (cp.burnRate ?? 0) > (bp.burnRate ?? 0)
446    else if (a === 3) better = c.key < best.key
447    if (better) best = c
448  }
449  return best ? best.key : null
450}
451
452// ---------- Usage trajectory (burn-up) ----------
453
454export type Point = { elapsed: number; used: number; t: number }
455export type Trend = 'accelerating' | 'easing' | 'steady'
456export type Series = {
457  key: string
458  reset: number
459  segments: Point[][]
460  gaps: { from: Point; to: Point }[]
461  midResets: { before: Point; after: Point }[]
462  projection: { from: Point; to: { elapsed: number; used: number } } | null
463  exhaustionAt: number | null
464  projectionCrossesGap: boolean
465  trend: Trend | null
466  peak: number
467}
468
469const WINDOW_TOLERANCE_MS = 90_000
470const RESET_DROP = 0.05
471const gapThreshold = (W: number) => Math.max(900, 0.02 * W)
472
473/** Windows (clustered by resets_at; within 90 s counts as the same one), newest first */
474export function windowsOf(key: string, samples: readonly Sample[]): number[] {
475  const resets = samples.map(s => s.w[key]?.r).filter((r): r is number => r != null).sort((a, b) => a - b)
476  const out: number[] = []
477  for (const r of resets) if (out.length === 0 || r - out[out.length - 1] > WINDOW_TOLERANCE_MS) out.push(r)
478  return out.reverse()
479}
480
481function slopeWithin(points: readonly Point[], lookback = 0.15): number | null {
482  if (points.length < 2) return null
483  const last = points[points.length - 1]
484  let anchor = points[0]
485  for (let i = points.length - 2; i >= 0; i--) {
486    if (last.elapsed - points[i].elapsed >= lookback) { anchor = points[i]; break }
487  }
488  if (anchor === last) anchor = points[0]
489  const dt = last.elapsed - anchor.elapsed
490  if (dt <= 0.001) return null
491  return (last.used - anchor.used) / dt
492}
493
494export function buildSeries(key: string, reset: number, samples: readonly Sample[], W: number): Series | null {
495  const pts: Point[] = []
496  for (const s of samples) {
497    const o = s.w[key]
498    if (!o || o.r == null || Math.abs(o.r - reset) > WINDOW_TOLERANCE_MS) continue
499    const remain = (reset - s.t) / 1000
500    if (remain < 0 || remain > W) continue
501    pts.push({ elapsed: 1 - remain / W, used: clamp(o.u / 100, 0, 1), t: s.t })
502  }
503  if (pts.length < 2) return null
504  pts.sort((a, b) => a.t - b.t)
505  const segments: Point[][] = [[pts[0]]]
506  const gaps: Series['gaps'] = []
507  const midResets: Series['midResets'] = []
508  for (let i = 1; i < pts.length; i++) {
509    const prev = pts[i - 1]
510    const p = pts[i]
511    if ((p.t - prev.t) / 1000 > gapThreshold(W)) { gaps.push({ from: prev, to: p }); segments.push([p]) }
512    else if (prev.used - p.used > RESET_DROP) { midResets.push({ before: prev, after: p }); segments.push([p]) }
513    else segments[segments.length - 1].push(p)
514  }
515  const last = pts[pts.length - 1]
516  let slope = slopeWithin(segments[segments.length - 1])
517  let projectionCrossesGap = false
518  if (slope == null) { slope = slopeWithin(pts); projectionCrossesGap = slope != null && segments.length > 1 }
519  let projection: Series['projection'] = null
520  let exhaustionAt: number | null = null
521  if (slope != null && slope > 0) {
522    const projected = last.used + slope * (1 - last.elapsed)
523    if (projected <= 1) projection = { from: last, to: { elapsed: 1, used: projected } }
524    else {
525      const at = Math.min(1, last.elapsed + (1 - last.used) / slope)
526      projection = { from: last, to: { elapsed: at, used: 1 } }
527      exhaustionAt = at
528    }
529  }
530  const overall = last.elapsed > 0.01 ? last.used / last.elapsed : null
531  let trend: Trend | null = null
532  if (slope != null && overall != null && overall > 0.01) {
533    const ratio = slope / overall
534    trend = ratio > 1.25 ? 'accelerating' : ratio < 0.75 ? 'easing' : 'steady'
535  }
536  return { key, reset, segments, gaps, midResets, projection, exhaustionAt, projectionCrossesGap, trend, peak: Math.max(...pts.map(q => q.used)) }
537}
538
539export type Range = 'current' | 'month' | 'all'
540export const rangeLabel = (r: Range): string =>
541  L({ current: '本窗口', month: '近一月', all: '全部' }, { current: 'This window', month: 'Last month', all: 'All' })[r]
542
543export type OverlayEntry = { series: Series; recency: number; isCurrent: boolean }
544/** inRange: number of windows in range; beyond MAX_OVERLAY overlaid, only an evenly spaced subset is drawn (entries fewer than inRange) */
545export type Overlay = { entries: OverlayEntry[]; totalWindows: number; inRange: number; focused: OverlayEntry | null; current: OverlayEntry | null }
546
547/** Max overlaid windows: Svg source is capped at 131072 chars; past that the engine rejects the whole pane */
548export const MAX_OVERLAY = 60
549
550/** Evenly pick n from windows ordered newest to oldest, keeping the newest and oldest */
551function spread(list: readonly number[], n: number): number[] {
552  if (list.length <= n) return [...list]
553  const out: number[] = []
554  for (let i = 0; i < n; i++) out.push(list[Math.round((i * (list.length - 1)) / (n - 1))])
555  return [...new Set(out)]
556}
557
558export function buildOverlay(key: string, samples: readonly Sample[], W: number, range: Range, offset: number, now: number): Overlay {
559  const all = windowsOf(key, samples)
560  let chosen: number[]
561  if (all.length === 0) chosen = []
562  else if (range === 'current') chosen = [all[clamp(offset, 0, all.length - 1)]]
563  else if (range === 'month') {
564    chosen = all.filter(r => now - r <= 31 * 86400 * 1000)
565    if (chosen.length === 0) chosen = [all[0]]
566  } else chosen = all
567  const inRange = chosen.length
568  chosen = spread(chosen, MAX_OVERLAY)
569  const newest = all[0]
570  const built = [...chosen].sort((a, b) => a - b)
571    .map(r => buildSeries(key, r, samples, W))
572    .filter((s): s is Series => s != null)
573  const entries = built.map((series, i) => ({
574    series,
575    recency: built.length > 1 ? i / (built.length - 1) : 1,
576    isCurrent: newest != null && Math.abs(series.reset - newest) <= WINDOW_TOLERANCE_MS,
577  }))
578  return {
579    entries,
580    totalWindows: all.length,
581    inRange,
582    focused: entries.length ? entries[entries.length - 1] : null,
583    current: entries.find(e => e.isCurrent) ?? null,
584  }
585}
586
587const md = (t: number) => { const d = new Date(t); return `${d.getMonth() + 1}/${d.getDate()}` }
588
589export function windowLabel(reset: number, W: number, isCurrent: boolean): string {
590  return `${md(reset - W * 1000)} – ${md(reset)}${isCurrent ? L('(本窗口)', ' (current)') : ''}`
591}
592
593/** Caption under the burn-up: peak trend for multiple windows, peak for a single past window; the current window relies on chart and legend, no extra text */
594export function overlayCaption(o: Overlay): string | null {
595  if (o.entries.length > 1) {
596    const first = Math.round(o.entries[0].series.peak * 100)
597    const last = Math.round(o.entries[o.entries.length - 1].series.peak * 100)
598    const used = o.entries.filter(e => e.series.peak >= 1).length
599    const ran = used ? L(`,${used} 个窗口用完过`, `, ${used} ran out`) : ''
600    const thinned = o.inRange > o.entries.length ? L(`(从 ${o.inRange} 个窗口里均匀取 ${o.entries.length} 个)`, ` (${o.entries.length} of ${o.inRange} windows, evenly spaced)`) : ''
601    return L(`峰值 ${first}% → ${last}%`, `Peak ${first}% → ${last}%`) + ran + thinned
602  }
603  const f = o.focused
604  if (f && !f.isCurrent) return L(`峰值 ${Math.round(f.series.peak * 100)}%`, `Peak ${Math.round(f.series.peak * 100)}%`)
605  return null
606}
607
608export function legendDates(o: Overlay): { from: string; to: string } | null {
609  if (o.entries.length < 2) return null
610  return { from: md(o.entries[0].series.reset), to: md(o.entries[o.entries.length - 1].series.reset) }
611}
612
613// ---------- Sample storage ----------
614
615/** Whether two observations are equivalent: same key set, usage diff < 0.001, reset time diff ≤ 5 s */
616export function sameSample(a: Sample, b: Sample): boolean {
617  const ka = Object.keys(a.w).sort()
618  const kb = Object.keys(b.w).sort()
619  if (ka.join() !== kb.join()) return false
620  return ka.every(k => {
621    const x = a.w[k]
622    const y = b.w[k]
623    if (Math.abs(x.u - y.u) >= 0.001) return false
624    if ((x.r == null) !== (y.r == null)) return false
625    return x.r == null || Math.abs(x.r - (y.r as number)) <= 5000
626  })
627}
628
629/**
630 * Append a sample: skip if equivalent to the latest one with the same key set and under 5 minutes apart; trim from the front past the cap.
631 * Sources cover different keys (live session has only 5h/7d, local cache also has Fable), so dedup only works against the same source.
632 */
633export function appendSample(list: readonly Sample[], s: Sample, max = 8000): Sample[] {
634  const sig = Object.keys(s.w).sort().join()
635  let last: Sample | undefined
636  for (let i = list.length - 1; i >= 0 && i >= list.length - 50; i--) {
637    if (Object.keys(list[i].w).sort().join() === sig) { last = list[i]; break }
638  }
639  if (last && s.t - last.t < 300_000 && sameSample(last, s)) return list as Sample[]
640  const out = [...list, s]
641  out.sort((a, b) => a.t - b.t)
642  return out.length > max ? out.slice(out.length - max) : out
643}
644
645/**
646 * Merge two sample lists: samples at the same ms combine into one (the latter wins); then dedup by appendSample's rules —
647 * when several sessions record the same reading at different ms, only the earliest within 5 minutes is kept.
648 */
649export function mergeSamples(a: readonly Sample[], b: readonly Sample[], max = 8000): Sample[] {
650  const byT = new Map<number, Sample>()
651  for (const s of a) byT.set(s.t, s)
652  for (const s of b) {
653    const prev = byT.get(s.t)
654    byT.set(s.t, prev ? { t: s.t, w: { ...prev.w, ...s.w } } : s)
655  }
656  const sorted = [...byT.values()].sort((x, y) => x.t - y.t)
657  const lastBySig = new Map<string, Sample>()
658  const out: Sample[] = []
659  for (const s of sorted) {
660    const sig = Object.keys(s.w).sort().join()
661    const last = lastBySig.get(sig)
662    if (last && s.t - last.t < 300_000 && sameSample(last, s)) continue
663    lastBySig.set(sig, s)
664    out.push(s)
665  }
666  return out.length > max ? out.slice(out.length - max) : out
667}
668
669// ---------- Data source parsing ----------
670
671function parseReset(v: unknown): number | undefined {
672  if (typeof v === 'number' && v > 0) return v < 1e12 ? v * 1000 : v
673  if (typeof v === 'string' && v) {
674    const t = Date.parse(v)
675    if (Number.isFinite(t)) return t
676    const n = Number(v)
677    if (Number.isFinite(n) && n > 0) return n < 1e12 ? n * 1000 : n
678  }
679  return undefined
680}
681
682const pctOf = (raw: number) => Math.min(Math.max(raw, 0), 100)
683
684export function quotaKey(kind: string | undefined, scopeName: string | undefined): string {
685  if (kind === 'session') return 'five_hour'
686  if (kind === 'weekly_all') return 'seven_day'
687  if (kind === 'weekly_scoped') return scopeName ? 'weekly_' + scopeName.toLowerCase().replace(/ /g, '_') : 'weekly_scoped'
688  return kind ?? 'unknown'
689}
690
691/** Parse an API-shaped usage response (top-level keys as fallback, limits[] takes precedence, incl. per-model quotas) */
692export function parseUtilization(root: unknown): Record<string, Obs> {
693  const out: Record<string, Obs> = {}
694  if (!root || typeof root !== 'object') return out
695  const obj = root as Record<string, unknown>
696  for (const [k, v] of Object.entries(obj)) {
697    if (!v || typeof v !== 'object' || Array.isArray(v)) continue
698    const o = v as Record<string, unknown>
699    const raw = typeof o.utilization === 'number' ? o.utilization : typeof o.percent === 'number' ? o.percent : null
700    if (raw == null) continue
701    out[k] = { u: pctOf(raw), r: parseReset(o.resets_at) }
702  }
703  for (const l of Array.isArray(obj.limits) ? obj.limits : []) {
704    if (!l || typeof l !== 'object') continue
705    const o = l as Record<string, unknown>
706    if (typeof o.percent !== 'number') continue
707    const scope = o.scope as { model?: { display_name?: unknown } } | undefined
708    const name = typeof scope?.model?.display_name === 'string' ? scope.model.display_name : undefined
709    const key = quotaKey(typeof o.kind === 'string' ? o.kind : undefined, name)
710    out[key] = { u: pctOf(o.percent), r: parseReset(o.resets_at), ...(name ? { n: name } : {}) }
711  }
712  // If a per-model quota appears both at top level (seven_day_<x>) and in limits[] (weekly_<x>), keep only the limits[] one
713  for (const k of Object.keys(out)) {
714    if (k.startsWith('seven_day_') && out['weekly_' + k.slice('seven_day_'.length)]) delete out[k]
715  }
716  return out
717}
718
719/** cachedUsageUtilization in ~/.claude.json: the official reader uses a 1-hour TTL; expired counts as absent */
720export function parseClaudeJsonCache(text: string, now: number): Sample | null {
721  let root: unknown
722  try { root = JSON.parse(text) } catch { return null }
723  const c = (root as { cachedUsageUtilization?: { fetchedAtMs?: unknown; utilization?: unknown } })?.cachedUsageUtilization
724  if (!c || typeof c.fetchedAtMs !== 'number') return null
725  const age = now - c.fetchedAtMs
726  if (age < 0 || age > 3600_000) return null
727  const w = parseUtilization(c.utilization)
728  return Object.keys(w).length ? { t: c.fetchedAtMs, w } : null
729}
730
731const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
732
733/** Wall-clock time in a time zone → ms timestamp; uses the local zone when the runtime has no time zone data */
734/** Common US time zone abbreviations → IANA names (Intl rejects abbreviations); Intl resolves DST for the location by date */
735const TZ_ALIAS: Record<string, string> = {
736  PST: 'America/Los_Angeles', PDT: 'America/Los_Angeles',
737  MST: 'America/Denver', MDT: 'America/Denver',
738  CST: 'America/Chicago', CDT: 'America/Chicago',
739  EST: 'America/New_York', EDT: 'America/New_York',
740}
741
742function zonedToUtc(y: number, mo: number, d: number, h: number, mi: number, tz?: string): number {
743  const local = () => new Date(y, mo, d, h, mi).getTime()
744  if (!tz) return local()
745  try {
746    const guess = Date.UTC(y, mo, d, h, mi)
747    const fmt = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })
748    const p: Record<string, string> = {}
749    for (const part of fmt.formatToParts(new Date(guess))) p[part.type] = part.value
750    const wall = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour) % 24, Number(p.minute))
751    return guess - (wall - guess)
752  } catch {
753    return local()
754  }
755}
756
757/** Reset time in `/usage`: "Oct 7 at 5:59pm (Asia/Singapore)"; the date may be omitted (today); year is the nearest future one */
758export function parseResetText(text: string, now: number): number | undefined {
759  const m = /^(?:([A-Za-z]{3})[a-z]*\.?\s+(\d{1,2})(?:,\s*(\d{4}))?\s+at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i.exec(text.trim())
760  // 12-hour format carries am/pm; 24-hour format must include minutes (17:59), so the 2 in "2h 30m" isn't read as an hour
761  if (!m || (!m[6] && m[5] == null)) return undefined
762  const h = m[6] ? (Number(m[4]) % 12) + (m[6].toLowerCase() === 'pm' ? 12 : 0) : Number(m[4])
763  if (h > 23) return undefined
764  const mi = Number(m[5] ?? 0)
765  const tz = m[7] ? (TZ_ALIAS[m[7].trim().toUpperCase()] ?? m[7].trim()) : undefined
766  // "Today" and "this year" use the time zone in parentheses: if the host zone is on a different date, the host would be off by a day
767  const today = dateIn(now, tz)
768  if (m[1]) {
769    const mo = MONTHS.indexOf(m[1].toLowerCase())
770    if (mo < 0) return undefined
771    const d = Number(m[2])
772    if (m[3]) return zonedToUtc(Number(m[3]), mo, d, h, mi, tz)
773    const t = zonedToUtc(today.y, mo, d, h, mi, tz)
774    // Only a date over half a year back is next year's ("Jan 2" seen in late Dec); a few days back is the past, left to the display to mark as reset
775    return t < now - 180 * 86400_000 ? zonedToUtc(today.y + 1, mo, d, h, mi, tz) : t
776  }
777  const t = zonedToUtc(today.y, today.mo, today.d, h, mi, tz)
778  return t < now ? t + 86400_000 : t
779}
780
781/** Year, month (0-based), day of now in a time zone; an unrecognized zone falls back to the host zone */
782function dateIn(now: number, tz?: string): { y: number; mo: number; d: number } {
783  if (tz) {
784    try {
785      const p: Record<string, string> = {}
786      for (const part of new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric' }).formatToParts(new Date(now))) p[part.type] = part.value
787      return { y: Number(p.year), mo: Number(p.month) - 1, d: Number(p.day) }
788    } catch {}
789  }
790  const t = new Date(now)
791  return { y: t.getFullYear(), mo: t.getMonth(), d: t.getDate() }
792}
793
794/**
795 * Text output of `claude -p /usage` → one sample:
796 * "Current session: 3% used · resets …" → five_hour; "Current week (all models)" → seven_day;
797 * "Current week (Fable)" → weekly_fable (with display name). Returns null if unrecognized.
798 */
799export function parseUsageCommand(text: string, now: number): Sample | null {
800  const w: Record<string, Obs> = {}
801  // Strip color escapes and trailing whitespace before matching (\r counts as trailing whitespace)
802  for (const raw of text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').split('\n')) {
803    const line = raw.trimEnd()
804    const m = /^\s*Current (session|week \(([^)]+)\)):\s*([\d.]+)%\s*used(?:\s*[·•-]\s*resets\s+(.+))?$/i.exec(line)
805    if (!m) continue
806    const scope = m[2]
807    const key = !scope ? 'five_hour' : /^all models$/i.test(scope) ? 'seven_day' : quotaKey('weekly_scoped', scope)
808    const r = m[4] ? parseResetText(m[4], now) : undefined
809    w[key] = { u: Math.min(Math.max(Number(m[3]), 0), 100), ...(r != null ? { r } : {}), ...(scope && key.startsWith('weekly_') && key !== 'seven_day' ? { n: scope } : {}) }
810  }
811  return Object.keys(w).length ? { t: now, w } : null
812}
813
814/** Per-model weekly quota (e.g. weekly_fable): hints must include the model name */
815export const isModelScoped = (key: string) => key.startsWith('weekly_') && key !== 'weekly_scoped'
816
817/** Model id → family name (claude-fable-5-1 → fable), matching per-model quota weekly_<family> */
818export function modelFamily(model: string | undefined): string | null {
819  const m = /(fable|opus|sonnet|haiku)/i.exec(model ?? '')
820  return m ? m[1].toLowerCase() : null
821}
822
823/** Time of the latest reply across local sessions: any = any model, byModel = per family */
824export type Activity = { any?: number; byModel: Record<string, number> }
825
826/**
827 * When this quota was last used, taking the later of:
828 * · the latest local reply (5-hour / 7-day: any model; per-model quota: that family's models) — the limit percentage is coarse,
829 *   steady use may not raise it 1% in half an hour, so watching increases alone would misread it as unused;
830 * · the last observed usage increase (covers use on other devices and on claude.ai).
831 * If neither exists, return the earliest observation time as a lower bound.
832 */
833export function lastUsed(samples: readonly Sample[], key: string, activity?: Activity): { at: number; isLowerBound: boolean } | null {
834  // Per-model quotas match local replies by family: weekly_fable and weekly_claude_fable both map to fable
835  const act = isModelScoped(key) ? activity?.byModel[modelFamily(key) ?? key.slice('weekly_'.length)] : activity?.any
836  const seen = lastIncrease(samples, key)
837  if (act != null && (!seen || seen.isLowerBound || act >= seen.at)) return { at: act, isLowerBound: false }
838  return seen
839}
840
841/**
842 * Time of the last usage increase. Compared with the reading at the last counted increase, it must rise half a point: two sources of different precision
843 * (session 64.2, cache 64) alternating won't count as an increase; on a window reset (a drop of over 5 points) restart from the new reading.
844 */
845function lastIncrease(samples: readonly Sample[], key: string): { at: number; isLowerBound: boolean } | null {
846  let base: number | undefined
847  let first: number | undefined
848  let last: number | undefined
849  for (const s of samples) {
850    const o = s.w[key]
851    if (!o) continue
852    if (first == null) first = s.t
853    if (base == null || o.u < base - 5) base = o.u
854    else if (o.u >= base + 0.5) { last = s.t; base = o.u }
855  }
856  if (last != null) return { at: last, isLowerBound: false }
857  return first != null ? { at: first, isLowerBound: true } : null
858}
859
860/** ~/.weektoken/samples.jsonl from the WeekToken macOS app: each line {t: seconds, rl: {key: {used_percentage, resets_at, display_name?}}} */
861export function parseSamplesJsonl(text: string, nowMs = Date.now()): Sample[] {
862  const out: Sample[] = []
863  for (const line of text.split('\n')) {
864    if (!line.trim()) continue
865    let row: { t?: unknown; rl?: unknown }
866    try { row = JSON.parse(line) } catch { continue }
867    if (typeof row.t !== 'number' || !row.rl || typeof row.rl !== 'object') continue
868    // Convention is seconds; ms is accepted too. Drop rows with absurd times (before 2020 or after tomorrow)
869    const t = row.t > 1e12 ? row.t : row.t * 1000
870    if (t < 1_577_836_800_000 || t > nowMs + 86400_000) continue
871    const w: Record<string, Obs> = {}
872    for (const [k, v] of Object.entries(row.rl as Record<string, unknown>)) {
873      if (!v || typeof v !== 'object') continue
874      const o = v as Record<string, unknown>
875      const used = typeof o.used_percentage === 'number' ? o.used_percentage : typeof o.used_percent === 'number' ? o.used_percent : null
876      if (used == null) continue
877      let r = parseReset(o.resets_at)
878      if (r == null && typeof o.resets_in_seconds === 'number') r = t + o.resets_in_seconds * 1000
879      w[k] = { u: pctOf(used), r, ...(typeof o.display_name === 'string' ? { n: o.display_name } : {}) }
880    }
881    if (Object.keys(w).length) out.push({ t, w })
882  }
883  return out.sort((a, b) => a.t - b.t)
884}
885
hooks/theme.ts 47 lines
1// Colors: values from PaceTheme / PaceVisuals in the WeekToken macOS app.
2// Status colors (pace-driven) are shared by light and dark mode; quota identity colors have light/dark variants.
3
4import type { Status } from './pace.ts'
5
6export type Tri = { start: string; end: string; solid: string }
7
8export const STATUS: Record<Status, Tri> = {
9  comfortable: { start: '#4FC94F', end: '#0A8A0A', solid: '#0CA30C' },
10  onPace: { start: '#2FA85F', end: '#05663A', solid: '#0A7D46' },
11  overPace: { start: '#FBC957', end: '#C98C00', solid: '#FAB219' },
12  exhausted: { start: '#E06A6A', end: '#A32C2C', solid: '#D03B3B' },
13  early: { start: '#D6DAE0', end: '#6B7280', solid: '#9AA1AC' },
14  unknown: { start: '#DDE0E4', end: '#8E949C', solid: '#A3A8B0' },
15}
16
17export type Identity = { light: Tri; dark: Tri }
18
19const tri = (s: string, e: string, so: string): Tri => ({ start: '#' + s, end: '#' + e, solid: '#' + so })
20
21const FIXED: Record<string, Identity> = {
22  seven_day: { light: tri('6BA3E8', '1B5AA8', '2A78D6'), dark: tri('74B0F0', '2A6DC4', '3987E5') },
23  five_hour: { light: tri('5FCFA4', '12805A', '1BAF7A'), dark: tri('4FC79A', '0F7A56', '199E70') },
24  weekly_fable: { light: tri('8B7DD8', '342A78', '4A3AA7'), dark: tri('B3ABF0', '6A5FC0', '9085E9') },
25}
26
27// Other keys hash by name into four slots (start-light, start-dark, end-light, end-dark, solid-light, solid-dark)
28const SLOTS: [string, string, string, string, string, string][] = [
29  ['F08A5C', 'E07A4A', 'C24E1C', 'B84518', 'EB6834', 'D95926'], // orange
30  ['F0A4C0', 'E58FB0', 'D05A85', 'C04A75', 'E87BA4', 'D55181'], // magenta
31  ['EDB84D', 'D9A030', 'C08000', 'A86E00', 'EDA100', 'C98500'], // yellow
32  ['EE7A79', 'F08585', 'C03332', 'D04A4A', 'E34948', 'E66767'], // red
33]
34
35export function identity(key: string): Identity {
36  const f = FIXED[key]
37  if (f) return f
38  let h = 0
39  for (const ch of key) h = (h * 31 + (ch.codePointAt(0) ?? 0)) & 0xffffff
40  const s = SLOTS[h % 4]
41  return { light: tri(s[0], s[2], s[4]), dark: tri(s[1], s[3], s[5]) }
42}
43
44/** Single color for native Text: the dark variant's solid, bright and legible on both light and dark backgrounds */
45export const identityText = (key: string) => identity(key).dark.solid
46export const statusText = (s: Status) => STATUS[s].solid
47
hooks/svg.ts 421 lines
1// SVG drawing: pace rings, the progress bar above the input box, usage trace (burn-up, legend drawn inside the chart).
2// Geometry and colors taken from the WeekToken macOS app (PaceRings / PaceBullet / BulletRenderer / BurnUpChart).
3// Light/dark: SVG adapts via prefers-color-scheme and declares color-scheme: light dark — a chart in the host sandbox frame
4// without it gets a white backdrop and light-mode drawing when the frame's scheme doesn't match the app (observed in dark app).
5// Neutral elements (track, grid, tick labels) use translucent gray readable on both light and dark backgrounds.
6// Each SVG scopes styles and gradients/filters with a unique id derived from its input: whether the host treats it as a standalone image
7// or inlines it into the same page, colors never leak between charts. Pure functions, output strings only.
8
9import { legendDates, windowLabel, type Overlay, type OverlayEntry, type Pace, type Series, type Status } from './pace.ts'
10import { STATUS, type Identity, type Tri } from './theme.ts'
11import { harmonizeHue, hexToOklch, oklchToHex } from './color.ts'
12import { getLang, L } from './i18n.ts'
13
14const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] as string))
15const f = (n: number) => (Math.round(n * 100) / 100).toString()
16const clamp = (x: number, lo: number, hi: number) => Math.min(Math.max(x, lo), hi)
17const FONT = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC', sans-serif`
18
19/** Stable id derived from kind and input: same chart, same id; different charts never collide */
20function uidOf(kind: string, data: unknown): string {
21  const s = kind + JSON.stringify(data)
22  let h = 0x811c9dc5
23  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) }
24  return `wt${kind}${(h >>> 0).toString(36)}`
25}
26
27/** Light and dark neutral palettes; identity's light/dark variants also set as CSS vars; all scoped to this chart only */
28function themeStyle(uid: string, id?: Identity): string {
29  const idVars = (t: 'light' | 'dark') => (id ? `--id-s:${id[t].start};--id-e:${id[t].end};--id:${id[t].solid};` : '')
30  return `<style>
31:root{color-scheme:light dark;background:transparent}
32#${uid}{color-scheme:light dark;--track:rgba(0,0,0,.07);--grid:rgba(0,0,0,.06);--vgrid:rgba(0,0,0,.06);--diag:rgba(0,0,0,.28);--ink2:rgba(60,60,67,.62);--ink3:rgba(60,60,67,.42);--tshadow:rgba(0,0,0,.22);--mark:rgba(0,0,0,.75);${idVars('light')}}
33@media (prefers-color-scheme: dark){#${uid}{--track:rgba(255,255,255,.08);--grid:rgba(255,255,255,.07);--vgrid:rgba(255,255,255,.11);--diag:rgba(255,255,255,.42);--ink2:rgba(235,235,245,.62);--ink3:rgba(235,235,245,.42);--tshadow:rgba(255,255,255,.34);--mark:rgba(255,255,255,.9);${idVars('dark')}}}
34#${uid} text{font-family:${FONT}}
35</style>`
36}
37
38const open = (uid: string, w: number, h: number) =>
39  `<svg xmlns="http://www.w3.org/2000/svg" id="${uid}" width="${f(w)}" height="${f(h)}" viewBox="0 0 ${f(w)} ${f(h)}" style="color-scheme:light dark;background:transparent">`
40
41/** Coordinates at progress p, clockwise from 12 o'clock */
42function polar(cx: number, cy: number, r: number, p: number): [number, number] {
43  const a = 2 * Math.PI * p - Math.PI / 2
44  return [cx + r * Math.cos(a), cy + r * Math.sin(a)]
45}
46
47function arcPath(cx: number, cy: number, r: number, p0: number, p1: number): string {
48  const [x0, y0] = polar(cx, cy, r, p0)
49  const [x1, y1] = polar(cx, cy, r, p1)
50  const large = p1 - p0 > 0.5 ? 1 : 0
51  return `M${f(x0)} ${f(y0)}A${f(r)} ${f(r)} 0 ${large} 1 ${f(x1)} ${f(y1)}`
52}
53
54function hexToRgb(h: string): [number, number, number] {
55  const n = parseInt(h.slice(1), 16)
56  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
57}
58
59function mix(a: string, b: string, t: number): string {
60  const [r1, g1, b1] = hexToRgb(a)
61  const [r2, g2, b2] = hexToRgb(b)
62  const c = (x: number, y: number) => Math.round(x + (y - x) * t).toString(16).padStart(2, '0')
63  return `#${c(r1, r2)}${c(g1, g2)}${c(b1, b2)}`
64}
65
66export type RingsInput = {
67  used: number | null // U (may be absent)
68  elapsed: number | null // T
69  status: Status
70  id: Identity
71  center: { kind: 'rate'; text: string } | { kind: 'pct'; text: string } | { kind: 'none' }
72  label: string
73  title: string
74  /** Column of readings right of the ring: small label above, number below; the accent one uses the quota color. Empty array draws the ring only */
75  readings: { label: string; value: string; accent?: boolean }[]
76}
77
78/**
79 * Pace rings, drawn the same way as the band bar (quota's own color family only):
80 * · Outer ring = usage U: angular gradient in quota color; under pace, faint same-hue stripes from used to elapsed (headroom);
81 *   over pace, the part past elapsed is one shade deeper + light stripes; when exhausted, that part uses the harmonized warm color.
82 * · Inner ring = time T: thin neutral gray ring. Both rings thin with tight spacing, leaving the center for numbers.
83 * · Center: number and status vertically centered as one block; font size based on the inner ring's inside diameter.
84 * · Column of readings on the right, with tiered font sizes (native Text has only one size).
85 */
86export function ringsSvg(x: RingsInput): string {
87  const u = uidOf('r', x)
88  const d = 156
89  const c = d / 2
90  const rw = 0.075 * d
91  const gap = 0.03 * d
92  const rwIn = 0.035 * d
93  const rO = (d - rw) / 2
94  const rI = rO - rw / 2 - gap - rwIn / 2
95  const Di = 2 * (rI - rwIn / 2) * 0.92
96  const U = x.used == null ? 0 : clamp(x.used, 0, 1)
97  const T = x.elapsed == null ? null : clamp(x.elapsed, 0, 1)
98  const over = T != null && U > T
99  const exhausted = x.status === 'exhausted'
100  // Stripes: 45°, 5px period, 2px line width (same as band bar)
101  const stripes = (id: string, back: string, backOp: number, line: string, lineOp: number) =>
102    `<pattern id="${id}" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">` +
103    `<rect width="5" height="5" fill="${back}" fill-opacity="${backOp}"/><rect width="2" height="5" fill="${line}" fill-opacity="${lineOp}"/></pattern>`
104  const parts: string[] = []
105  parts.push(`<defs>${stripes(u + 'm', 'var(--id)', 0.1, 'var(--id)', 0.55)}${stripes(u + 'x', 'var(--deep)', 1, 'var(--id-s)', 0.35)}</defs>`)
106  // Inner ring: time
107  parts.push(`<circle cx="${f(c)}" cy="${f(c)}" r="${f(rI)}" fill="none" stroke="var(--track)" stroke-width="${f(rwIn)}"/>`)
108  if (T != null && T > 0.002) parts.push(`<path d="${arcPath(c, c, rI, 0, Math.min(T, 0.9999))}" fill="none" stroke="var(--time)" stroke-width="${f(rwIn)}" stroke-linecap="round"/>`)
109  // Outer ring: track, headroom, usage, overage
110  parts.push(`<circle cx="${f(c)}" cy="${f(c)}" r="${f(rO)}" fill="none" stroke="var(--track)" stroke-width="${f(rw)}"/>`)
111  if (T != null && T > U) parts.push(`<path d="${arcPath(c, c, rO, Math.max(0, U - 0.01), Math.min(T, 0.9999))}" fill="none" stroke="url(#${u}m)" stroke-width="${f(rw)}"/>`)
112  const upto = over ? (T as number) : U
113  if (upto > 0.002) {
114    // Angular gradient approximated by segments; one group each for light and dark colors, one shown per appearance
115    const n = Math.max(2, Math.ceil(upto * 72))
116    const grad = (t: Tri, cls: string) => {
117      const segs: string[] = []
118      for (let i = 0; i < n; i++) {
119        const p0 = (upto * i) / n
120        const p1 = (upto * (i + 1)) / n + (i < n - 1 ? 0.002 : 0)
121        segs.push(`<path d="${arcPath(c, c, rO, p0, Math.min(p1, 0.9999))}" fill="none" stroke="${mix(t.end, t.start, (i + 1) / n)}" stroke-width="${f(rw)}"/>`)
122      }
123      return `<g class="${cls}">${segs.join('')}</g>`
124    }
125    parts.push(grad(x.id.light, 'lt'), grad(x.id.dark, 'dk'))
126    const [sx, sy] = polar(c, c, rO, 0)
127    parts.push(`<circle cx="${f(sx)}" cy="${f(sy)}" r="${f(rw / 2)}" fill="var(--id-e)"/>`)
128    if (!over) {
129      const [ex, ey] = polar(c, c, rO, Math.min(upto, 0.9999))
130      parts.push(`<circle cx="${f(ex)}" cy="${f(ey)}" r="${f(rw / 2)}" fill="var(--id-s)"/>`)
131    }
132  }
133  if (over) {
134    const paint = exhausted ? 'var(--warm)' : `url(#${u}x)`
135    parts.push(`<path d="${arcPath(c, c, rO, T as number, Math.min(U, 0.9999))}" fill="none" stroke="${paint}" stroke-width="${f(rw)}"/>`)
136    if (U < 0.999) {
137      const [ex, ey] = polar(c, c, rO, U)
138      parts.push(`<circle cx="${f(ex)}" cy="${f(ey)}" r="${f(rw / 2)}" fill="${paint}"/>`)
139    }
140  }
141  // Center: number + status, vertically centered as one block
142  const em = 0.25 * Di
143  const lf = Math.max(10, 0.1 * Di)
144  const lead = 0.07 * Di
145  const nb = c - (0.72 * em + lead + 0.72 * lf) / 2 + 0.72 * em
146  if (x.center.kind === 'rate') {
147    parts.push(`<text x="${f(c)}" y="${f(nb)}" text-anchor="middle" font-size="${f(em)}" font-weight="600" fill="var(--ink)" style="font-variant-numeric:tabular-nums;letter-spacing:-.02em">${esc(x.center.text)}<tspan font-size="${f(em * 0.55)}" font-weight="500" fill="var(--ink3)" dx="1.5">×</tspan></text>`)
148  } else {
149    const t = x.center.kind === 'pct' ? x.center.text : '—'
150    parts.push(`<text x="${f(c)}" y="${f(nb)}" text-anchor="middle" font-size="${f(em)}" font-weight="600" fill="${x.center.kind === 'pct' ? 'var(--ink)' : 'var(--ink3)'}" style="font-variant-numeric:tabular-nums;letter-spacing:-.02em">${esc(t)}</text>`)
151  }
152  parts.push(`<text x="${f(c)}" y="${f(nb + lead + 0.72 * lf)}" text-anchor="middle" font-size="${f(lf)}" font-weight="500" fill="var(--ink2)">${esc(x.label)}</text>`)
153  // Column of readings on the right
154  const LS = 10
155  const VS = 22
156  const rx = d + 24
157  // Chinese labels too small at 10px, use 11px; width estimate runs narrow (% and bold digits are wider), so pad it
158  const lsOf = (t: string) => (/[\u2E80-\u9FFF]/.test(t) ? 11 : LS)
159  const colW = x.readings.reduce((a, r) => Math.max(a, textWidth(r.value, VS) * 1.15, textWidth(r.label, lsOf(r.label)) * 1.2), 0) + 6
160  const step = 46
161  const top = (d - (x.readings.length * step - 8)) / 2
162  x.readings.forEach((r, i) => {
163    const y = top + i * step
164    const col = r.accent ? (exhausted ? 'var(--warm)' : 'var(--id)') : 'var(--ink)'
165    parts.push(`<text x="${f(rx)}" y="${f(y + 9)}" font-size="${lsOf(r.label)}" font-weight="600" fill="var(--ink3)" style="letter-spacing:.09em">${esc(r.label)}</text>` +
166      `<text x="${f(rx)}" y="${f(y + 33)}" font-size="${VS}" font-weight="600" fill="${col}" style="font-variant-numeric:tabular-nums;letter-spacing:-.01em">${esc(r.value)}</text>`)
167  })
168  const W = x.readings.length ? Math.ceil(rx + colW) : d
169  const ol = overTones(x.id.light)
170  const od = overTones(x.id.dark)
171  const vars = `<style>#${u}{--ink:#1d1d1f;--time:#8E8E93;--deep:${ol.deep};--warm:${ol.warm}}#${u} .dk{display:none}` +
172    `@media (prefers-color-scheme: dark){#${u}{--ink:#f5f5f7;--deep:${od.deep};--warm:${od.warm}}#${u} .lt{display:none}#${u} .dk{display:inline}}</style>`
173  return `${open(u, W, d)}${themeStyle(u, x.id)}${vars}<title>${esc(x.title)}</title>${parts.join('')}</svg>`
174}
175
176/**
177 * Band progress bar, following Apple Fitness rings' "past the goal = one shade deeper", quota's own color family only:
178 * · Using slower than time: faint same-hue stripes behind the usage bar from used to elapsed, the remaining headroom;
179 * · Using faster than time: the part past elapsed turns one shade deeper (OKLCH lightness down 0.13), with light stripes in the bright-end hue;
180 * · Exhausted: that part is solid warm — persimmon hue rotated toward the quota color via Material 3 harmonize, lightness/chroma matched to it.
181 * Method: see docs/COLOR-METHOD-2026-10-04.md.
182 * A numeric width is fixed (the small collapsed bar); 'fluid' gives 100% width with all geometry in percent,
183 * stretched by the host to the band's remaining width: only lengthens, never distorts (corner radii unchanged).
184 */
185export function bandBarSvg(pace: Pace | null, used: number | null, status: Status, id: Identity, title: string, width: number | 'fluid' = 120): string {
186  const u = uidOf('n', [pace?.used, pace?.elapsed, used, status, id, title, width])
187  const fluid = width === 'fluid'
188  const h = 14
189  const bh = 8
190  const y = (h - bh) / 2
191  const r = bh / 2
192  const st = STATUS[status]
193  // Fixed width: pixels (with minimum width); fluid: percent
194  const W = (frac: number, min = 0) => (fluid ? `${f(clamp(frac, 0, 1) * 100)}%` : f(Math.max(min, (width as number) * clamp(frac, 0, 1))))
195  const X = (frac: number) => (fluid ? `${f(clamp(frac, 0, 1) * 100)}%` : f((width as number) * clamp(frac, 0, 1)))
196  // Stripes: 45°, 5px period, 2px line width
197  const stripes = (id: string, back: string, backOp: number, line: string, lineOp: number) =>
198    `<pattern id="${id}" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">` +
199    `<rect width="5" height="5" fill="${back}" fill-opacity="${backOp}"/><rect width="2" height="5" fill="${line}" fill-opacity="${lineOp}"/></pattern>`
200  const exhausted = status === 'exhausted'
201  const parts: string[] = []
202  parts.push(`<defs><linearGradient id="${u}u" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="var(--id-e)"/><stop offset="1" stop-color="var(--id-s)"/></linearGradient>` +
203    stripes(u + 'm', 'var(--id)', 0.1, 'var(--id)', 0.55) +
204    stripes(u + 'x', 'var(--deep)', 1, 'var(--id-s)', 0.35) +
205    `<clipPath id="${u}t"><rect y="${y}" width="${fluid ? '100%' : width}" height="${bh}" rx="${r}"/></clipPath></defs>`)
206  parts.push(`<rect y="${y}" width="${fluid ? '100%' : width}" height="${bh}" rx="${r}" fill="var(--track)"/>`)
207  if (pace) {
208    const T = clamp(pace.elapsed, 0, 1)
209    const U = clamp(pace.used, 0, 1)
210    // Headroom: from used to elapsed, behind the usage bar (its round cap hides the seam)
211    if (T > U) parts.push(`<g clip-path="url(#${u}t)"><rect y="${y}" width="${X(T)}" height="${bh}" fill="url(#${u}m)"/></g>`)
212    if (U > 0) {
213      parts.push(`<rect y="${y}" width="${W(U, bh)}" height="${bh}" rx="${r}" fill="url(#${u}u)"/>`)
214      // Overage: from elapsed to used, clipped to the usage bar's shape, round cap kept
215      if (U > T) {
216        parts.push(`<clipPath id="${u}c"><rect y="${y}" width="${W(U, bh)}" height="${bh}" rx="${r}"/></clipPath>`)
217        parts.push(`<g clip-path="url(#${u}c)"><rect x="${X(T)}" y="${y}" width="${X(U - T)}" height="${bh}" fill="${exhausted ? 'var(--warm)' : `url(#${u}x)`}"/></g>`)
218      }
219    }
220  } else if (used != null && used > 0) {
221    parts.push(`<rect y="${y}" width="${W(used, bh)}" height="${bh}" rx="${r}" fill="url(#${u}u)"/>`)
222  }
223  // Bar is thin, so the track is darker than in the panel to show the full length
224  // Fluid bar is a plain height-only image (no interactive frame, it flickers on panel redraw): root declares no width, all geometry in percent
225  const ol = overTones(id.light)
226  const od = overTones(id.dark)
227  const track = `<style>#${u}{--track:rgba(0,0,0,.13);--deep:${ol.deep};--warm:${ol.warm}}@media (prefers-color-scheme: dark){#${u}{--track:rgba(255,255,255,.16);--deep:${od.deep};--warm:${od.warm}}}</style>`
228  const head = fluid
229    ? `<svg xmlns="http://www.w3.org/2000/svg" id="${u}" width="100%" height="${h}" style="display:block;overflow:visible;color-scheme:light dark;background:transparent">`
230    : open(u, width as number, h)
231  return `${head}${themeStyle(u, id)}${track}<title>${esc(title)}</title>${parts.join('')}</svg>`
232}
233
234/**
235 * Credit line at the panel's bottom-right: one line of 10px translucent text (native Text can't set font size, and nothing is fainter than dimColor).
236 * textLength pins the width; a different font only changes spacing and never overflows the image.
237 */
238export function creditSvg(text: string): string {
239  const u = uidOf('c', text)
240  const size = 10
241  const w = Math.ceil(textWidth(text, size)) + 2
242  const h = 14
243  return `${open(u, w, h)}<style>:root{color-scheme:light dark;background:transparent}#${u} text{font-family:${FONT};fill:rgba(60,60,67,.42)}@media (prefers-color-scheme: dark){#${u} text{fill:rgba(235,235,245,.36)}}</style>` +
244    `<title>${esc(text)}</title><text x="${w - 1}" y="10.5" font-size="${size}" text-anchor="end" textLength="${w - 2}" lengthAdjust="spacingAndGlyphs">${esc(text)}</text></svg>`
245}
246
247/** OKLCH hue of persimmon orange: starting point for the exhausted warm color, then harmonized toward the quota color */
248const WARM_HUE = 32.68
249
250/** Two colors for the overage part: one shade deeper (over pace) and harmonized warm (exhausted), both derived from the quota color */
251function overTones(t: Tri): { deep: string; warm: string } {
252  const [L, C, H] = hexToOklch(t.solid)
253  return {
254    deep: oklchToHex(Math.max(0.3, L - 0.13), C * 1.05, H),
255    warm: oklchToHex(L - 0.02, Math.max(C, 0.15), harmonizeHue(WARM_HUE, H)),
256  }
257}
258
259export type BurnUpInput = {
260  width: number
261  overlay: Overlay
262  W: number
263  status: Status
264  id: Identity
265  title: string
266  /** Per-line inspection: drawn in an interactive frame; the hovered line thickens and shows its date and peak, others fade */
267  interactive?: boolean
268}
269
270const f1 = (n: number) => (Math.round(n * 10) / 10).toString()
271
272/** Join adjacent segments separated only by data gaps (not mid-window resets) into one */
273function joinGaps(s: Series): Series['segments'] {
274  const gapStarts = new Set(s.gaps.map(g => g.to))
275  const out: Series['segments'] = []
276  for (const seg of s.segments) {
277    if (out.length && gapStarts.has(seg[0])) out[out.length - 1] = [...out[out.length - 1], ...seg]
278    else out.push(seg)
279  }
280  return out
281}
282
283/** Estimate text width: CJK chars at 1em, others at 0.56em (for legend wrapping) */
284const textWidth = (s: string, size: number) => [...s].reduce((a, ch) => a + (/[\u2E80-\u9FFF\uFF00-\uFFEF]/.test(ch) ? size : size * 0.56), 0)
285
286/**
287 * Usage trace: x = time position in window, y = usage; dashed diagonal for ideal pace; past windows overlaid, opacity by recency; current window has projection and exhaustion line.
288 * Legend drawn at the bottom of the same chart, line styles matched one-to-one with labels; width set to the panel's usual width so text stays legible when scaled.
289 */
290export function burnUpSvg(x: BurnUpInput): string {
291  const u = uidOf('u', [x.width, x.W, x.status, x.id, x.title, getLang(), x.overlay.entries.map(e => [e.series.reset, e.series.segments.length, e.series.peak, e.recency]), !!x.interactive])
292  const Wd = x.width
293  const left = 32
294  const right = 8
295  const top = 8
296  const h = 150
297  const w = Wd - left - right
298  const X = (e: number) => left + w * clamp(e, 0, 1)
299  const Y = (v: number) => top + h * (1 - clamp(v, 0, 1))
300  const days = Math.max(1, Math.round(x.W / 86400))
301  const st = STATUS[x.status]
302  const TF = 10
303  const p: string[] = []
304  p.push(`<defs><linearGradient id="${u}r" x1="0" x2="1"><stop offset="0" stop-color="var(--id)" stop-opacity=".22"/><stop offset="1" stop-color="var(--id)"/></linearGradient></defs>`)
305  // Grid
306  for (let i = 1; i <= 3; i++) p.push(`<line x1="${left}" x2="${f(left + w)}" y1="${f(top + (h * i) / 4)}" y2="${f(top + (h * i) / 4)}" stroke="var(--grid)"/>`)
307  for (let i = 1; i < Math.max(2, days); i++) {
308    const gx = left + (w * i) / days
309    if (gx < left + w - 0.5) p.push(`<line x1="${f(gx)}" x2="${f(gx)}" y1="${top}" y2="${f(top + h)}" stroke="var(--vgrid)"/>`)
310  }
311  p.push(`<rect x="${left}" y="${top}" width="${f(w)}" height="${f(h)}" fill="none" stroke="var(--grid)"/>`)
312  // Ticks
313  for (const v of [0, 50, 100]) p.push(`<text x="${left - 5}" y="${f(Y(v / 100) + 3.5)}" text-anchor="end" font-size="${TF}" fill="var(--ink3)">${v}%</text>`)
314  const axisY = top + h + 14
315  if (days > 1) {
316    for (let dd = 0; dd <= days; dd++) p.push(`<text x="${f(clamp(left + (w * dd) / days, left + 12, left + w - 12))}" y="${f(axisY)}" text-anchor="middle" font-size="${TF}" fill="var(--ink3)">${L(`${dd}天`, `${dd}d`)}</text>`)
317  } else {
318    p.push(`<text x="${left}" y="${f(axisY)}" font-size="${TF}" fill="var(--ink3)">${L('开始', 'Start')}</text><text x="${f(left + w)}" y="${f(axisY)}" text-anchor="end" font-size="${TF}" fill="var(--ink3)">${L('重置', 'Reset')}</text>`)
319  }
320  // Ideal pace diagonal
321  p.push(`<line x1="${f(X(0))}" y1="${f(Y(0))}" x2="${f(X(1))}" y2="${f(Y(1))}" stroke="var(--diag)" stroke-width="1.2" stroke-dasharray="4 4"/>`)
322  // Keep at most first and last points per pixel column, coordinates to one decimal: source length stays bounded however many points
323  const poly = (pts: { elapsed: number; used: number }[]) => {
324    const kept: { x: number; y: number; col: number }[] = []
325    for (const q of pts) {
326      const pt = { x: X(q.elapsed), y: Y(q.used), col: Math.round(X(q.elapsed)) }
327      const n = kept.length
328      if (n >= 2 && kept[n - 1].col === pt.col && kept[n - 2].col === pt.col) kept[n - 1] = pt
329      else kept.push(pt)
330    }
331    return kept.map(k => `${f1(k.x)},${f1(k.y)}`).join(' ')
332  }
333  const entries = x.overlay.entries
334  // Past windows: opacity rises with recency; segments separated by "no data" are joined directly (with sparse sampling each point is its own segment,
335  // so drawing only multi-point segments would draw nothing); still broken at mid-window resets.
336  // With per-line inspection, one group per window: an invisible wide hit line (easy to target), the original line, a hover-only label
337  const hover = !!x.interactive
338  const history = entries.slice(0, -1)
339  const TF2 = 10.5
340  // Labels all drawn on the top layer (not covered by later lines), matched to their group by index
341  const labels: string[] = []
342  let gi = 0
343  const tag = (e: OverlayEntry, lp: { elapsed: number; used: number }) => {
344    const text = `${windowLabel(e.series.reset, x.W, e.isCurrent)} · ${L('峰值', 'peak')} ${Math.round(e.series.peak * 100)}%`
345    const pw = textWidth(text, TF2) + 16
346    const px = clamp(X(lp.elapsed) - pw - 6, left, Wd - right - pw)
347    const py = clamp(Y(lp.used) - 26, top, top + h - 18)
348    labels.push(`<g class="lbl l${gi}"><rect x="${f(px)}" y="${f(py)}" width="${f(pw)}" height="18" rx="9" fill="var(--tip-bg)" stroke="var(--tip-bd)"/>` +
349      `<text x="${f(px + pw / 2)}" y="${f(py + 12.5)}" text-anchor="middle" font-size="${TF2}" fill="var(--tip-fg)">${esc(text)}</text></g>`)
350    return `w w${gi++}`
351  }
352  const lastPoint = (runs: { elapsed: number; used: number }[][]) => { const r = runs[runs.length - 1]; return r[r.length - 1] }
353  // Hit lines only need to be easy to target (12px wide); one integer point per 4px is enough, adding only ~10% to source size
354  const coarse = (pts: { elapsed: number; used: number }[]) => {
355    const out: string[] = []
356    let col = NaN
357    pts.forEach((q, i) => {
358      const c = Math.round(X(q.elapsed) / 4)
359      if (c !== col || i === pts.length - 1) { out.push(`${Math.round(X(q.elapsed))},${Math.round(Y(q.used))}`); col = c }
360    })
361    return out.join(' ')
362  }
363  const hits = (runs: { elapsed: number; used: number }[][]) => runs.map(run => `<polyline class="hit" points="${coarse(run)}"/>`).join('')
364  history.forEach(e => {
365    const op = 0.4 + 0.52 * e.recency
366    const runs = joinGaps(e.series).filter(run => run.length >= 2).map(run => ({ run, pts: poly(run) }))
367    const lines = runs.map(({ pts }) => `<polyline class="ln" points="${pts}" fill="none" stroke="var(--id)" stroke-opacity="${f(op)}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`).join('')
368    if (!hover || !runs.length) { p.push(lines); return }
369    p.push(`<g class="${tag(e, lastPoint(runs.map(r => r.run)))}">${hits(runs.map(r => r.run))}${lines}</g>`)
370  })
371  const fo = x.overlay.focused
372  const cur = fo?.isCurrent ? fo.series : null
373  if (fo) {
374    const s = fo.series
375    for (const m of s.midResets) p.push(`<line x1="${f(X(m.before.elapsed))}" x2="${f(X(m.before.elapsed))}" y1="${top}" y2="${f(top + h)}" stroke="var(--ink3)" stroke-dasharray="1 3"/>`)
376    const gapLines = s.gaps.map(g => `<line class="gp" x1="${f(X(g.from.elapsed))}" y1="${f(Y(g.from.used))}" x2="${f(X(g.to.elapsed))}" y2="${f(Y(g.to.used))}" stroke="var(--id)" stroke-opacity=".3" stroke-width="1.5" stroke-dasharray="2 3"/>`).join('')
377    const segs = s.segments.filter(seg => seg.length >= 2).map(seg => ({ seg, pts: poly(seg) }))
378    const lines = segs.map(({ pts }) => `<polyline class="ln" points="${pts}" fill="none" stroke="var(--id)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`).join('')
379    // Hit lines reuse the "join gaps" path: still targetable when sampling is sparse and each segment has one point
380    const joined = joinGaps(s).filter(run => run.length >= 2)
381    if (hover && joined.length) p.push(`<g class="${tag(fo, lastPoint(joined))}">${hits(joined)}${gapLines}${lines}</g>`)
382    else p.push(gapLines, lines)
383    if (cur) {
384      if (cur.projection) p.push(`<line x1="${f(X(cur.projection.from.elapsed))}" y1="${f(Y(cur.projection.from.used))}" x2="${f(X(cur.projection.to.elapsed))}" y2="${f(Y(cur.projection.to.used))}" stroke="${st.solid}" stroke-opacity=".85" stroke-width="1.5" stroke-dasharray="5 3"/>`)
385      const lastSeg = cur.segments[cur.segments.length - 1]
386      const lp = lastSeg[lastSeg.length - 1]
387      p.push(`<circle cx="${f(X(lp.elapsed))}" cy="${f(Y(lp.used))}" r="3.5" fill="var(--id)"/>`)
388      if (cur.exhaustionAt != null) p.push(`<line x1="${f(X(cur.exhaustionAt))}" x2="${f(X(cur.exhaustionAt))}" y1="${top}" y2="${f(top + h)}" stroke="#D03B3B" stroke-opacity=".7" stroke-dasharray="2 2"/>`)
389    }
390  }
391  p.push(...labels)
392  // Legend: each item's line style matches its line in the chart
393  const items: { mark: string; text: string }[] = []
394  const dates = legendDates(x.overlay)
395  if (dates) items.push({ mark: `<rect y="2" width="16" height="4" rx="2" fill="url(#${u}r)"/>`, text: `${dates.from} → ${dates.to}` })
396  else items.push({ mark: `<line x1="0" y1="4" x2="16" y2="4" stroke="var(--id)" stroke-width="2.2" stroke-linecap="round"/>`, text: L('用量', 'Usage') })
397  items.push({ mark: `<line x1="0" y1="4" x2="16" y2="4" stroke="var(--diag)" stroke-width="1.2" stroke-dasharray="4 3"/>`, text: L('匀速线', 'Even pace') })
398  if (cur?.projection) items.push({ mark: `<line x1="0" y1="4" x2="16" y2="4" stroke="${st.solid}" stroke-opacity=".85" stroke-width="1.5" stroke-dasharray="5 3"/>`, text: L('按近期速度', 'Recent rate') })
399  if (cur?.exhaustionAt != null) items.push({ mark: `<line x1="8" y1="-1" x2="8" y2="9" stroke="#D03B3B" stroke-opacity=".7" stroke-dasharray="2 2"/>`, text: L('用完', 'Runs out') })
400  if (fo?.series.gaps.length) items.push({ mark: `<line x1="0" y1="4" x2="16" y2="4" stroke="var(--id)" stroke-opacity=".3" stroke-width="1.5" stroke-dasharray="2 3"/>`, text: L('无数据', 'No data') })
401  const LF = 11
402  let lx = left
403  let ly = axisY + 20
404  for (const it of items) {
405    const iw = 21 + textWidth(it.text, LF)
406    if (lx > left && lx + iw > Wd - right) { lx = left; ly += 17 }
407    p.push(`<g transform="translate(${f(lx)},${f(ly - 8)})">${it.mark}</g><text x="${f(lx + 21)}" y="${f(ly)}" font-size="${LF}" fill="var(--ink2)">${esc(it.text)}</text>`)
408    lx += iw + 12
409  }
410  const H = ly + 5
411  // Per-line inspection styles: hovered group thickens and shows its label, other groups fade; labels don't block the pointer
412  const hoverStyle = hover
413    ? `<style>#${u}{--tip-bg:rgba(255,255,255,.96);--tip-fg:#1d1d1f;--tip-bd:rgba(0,0,0,.14)}` +
414      `@media (prefers-color-scheme: dark){#${u}{--tip-bg:rgba(30,30,32,.94);--tip-fg:#f5f5f7;--tip-bd:rgba(255,255,255,.18)}}` +
415      `#${u} .w .hit{fill:none;stroke:transparent;stroke-width:12;pointer-events:stroke}` +
416      `#${u} .lbl{opacity:0;pointer-events:none}` + labels.map((_, i) => `#${u}:has(.w${i}:hover) .l${i}`).join(',') + `{opacity:1}` +
417      `#${u} .w:hover .ln{stroke-opacity:1;stroke-width:2.6}#${u} .w:hover .gp{stroke-opacity:.85}#${u}:has(.w:hover) .w:not(:hover) .ln{stroke-opacity:.12}</style>`
418    : ''
419  return `${open(u, Wd, H)}${themeStyle(u, x.id)}${hoverStyle}<title>${esc(x.title)}</title>${p.join('')}</svg>`
420}
421
hooks/color.ts 34 lines
1// Color utils: sRGB ↔ OKLCH (Björn Ottosson's reference formulas), plus Material 3 hue harmonization. Pure functions.
2// Used for the band's overrun segment ("same hue, one shade darker") and the exhausted "harmonized warm color", both derived from the quota's own color.
3
4const toLin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
5const toSrgb = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)
6export function hexToOklch(hex: string): [number, number, number] {
7  const n = parseInt(hex.slice(1), 16)
8  const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map(v => toLin(v / 255))
9  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
10  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
11  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
12  const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
13  const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
14  const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
15  return [L, Math.hypot(A, B), ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360]
16}
17export function oklchToHex(L: number, C: number, H: number): string {
18  // Out of gamut: reduce chroma, keep lightness and hue
19  for (let c = C; c >= 0; c -= 0.002) {
20    const A = c * Math.cos((H * Math.PI) / 180), B = c * Math.sin((H * Math.PI) / 180)
21    const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3
22    const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3
23    const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3
24    const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s]
25    if (rgb.every(v => v >= -0.0005 && v <= 1.0005)) return '#' + rgb.map(v => Math.round(Math.min(1, Math.max(0, toSrgb(Math.max(0, v)))) * 255).toString(16).padStart(2, '0')).join('')
26  }
27  return '#000000'
28}
29/** Material 3 harmonize: rotate hue toward the reference color, at most 15° (the smaller of half the angle between them and 15°); lightness and chroma unchanged */
30export function harmonizeHue(h: number, toward: number): number {
31  const diff = ((toward - h + 540) % 360) - 180
32  return (h + Math.sign(diff) * Math.min(Math.abs(diff) * 0.5, 15) + 360) % 360
33}
34
types/index.d.ts 66 lines
1/** One quota in an observation: usage 0–100, reset time (ms), server display name */
2export type Obs = { u: number; r?: number; n?: string }
3/** One sample: time (ms) + per-quota observations */
4export type Sample = { t: number; w: Record<string, Obs> }
5
6/** Pane view state: selected quota, tab, burn-up range and page offset */
7export type PaneView = {
8  key: string | null
9  tab: 'pace' | 'burnup'
10  range: 'current' | 'month' | 'all'
11  offset: number
12  /** Per-line inspection for "Last month / All": the chart becomes hoverable boxes; the hovered line shows its dates and peak */
13  explore?: boolean
14}
15
16/** Band above the input box: shown / hidden (takes no row; restore from the pane) */
17export type BandMode = 'open' | 'hidden'
18
19/**
20 * Everything the band draws, rounded to display precision (whole percent). Written only when it changes, so only then does the band redraw:
21 * a new sample or passing time that leaves the display unchanged causes no redraw.
22 */
23export type BandShow = {
24  key: string
25  /** Display name from the server (per-model quotas) */
26  n?: string
27  /** All switchable quotas; the arrows step through them in this order */
28  keys: string[]
29  used: number | null
30  elapsed: number | null
31  /** The last observation's window is past its reset time: that usage is void and the band shows "reset" */
32  ended?: boolean
33  status: 'unknown' | 'early' | 'comfortable' | 'onPace' | 'overPace' | 'exhausted'
34}
35
36/** Time of the latest reply across local sessions: any = any model, byModel = per model family (fable / opus …) */
37export type Activity = { any?: number; byModel: Record<string, number> }
38
39/** UI language: Chinese / English */
40export type Lang = 'zh' | 'en'
41
42/** Manual refresh in progress (the refresh button shows "Refreshing…" from this); at is the start time. Older versions stored a receipt with text; no busy means idle */
43export type Note = { busy?: boolean; at: number; text?: string } | null
44
45declare module 'claude-code' {
46  interface PluginState {
47    weektoken: {
48      samples: Sample[]
49      view: PaneView
50      note: Note
51      band: BandMode
52      /** Quota shown in the band: remembered once picked with the band's prev/next arrows; null = auto-show the tightest */
53      bandKey: string | null
54      bandShow: BandShow | null
55      /** The band is asking "Hide the band?" */
56      confirmHide: boolean
57      /** Whether the pane is open (band button reads "Close" or "Details") */
58      paneOpen: boolean
59      lang: Lang
60      activity: Activity
61      /** Loaded version (from its own plugin.json), drawn after the pane's credit line */
62      version: string
63    }
64  }
65}
66