SLOPSHOPPER

token-range-monitor

Projects what will be left of your weekly and 5-hour Claude limits at reset, from your recent usage rate

newpanebandguardcommandtimer
v1.2.1MITupdated 2026-10-09micke-dahlgren/token-range-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-range-monitor
│ ┃ Token Range Monitor ✕ › fix the failing auth test and add an audit log call │ ┃ No weekly limit reported. │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ │ ⏺ Update(src/auth.ts) │ ┃ │ 5-hour window Resets ⎿ Added 2 lines, removed 1 line │ ┃ │ Left at 5h reset −NaN% · Average ⏺ Bash(bun test) │ ┃ │ NaN%/h · Limit NaN%/h ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Average since the 5-hour window opened │ ┃ │ — ago. ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ ┃ │ › /token-range │ ┃ ╰─────────────────────────────────────────── ⎿ token-range-monitor: Token Range Monitor opened. │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ │ │ ┃ │ │ │ ┃ │ Models │ │ ┃ │ │ │ ┃ │ │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ Left at 5h reset −NaN% Resets in NaN hours [ Details ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Left at 5h reset −NaN% Resets in NaN hours [ Details ]
Pane · Token Range Monitor
No weekly limit reported. ╭─────────────────────────────────────────────────────────── │ │ │ 5-hour window R │ Left at 5h reset −NaN% · Average NaN%/h · Limit NaN%/h │ │ │ Average since the 5-hour window opened — ago. │ │ ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ │ │ │ │ Models │ │ │ │ │ ╰──────────────────────────────────────────────────────────╯
README

Token Range Monitor

A Claude Code mod that tells you, like an electric car's range display, whether your current pace will carry you to the next limit reset.

It watches the weekly and 5-hour usage limits of your Claude Pro or Max subscription. It projects what will be left of each one when it resets, if you keep using Claude at the rate you have been.

<!-- Screenshot: add docs/pane.png and uncomment The Token Range Monitor pane -->

Install

In a terminal, start claude and run:

/plugin install token-range-monitor --marketplace micke-dahlgren/token-range-monitor

Answer y to add the marketplace, then choose the user scope so it runs in every session. It also runs in sessions in the Claude desktop app's Code tab. The install line itself only works in a terminal.

To update later:

claude plugin update token-range-monitor

What it shows

Above the prompt, one line:

Left at week reset −25% · Resets in 3.7 days · Left at 5h reset +12% · Resets in 2.4 hours · Details

  • Left at reset is what's projected to remain of the limit when it resets, at your current rate. A positive value (green) means you'll make it with that much to spare. A negative value (red) means you'd need that much more than you have.
  • Click Resets in… to switch between days and hours (or hours and minutes for the 5-hour window). Under an hour, the 5-hour countdown is in minutes.
  • Details opens the pane.

The pane has one card per limit, showing:

  • Left at reset, Average (your usage rate) and Limit (the highest rate you can keep up until the reset without running out). When you're over, it also says when you'd run out.
  • A chart of your usage over the averaging window, ending at Now. The dotted line is your average and the solid line is the limit. If the dotted line is above the solid one, you'll run out before the reset.
  • Bars are usage seen as it happened. Anthropic reports each limit in whole percents, so each rise is shared among the responses made since the previous one, by their size. That way the bars follow your actual work and don't all come out one percent high. Stretches the plugin didn't see (before it was installed, or while no session here was getting responses) show as one low block labelled with what's known, like "12% used while away, 9h".
  • For the weekly limit, a choice of what the average covers:
  • Since reset (the default): your usage since the weekly reset, from Anthropic's own figure. It needs no recorded history, so it works right after you install.
  • Custom: the last 1–24 hours or 1–7 days.

A third card, Models, shows what each model costs and where your week went:

  • Cost compares each model token for token with a baseline you pick under Compare with (Sonnet by default). "Opus 4.7×" means the same tokens on Opus use 4.7 times as much of your limit as on Sonnet, whether it's a quick answer or a long agentic run. The likely range is shown beneath.
  • This week splits the points of your weekly limit by model and effort level. Higher effort means more thinking tokens, so it shows here, not in the cost. Usage the plugin didn't see stays apart as Not recorded.

Open the pane with /token-range. You can also set the window from the prompt: /token-range 2d, /token-range 12h or /token-range reset.

How the estimate works

Each limit's percentage and reset time arrive with Claude's responses. The mod records them and works out your usage rate over the chosen window:

  • Projected left at reset = 100% − (used now + rate × time until reset)
  • Limit = what's left ÷ time until reset

The mod shows No data rather than a misleading number when there isn't enough behind the average:

  • A custom window needs that much recorded history. A 2-day average needs 2 days of readings.
  • Weekly estimates need at least 6 hours of data. The weekly percentage moves in steps of about a point, so over a short stretch a single step reads as a huge rate.
  • Since reset waits 6 hours after each weekly reset.
  • The 5-hour estimate averages since the window opened, from Anthropic's own figure, and its chart shows the whole window. It starts 15 minutes after the window opens.

Recent pace

The weekly card also answers "if I keep going like the last hour, when do I run out?"

The weekly percentage moves in whole points, too coarse to read one hour from. The 5-hour percentage moves several times faster, so the pace is read from it:

  1. From your recorded history, the mod learns how many 5-hour points go with one weekly point on your account. It waits for 3 weekly points before trusting that.
  2. Your last hour of 5-hour usage, divided by that ratio, is your weekly pace.

How is this worked out? under the line shows the numbers for your account. It's an estimate, and it settles as more usage is recorded.

Model costs

Every response, subagents' included, reports its model, effort and tokens. Between two readings of the 5-hour limit that this computer watched throughout, the mod knows how far the limit rose and which models did the work. Over the last 14 days it solves for each model's weight. It reads the 5-hour limit because it moves several times faster than the weekly one, which gives many more readings to learn from. Models compare the same on either limit, and this week's points use the same exchange rate as the 1hr pace. Within a model, output, input and cache tokens are combined at that model's published price ratios, so only the weight across models is learned.

Each model is judged on its own. Its cost shows once the 90% range of its weight is within ±20%. Until then its row shows Learning with a meter, and its points this week wait under Not split yet. With steady use, Opus usually shows within hours. A cheap model you rarely use, like Haiku in subagents, takes longer.

Good to know

  • Pro and Max subscriptions only. API-key usage has no subscription limits to show.
  • History starts when you install it. Readings are recorded while a Claude Code session is open, and every session on this computer shares them. Usage on claude.ai or another device shows up as a jump at the next reading.
  • It keeps one history per account. Limits belong to the signed-in account and organization, so signing in to another account switches to that account's own history.
  • Your data stays on your computer, in the mod's own storage in your Claude Code configuration folder. Nothing is sent anywhere. The drawings load the IBM Plex fonts from Google Fonts, and use your system's font where they can't.
  • Built on Claude Code's early-access mod API. A Claude Code update may change that API and break the mod before it's updated.

Development

Run it from this folder without installing:

claude --plugin-dir .

Check and test it:

claude plugin validate .
claude plugin test .

License

MIT

Source 5 files
hooks/register.tsx 538 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { RangeReading, RangeSettings, RangeStep, RangeWatch } from '../types'
5import {
6  DEFAULT_SETTINGS, KEEP, MIN, heartbeat, MIN_RECORDED_H, UNIT_MAX, UNIT_MIN, averageName, clampWindow, averageNote, chosenAverage,
7  drawingHeight, emptyChartSvg, emptyChartText, fit, fx, headerDraw, paceExplain, paceText, recentPace, withInfo, fiveChart, headerSvg, noteSvg, merge, parseWindow, project, rateText, recordedHours, resetsIn,
8  isShort, leftText, refine, runsOut, weekChart, windowHours,
9} from './range'
10import type { Average, Model } from './range'
11import { DEFAULT_PALETTES, palettesFor, resolveTheme } from './theme'
12import { baselineOf, learn, mergeSteps, MODELS_INFO, modelsCosts, modelsHead, modelsSpend, modelsText, shortNames, spend, units } from './models'
13
14const PANE = 'token-range-monitor'
15const TITLE = 'Token Range Monitor'
16const readings = atom({ plugin: 'token-range-monitor', key: 'readings' } as const, [])
17const settings = atom({ plugin: 'token-range-monitor', key: 'settings' } as const, DEFAULT_SETTINGS)
18const tick = atom({ plugin: 'token-range-monitor', key: 'tick' } as const, 0)
19const seen = atom({ plugin: 'token-range-monitor', key: 'seen' } as const, [])
20const palettes = atom({ plugin: 'token-range-monitor', key: 'palettes' } as const, DEFAULT_PALETTES)
21const steps = atom({ plugin: 'token-range-monitor', key: 'steps' } as const, [])
22
23/**
24 * Usage limits are the account's, so the record is too: everything is stored
25 * under the signed-in account (`<account>/`), and signing in to another
26 * account starts from that account's own record. Within it each session
27 * writes its readings under its own key (`r:`), and the spans it was watching
28 * under the twin key (`w:`), so sessions never overwrite each other, and every
29 * session reads them all. The responses it saw, each one's model and tokens,
30 * go under a third (`u:`).
31 */
32let account = ''
33const OWN = 'r:'
34const SEEN = 'w:'
35const STEPS = 'u:'
36const ownPrefix = () => `${account}/${OWN}`
37const seenKeyOf = (key: string) => key.replace(`/${OWN}`, `/${SEEN}`)
38const stepsKeyOf = (key: string) => key.replace(`/${OWN}`, `/${STEPS}`)
39const newOwnKey = (now: number) => `${ownPrefix()}${now.toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`
40/** A stored list's key: whose (none for a record kept before accounts), and which kind. */
41const parseKey = (key: string) => /^(?:(.+)\/)?([rwu]):/.exec(key)
42
43let ownKey = ''
44let own: RangeReading[] = []
45let ownSeen: RangeWatch[] = []
46let othersSeen: RangeWatch[] = []
47let ownSteps: RangeStep[] = []
48let othersSteps: RangeStep[] = []
49
50/**
51 * The signed-in account and organisation, from Claude Code's own config:
52 * the limits belong to both. `none` off a subscription, which has no limits.
53 */
54let accountRead: { mtime: number; account: string } | undefined
55async function accountOf($: EngineInterface): Promise<string | null> {
56  try {
57    const dir = await $.env.get('CLAUDE_CONFIG_DIR')
58    const file = dir ? `${dir}/.claude.json` : `${(await $.env.get('HOME')) ?? ''}/.claude.json`
59    // the file is read again only once it changed
60    const { mtimeMs } = await $.fs.stat(file)
61    if (accountRead?.mtime === mtimeMs) return accountRead.account
62    const o = (JSON.parse(await $.fs.read(file)) as { oauthAccount?: { accountUuid?: string; organizationUuid?: string } }).oauthAccount
63    const a = o?.accountUuid ? `${o.accountUuid}.${o.organizationUuid ?? ''}` : 'none'
64    accountRead = { mtime: mtimeMs, account: a }
65    return a
66  } catch {
67    // unreadable (mid-write, say): no answer, which is no sign-in elsewhere
68    return null
69  }
70}
71
72/** Follows a sign-in to another account: this session starts a fresh list under it. True when it moved. */
73async function followAccount($: EngineInterface): Promise<boolean> {
74  const now = await $.clock.now()
75  const a = (await accountOf($)) ?? (account || 'none')
76  if (a === account) return false
77  account = a
78  ownKey = newOwnKey(now)
79  own = []
80  ownSeen = []
81  ownSteps = []
82  return true
83}
84
85/**
86 * Claude Code keeps a store per copy of a plugin: one installed from the
87 * marketplace and one run from a folder don't share. Every copy's store sits
88 * in the same folder, so each copy also reads the others' (never writes them),
89 * and sessions running different copies still see each other's usage.
90 */
91async function otherCopies($: EngineInterface): Promise<Array<[string, unknown]>> {
92  try {
93    const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
94    const stores = `${dir}/plugins/store`
95    const out: Array<[string, unknown]> = []
96    for (const f of await $.fs.list(stores)) {
97      if (!/^token-range-monitor_.*\.json$/.test(f.name)) continue
98      // a file being written as it's read is skipped this time, the others still count
99      try {
100        out.push(...Object.entries(JSON.parse(await $.fs.read(`${stores}/${f.name}`)) as Record<string, unknown>))
101      } catch { /* next minute */ }
102    }
103    return out
104  } catch {
105    return []
106  }
107}
108
109async function loadAll($: EngineInterface) {
110  const now = await $.clock.now()
111  const lists: RangeReading[][] = [own]
112  const spans: RangeWatch[] = []
113  const stepLists: RangeStep[][] = []
114  // the other copies' lists, read as they stand; this copy's own file is among them, and merging drops the repeats
115  for (const [key, value] of await otherCopies($)) {
116    const k = parseKey(key)
117    if (!k || !Array.isArray(value) || (k[1] !== undefined && k[1] !== account)) continue
118    if (k[2] === 'w') spans.push(...(value as RangeWatch[]))
119    else if (k[2] === 'u') stepLists.push(value as RangeStep[])
120    else lists.push(value as RangeReading[])
121  }
122  for (const key of await $.store.keys()) {
123    const k = parseKey(key)
124    if (!k || key === ownKey || key === seenKeyOf(ownKey) || key === stepsKeyOf(ownKey)) continue
125    const [, whose, kind] = k
126    const list = ((await $.store.get(key)) ?? []) as Array<RangeReading | RangeWatch | RangeStep>
127    // drop lists once everything in them has aged out, whichever account's
128    const end = (x: RangeReading | RangeWatch | RangeStep) => (kind === 'w' ? (x as RangeWatch)[1] : x[0])
129    if (list.every(x => end(x) < now - KEEP)) { await $.store.delete(key); continue }
130    if (whose === undefined) {
131      // a record kept before accounts: it was this account's, so it moves under it
132      await $.store.set(`${account}/${key}`, list)
133      await $.store.delete(key)
134    } else if (whose !== account) continue
135    if (kind === 'w') spans.push(...(list as RangeWatch[]))
136    else if (kind === 'u') stepLists.push(list as RangeStep[])
137    else lists.push(list as RangeReading[])
138  }
139  othersSeen = spans
140  othersSteps = mergeSteps(stepLists, now)
141  await update($, steps, () => mergeSteps([othersSteps, ownSteps], now))
142  await update($, readings, () => merge(lists, now))
143  await update($, seen, () => [...othersSeen, ...ownSeen])
144}
145
146/**
147 * Notes that this session just got fresh figures (a response arrived), for telling
148 * usage seen here from usage made elsewhere. Saved at most every 30 seconds.
149 */
150let seenSavedAt = 0
151async function watch($: EngineInterface) {
152  const now = await $.clock.now()
153  if (!ownKey) ownKey = newOwnKey(now)
154  const before = ownSeen.length
155  ownSeen = heartbeat(ownSeen, now)
156  if (ownSeen.length !== before || now - seenSavedAt > 30_000) {
157    seenSavedAt = now
158    await $.store.set(seenKeyOf(ownKey), ownSeen)
159  }
160  await update($, seen, () => [...othersSeen, ...ownSeen])
161}
162
163/** Notes one response: its model, effort and weighted tokens. Saved at most every 30 seconds. */
164let stepsSavedAt = 0
165let stepsUnsaved = false
166async function record($: EngineInterface, step: RangeStep) {
167  const now = step[0]
168  if (!ownKey) ownKey = newOwnKey(now)
169  ownSteps = [...ownSteps.filter(s => s[0] >= now - KEEP), step]
170  stepsUnsaved = true
171  if (now - stepsSavedAt > 30_000) await saveSteps($, now)
172  await update($, steps, list => [...list, step])
173}
174async function saveSteps($: EngineInterface, now: number) {
175  if (!stepsUnsaved || !ownKey) return
176  stepsSavedAt = now
177  stepsUnsaved = false
178  await $.store.set(stepsKeyOf(ownKey), ownSteps)
179}
180
181async function capture($: EngineInterface, limits: readonly SessionRateLimit[]) {
182  // figures from another account mean a sign-in since: read that account's record first
183  if (await followAccount($)) await loadAll($)
184  const now = await $.clock.now()
185  const known = await read($, readings)
186  const fresh: RangeReading[] = []
187  for (const rl of limits) {
188    const kind = rl.kind === 'five_hour' ? 0 : rl.kind === 'seven_day' ? 1 : -1
189    if (kind === -1 || !rl.resetsAt) continue
190    const resetsAt = Date.parse(rl.resetsAt)
191    let last: RangeReading | undefined
192    for (const r of known) if (r[1] === kind && (!last || r[0] >= last[0])) last = r
193    if (!last || last[2] !== rl.percentUsed || Math.abs(last[3] - resetsAt) > 5 * MIN) {
194      fresh.push([now, kind, rl.percentUsed, resetsAt])
195    }
196  }
197  if (fresh.length === 0) return
198  if (!ownKey) ownKey = newOwnKey(now)
199  own = merge([own, fresh], now)
200  await $.store.set(ownKey, own)
201  await update($, readings, list => merge([list, fresh], now))
202}
203
204/**
205 * Reads the person's theme and derives the card's palettes from it. A custom
206 * theme (`custom:<slug>`) is the person's own file or one a plugin ships.
207 */
208let themeFile: { slug: string; path: string } | undefined
209async function loadTheme($: EngineInterface) {
210  const setting = ((await $.settings.read()) as { theme?: unknown }).theme
211  const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
212  const theme = await resolveTheme(setting, async slug => {
213    // found once, then read where it was; searched again only if it's gone
214    if (themeFile?.slug === slug && await $.fs.exists(themeFile.path)) return await $.fs.read(themeFile.path)
215    const own = `${dir}/themes/${slug}.json`
216    if (await $.fs.exists(own)) { themeFile = { slug, path: own }; return await $.fs.read(own) }
217    // plugins are cached as cache/<marketplace>/<plugin>/<version>/
218    const cache = `${dir}/plugins/cache`
219    for (const m of await $.fs.list(cache).catch(() => [])) {
220      for (const pl of await $.fs.list(`${cache}/${m.name}`).catch(() => [])) {
221        for (const ver of await $.fs.list(`${cache}/${m.name}/${pl.name}`).catch(() => [])) {
222          const file = `${cache}/${m.name}/${pl.name}/${ver.name}/themes/${slug}.json`
223          if (await $.fs.exists(file)) { themeFile = { slug, path: file }; return await $.fs.read(file) }
224        }
225      }
226    }
227    return undefined
228  })
229  const next = palettesFor(theme)
230  if (JSON.stringify(next) !== JSON.stringify(await read($, palettes))) await update($, palettes, () => next)
231}
232
233async function models($: EngineInterface) {
234  const list = await read($, readings)
235  const watched = await read($, seen)
236  const s = await read($, settings)
237  const pal = await read($, palettes)
238  await read($, tick)
239  const now = await $.clock.now()
240  const weekRecorded = recordedHours(list, 'week', now)
241  const weekAvg = chosenAverage(s)
242  // the 5-hour average runs from the window's opening: Anthropic's own figure, and the chart shows the whole window
243  const fiveAvg: Average = { type: 'reset' }
244  const stepList = await read($, steps)
245  const learned = learn(list, stepList, watched, now)
246  const projected = project(list, 'week', now, weekAvg, watched)
247  // the bars follow the responses behind each rise, each weighed by its model's cost where that's known
248  const shown = learned.models.filter(m => m.shown)
249  const usual = shown.length ? shown.reduce((a, m) => a + m.w, 0) / shown.length : 1
250  const weightOf = new Map(learned.models.map(m => [m.id, m.shown ? m.w : usual]))
251  const work = stepList.map(st => [st[0], st[3] * (weightOf.get(st[1]) ?? usual)] as const)
252  const shaped = (m: Model | null) => (m ? { ...m, increments: refine(m.increments, work) } : null)
253  const week = shaped(projected)
254  return {
255    now, s, weekRecorded, pal, week,
256    five: shaped(project(list, 'five', now, fiveAvg, watched)),
257    // the pace and the costs read the rises as the readings gave them
258    pace: projected ? recentPace(list, projected, now, watched) : null,
259    learned,
260    steps: stepList,
261  }
262}
263
264async function toggleFine($: EngineInterface) {
265  await update($, settings, s => ({ ...s, fine: !s.fine }))
266  await $.store.set('settings', await read($, settings))
267}
268
269async function choose($: EngineInterface, change: Partial<RangeSettings>) {
270  await update($, settings, s => ({ ...s, ...change }))
271  await $.store.set('settings', await read($, settings))
272}
273
274/** Sets the weekly window: any length, one that can't give an estimate yet saying so in the chart's place. */
275async function setWindow($: EngineInterface, n: number, unit: 'h' | 'd') {
276  await choose($, { mode: 'window', n: clampWindow(n, unit), unit })
277}
278
279export const register: Register = on => {
280  on('session.start', async ($, e, next) => {
281    account = ''
282    await followAccount($)
283    const saved = (await $.store.get('settings')) as RangeSettings | undefined
284    // a saved "everything recorded" (now retired) becomes the custom window it sat beside
285    if (saved) await update($, settings, () => ({ ...DEFAULT_SETTINGS, ...saved, mode: saved.mode === 'reset' ? 'reset' : 'window' }))
286    await $.command.register({
287      name: 'token-range',
288      description: 'Token Range Monitor: open the pane, or set what the weekly average covers (/token-range 2d, /token-range 6h, /token-range reset)',
289      argumentHint: '[window | reset]',
290    })
291    await loadAll($)
292    await loadTheme($)
293    await capture($, (await $.session.usage()).rateLimits)
294    // once a minute: pick up other sessions' readings and move "now" along
295    $.clock.every(60_000, async () => {
296      await saveSteps($, await $.clock.now())
297      await followAccount($)
298      await loadAll($)
299      await loadTheme($)   // picks up a theme file edited in place
300      await capture($, (await $.session.usage()).rateLimits)
301      await update($, tick, n => n + 1)
302    })
303    return next(e)
304  })
305
306  // a turn's end and each tool call both follow a response, so the figures are fresh then;
307  // the once-a-minute check above only re-reads the last response's figures, so it isn't watching
308  on('session.measure', async ($, e, next) => {
309    if (e.changed.includes('rateLimits')) await capture($, e.rateLimits)
310    await watch($)
311    return next(e)
312  })
313
314  // a theme switched from /theme, the menu or another plugin redraws the card at once
315  on('config.set', { key: 'theme' }, async ($, e, next) => {
316    const result = await next(e)
317    await loadTheme($)
318    return result
319  }).catch(($, e, next) => next(e))   // never stands in the way of the theme change itself
320
321  // every model response, main and subagents': its model, effort and tokens, for learning what each model costs
322  on('turn.step', async function* ($, e, next) {
323    const r = yield* next(e)
324    try {
325      if (r?.usage) await record($, [await $.clock.now(), r.usage.model || e.model, e.effort === undefined ? '' : String(e.effort), units(r.usage), e.agentId ? 1 : 0])
326    } catch { /* never in the way of the response */ }
327    return r
328  })
329
330  on('tool.call', async ($, e, next) => {
331    await watch($)
332    return next(e)
333  })
334
335  on('command.run', { command: 'token-range' }, async ($, e) => {
336    const arg = e.args.trim().toLowerCase()
337    await $.ui.open({ id: PANE, title: TITLE })
338    if (!arg) return { text: 'Token Range Monitor opened.' }
339    if (arg === 'reset' || arg === 'since reset') {
340      await choose($, { mode: 'reset' })
341      return { text: 'Weekly average now covers the time since the reset.' }
342    }
343    const w = parseWindow(arg)
344    if (!w) return { text: `Couldn't read "${arg}". Use a window like 6h or 2d (at most 7d), or "reset".` }
345    await setWindow($, w.unit === 'h' ? w.n : w.n * (w.unit === 'w' ? 7 : 1), w.unit === 'h' ? 'h' : 'd')
346    // held to what's on offer: under 6 hours becomes 6
347    const avg: Average = { type: 'hours', hours: windowHours(await read($, settings)) }
348    return { text: `Weekly average now uses the last ${averageName(avg)}.` }
349  })
350
351  // C: one line above the prompt
352  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
353    if (e.props.hasSurvey) return next(e)
354    const { now, s, week, five } = await models($)
355    void now
356    if (!week && !five) return next(e)
357    const { Box, Text, Button } = $.ui.resolve(e)
358    return (
359      <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center">
360        {week && (
361          <Box flexDirection="row" columnGap={1}>
362            <Text dimColor>Left at week reset</Text>
363            <Text bold dimColor={!!week.noData} color={week.noData ? undefined : week.over ? 'error' : 'success'}>{leftText(week)}</Text>
364            <Button key="reset-week" plain dimColor label={resetsIn(week, s.fine)} onPress={() => toggleFine($)} />
365          </Box>
366        )}
367        {week && five && <Text dimColor>·</Text>}
368        {five && (
369          <Box flexDirection="row" columnGap={1}>
370            <Text dimColor>Left at 5h reset</Text>
371            <Text bold dimColor={!!five.noData} color={five.noData ? undefined : five.over ? 'error' : 'success'}>{leftText(five)}</Text>
372            <Button key="reset-five" plain dimColor label={resetsIn(five, s.fine)} onPress={() => toggleFine($)} />
373          </Box>
374        )}
375        <Button key="details" label="Details" onPress={() => void $.ui.open({ id: PANE, title: TITLE })} />
376      </Box>
377    )
378  })
379
380  // B: the side pane
381  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
382    const { now, s, week, five, pal, pace, weekRecorded, learned, steps: stepList } = await models($)
383    const els = $.ui.resolve(e)
384    const { Box, Text, Button } = els
385    const Svg = 'Svg' in els ? els.Svg : null
386    // both cards fit the pane: charts drawn at its width, sharing the height that's left
387    const size = fit(e.props.bodyColumns)
388    // the weekly head carries the 1hr pace, its info circle opening a tooltip over the chart: head and chart are one drawing
389    const weekDrawing = (m: Model) => {
390      const chart = emptyChartText(m, now) ? emptyChartSvg(m, now, size.width, pal) : weekChart(m, now, size.width, pal, size.weekHeight)
391      if (!pace) return chart
392      const head = headerDraw(m, 'This week', size.width, pal, resetsIn(m, s.fine), pace)
393      return withInfo(head, chart, `${paceText(pace)}\n${paceExplain(pace)}`, size.width, pal)
394    }
395
396    // one dark card per limit, as on the car's display: head, chart, controls, then the notes
397    // every drawing in a frame as wide as the card, given its height: it fills the width, and its text keeps its size
398    const Draw = ({ svg, alt }: { svg: string; alt: string }) =>
399      Svg ? <Svg source={svg} height={drawingHeight(svg)} isInteractive alt={alt} /> : null
400    // `chart` may hold the head too (the weekly card with its pace): then no separate head is drawn
401    const block = (title: string, m: Model, chart: string | null, controls: JSX.Element | null, notes: string[]) => {
402      const headInChart = !!chart?.includes('class="info"')
403      return (
404      <Box
405        key={`block-${m.kind}`}
406        flexDirection="column"
407        gap={1}
408        marginBottom={1}
409        padding={2}
410        borderStyle="round"
411        borderColor="userMessageBackground"
412        backgroundColor="userMessageBackground"
413      >
414        {/* the countdown is drawn in the head, at its right, so its text takes the drawing's styles */}
415        {!headInChart && <Box flexDirection="row" justifyContent="space-between" alignItems="flex-start" columnGap={2}>
416          {Svg ? (
417            <Draw
418              svg={headerSvg(m, title, size.width, pal, resetsIn(m, s.fine))}
419              alt={`${title}. ${resetsIn(m, s.fine)}. ${isShort(m) ? `${runsOut(m)}. ` : ''}Left at reset ${leftText(m)}, average ${m.noData ? 'no data' : rateText(m, m.rate)}, limit ${rateText(m, m.limit)}.`}
420            />
421          ) : (
422            <Box flexDirection="column">
423              <Text bold>{title}</Text>
424              {isShort(m) && <Text color="error">{runsOut(m)}</Text>}
425              <Text>
426                {`${m.kind === 'week' ? 'Left at week reset' : 'Left at 5h reset'} ${leftText(m)} · Average ${m.noData ? 'no data' : rateText(m, m.rate)} · Limit ${rateText(m, m.limit)}${m.kind === 'week' && pace ? ` · 1hr pace ${pace.ready ? `${fx(pace.rate * 24)}%/day` : 'no data'}` : ''}`}
427              </Text>
428            </Box>
429          )}
430          {!Svg && <Button key={`reset-${m.kind}`} plain label={resetsIn(m, s.fine)} onPress={() => toggleFine($)} />}
431        </Box>}
432        {/* the chart and its controls are one unit: the controls stay right under the chart */}
433        <Box key={`graph-${m.kind}`} flexDirection="column" gap={1}>
434          {chart && Svg && (
435            <Draw
436              svg={chart}
437              alt={`${headInChart ? `${title}. ${resetsIn(m, s.fine)}. ${isShort(m) ? `${runsOut(m)}. ` : ''}Left at reset ${leftText(m)}, average ${m.noData ? 'no data' : rateText(m, m.rate)}, limit ${rateText(m, m.limit)}. ${pace ? `1hr pace ${pace.ready ? `${fx(pace.rate * 24)}%/day` : 'no data'}. ${paceText(pace)} ${paceExplain(pace)} ` : ''}` : ''}${emptyChartText(m, now) ? emptyChartText(m, now)!.join('. ') : `${averageNote(m)} Average ${rateText(m, m.rate)}, limit ${rateText(m, m.limit)}.`}`}
438            />
439          )}
440          {controls}
441        </Box>
442        <Box flexDirection="column" marginTop={controls ? 1 : 0}>
443          {notes.map(note => (Svg
444            ? <Draw svg={noteSvg(note, size.width, pal)} alt={note} />
445            : <Text color="inactive">{note}</Text>))}
446        </Box>
447      </Box>
448      )
449    }
450
451    // the window selector: native widgets, so hover, focus and clicks are the surface's own
452    const unit = s.unit === 'h' ? 'h' : 'd'
453    const n = clampWindow(s.n, unit)
454    const isCustom = s.mode === 'window'
455    const seg = (key: string, label: string, isOn: boolean, onPress: () => void, off = false) => (
456      <Button key={key} label={label} variant={isOn && !off ? 'primary' : 'secondary'} dimColor={!isOn || off} onPress={() => { if (!off) onPress() }} />
457    )
458    // until there's enough recorded for a weekly average, there's nothing to choose between: the choices dim and do nothing
459    const choicesOff = weekRecorded < MIN_RECORDED_H.week
460    const windowControls = (
461      // a block at the right under the chart: what the average covers, and for a custom window its length beneath
462      <Box flexDirection="column" alignSelf="flex-end" alignItems="flex-start" gap={1}>
463        <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} rowGap={1}>
464          {seg('mode-reset', 'Since reset', s.mode === 'reset', () => void choose($, { mode: 'reset' }), choicesOff)}
465          {seg('mode-custom', 'Custom', isCustom, () => void setWindow($, n, unit), choicesOff)}
466        </Box>
467        {isCustom && !choicesOff && (
468          <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} rowGap={1}>
469            <Text color="inactive">Last</Text>
470            <Button key="win-dec" label="−" dimColor={n <= UNIT_MIN[unit]} onPress={() => void setWindow($, n - 1, unit)} />
471            <Text bold color="text">{` ${n} `}</Text>
472            <Button key="win-inc" label="+" dimColor={n >= UNIT_MAX[unit]} onPress={() => void setWindow($, n + 1, unit)} />
473            {seg('unit-h', 'hours', unit === 'h', () => void setWindow($, n, 'h'))}
474            {seg('unit-d', 'days', unit === 'd', () => void setWindow($, n, 'd'))}
475          </Box>
476        )}
477      </Box>
478    )
479
480    // the models card: each model's cost against the baseline, and this week's points by model and effort
481    const base = baselineOf(learned, s.baseline)
482    const sp = week ? spend(week, learned, stepList, now) : null
483    const shown = learned.models.filter(m => m.shown)
484    const names = shortNames(shown)
485    const modelsCard = (
486      <Box key="block-models" flexDirection="column" gap={1} marginBottom={1} padding={2} borderStyle="round" borderColor="userMessageBackground" backgroundColor="userMessageBackground">
487        {Svg ? (
488          <>
489            {/* the head's info circle opens its tooltip over the rows: head and rows are one drawing */}
490            <Draw svg={withInfo(modelsHead(learned, now, pal), modelsCosts(learned, base, size.width, pal), MODELS_INFO, size.width, pal, 'What do these figures mean?')} alt={`Models. ${modelsText(learned, base, null).join(' ')}`} />
491            {base && (
492              <Box flexDirection="row" flexWrap="wrap" justifyContent="flex-end" alignItems="center" columnGap={1} rowGap={1}>
493                <Text color="inactive">Compare with</Text>
494                {shown.map(m => seg(`base-${m.id}`, names.get(m.id) ?? m.name, m === base, () => void choose($, { baseline: m.id })))}
495              </Box>
496            )}
497            {sp && <Draw svg={modelsSpend(sp, size.width, pal)} alt={modelsText({ ...learned, models: [] }, null, sp).join(' ')} />}
498          </>
499        ) : (
500          <Box flexDirection="column">
501            <Text bold>Models</Text>
502            {modelsText(learned, base, sp).map(line => <Text>{line}</Text>)}
503            {base && (
504              <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
505                <Text color="inactive">Compare with</Text>
506                {shown.map(m => seg(`base-${m.id}`, names.get(m.id) ?? m.name, m === base, () => void choose($, { baseline: m.id })))}
507              </Box>
508            )}
509          </Box>
510        )}
511      </Box>
512    )
513
514    if (!week && !five) {
515      return (
516        <Box flexDirection="column" gap={1}>
517          <Text>No usage limits reported yet.</Text>
518          <Text dimColor>They arrive with Claude's next response, on a Pro or Max subscription.</Text>
519        </Box>
520      )
521    }
522
523    return (
524      <Box key="cards" flexDirection="column">
525        {week
526          ? block('This week', week, Svg ? weekDrawing(week) : null, windowControls, [
527            ...(Svg && emptyChartText(week, now) ? [] : [averageNote(week)]),
528          ])
529          : <Text dimColor>No weekly limit reported.</Text>}
530        {five
531          ? block('5-hour window', five, Svg ? (emptyChartText(five, now) ? emptyChartSvg(five, now, size.width, pal) : fiveChart(five, now, size.width, pal, size.fiveHeight)) : null, null, Svg && emptyChartText(five, now) ? [] : [averageNote(five)])
532          : <Text dimColor>No active 5-hour window.</Text>}
533        {modelsCard}
534      </Box>
535    )
536  })
537}
538
hooks/range.ts 913 lines
1import type { Palettes, RangeReading, RangeSettings } from '../types'
2import { DEFAULT_PALETTES, paletteStyle, v } from './theme'
3import type { PaletteKey } from './theme'
4
5export const MIN = 60_000
6export const HOUR = 60 * MIN
7export const DAY = 24 * HOUR
8
9export type Kind = 'five' | 'week'
10const KIND = { five: 0, week: 1 } as const
11const SPAN = { five: 5 * HOUR, week: 7 * DAY }
12/** How far back one reading's increase is spread when readings are far apart. */
13const SPREAD = 10 * MIN
14/** Readings older than this are dropped: enough for a 2-week window plus slack. */
15export const KEEP = 22 * DAY
16
17export const DEFAULT_SETTINGS: RangeSettings = { mode: 'reset', n: 1, unit: 'd', fine: false }
18export const UNIT_HOURS = { h: 1, d: 24, w: 168 } as const
19/** The longest window on offer per unit: a day in hours, the week's own length in days. */
20export const UNIT_MAX = { h: 24, d: 7 } as const
21
22export const windowHours = (s: Pick<RangeSettings, 'n' | 'unit'>) => Math.min(s.n * UNIT_HOURS[s.unit], KEEP / HOUR - 24)
23export const windowName = (s: Pick<RangeSettings, 'n' | 'unit'>) => `${s.n}${s.unit}`
24
25/** What an average covers: the last `hours`, or the time since the reset. */
26export type Average = { type: 'hours'; hours: number } | { type: 'reset' }
27
28/**
29 * Least data behind any average before it is an estimate. The weekly figure moves
30 * in coarse steps, so under six hours one step reads as a huge rate.
31 */
32export const MIN_RECORDED_H = { week: 6, five: 5 / 60 }
33/** The shortest window on offer per unit: in hours the weekly least, since anything shorter can't give an estimate. */
34export const UNIT_MIN = { h: MIN_RECORDED_H.week, d: 1 } as const
35/** `n` held to what the unit offers. */
36export const clampWindow = (n: number, unit: 'h' | 'd') => Math.min(UNIT_MAX[unit], Math.max(UNIT_MIN[unit], Math.round(n)))
37/** Least time since the reset before its average is an estimate: just after a reset a few % projects wildly. */
38export const MIN_SINCE_RESET_H = { week: 6, five: 0.25 }
39
40export const chosenAverage = (s: RangeSettings): Average =>
41  s.mode === 'window' ? { type: 'hours', hours: windowHours(s) } : { type: s.mode }
42
43/** Hours of usage on record for this limit: from its first reading to now. */
44export function recordedHours(readings: readonly RangeReading[], kind: Kind, now: number): number {
45  const first = ofKind(readings, kind)[0]
46  return first ? Math.max(0, (now - first[0]) / HOUR) : 0
47}
48
49/** Parses `2d`, `6h`, `1w`, `3 days`; null when it is none of those. */
50export function parseWindow(text: string): Pick<RangeSettings, 'n' | 'unit'> | null {
51  const m = /^\s*(\d{1,3})\s*(h|hours?|d|days?|w|weeks?)\s*$/i.exec(text)
52  if (!m) return null
53  const n = Number(m[1] ?? 0)
54  const unit = (m[2] ?? '').charAt(0).toLowerCase() as RangeSettings['unit']
55  if (n < 1 || n * UNIT_HOURS[unit] > 168) return null
56  return { n, unit }
57}
58
59const sameWindow = (a: RangeReading, b: RangeReading) => Math.abs(a[3] - b[3]) < 5 * MIN
60const sameWindowAs = (r: RangeReading, resetsAt: number) => Math.abs(r[3] - resetsAt) < 5 * MIN
61
62export function ofKind(readings: readonly RangeReading[], kind: Kind): RangeReading[] {
63  return readings.filter(r => r[1] === KIND[kind]).sort((a, b) => a[0] - b[0])
64}
65
66/** Merges reading lists, dropping duplicates and anything older than KEEP. */
67export function merge(lists: ReadonlyArray<readonly RangeReading[]>, now: number): RangeReading[] {
68  const seen = new Set<string>()
69  const out: RangeReading[] = []
70  for (const list of lists) {
71    for (const r of list) {
72      const id = `${r[0]}:${r[1]}:${r[2]}`
73      if (r[0] < now - KEEP || seen.has(id)) continue
74      seen.add(id)
75      out.push(r)
76    }
77  }
78  return out.sort((a, b) => a[0] - b[0])
79}
80
81/**
82 * A stretch of time some session on this computer was getting fresh figures:
83 * its turns were getting responses from Claude, each carrying the account's
84 * current usage. An open but idle session gets none, so it isn't watching.
85 * [from, to] in ms.
86 */
87export type Watch = [number, number]
88
89/**
90 * Two fresh figures further apart than this leave a hole: nothing here was
91 * watching in between. Long enough to span a pause while you read or type,
92 * short enough that an idle hour counts as away.
93 */
94export const WATCH_GAP = 15 * MIN
95
96/** Whether [a, b] lies wholly inside the watched spans. */
97export function isWatched(seen: readonly Watch[], a: number, b: number): boolean {
98  let t = a
99  for (const [s, e] of [...seen].sort((x, y) => x[0] - y[0])) {
100    if (s > t + WATCH_GAP) break
101    t = Math.max(t, e)
102    if (t >= b - WATCH_GAP) return true
103  }
104  return t >= b - WATCH_GAP
105}
106
107/** Adds a heartbeat at `now`: extends the last span when it is recent, else starts one. */
108export function heartbeat(seen: readonly Watch[], now: number): Watch[] {
109  const last = seen[seen.length - 1]
110  const kept = seen.filter(w => w[1] >= now - KEEP)
111  if (last && now - last[1] <= WATCH_GAP) return [...kept.slice(0, -1), [last[0], now]]
112  return [...kept, [now, now]]
113}
114
115export type Increment = {
116  start: number
117  end: number
118  amount: number
119  /** Set when the rise came over a gap this computer didn't watch: the gap's start and where the rise was placed. Drawn as the gap's block. */
120  hole?: [number, number]
121  /** For a rise seen while watching: when the reading before it was taken, so the rise came somewhere after. */
122  from?: number
123}
124
125/**
126 * Usage gained between readings. Within one window only rises past the highest
127 * reading so far count, so rounding wobble isn't counted twice; across a reset,
128 * the new window's whole reading counts.
129 *
130 * Where it goes in time: while this computer was watching, a rise sits in the
131 * minutes before the reading that saw it. A rise over a gap the computer
132 * wasn't watching came from elsewhere (another computer, claude.ai): it goes
133 * into the account's current 5-hour window when that opened inside the gap,
134 * since a 5-hour window opens with the first message of a stretch of work;
135 * with no such clue it is spread evenly over the gap.
136 */
137export function increments(readings: readonly RangeReading[], kind: Kind, seen: readonly Watch[] = []): Increment[] {
138  const rs = ofKind(readings, kind)
139  const fives = ofKind(readings, 'five')
140  const out: Increment[] = []
141  let high = rs[0]?.[2] ?? 0
142  let reached = rs[0]?.[0] ?? 0
143  for (let i = 1; i < rs.length; i++) {
144    const a = rs[i - 1]!, b = rs[i]!
145    let amount: number
146    let start: number
147    let hole: [number, number] | undefined
148    if (b[0] - a[0] <= SPREAD || isWatched(seen, a[0], b[0])) {
149      start = b[0] - Math.min(b[0] - a[0], SPREAD)
150    } else {
151      let five: RangeReading | undefined
152      for (const f of fives) if (f[0] <= b[0] + MIN) five = f
153      const fiveStart = five && five[3] > b[0] ? five[3] - SPAN.five : undefined
154      start = fiveStart !== undefined && fiveStart > a[0] ? fiveStart : a[0]
155      hole = [a[0], start > a[0] ? start : b[0]]
156    }
157    // the rise came after the figure first reached its last point: readings repeat while it stays put
158    let from = reached
159    if (sameWindow(a, b)) {
160      amount = b[2] - high
161      high = Math.max(high, b[2])
162    } else {
163      amount = b[2]
164      high = b[2]
165      start = Math.max(start, b[3] - SPAN[kind])
166      from = Math.max(a[0], b[3] - SPAN[kind])
167    }
168    if (amount > 0) {
169      out.push({ start: Math.min(start, b[0] - 1), end: b[0], amount, ...(hole ? { hole } : { from }) })
170      reached = b[0]
171    }
172  }
173  return out
174}
175
176/**
177 * Rises placed where the work behind them happened. A limit is read in whole
178 * points, so on its own a rise only says which reading saw it: every bar of
179 * one point would be the same height. Each rise seen while watching is shared
180 * among the responses made since the figure reached its previous point, by their weight
181 * (`points`: when, and how much work), so the total stays Anthropic's and the
182 * shape is the work's. A rise with no responses behind it, or over a gap,
183 * stays as it was.
184 */
185export function refine(incs: readonly Increment[], points: ReadonlyArray<readonly [number, number]>): Increment[] {
186  const pts = [...points].sort((a, b) => a[0] - b[0])
187  const out: Increment[] = []
188  let k = 0
189  for (const inc of [...incs].sort((a, b) => a.end - b.end)) {
190    if (inc.hole || inc.from === undefined) { out.push(inc); continue }
191    while (k < pts.length && pts[k]![0] <= inc.from) k++
192    let j = k, total = 0
193    while (j < pts.length && pts[j]![0] <= inc.end) total += pts[j++]![1]
194    if (total <= 0) { out.push(inc); continue }
195    for (let i = k; i < j; i++) {
196      const [t, w] = pts[i]!
197      if (w > 0) out.push({ start: t - MIN, end: t, amount: inc.amount * w / total })
198    }
199  }
200  return out
201}
202
203export function usedBetween(incs: readonly Increment[], from: number, to: number): number {
204  let sum = 0
205  for (const inc of incs) {
206    const overlap = Math.min(inc.end, to) - Math.max(inc.start, from)
207    if (overlap > 0) sum += inc.amount * overlap / (inc.end - inc.start)
208  }
209  return sum
210}
211
212export type Model = {
213  kind: Kind
214  /** % used now, and when the window resets (ms). */
215  pct: number
216  resetsAt: number
217  /** Hours until the reset. */
218  left: number
219  /** Average and limit, in % per hour. */
220  rate: number
221  limit: number
222  /** % projected to be left at the reset; negative means short by that much. */
223  arrive: number
224  over: boolean
225  /** Hours until 100% at this rate (Infinity at rate 0), and how long before the reset that is. */
226  runsOutIn: number
227  early: number
228  /** What the average covers, from when (ms), and for how many hours. */
229  average: Average
230  from: number
231  winH: number
232  /** When recording of this limit began (ms): before it the chart has no bars. */
233  recordedFrom: number
234  /**
235   * Usage since the reset from before recording began, when the average runs
236   * from the reset and the record begins inside this window: % used by the
237   * first reading, and when that was (ms). Drawn as one block up to then.
238   */
239  before: { pct: number; until: number } | null
240  /** Why there is no estimate, when there isn't enough behind the average; then the rate and what follows from it mean nothing. */
241  noData: string | null
242  increments: Increment[]
243}
244
245/**
246 * The projection for one limit at `now`, from the latest reading and the
247 * average `avg`. With too little behind that average (a window longer than
248 * the record, or just after a reset) there is no estimate: `noData` says why.
249 */
250export function project(readings: readonly RangeReading[], kind: Kind, now: number, avg: Average, seen: readonly Watch[] = []): Model | null {
251  const rs = ofKind(readings, kind)
252  const last = rs[rs.length - 1]
253  if (!last) return null
254  let pct = last[2], resetsAt = last[3]
255  if (resetsAt <= now) {
256    if (kind === 'five') return null            // no active 5-hour window
257    while (resetsAt <= now) resetsAt += SPAN.week
258    pct = 0
259  }
260  const incs = increments(readings, kind, seen)
261  const recordedFrom = rs[0]![0]
262  const from = avg.type === 'reset' ? resetsAt - SPAN[kind] : now - avg.hours * HOUR
263  const winH = Math.max((now - from) / HOUR, 1 / 60)
264  // since the reset, Anthropic's own figure is exact; otherwise add up what was recorded
265  const recordedH = (now - recordedFrom) / HOUR
266  const sinceResetH = (now - (resetsAt - SPAN[kind])) / HOUR
267  const noData = avg.type === 'reset'
268    ? sinceResetH < MIN_SINCE_RESET_H[kind] ? `No estimate this soon after ${kind === 'five' ? 'the 5-hour window opened' : 'the reset'}. About ${dur(MIN_SINCE_RESET_H[kind] - sinceResetH)} to go.` : null
269    : avg.hours < MIN_RECORDED_H[kind] ? tooShort(kind) : avg.hours > recordedH ? needsMore(avg, recordedH) : null
270  const rate = noData ? 0 : (avg.type === 'reset' ? pct : usedBetween(incs, from, now)) / winH
271  const left = (resetsAt - now) / HOUR
272  const remaining = 100 - pct
273  const proj = pct + rate * left
274  const runsOutIn = rate > 0 ? remaining / rate : Infinity
275  // the window's first reading, when nothing was recorded before it, holds all usage up to then
276  const first = rs[0]!
277  const before = avg.type === 'reset' && sameWindowAs(first, resetsAt) && first[0] > from ? { pct: first[2], until: first[0] } : null
278  return {
279    kind, pct, resetsAt, left, rate, limit: remaining / left, arrive: 100 - proj, over: proj > 100,
280    runsOutIn, early: Math.max(0, left - runsOutIn), average: avg, from, winH, recordedFrom, before, noData, increments: incs,
281  }
282}
283
284/** Bar size in minutes so the window holds at most 48 bars. */
285export const bucketMinutes = (winMin: number) =>
286  [1, 5, 10, 15, 30, 60, 120, 180, 240, 360, 480, 720, 1440].find(b => winMin / b <= 48) ?? 1440
287
288/**
289 * Usage rate per bar from the average's start to now, in % per `perHours`
290 * hours: about `bucketMin` minutes a bar, the bars filling the span exactly.
291 */
292export function bars(m: Model, now: number, bucketMin: number, perHours: number): number[] {
293  const n = Math.max(1, Math.round(m.winH * 60 / bucketMin)), width = (now - m.from) / n, out: number[] = []
294  // usage seen as it happened; what came in over a gap is drawn as that gap's block instead
295  const seen = m.increments.filter(i => !i.hole)
296  for (let i = 0; i < n; i++) {
297    const s = m.from + i * width
298    out.push(usedBetween(seen, s, s + width) / (width / HOUR) * perHours)
299  }
300  return out
301}
302
303// ---- words ----
304
305export const signed = (x: number) => (Math.round(x) >= 0 ? '+' : '−') + Math.abs(Math.round(x)) + '%'
306export const fx = (v: number, d = 1) => v.toFixed(d)
307
308export function dur(h: number): string {
309  if (!isFinite(h)) return '—'
310  if (h >= 24) return Math.round(h % 24) % 24 === 0 ? `${Math.round(h / 24)}d` : `${Math.floor(h / 24)}d ${Math.round(h % 24)}h`
311  if (h >= 1) {
312    const m = Math.round(h % 1 * 60)
313    // a whole number of hours reads plainly: "8h", not "8h 00m"
314    return m === 0 || m === 60 ? `${Math.round(h)}h` : `${Math.floor(h)}h ${String(m).padStart(2, '0')}m`
315  }
316  return `${Math.max(0, Math.round(h * 60))}m`
317}
318
319/** "Resets in 3.7 days" / "Resets in 89 hours", and for the 5-hour window hours / minutes. */
320export function resetsIn(m: Model, fine: boolean): string {
321  if (m.kind === 'week') return fine ? `Resets in ${Math.round(m.left)} hours` : `Resets in ${fx(m.left / 24)} days`
322  // under an hour, hours read as "0.4 hours": minutes then, whichever unit was picked
323  const minutes = Math.round(m.left * 60)
324  if (fine || m.left < 1) return `Resets in ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`
325  return `Resets in ${fx(m.left)} hours`
326}
327
328/** The name of an average, as its button shows it. */
329export const averageName = (avg: Average) =>
330  avg.type === 'reset' ? 'Since reset' : avg.hours < 1 ? `${Math.round(avg.hours * 60)}m` : windowName(hoursToWindow(avg.hours))
331
332const hoursToWindow = (h: number): Pick<RangeSettings, 'n' | 'unit'> =>
333  h % 24 === 0 ? { n: h / 24, unit: 'd' } : { n: h, unit: 'h' }
334
335/** One line on what the average covers. */
336export function averageNote(m: Model): string {
337  if (m.noData) return m.noData
338  if (m.average.type === 'reset') {
339    const since = m.kind === 'five' ? `the 5-hour window opened` : `the reset`
340    return m.recordedFrom > m.from
341      ? `Average since ${since} ${dur(m.winH)} ago, from Anthropic's figure.`
342      : `Average since ${since} ${dur(m.winH)} ago.`
343  }
344  return `Average over the last ${averageName(m.average)}.`
345}
346
347/** Why an average can't be picked yet, and when it can. */
348export const needsMore = (avg: { type: 'hours'; hours: number }, recordedH: number) =>
349  `${averageName(avg)} needs ${dur(avg.hours)} of recorded usage. ${dur(recordedH)} recorded so far, about ${dur(avg.hours - recordedH)} to go.`
350
351/** For a window shorter than a limit's least data: no estimate, whatever is on record. */
352const tooShort = (kind: Kind) =>
353  `${kind === 'week' ? 'Weekly' : '5-hour'} estimates need at least ${dur(MIN_RECORDED_H[kind])} of data. Set the window to ${dur(MIN_RECORDED_H[kind])} or more.`
354
355export const runsOut = (m: Model) => `Runs out in ${dur(m.runsOutIn)}, ${dur(m.early)} early`
356
357/** What's projected to be left at the reset, or "No data" without an estimate. */
358export const leftText = (m: Model) => (m.noData ? 'No data' : signed(m.arrive))
359export const isShort = (m: Model) => m.over && !m.noData
360
361export const rateText = (m: Model, perHour: number) =>
362  m.kind === 'week' ? `${fx(perHour * 24)}%/day` : `${fx(perHour)}%/h`
363
364// ---- drawing ----
365
366/** The card's colours, as CSS variables a chart's <style> sets from the person's theme (see theme.ts). */
367export const C = Object.fromEntries(
368  (['card', 'veil', 'fg', 'dim', 'off', 'barTop', 'barBottom', 'limit', 'over', 'under', 'bad', 'fable', 'opus', 'sonnet', 'haiku'] as const).map(k => [k, v(k)]),
369) as Record<PaletteKey, string>
370/** IBM Plex for words, Plex Mono for figures that line up; the system's own faces where Plex can't load. */
371export const FONT = "'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif"
372export const MONO = "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace"
373const FONTS = `<style>@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&amp;family=IBM+Plex+Sans:wght@400;500;600&amp;display=swap');</style>`
374
375/**
376 * Every text style in the drawings, by role: size in rem (1rem = 16px), weight,
377 * and colour as a palette key, so it follows the theme. Change one here and
378 * the layout around it follows. A colour that carries state (the green or red
379 * figure, the limit line's label) stays that state's colour.
380 */
381export const TEXT: Record<Role, { rem: number; weight: number; color: PaletteKey; mono?: boolean }> = {
382  /** The card's title, "This week". */
383  title: { rem: 1.2, weight: 500, color: 'fg' },
384  /** The run-out warning under the title. */
385  warning: { rem: 0.85, weight: 400, color: 'over' },
386  /** A figure's caption: "Average", small and mono. */
387  caption: { rem: 0.6875, weight: 500, color: 'dim', mono: true },
388  /** A figure: "19.4%/day". */
389  figure: { rem: 0.9375, weight: 500, color: 'fg', mono: true },
390  /** The axes, the unit line and notes inside the plot. */
391  axis: { rem: 0.7, weight: 400, color: 'dim', mono: true },
392  /** The labels on the average and limit lines. */
393  line: { rem: 0.8125, weight: 500, color: 'fg', mono: true },
394  /** The reset countdown beside the title: "Resets in 6.1 days". */
395  countdown: { rem: 0.75, weight: 400, color: 'dim' },
396  /** The note under a card: "Average since the reset 23h ago, ...". */
397  note: { rem: 0.78, weight: 400, color: 'dim' },
398}
399type Role = 'title' | 'warning' | 'caption' | 'figure' | 'axis' | 'line' | 'countdown' | 'note'
400const px = (role: Role) => TEXT[role].rem * 16
401/** About how wide a text runs, for laying out around it: mono is 0.6em a character. */
402const textWidth = (str: string, role: Role) =>
403  str.length * px(role) * (TEXT[role].mono ? 0.6 : TEXT[role].weight >= 600 ? 0.62 : 0.58)
404export const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
405/**
406 * The width a drawing tells the app it has. The app shows each drawing in a
407 * frame as wide as its slot, up to this; inside, the drawing fills the frame
408 * (`:root{width:100%}`) and is laid out in percentages and pixels, never a
409 * viewBox, so it stretches to any width while its text stays the size set.
410 */
411const FRAME_W = 2000
412/** A drawing of `content`, `height` pixels tall, filling whatever width its frame has. */
413export function drawing(content: string, height: number, pal: Palettes = DEFAULT_PALETTES): string {
414  return `<svg xmlns="http://www.w3.org/2000/svg" width="${FRAME_W}" height="${Math.ceil(height)}">${FONTS}<style>:root{width:100%;height:100%;overflow:hidden}</style>${paletteStyle(pal)}${content}</svg>`
415}
416/** A drawing's height: what its frame is given. */
417export const drawingHeight = (svg: string) => Number(/^<svg [^>]* height="(\d+)">/.exec(svg)![1])
418/** A share of the width, as a length. */
419export const pct = (f: number) => `${(f * 100).toFixed(3)}%`
420/** Content placed with its origin at a share of the width (1: the right edge); it draws at negative x to sit left of it. */
421export const at = (f: number, content: string) => `<svg x="${pct(f)}" y="0" width="1" height="1" overflow="visible">${content}</svg>`
422
423/** A text in its role's style; `fill` and `weight` only where state sets them. */
424const text = (x: number | string, y: number, s: string, o: { role?: Role; fill?: string; anchor?: string; weight?: number } = {}) => {
425  const t = TEXT[o.role ?? 'axis']
426  return `<text x="${typeof x === 'number' ? x.toFixed(1) : x}" y="${y.toFixed(1)}" text-anchor="${o.anchor ?? 'start'}" style="fill:${o.fill ?? C[t.color]}" font-family="${t.mono ? MONO : FONT}" font-size="${t.rem}rem" font-weight="${o.weight ?? t.weight}">${esc(s)}</text>`
427}
428
429/**
430 * The card's head: the title, the run-out line when the projection goes over,
431 * and the three figures. The reset countdown beside it is a Button, not drawn here.
432 */
433/** Where the head's lines sit: the title, the warning, the captions and the figures, and its height. */
434function headLayout(m: Model, width: number, pace?: Pace) {
435  const isOver = m.over && !m.noData
436  const title = px('title')
437  const warning = isOver ? title + px('warning') * 1.6 : title
438  const stats: Array<{ label: string; value: string; fill?: string; info: boolean; w: number; x: number; row: number }> = []
439  const add = (label: string, value: string, fill: string | undefined, info = false) =>
440    stats.push({ label, value, ...(fill ? { fill } : {}), info, w: Math.max(textWidth(label, 'caption') + (info ? px('caption') + 6 : 0), textWidth(value, 'figure')), x: 0, row: 0 })
441  // the left-at-reset figure is coloured by its state; the others take the figure role's colour
442  add(m.kind === 'week' ? 'Left at week reset' : 'Left at 5h reset', m.noData ? 'No data' : signed(m.arrive), m.noData ? C.dim : m.over ? C.bad : C.under)
443  add('Average', m.noData ? 'No data' : rateText(m, m.rate), m.noData ? C.dim : undefined)
444  add('Limit', rateText(m, m.limit), undefined)
445  if (pace) add(PACE_LABEL, pace.ready ? `${fx(pace.rate * 24)}%/day` : 'No data', pace.ready ? undefined : C.dim, true)
446  // the figures flow left to right, onto another row where the card is too narrow for the next
447  const gap = px('figure') * 1.5
448  let x = 0, row = 0
449  for (const st of stats) {
450    if (x > 0 && x + st.w > width) { x = 0; row++ }
451    st.x = x; st.row = row
452    x += st.w + gap
453  }
454  const step = px('caption') * 1.6 + px('figure') * 1.5 + 6
455  const caption = (r: number) => warning + px('caption') * 2 + r * step
456  const figure = (r: number) => caption(r) + px('figure') * 1.5
457  return { isOver, title, warning, stats, caption, figure, height: Math.ceil(figure(row) + px('figure') * 0.4) }
458}
459
460/** Where the head's info circle sits, in drawn pixels: what the drawing around it places the tooltip by. */
461export type InfoSpot = { cx: number; cy: number; r: number }
462
463/**
464 * The head: title, warning, the figures, and the countdown. Given a pace, a
465 * fourth figure "1hr pace" whose caption ends in an info circle; the circle
466 * itself is drawn by `withInfo`, which can open its tooltip over the chart.
467 */
468export function headerDraw(m: Model, title: string, width: number, pal: Palettes = DEFAULT_PALETTES, countdown = '', pace?: Pace): { svg: string; height: number; info?: InfoSpot } {
469  const L = headLayout(m, width, pace)
470  let s = text(0, L.title, title, { role: 'title' })
471  if (L.isOver) s += text(0, L.warning, runsOut(m), { role: 'warning' })
472  // the countdown sits at the right on the title's line
473  if (countdown) s += text('100%', L.title, countdown, { role: 'countdown', anchor: 'end' })
474  let spot: InfoSpot | undefined
475  for (const st of L.stats) {
476    s += text(st.x, L.caption(st.row), st.label, { role: 'caption' })
477    s += text(st.x, L.figure(st.row), st.value, { role: 'figure', ...(st.fill ? { fill: st.fill } : {}) })
478    if (st.info) {
479      // the circle after the caption
480      const r = px('caption') * 0.5
481      spot = { cx: st.x + textWidth(st.label, 'caption') + 6 + r, cy: L.caption(st.row) - px('caption') * 0.35, r }
482    }
483  }
484  return { svg: drawing(s, L.height, pal), height: L.height, ...(spot ? { info: spot } : {}) }
485}
486export const headerSvg = (m: Model, title: string, width: number, pal: Palettes = DEFAULT_PALETTES, countdown = '') =>
487  headerDraw(m, title, width, pal, countdown).svg
488
489/**
490 * Moves labels apart so none overlap: each sits as near its own line as it can.
491 * Labels that would collide are grouped and centred on their lines' average,
492 * then the whole stack is kept between `lo` and `hi`. `ys` are the lines'
493 * heights; the result is in the same order.
494 */
495export function spreadLabels(ys: readonly number[], gap: number, lo: number, hi: number): number[] {
496  const order = ys.map((y, i) => ({ y, i })).sort((a, b) => a.y - b.y)
497  // clusters of labels that share space, each centred on its members' lines
498  let groups = order.map(o => ({ members: [o], top: o.y }))
499  const place = (g: { members: { y: number }[] }) =>
500    g.members.reduce((a, m) => a + m.y, 0) / g.members.length - (g.members.length - 1) * gap / 2
501  for (let changed = true; changed;) {
502    changed = false
503    for (let k = 1; k < groups.length; k++) {
504      const a = groups[k - 1]!, b = groups[k]!
505      if (a.top + a.members.length * gap > b.top) {
506        const merged = { members: [...a.members, ...b.members], top: 0 }
507        merged.top = place(merged)
508        groups.splice(k - 1, 2, merged)
509        changed = true
510        break
511      }
512    }
513  }
514  const out = new Array<number>(ys.length)
515  const flat: { i: number; y: number }[] = []
516  for (const g of groups) g.members.forEach((m, j) => flat.push({ i: (m as { i: number }).i, y: g.top + j * gap }))
517  // keep the stack inside the plot, pushing neighbours along
518  for (let k = 0; k < flat.length; k++) flat[k]!.y = Math.max(flat[k]!.y, lo + k * gap)
519  for (let k = flat.length - 1; k >= 0; k--) flat[k]!.y = Math.min(flat[k]!.y, hi - (flat.length - 1 - k) * gap)
520  for (const f of flat) out[f.i] = f.y
521  return out
522}
523
524export type ChartSpec = {
525  width: number
526  height: number
527  bars: number[]
528  /** The average, or null with no estimate: then no dotted line. */
529  avg: number | null
530  limit: number
531  xTicks: Array<{ f: number; label: string }>
532  marks: Array<{ f: number; label: string }>
533  /** The share of the chart, from the left, before recording began with nothing known of it: lightly tinted. */
534  unrecorded: number
535  /**
536   * Stretches whose usage is known only as a total (before recording began,
537   * or while this computer wasn't watching): each one low block at the rate
538   * that total comes to, labelled with it. Shares of the chart, the rate per
539   * bar unit, and a long and a short label.
540   */
541  blocks: Array<{ f0: number; f1: number; rate: number; label: string; short: string }>
542  unitLabel: string
543  /** The theme's palettes; absent, the default dark theme's. */
544  palettes?: Palettes
545}
546
547/**
548 * Bars from the average's start to Now in a framed plot, scaled to the tallest
549 * bar, with the average (dotted) and limit (solid) lines across all of them.
550 * Recorded slices with no usage show as a dashed baseline. A limit above the
551 * tallest bar sits on the top edge, its label marked ▲.
552 */
553export function chartSvg(c: ChartSpec): string {
554  const est = c.width, Hh = c.height
555  const ax = px('axis')
556  const padT = Math.ceil(ax * 1.5 + 12), padB = Math.ceil(ax * 1.5 + 12)
557  const peak = Math.max(...c.bars, c.avg ?? 0, ...c.blocks.map(b => b.rate), 0.0001) * 1.04
558  const top = peak >= 10 ? Math.ceil(peak / 2) * 2 : peak
559  const Y = (v: number) => Hh - padB - Math.min(v, top) / top * (Hh - padT - padB)
560  const base = Y(0)
561  const num = (v: number) => (top >= 10 ? String(Math.round(v)) : fx(v))
562  const veil = (x: number, y: number, w: number, h: number, r = 5) =>
563    `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" rx="${r}" style="fill:${C.veil}" fill-opacity="0.6"/>`
564  let s = `<defs>
565    <linearGradient id="bar" x1="0" y1="0" x2="0" y2="1"><stop offset="0" style="stop-color:${C.barTop}"/><stop offset="1" style="stop-color:${C.barBottom}"/></linearGradient>
566    <linearGradient id="plot" x1="0" y1="1" x2="0" y2="0"><stop offset="0" style="stop-color:${C.dim}" stop-opacity="0"/><stop offset="1" style="stop-color:${C.dim}" stop-opacity="0.07"/></linearGradient>
567  </defs>`
568  s += text(2, padT - ax * 0.6, c.unitLabel)
569  s += `<rect x="0" y="${padT}" width="100%" height="${base - padT}" rx="6" fill="url(#plot)"/>`
570  for (const g of [top / 4, top / 2, top * 3 / 4]) {
571    s += `<line x1="0" x2="100%" y1="${Y(g)}" y2="${Y(g)}" style="stroke:${C.dim}" stroke-opacity="0.18"/>`
572  }
573  for (const tk of c.xTicks) {
574    if (tk.f > 0 && tk.f < 1) s += `<line x1="${pct(tk.f)}" x2="${pct(tk.f)}" y1="${padT}" y2="${base}" style="stroke:${C.dim}" stroke-opacity="0.18" stroke-dasharray="4 4"/>`
575    const isNow = tk.f >= 1
576    s += text(pct(tk.f), Hh - ax * 0.6, tk.label, { anchor: isNow ? 'end' : tk.f <= 0 ? 'start' : 'middle', ...(isNow ? { fill: C.fg, weight: 600 } : {}) })
577  }
578  if (c.unrecorded > 0) {
579    s += `<rect x="0" y="${padT}" width="${pct(c.unrecorded)}" height="${base - padT}" style="fill:${C.dim}" fill-opacity="0.06"/>`
580    if (c.unrecorded * est > textWidth('not recorded', 'axis') + 16) s += text(pct(c.unrecorded / 2), base - ax * 0.6, 'not recorded', { anchor: 'middle' })
581  }
582  let late = ''   // labels drawn last, over the bars and lines
583  // each stretch known only as a total: one low block at the rate it comes to, its line on top, and what's known as its label
584  for (const b of c.blocks) {
585    const y = Y(b.rate), room = (b.f1 - b.f0) * est
586    s += `<rect x="${pct(b.f0)}" y="${y.toFixed(1)}" width="${pct(b.f1 - b.f0)}" height="${(base - y).toFixed(1)}" style="fill:${C.barBottom}" fill-opacity="0.22"/>`
587    s += `<line x1="${pct(b.f0)}" x2="${pct(b.f1)}" y1="${y.toFixed(1)}" y2="${y.toFixed(1)}" style="stroke:${C.barBottom}" stroke-width="1.5"/>`
588    // above the line, or just inside it when the line is near the top
589    const ly = y - 6 - ax < padT ? y + ax + 4 : y - 6
590    const fits = (l: string) => textWidth(l, 'axis') + 8 <= room
591    const label = fits(b.label) ? b.label : fits(b.short) ? b.short : null
592    // on a backing, so a line crossing it doesn't run through the words
593    const tag = (x: number, l: string, anchor: 'middle' | 'start') => {
594      const w = textWidth(l, 'axis') + 10, bx = anchor === 'middle' ? x - w / 2 : x - 5
595      return `<rect x="${bx.toFixed(1)}" y="${(ly - ax).toFixed(1)}" width="${w.toFixed(1)}" height="${(ax + 5).toFixed(1)}" rx="4" style="fill:${C.veil}" fill-opacity="0.6"/>` + text(x, ly, l, { anchor, fill: C.fg })
596    }
597    if (label) late += at((b.f0 + b.f1) / 2, tag(0, label, 'middle'))
598    // too narrow to hold even the short one: it starts at the block and runs right, where there's room
599    else if (textWidth(b.short, 'axis') + 8 <= (1 - b.f0) * est) late += at(b.f0, tag(9, b.short, 'start'))
600  }
601  for (const mk of c.marks) {
602    s += `<line x1="${pct(mk.f)}" x2="${pct(mk.f)}" y1="${padT}" y2="${base}" style="stroke:${C.dim}" stroke-dasharray="3 3"/>`
603    const w = textWidth(`↺ ${mk.label}`, 'axis') + 12, h = ax + 6
604    // near the right edge the label goes on the left of its line, clear of the line labels
605    const rx = mk.f * est + 3 + w > est - 190 ? -3 - w : 3
606    late += at(mk.f, veil(rx, padT + 4, w, h) + text(rx + 6, padT + 4 + h / 2 + ax * 0.35, `↺ ${mk.label}`, { fill: C.fg }))
607  }
608  // the scale, inside the plot at its left, on a backing so bars don't run through it
609  for (const g of [top / 2, top]) {
610    const y = Y(g) + ax + 4, label = num(g)
611    late += veil(4, y - ax - 1, textWidth(label, 'axis') + 8, ax + 6, 4) + text(8, y, label)
612  }
613  const n = Math.max(1, c.bars.length)
614  // a recorded slice with no usage shows a dashed baseline; one inside a block or before recording doesn't
615  const covered = (f: number) => f < c.unrecorded || c.blocks.some(b => f >= b.f0 && f <= b.f1)
616  const bar = (i: number, v: number) => {
617    const y = Y(v)
618    return `<rect x="${pct((i + 0.25) / n)}" y="${y.toFixed(1)}" width="${pct(0.5 / n)}" height="${Math.max(0, base - y).toFixed(1)}" rx="1.5" fill="url(#bar)"/>`
619  }
620  c.bars.forEach((v, i) => {
621    if (v > 0) s += bar(i, v)
622    else if (!covered((i + 0.5) / n)) {
623      s += `<line x1="${pct(i / n)}" x2="${pct((i + 1) / n)}" y1="${base - 1}" y2="${base - 1}" style="stroke:${C.barBottom}" stroke-width="1.5" stroke-dasharray="5 4"/>`
624    }
625  })
626  const limitOff = c.limit > top
627  const yl = limitOff ? Y(top) : Y(c.limit)
628  const ya = c.avg !== null ? Y(c.avg) : null
629  s += `<line x1="0" x2="100%" y1="${yl}" y2="${yl}" style="stroke:${C.limit}" stroke-width="2.5"/>`
630  if (ya !== null) s += `<line x1="0" x2="100%" y1="${ya}" y2="${ya}" style="stroke:${C.fg}" stroke-width="3" stroke-dasharray="3 5" stroke-linecap="round"/>`
631  // the labels sit on their lines at the right edge, each on a backing so bars and lines behind don't show through
632  const label = (y: number, s2: string, fill?: string) => {
633    const w = textWidth(s2, 'line') + 16, h = px('line') + 8
634    return at(1, veil(-4 - w, y - h / 2, w, h, 6) + text(-12, y + px('line') * 0.35, s2, { role: 'line', anchor: 'end', ...(fill ? { fill } : {}) }))
635  }
636  // keep the labels clear of each other and inside the plot
637  const [lY, aY] = spreadLabels(ya === null ? [yl] : [yl, ya], px('line') + 12, padT + px('line') / 2 + 6, base - px('line') / 2 - 6)
638  s += late
639  s += label(lY!, `limit ${fx(c.limit)}${limitOff ? ' ▲' : ''}`, C.limit)
640  if (aY !== undefined && c.avg !== null) s += label(aY, `average ${fx(c.avg)}`)
641  return drawing(s, Hh, c.palettes ?? DEFAULT_PALETTES)
642}
643/** The weekly chart for model `m` at `now`. */
644export function weekChart(m: Model, now: number, width: number, pal: Palettes = DEFAULT_PALETTES, height = 400): string {
645  return timeChart(m, now, width, height, pal)
646}
647
648/** The 5-hour chart: the window since it opened. */
649export function fiveChart(m: Model, now: number, width: number, pal: Palettes = DEFAULT_PALETTES, height = 300): string {
650  return timeChart(m, now, width, height, pal)
651}
652
653/**
654 * Recorded usage a chart needs before its bars mean anything: the weekly
655 * figure moves in steps of about a point, so it needs hours to show a shape;
656 * the 5-hour one, half an hour.
657 */
658export const CHART_NEEDS_H = { week: 6, five: 0.5 }
659
660/** Hours until the chart has enough recorded behind it; 0 once it has. */
661export const chartWait = (m: Model, now: number) => Math.max(0, CHART_NEEDS_H[m.kind] - (now - m.recordedFrom) / HOUR)
662
663
664/**
665 * What the empty state says in the chart's place, or null when there is a
666 * chart to draw: an average that can't be worked out yet (a window shorter
667 * than the least, or longer than the record; too soon after the reset), or a
668 * record too short for the bars to show a shape.
669 */
670export function emptyChartText(m: Model, now: number): [string, string] | null {
671  const name = m.kind === 'week' ? 'Weekly' : '5-hour'
672  const recordedH = (now - m.recordedFrom) / HOUR
673  const avg = m.average
674  if (avg.type === 'hours' && avg.hours < MIN_RECORDED_H[m.kind]) {
675    return [`${name} averages need at least ${dur(MIN_RECORDED_H[m.kind])}`, `Pick a window of ${dur(MIN_RECORDED_H[m.kind])} or more, or Since reset.`]
676  }
677  if (avg.type === 'hours' && avg.hours > recordedH) {
678    return [`Needs ${dur(avg.hours)} of recorded usage`, `About ${dur(avg.hours - recordedH)} to go. ${dur(recordedH)} recorded so far.`]
679  }
680  if (m.noData) return ['No estimate yet', m.noData]
681  const wait = chartWait(m, now)
682  if (wait > 0) return [`Chart in about ${dur(wait)}`, `It shows once ${dur(CHART_NEEDS_H[m.kind])} of usage is recorded. The figures above already count.`]
683  return null
684}
685
686/** In the chart's place until it has enough behind it: what it waits for, and how long that takes. */
687function emptyLayout(m: Model, now: number, width: number) {
688  const [head, sub] = emptyChartText(m, now) ?? ['', '']
689  const lines = wrap(sub, Math.max(160, width - 32), 'note')
690  const top = 24 + px('caption')
691  return { head, lines, top, height: Math.ceil(top + 10 + linesHeight(lines.length, 'note') + 18) }
692}
693
694
695export function emptyChartSvg(m: Model, now: number, width: number, pal: Palettes = DEFAULT_PALETTES): string {
696  const L = emptyLayout(m, now, width), H = L.height
697  // a dashed box edge to edge: its right side drawn from the right edge in
698  const dash = `style="stroke:${C.dim}" stroke-opacity="0.35" stroke-dasharray="4 5"`
699  const s = `<line x1="1" x2="100%" y1="1" y2="1" ${dash}/><line x1="1" x2="100%" y1="${H - 1}" y2="${H - 1}" ${dash}/>`
700    + `<line x1="1" x2="1" y1="1" y2="${H - 1}" ${dash}/>` + at(1, `<line x1="-1" x2="-1" y1="1" y2="${H - 1}" ${dash}/>`)
701    + text('50%', L.top, L.head, { role: 'caption', anchor: 'middle', fill: C.fg })
702    + L.lines.map((l, i) => text('50%', L.top + 10 + baseline(i, 'note'), l, { role: 'note', anchor: 'middle' })).join('')
703  return drawing(s, H, pal)
704}
705/** Text broken into lines that fit `width` in a role's style; paragraphs split on newlines. */
706function wrap(note: string, width: number, role: Role): string[] {
707  const lines: string[] = []
708  for (const para of note.split('\n')) {
709    let first = true
710    for (const word of para.split(' ')) {
711      const last = lines[lines.length - 1]
712      if (!first && last !== undefined && textWidth(`${last} ${word}`, role) <= width) lines[lines.length - 1] = `${last} ${word}`
713      else lines.push(word)
714      first = false
715    }
716  }
717  return lines
718}
719/** A wrapped line's baseline, and the height `n` lines take. */
720const baseline = (i: number, role: Role) => (i + 1) * px(role) * 1.4 - px(role) * 0.3
721const linesHeight = (n: number, role: Role) => Math.ceil(n * px(role) * 1.4 + px(role) * 0.3)
722
723/** Text in a role's style, wrapped to `width`. `fill` where state sets the colour. */
724export function noteSvg(note: string, width: number, pal: Palettes = DEFAULT_PALETTES, role: Role = 'note', fill?: string): string {
725  const lines = wrap(note, width, role), H = linesHeight(lines.length, role)
726  return drawing(lines.map((l, i) => text(0, baseline(i, role), l, { role, ...(fill ? { fill } : {}) })).join(''), H, pal)
727}
728
729// ---- recent pace ----
730
731/** The stretch the recent pace looks back over. */
732export const PACE_HOURS = 1
733/** Weekly points recorded alongside the 5-hour figure before the two can be related. */
734export const PACE_MIN_POINTS = 3
735
736export type Pace =
737  | { ready: false; why: string }
738  | {
739    ready: true
740    /** 5-hour points per weekly point, over `basisH` hours of record. */
741    ratio: number
742    basisH: number
743    /** 5-hour points used in the last PACE_HOURS. */
744    recent: number
745    /** The weekly pace that is, in % per hour. */
746    rate: number
747    runsOutIn: number
748    early: number
749    over: boolean
750    arrive: number
751  }
752
753/**
754 * The weekly limit at the last hour's pace. The weekly figure moves in whole
755 * points, too coarse to read an hour from; the 5-hour figure moves several
756 * times faster. Over the record, the 5-hour points that went with each weekly
757 * point give the exchange rate; the last hour's 5-hour points, so exchanged,
758 * give the weekly pace.
759 */
760export function recentPace(readings: readonly RangeReading[], week: Model, now: number, seen: readonly Watch[] = []): Pace {
761  const fives = ofKind(readings, 'five'), weeks = ofKind(readings, 'week')
762  if (!fives.length || !weeks.length) return { ready: false, why: 'Your weekly and 5-hour usage arrive with Claude’s next response.' }
763  const fiveFor = (now - fives[0]![0]) / HOUR
764  if (fiveFor < PACE_HOURS) return { ready: false, why: `It needs ${dur(PACE_HOURS)} of recording. About ${dur(PACE_HOURS - fiveFor)} to go.` }
765  const start = Math.max(fives[0]![0], weeks[0]![0])
766  const fiveIncs = increments(readings, 'five', seen)
767  const weekPoints = usedBetween(week.increments, start, now)
768  if (weekPoints < PACE_MIN_POINTS) {
769    return { ready: false, why: `It needs your weekly usage to go up ${PACE_MIN_POINTS}% while recording. Up ${Math.floor(weekPoints)}% so far.` }
770  }
771  // the last hour has to be on record here, not a gap whose rise was placed in it afterwards
772  if (!isWatched(seen, now - PACE_HOURS * HOUR, now)) return { ready: false, why: `It needs the last ${dur(PACE_HOURS)} recorded without a break.` }
773  const ratio = usedBetween(fiveIncs, start, now) / weekPoints
774  const recent = usedBetween(fiveIncs, now - PACE_HOURS * HOUR, now)
775  const rate = ratio > 0 ? recent / ratio / PACE_HOURS : 0
776  const proj = week.pct + rate * week.left
777  const runsOutIn = rate > 0 ? (100 - week.pct) / rate : Infinity
778  return {
779    ready: true, ratio, basisH: (now - start) / HOUR, recent, rate, runsOutIn,
780    early: Math.max(0, week.left - runsOutIn), over: proj > 100, arrive: 100 - proj,
781  }
782}
783
784/** The caption of the pace figure. */
785export const PACE_LABEL = '1hr pace'
786
787/**
788 * The head and the chart (or its empty state) as one drawing, so the head's
789 * info circle can open its tooltip over the chart. A click or the pointer on
790 * the circle opens it, a click elsewhere closes it: no script, the circle
791 * takes focus on a click and CSS shows the box while it has focus or the
792 * pointer. Drawn interactive for that.
793 */
794export function withInfo(head: { svg: string; height: number; info?: InfoSpot }, chart: string, tip: string, width: number, pal: Palettes = DEFAULT_PALETTES, label = 'How is the 1hr pace worked out?'): string {
795  const inner = (svg: string) => /^<svg [^>]*>([\s\S]*)<\/svg>$/.exec(svg)![1]!
796  const hH = drawingHeight(head.svg), cH = drawingHeight(chart), gap = 10
797  const nest = (svg: string, y: number, h: number) => `<svg x="0" y="${y}" width="100%" height="${h}" overflow="visible">${inner(svg)}</svg>`
798  let H = hH + gap + cH, over = ''
799  if (head.info) {
800    const { cx, cy, r } = head.info
801    const boxW = Math.min(width, 440), pad = 12
802    const lines = wrap(tip, boxW - pad * 2, 'note')
803    const boxH = linesHeight(lines.length, 'note') + pad * 2 - px('note') * 0.3
804    // the box opens just under the circle and ends at it, so it stays inside the card whatever its real width
805    const bx = Math.max(0, cx + r + 4 - boxW), by = cy + r + 8
806    H = Math.max(H, Math.ceil(by + boxH + 2))
807    const info = `<g class="info" tabindex="0" role="button" aria-label="${esc(label)}">`
808      + `<circle cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${(r + 4).toFixed(1)}" fill="transparent"/>`
809      + `<circle cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${r.toFixed(1)}" fill="transparent" style="stroke:${C.dim}" stroke-width="1.2"/>`
810      + `<text x="${cx.toFixed(1)}" y="${(cy + r * 0.55).toFixed(1)}" text-anchor="middle" style="fill:${C.dim}" font-family="${FONT}" font-size="${(r * 1.5).toFixed(1)}" font-weight="600">i</text></g>`
811    const box = `<g class="tip"><rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${boxW}" height="${boxH.toFixed(1)}" rx="8" style="fill:${C.veil};stroke:${C.dim}" fill-opacity="0.94" stroke-opacity="0.4"/>`
812      + lines.map((l, i) => text(bx + pad, by + pad + baseline(i, 'note') - px('note') * 0.15, l, { role: 'note', fill: C.fg })).join('') + `</g>`
813    const css = `<style>.info{cursor:pointer;outline:none}.info:hover circle+circle,.info:focus circle+circle{stroke:var(--fg)}.info:hover text,.info:focus text{fill:var(--fg)}`
814      + `.tip{display:none}.info:hover~.tip,.info:focus~.tip{display:inline}</style>`
815    over = css + info + box
816  }
817  return drawing(nest(head.svg, 0, hH) + nest(chart, hH + gap, cH) + over, H, pal)
818}
819/** What the pace comes to: the tooltip's first line. */
820export function paceText(p: Pace): string {
821  if (!p.ready) return `No pace yet. ${p.why}`
822  return p.over
823    ? `At this pace, the weekly limit runs out ${dur(p.early)} early.`
824    : `At this pace, the weekly limit lasts to the reset with ${Math.max(0, Math.round(p.arrive))}% left.`
825}
826
827/** How the pace is worked out, with this account's own numbers once there are some. */
828export function paceExplain(p: Pace): string {
829  if (!p.ready) return 'While recording, the plugin learns how much of your 5-hour limit goes with each 1% of your weekly limit.'
830  return [
831    `In the last hour you used ${fx(p.recent)}% of your 5-hour limit. That's about ${fx(p.recent / p.ratio, 2)}% of your weekly limit, or ${fx(p.rate * 24)}% a day.`,
832    'These numbers are estimates that get steadier over time.',
833  ].join('\n')
834}
835
836// ---- fitting the pane ----
837
838/**
839 * A cell of the desktop's code font in CSS pixels, as measured in the Code
840 * tab: the pane reports its width in these cells, the drawings are in pixels.
841 */
842export const CELL_PX = { w: 9.5 }
843
844export type Fit = { width: number; weekHeight: number; fiveHeight: number }
845
846/** Chart heights: a share of the card's width, within bounds. */
847const CHART_H = { week: { ratio: 0.55, min: 260, max: 440 }, five: { ratio: 0.42, min: 200, max: 340 } }
848
849/**
850 * Sizes the drawings for a pane `columns` wide: the card's inner width, for
851 * what wraps, and each chart's height, which follows that width.
852 */
853export function fit(columns: number): Fit {
854  // the card's inner width: the pane less the card's padding and edge. Drawings fill whatever width they get;
855  // this is for laying out what wraps (figures, notes) and for the charts' heights
856  const width = Math.max(240, Math.floor((columns - 6) * CELL_PX.w))
857  // the pane's reported rows don't follow its real height in the desktop app, so the charts' heights
858  // follow the card's width instead: a wider card, a taller chart, within bounds
859  const tall = (k: 'week' | 'five') => Math.round(Math.min(CHART_H[k].max, Math.max(CHART_H[k].min, width * CHART_H[k].ratio)))
860  return { width, weekHeight: tall('week'), fiveHeight: tall('five') }
861}
862const pctText = (v: number) => (v > 0 && v < 1 ? '<1%' : `${Math.round(v)}%`)
863
864/**
865 * The chart's stretches known only as a total: before recording began (the
866 * first reading's figure, since the reset), and each gap this computer wasn't
867 * watching. A weekly gap's rise could have come anywhere in it, so its block
868 * spans the gap; a 5-hour one belongs to the window it was seen in, so its
869 * block starts no earlier than the window opened.
870 */
871function knownBlocks(m: Model, now: number, perHours: number): ChartSpec['blocks'] {
872  const span = now - m.from, out: ChartSpec['blocks'] = []
873  const add = (a: number, b: number, amount: number, what: (p: string, h: string) => [string, string]) => {
874    const v0 = Math.max(a, m.from), v1 = Math.min(b, now)
875    if (v1 <= v0 || amount <= 0) return
876    const shown = amount * (v1 - v0) / (b - a), hours = (v1 - v0) / HOUR
877    const [label, short] = what(pctText(shown), dur(hours))
878    out.push({ f0: (v0 - m.from) / span, f1: (v1 - m.from) / span, rate: shown / hours * perHours, label, short })
879  }
880  if (m.before) add(m.from, m.before.until, m.before.pct, p => [`${p} before tracking`, p])
881  for (const inc of m.increments) {
882    if (!inc.hole) continue
883    add(m.kind === 'week' ? inc.hole[0] : inc.start, inc.end, inc.amount, (p, h) => [`${p} used while away, ${h}`, `${p} · ${h}`])
884  }
885  return out
886}
887
888function timeChart(m: Model, now: number, width: number, height: number, palettes: Palettes): string {
889  const isWeek = m.kind === 'week'
890  const values = bars(m, now, bucketMinutes(m.winH * 60), isWeek ? 24 : 1)
891  const barMin = m.winH * 60 / values.length
892  const ago = (h: number) => h >= 48 ? `−${Math.round(h / 24)}d` : h >= 2 ? `−${Math.round(h)}h` : `−${Math.round(h * 60)}m`
893  // the axis keeps one unit across, picked by the window's length
894  const tick = (h: number) => m.winH >= 48 ? `−${fx(h / 24, h / 24 < 10 && h % 24 ? 1 : 0)}d`
895    : m.winH >= 2 ? `−${Math.round(h * 10) % 10 && h < 5 ? fx(h) : Math.round(h)}h`
896    : `−${Math.round(h * 60)}m`
897  const perHours = isWeek ? 24 : 1
898  const marks: ChartSpec['marks'] = []
899  for (let k = 0; isWeek && k < 4; k++) {
900    const at = m.resetsAt - (k + 1) * SPAN.week
901    if (at > m.from && at < now) marks.push({ f: (at - m.from) / (now - m.from), label: `reset ${ago((now - at) / HOUR)}` })
902  }
903  return chartSvg({
904    width, height, bars: values, avg: m.noData ? null : m.rate * (isWeek ? 24 : 1), limit: m.limit * (isWeek ? 24 : 1),
905    xTicks: [0, 0.25, 0.5, 0.75].map(f => ({ f, label: tick(m.winH * (1 - f)) })).concat({ f: 1, label: 'Now' }),
906    marks,
907    unrecorded: m.before ? 0 : Math.min(1, Math.max(0, (m.recordedFrom - m.from) / (now - m.from))),
908    blocks: knownBlocks(m, now, perHours),
909    palettes,
910    unitLabel: `% per ${isWeek ? 'day' : 'hour'} · bar = ${barMin >= 60 ? dur(barMin / 60) : `${Math.max(1, Math.round(barMin))}m`}`,
911  })
912}
913
hooks/theme.ts 203 lines
1/**
2 * The card's colours, derived from the person's Claude Code theme rather than
3 * fixed: the theme's own tokens (text, inactive, claude, success, ...) set the
4 * palette, so a custom theme, or a mod that ships one, carries over to the charts.
5 *
6 * The charts are SVG images, which can't name theme keys the way a Text can,
7 * so each chart carries the palette as CSS variables: one set for a dark
8 * appearance and one for a light, chosen by `prefers-color-scheme`.
9 */
10import type { Palette, Palettes } from '../types'
11
12/** The theme tokens the palette is made from. */
13export type Tokens = {
14  text: string; inverseText: string; inactive: string; subtle: string; userMessageBackground: string
15  claude: string; success: string; error: string; warning: string; suggestion: string
16}
17const TOKEN_KEYS = ['text', 'inverseText', 'inactive', 'subtle', 'userMessageBackground', 'claude', 'success', 'error', 'warning', 'suggestion'] as const
18/** Tokens a theme gives its own hue: kept in the other appearance too. */
19const ACCENTS = ['claude', 'success', 'error', 'warning', 'suggestion'] as const
20
21/** Claude Code's built-in presets, as far as the palette needs them. */
22const DARK: Tokens = {
23  text: 'rgb(255,255,255)', inverseText: 'rgb(0,0,0)', inactive: 'rgb(153,153,153)', subtle: 'rgb(80,80,80)', userMessageBackground: 'rgb(55,55,55)',
24  claude: 'rgb(215,119,87)', success: 'rgb(78,186,101)', error: 'rgb(255,107,128)', warning: 'rgb(255,193,7)', suggestion: 'rgb(177,185,249)',
25}
26const LIGHT: Tokens = {
27  text: 'rgb(0,0,0)', inverseText: 'rgb(255,255,255)', inactive: 'rgb(102,102,102)', subtle: 'rgb(175,175,175)', userMessageBackground: 'rgb(240,240,240)',
28  claude: 'rgb(215,119,87)', success: 'rgb(44,122,57)', error: 'rgb(171,43,63)', warning: 'rgb(150,108,30)', suggestion: 'rgb(87,105,247)',
29}
30export const PRESETS: Record<string, Tokens> = {
31  dark: DARK,
32  light: LIGHT,
33  'dark-daltonized': { ...DARK, success: 'rgb(51,153,255)', error: 'rgb(255,102,102)', warning: 'rgb(255,204,0)' },
34  'light-daltonized': { ...LIGHT, success: 'rgb(0,102,153)', error: 'rgb(204,0,0)', warning: 'rgb(255,153,51)' },
35  'dark-ansi': { ...DARK, text: 'ansi:whiteBright', inactive: 'ansi:white', subtle: 'ansi:blackBright', claude: 'ansi:redBright', success: 'ansi:greenBright', error: 'ansi:redBright', warning: 'ansi:yellowBright', suggestion: 'ansi:blueBright' },
36  'light-ansi': { ...LIGHT, text: 'ansi:black', inactive: 'ansi:blackBright', subtle: 'ansi:white', claude: 'ansi:red', success: 'ansi:green', error: 'ansi:red', warning: 'ansi:yellow', suggestion: 'ansi:blue' },
37}
38
39/** A theme as the setting and its file describe it: which preset it builds on, and what it changes. */
40export type Theme = { base: string; overrides: Partial<Tokens> }
41
42const isDark = (base: string) => !base.startsWith('light')
43/** The preset of the other appearance, of the same kind (plain, daltonized, ansi). */
44const counterpart = (base: string) => (isDark(base) ? base.replace(/^dark/, 'light') : base.replace(/^light/, 'dark'))
45
46/** Reads a theme file's `{ base, overrides }`, keeping only tokens the palette uses and colours that parse. */
47export function parseThemeFile(json: unknown): Theme {
48  const o = (json ?? {}) as { base?: unknown; overrides?: Record<string, unknown> }
49  const base = typeof o.base === 'string' && PRESETS[o.base] ? o.base : 'dark'
50  const overrides: Partial<Tokens> = {}
51  for (const k of TOKEN_KEYS) {
52    const v = o.overrides?.[k]
53    if (typeof v === 'string' && parseColor(v)) overrides[k] = v
54  }
55  return { base, overrides }
56}
57
58/**
59 * The tokens for each appearance. The theme's own appearance takes the theme
60 * whole; the other takes its counterpart preset with the theme's accents, so
61 * the hues it chose carry over while text and background suit that appearance.
62 * `auto` has no appearance of its own: each takes its plain preset.
63 */
64export function tokensFor(theme: Theme | 'auto'): { dark: Tokens; light: Tokens } {
65  if (theme === 'auto') return { dark: DARK, light: LIGHT }
66  const own = { ...PRESETS[theme.base] ?? DARK, ...theme.overrides }
67  const accents: Partial<Tokens> = {}
68  for (const k of ACCENTS) if (theme.overrides[k]) accents[k] = theme.overrides[k]
69  const other = { ...PRESETS[counterpart(theme.base)] ?? LIGHT, ...accents }
70  return isDark(theme.base) ? { dark: own, light: other } : { dark: other, light: own }
71}
72
73// ---- colours ----
74
75type RGB = [number, number, number]
76
77const ANSI: Record<string, RGB> = {
78  black: [0, 0, 0], red: [205, 49, 49], green: [13, 188, 121], yellow: [229, 229, 16], blue: [36, 114, 200], magenta: [188, 63, 188], cyan: [17, 168, 205], white: [229, 229, 229],
79  blackBright: [102, 102, 102], redBright: [241, 76, 76], greenBright: [35, 209, 139], yellowBright: [245, 245, 67], blueBright: [59, 142, 234], magentaBright: [214, 112, 214], cyanBright: [41, 184, 219], whiteBright: [255, 255, 255],
80}
81const ANSI_ORDER = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'] as const
82
83/** Parses the colours a theme file takes: `#rrggbb`, `#rgb`, `rgb(r,g,b)`, `ansi256(n)`, `ansi:<name>`. */
84export function parseColor(s: string): RGB | null {
85  const t = s.trim()
86  let m = /^#([0-9a-f]{6})$/i.exec(t)
87  if (m) { const n = parseInt(m[1]!, 16); return [n >> 16, (n >> 8) & 255, n & 255] }
88  m = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(t)
89  if (m) return [m[1]!, m[2]!, m[3]!].map(h => parseInt(h + h, 16)) as RGB
90  m = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i.exec(t)
91  if (m) return [m[1]!, m[2]!, m[3]!].map(v => Math.min(255, Number(v))) as RGB
92  m = /^ansi256\(\s*(\d{1,3})\s*\)$/i.exec(t)
93  if (m) return ansi256(Number(m[1]))
94  m = /^ansi:(\w+)$/i.exec(t)
95  if (m) return ANSI[m[1]!] ?? null
96  return null
97}
98
99function ansi256(n: number): RGB | null {
100  if (n < 0 || n > 255) return null
101  if (n < 16) return ANSI[(n < 8 ? ANSI_ORDER[n] : `${ANSI_ORDER[n - 8]}Bright`)!] ?? null
102  if (n >= 232) { const v = 8 + (n - 232) * 10; return [v, v, v] }
103  const i = n - 16, step = (c: number) => (c === 0 ? 0 : 55 + c * 40)
104  return [step(Math.floor(i / 36)), step(Math.floor(i / 6) % 6), step(i % 6)]
105}
106
107const hex = (c: RGB) => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')
108/** `a` moved toward `b` by `t` (0 = a, 1 = b). */
109const mix = (a: RGB, b: RGB, t: number): RGB => [0, 1, 2].map(i => a[i]! + (b[i]! - a[i]!) * t) as RGB
110
111const luminance = (c: RGB) => {
112  const ch = (v: number) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }
113  return 0.2126 * ch(c[0]) + 0.7152 * ch(c[1]) + 0.0722 * ch(c[2])
114}
115export const contrast = (a: RGB, b: RGB) => {
116  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p)
117  return (x! + 0.05) / (y! + 0.05)
118}
119/** `c` as it is, or moved toward `toward` just enough to reach `min` contrast on `bg`. */
120function legible(c: RGB, bg: RGB, toward: RGB, min: number): RGB {
121  for (let t = 0; t <= 1; t += 0.05) {
122    const x = mix(c, toward, t)
123    if (contrast(x, bg) >= min) return x
124  }
125  return toward
126}
127
128// ---- the palette ----
129
130export type PaletteKey = keyof Palette
131
132/**
133 * The card's palette from one appearance's tokens. The card is the theme's
134 * message background, and the veil behind a label its inverse text colour, the
135 * appearance's own black or white; bars are the Claude colour warming toward the
136 * warning colour; the limit line is the suggestion colour; over, under and
137 * short are warning, success and error. Every colour that carries meaning is
138 * nudged toward the text colour until it reads on the card.
139 */
140export function derivePalette(t: Tokens): Palette {
141  const c = (s: string, fallback: string) => parseColor(s) ?? parseColor(fallback)!
142  const fallback = luminance(c(t.inverseText, '#000')) < 0.5 ? DARK : LIGHT
143  const k = Object.fromEntries(TOKEN_KEYS.map(key => [key, c(t[key], fallback[key])])) as Record<keyof Tokens, RGB>
144  const card = k.userMessageBackground
145  const on = (x: RGB, min: number) => hex(legible(x, card, k.text, min))
146  return {
147    card: hex(card),
148    veil: hex(k.inverseText),
149    fg: on(k.text, 7),
150    dim: on(k.inactive, 4.5),
151    off: on(k.subtle, 1.8),
152    barTop: on(mix(k.claude, k.warning, 0.35), 2),
153    barBottom: on(k.claude, 2),
154    limit: on(k.suggestion, 3),
155    over: on(k.warning, 3),
156    under: on(k.success, 3),
157    bad: on(k.error, 3),
158    // the models: Claude's own colour for Opus, then hues apart from it and from each other
159    fable: on(mix(k.error, k.suggestion, 0.5), 3),
160    opus: on(k.claude, 3),
161    sonnet: on(k.suggestion, 3),
162    haiku: on(mix(k.success, k.suggestion, 0.35), 3),
163  }
164}
165
166export function palettesFor(theme: Theme | 'auto'): Palettes {
167  const t = tokensFor(theme)
168  return { dark: derivePalette(t.dark), light: derivePalette(t.light), own: theme === 'auto' || isDark(theme.base) ? 'dark' : 'light' }
169}
170
171export const DEFAULT_PALETTES = palettesFor({ base: 'dark', overrides: {} })
172
173/** A colour by name, for a style: `style="fill:${v('fg')}"`. */
174export const v = (key: PaletteKey) => `var(--${key})`
175
176/**
177 * The `<style>` a chart carries: its theme's own appearance as the default,
178 * the other one under `prefers-color-scheme`, so the chart follows the app's
179 * appearance where the surface passes it on, and the theme where it doesn't.
180 */
181export function paletteStyle(p: Palettes): string {
182  const vars = (pal: Palette) => Object.entries(pal).map(([key, val]) => `--${key}:${val}`).join(';')
183  const other = p.own === 'dark' ? 'light' : 'dark'
184  // shown in a frame, a drawing is a page of its own: one that doesn't say it can be dark gets an opaque
185  // white backdrop in a dark app, so each says it suits both, and stays see-through
186  return `<style>:root{color-scheme:light dark;background:transparent}svg{${vars(p[p.own])}}@media (prefers-color-scheme:${other}){svg{${vars(p[other])}}}</style>`
187}
188
189/** The theme a `theme` setting names: a preset, `auto`, or `custom:<slug>`, read by `readFile`. */
190export async function resolveTheme(setting: unknown, readFile: (slug: string) => Promise<string | undefined>): Promise<Theme | 'auto'> {
191  const name = typeof setting === 'string' ? setting : 'dark'
192  if (name === 'auto') return 'auto'
193  if (PRESETS[name]) return { base: name, overrides: {} }
194  const slug = /^custom:(.+)$/.exec(name)?.[1]
195  if (slug) {
196    const text = await readFile(slug).catch(() => undefined)
197    if (text) {
198      try { return parseThemeFile(JSON.parse(text)) } catch { /* fall through to the default */ }
199    }
200  }
201  return { base: 'dark', overrides: {} }
202}
203
hooks/models.ts 433 lines
1import type { Palettes, RangeReading, RangeStep } from '../types'
2import { C, DAY, FONT, HOUR, KEEP, MONO, at, drawing, esc, increments, isWatched, ofKind, pct, usedBetween } from './range'
3import type { InfoSpot, Model, Watch } from './range'
4import { DEFAULT_PALETTES } from './theme'
5
6/**
7 * What each model costs, learned from this account's own history.
8 *
9 * Every response reports its model and tokens. Between two readings of the
10 * 5-hour limit the plugin knows how far the limit rose and which models did
11 * the work then, so over many such stretches it can solve for each model's
12 * weight: rise ≈ Σ w·T. The 5-hour figure moves several times faster than the
13 * weekly one, so it gives several times the stretches; how models compare
14 * comes out the same from either limit, and the week's points are the 5-hour
15 * ones exchanged at the account's own rate (as the 1hr pace does). Within a
16 * model, its token kinds are combined at that model's published price ratios
17 * (UNITS), so only the weight across models is learned. A model's cost shows
18 * once its weight is known within ±SHOW_WITHIN.
19 */
20
21/** A response's tokens, weighted as a model's price list weighs them: input 1, output 5, cache writes 1.25, cache reads 0.1. */
22export const units = (u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) =>
23  u.input_tokens + 5 * u.output_tokens + 1.25 * u.cache_creation_input_tokens + 0.1 * u.cache_read_input_tokens
24
25/** How far back the costs are learned from. */
26export const LEARN_SPAN = 14 * DAY
27/** A cost shows once its 90% range is within this share of it. */
28export const SHOW_WITHIN = 0.2
29/** And once this many stretches had the model in them: a few stretches can fit too well by chance. */
30const MIN_STRETCHES = 5
31/** z for a 90% range. */
32const Z90 = 1.645
33
34const FAMILIES = ['fable', 'opus', 'sonnet', 'haiku'] as const
35export type Family = (typeof FAMILIES)[number] | 'other'
36
37/** A model's name and family from its id: `claude-opus-5-5` is Opus 5.5, `claude-3-5-sonnet-20241022` Sonnet 3.5. */
38export function modelName(id: string): { name: string; family: Family; version: number[] } {
39  const parts = id.toLowerCase().replace(/\[.*\]$/, '').replace(/^claude-/, '').split('-').filter(p => !/^\d{8}$/.test(p) && p !== 'latest')
40  const family = (FAMILIES as readonly string[]).find(f => parts.includes(f)) as Family | undefined
41  const version = parts.filter(p => /^\d{1,2}$/.test(p)).map(Number)
42  if (!family) return { name: id, family: 'other', version }
43  const title = family[0]!.toUpperCase() + family.slice(1)
44  return { name: version.length ? `${title} ${version.join('.')}` : title, family, version }
45}
46
47/** Fable, Opus, Sonnet, Haiku, then the rest; newest version first within a family. */
48function byRank(a: string, b: string) {
49  const A = modelName(a), B = modelName(b)
50  const rank = (f: Family) => (f === 'other' ? 9 : FAMILIES.indexOf(f))
51  if (rank(A.family) !== rank(B.family)) return rank(A.family) - rank(B.family)
52  for (let i = 0; i < Math.max(A.version.length, B.version.length); i++) {
53    const d = (B.version[i] ?? 0) - (A.version[i] ?? 0)
54    if (d) return d
55  }
56  return a.localeCompare(b)
57}
58
59export type ModelCost = {
60  id: string
61  name: string
62  family: Family
63  /** 5-hour points per million weighted tokens; 0 until anything is known. */
64  w: number
65  /** The 90% range as a share of `w`: Infinity while nothing is known. */
66  rel: number
67  shown: boolean
68  /** Responses in the learning span, and how many of them subagents made. */
69  responses: number
70  sub: number
71  /** About how many more responses until it shows; 0 once it does or when it can't be told. */
72  more: number
73}
74
75export type Learned = {
76  models: ModelCost[]
77  /** When the first stretch learned from began (ms), or null with none yet. */
78  since: number | null
79  /** 5-hour points per weekly point over the same span, or null until the weekly figure has moved enough to tell. */
80  perWeek: number | null
81}
82
83/** Solves the normal equations A·x = b (A symmetric, small) and gives A's inverse too; null when A is singular. */
84function solve(A: number[][], b: number[]): { x: number[]; inv: number[][] } | null {
85  const n = b.length
86  const M = A.map((row, i) => [...row, ...Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)), b[i]!])
87  for (let c = 0; c < n; c++) {
88    let p = c
89    for (let r = c + 1; r < n; r++) if (Math.abs(M[r]![c]!) > Math.abs(M[p]![c]!)) p = r
90    if (Math.abs(M[p]![c]!) < 1e-12) return null
91    ;[M[c], M[p]] = [M[p]!, M[c]!]
92    const d = M[c]![c]!
93    for (let k = 0; k <= 2 * n; k++) M[c]![k]! /= d
94    for (let r = 0; r < n; r++) {
95      if (r === c) continue
96      const f = M[r]![c]!
97      if (f) for (let k = 0; k <= 2 * n; k++) M[r]![k]! -= f * M[c]![k]!
98    }
99  }
100  return { x: M.map(r => r[2 * n]!), inv: M.map(r => r.slice(n, 2 * n)) }
101}
102
103/**
104 * Each model's weight from the stretches between weekly readings that this
105 * computer watched whole, and how sure that weight is. Weights can't go
106 * below zero: a model whose best fit is negative is left out and refitted.
107 */
108export function learn(readings: readonly RangeReading[], steps: readonly RangeStep[], seen: readonly Watch[], now: number): Learned {
109  const from = now - LEARN_SPAN
110  const recent = steps.filter(s => s[0] >= from && s[0] <= now)
111  const ids = [...new Set(recent.map(s => s[1]))].sort(byRank)
112  const fives = ofKind(readings, 'five').filter(r => r[0] >= from)
113  const sorted = [...recent].sort((a, b) => a[0] - b[0])
114
115  // the stretches: how far the limit rose, and each model's tokens (in millions) meanwhile
116  const rows: Array<{ y: number; x: number[]; n: number }> = []
117  let since: number | null = null
118  for (let i = 1; i < fives.length; i++) {
119    const a = fives[i - 1]!, b = fives[i]!
120    // a reset in between, or a gap nobody here watched: usage from elsewhere would count against these models
121    if (Math.abs(a[3] - b[3]) > 5 * 60_000 || !isWatched(seen, a[0], b[0])) continue
122    const y = b[2] - a[2]
123    if (y < 0) continue
124    const x = ids.map(() => 0)
125    let n = 0
126    for (const s of sorted) if (s[0] > a[0] && s[0] <= b[0]) { x[ids.indexOf(s[1])]! += s[3] / 1e6; n++ }
127    if (n === 0) continue
128    rows.push({ y, x, n })
129    since ??= a[0]
130  }
131
132  // least squares over the models still in, dropping the most negative until none is
133  let active = ids.map((_, i) => i).filter(i => rows.some(r => r.x[i]! > 0))
134  let fit: { x: number[]; inv: number[][] } | null = null
135  while (active.length) {
136    const A = active.map(i => active.map(j => rows.reduce((s, r) => s + r.x[i]! * r.x[j]!, 0)))
137    const b = active.map(i => rows.reduce((s, r) => s + r.x[i]! * r.y, 0))
138    fit = solve(A, b)
139    if (!fit) break
140    const worst = fit.x.reduce((k, v, j) => (v < (fit!.x[k] ?? 0) ? j : k), 0)
141    if (fit.x[worst]! >= 0) break
142    active = active.filter((_, j) => j !== worst)
143    fit = null
144  }
145  const w = ids.map(() => 0), rel = ids.map(() => Infinity)
146  if (fit && rows.length > active.length) {
147    active.forEach((i, j) => { w[i] = fit!.x[j]! })
148    const rss = rows.reduce((s, r) => s + (r.y - r.x.reduce((t, v, i) => t + v * w[i]!, 0)) ** 2, 0)
149    // the limit is read at whole points, so each end of a stretch is off by part of the response that crossed
150    // the point, evenly anywhere in it: never trust a fit closer than that (two ends, each size²/12)
151    const perResponse = rows.reduce((s, r) => s + r.y, 0) / rows.reduce((s, r) => s + r.n, 0)
152    const s2 = Math.max(rss / (rows.length - active.length), perResponse ** 2 / 6)
153    active.forEach((i, j) => {
154      if (w[i]! > 0) rel[i] = Z90 * Math.sqrt(s2 * fit!.inv[j]![j]!) / w[i]!
155    })
156  }
157
158  // the exchange rate to weekly points, from what both limits rose by while watched
159  const weekPts = usedBetween(increments(readings, 'week', seen).filter(x => !x.hole), from, now)
160  const fivePts = usedBetween(increments(readings, 'five', seen).filter(x => !x.hole), from, now)
161
162  return {
163    since,
164    perWeek: weekPts >= 3 && fivePts > 0 ? fivePts / weekPts : null,
165    models: ids.map((id, i) => {
166      const mine = recent.filter(s => s[1] === id)
167      const stretches = rows.filter(r => r.x[i]! > 0).length
168      const shown = rel[i]! <= SHOW_WITHIN && stretches >= MIN_STRETCHES
169      // the range narrows about as the square root of what's behind it
170      const more = shown || !isFinite(rel[i]!) ? 0 : Math.max(1, Math.ceil(mine.length * ((rel[i]! / SHOW_WITHIN) ** 2 - 1)))
171      return { id, ...modelName(id), w: w[i]!, rel: rel[i]!, shown, responses: mine.length, sub: mine.filter(s => s[4]).length, more }
172    }),
173  }
174}
175
176/** The model the others compare to: the one picked, else the newest Sonnet shown, else the most used shown. Null under two shown. */
177export function baselineOf(l: Learned, picked: string | undefined): ModelCost | null {
178  const shown = l.models.filter(m => m.shown)
179  if (shown.length < 2) return null
180  return shown.find(m => m.id === picked) ?? shown.find(m => m.family === 'sonnet') ?? [...shown].sort((a, b) => b.responses - a.responses)[0]!
181}
182
183/** The names the baseline buttons show: the family alone, unless two versions of it are shown. */
184export function shortNames(models: readonly ModelCost[]): Map<string, string> {
185  const count = (f: Family) => models.filter(m => m.family === f).length
186  return new Map(models.map(m => [m.id, m.family !== 'other' && count(m.family) === 1 ? m.name.replace(/ [\d.]+$/, '') : m.name]))
187}
188
189export type Spend = {
190  /** % of the weekly limit used. */
191  week: number
192  /** Points per shown model, split by effort (largest first). */
193  models: Array<{ cost: ModelCost; pts: number; effort: Array<[string, number]> }>
194  /** Recorded here, but by models whose cost isn't known yet. */
195  unsplit: number
196  /** Used where this computer wasn't watching. */
197  away: number
198}
199
200/** This week's points by model and effort, from the responses since the reset and the learned weights. */
201export function spend(week: Model, l: Learned, steps: readonly RangeStep[], now: number): Spend {
202  const start = week.resetsAt - 7 * DAY
203  const models = (l.perWeek ? l.models.filter(m => m.shown) : []).map(cost => {
204    const by = new Map<string, number>()
205    for (const s of steps) if (s[1] === cost.id && s[0] > start && s[0] <= now) by.set(s[2] || 'default', (by.get(s[2] || 'default') ?? 0) + s[3] / 1e6 * cost.w / l.perWeek!)
206    const effort = [...by].sort((a, b) => b[1] - a[1])
207    return { cost, pts: effort.reduce((s, e) => s + e[1], 0), effort }
208  }).filter(m => m.pts > 0)
209  const recorded = usedBetween(week.increments.filter(i => !i.hole), start, now)
210  const split = models.reduce((s, m) => s + m.pts, 0)
211  return { week: week.pct, models, unsplit: Math.max(0, recorded - split), away: Math.max(0, week.pct - recorded) }
212}
213
214// ---- words ----
215
216const comma = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+$)/, ',')
217const times = (x: number) => (x >= 1 ? x.toFixed(1) : x.toFixed(2)) + '×'
218const one = (n: number) => n.toFixed(1).replace(/\.0$/, '')
219
220/** A model's cost against the baseline, and its likely range. */
221export function ratio(m: ModelCost, b: ModelCost): { x: string; range: string } {
222  const x = m.w / b.w, err = Math.hypot(m.rel, b.rel)
223  return { x: times(x), range: `${times(Math.max(0, x * (1 - err)))}–${times(x * (1 + err))}` }
224}
225
226const responsesText = (m: ModelCost) =>
227  `${comma(m.responses)} ${m.responses === 1 ? 'response' : 'responses'}${m.sub === m.responses && m.sub > 0 ? ', all subagents' : m.sub > m.responses / 2 ? ', mostly subagents' : ''}`
228
229/** The tooltip on the card's ⓘ. */
230export const MODELS_INFO = 'Cost: how much of your limit each model uses token for token, compared with the model you pick. Learned from your history; shown once it’s within ±20%. This week: points of the weekly limit that went to each model, by effort.'
231
232/** The card in words, for a reader that can't see it and for surfaces without drawings. */
233export function modelsText(l: Learned, b: ModelCost | null, sp: Spend | null): string[] {
234  const rows = l.models.map(m => {
235    if (!m.shown) return `${m.name}: learning, ${responsesText(m)}${m.more ? `, about ${comma(m.more)} more to go` : ''}.`
236    if (!b) return `${m.name}: known, ${responsesText(m)}.`
237    if (m === b) return `${m.name}: 1×, the baseline.`
238    const r = ratio(m, b)
239    return `${m.name}: ${r.x} ${b.name}, likely ${r.range}.`
240  })
241  if (sp) {
242    rows.push(`This week ${Math.round(sp.week)}% used: ${[
243      ...sp.models.map(m => `${m.cost.name} ${one(m.pts)} (${m.effort.map(([e, v]) => `${e} ${one(v)}`).join(', ')})`),
244      ...(sp.unsplit ? [`not split yet ${one(sp.unsplit)}`] : []),
245      `not recorded ${one(sp.away)}`,
246    ].join(', ')}.`)
247  }
248  return rows
249}
250
251// ---- drawing ----
252
253/** Lower effort draws lighter within its model's colour. */
254const SHADE: Record<string, number> = { low: 0.35, medium: 0.55, high: 0.78, xhigh: 0.9, max: 1 }
255const shade = (e: string) => SHADE[e] ?? 0.7
256const colorOf = (f: Family) => (f === 'other' ? C.dim : C[f])
257
258type Txt = { size: number; weight?: number; fill?: string; anchor?: string; mono?: boolean }
259const t = (x: number | string, y: number, s: string, o: Txt) =>
260  `<text x="${typeof x === 'number' ? x.toFixed(1) : x}" y="${y.toFixed(1)}" text-anchor="${o.anchor ?? 'start'}" style="fill:${o.fill ?? C.fg}" font-family="${o.mono ? MONO : FONT}" font-size="${o.size}px" font-weight="${o.weight ?? 400}">${esc(s)}</text>`
261/** About how wide a text runs. */
262const tw = (s: string, size: number, _mono = false) => s.length * size * 0.6
263const dot = (x: number, y: number, fill: string, size = 10, opacity = 1) =>
264  `<rect x="${x}" y="${(y - size / 2).toFixed(1)}" width="${size}" height="${size}" rx="${size * 0.3}" style="fill:${fill}"${opacity < 1 ? ` fill-opacity="${opacity}"` : ''}/>`
265const rule = (y: number) => `<line x1="0" x2="100%" y1="${y}" y2="${y}" style="stroke:${C.dim}" stroke-opacity="0.25"/>`
266const LABEL = 11
267
268/** A span in words: "14 days", "9 hours", "40 minutes". */
269const spanText = (h: number) => {
270  const [n, unit] = h >= 48 ? [Math.round(h / 24), 'day'] : h >= 1 ? [Math.round(h), 'hour'] : [Math.max(1, Math.round(h * 60)), 'minute']
271  return `${n} ${unit}${n === 1 ? '' : 's'}`
272}
273
274/** The card's head: "Models" and how long the costs were learned from; its info circle is drawn by `withInfo`, which opens the tooltip over the rows. */
275export function modelsHead(l: Learned, now: number, pal: Palettes = DEFAULT_PALETTES): { svg: string; height: number; info: InfoSpot } {
276  const size = 1.2 * 16, y = size, r = 8
277  const learned = l.since === null ? 'learning' : `learned from ${spanText((now - l.since) / HOUR)}`
278  const height = Math.ceil(y + 8)
279  return {
280    svg: drawing(t(0, y, 'Models', { size, weight: 500 }) + t('100%', y, learned, { size: 12, fill: C.dim, anchor: 'end' }), height, pal),
281    height,
282    info: { cx: tw('Models', size) * 0.95 + 14, cy: y - size * 0.32, r },
283  }
284}
285
286/** The cost rows, `width` wide: each model's cost against the baseline, or a meter while it learns. */
287export function modelsCosts(l: Learned, b: ModelCost | null, width: number, pal: Palettes = DEFAULT_PALETTES): string {
288  let s = '', y = 0
289  // ---- costs ----
290  l.models.forEach((m, i) => {
291    if (i > 0) s += rule(y)
292    const top = y + (i > 0 ? 11 : 4), name = top + 13, meta = name + 19
293    const learning = !m.shown
294    s += dot(0, name - 4.5, colorOf(m.family)) + t(18, name, m.name, { size: 15, weight: 500 })
295    const isBase = !!b && m === b
296    if (isBase) {
297      const x = 18 + tw(m.name, 15) + 10
298      s += `<rect x="${x.toFixed(1)}" y="${(name - 12).toFixed(1)}" width="${(tw('baseline', 11, true) + 12).toFixed(1)}" height="17" rx="4" style="fill:${C.dim}" fill-opacity="0.14"/>`
299        + t(x + 6, name, 'baseline', { size: 11, weight: 500, mono: true })
300    }
301    let fig: string, right: string
302    if (learning) { fig = ''; right = 'not sure enough yet' }
303    else if (!b) { fig = ''; right = 'compares once a second model shows' }
304    else if (isBase) { fig = '1×'; right = 'the others compare to this' }
305    else { const r = ratio(m, b); fig = r.x; right = `likely ${r.range}` }
306    if (learning) s += t('100%', name, 'Learning', { size: 12, mono: true, fill: C.over, anchor: 'end' })
307    else if (fig) s += t('100%', name, fig, { size: 15, weight: 500, mono: true, anchor: 'end' })
308    else s += t('100%', name, 'Known', { size: 12, mono: true, fill: C.dim, anchor: 'end' })
309    s += t(0, meta, responsesText(m), { size: 12.5, fill: C.dim }) + t('100%', meta, right, { size: 12.5, fill: C.dim, anchor: 'end' })
310    y = meta + 5
311    if (learning) {
312      // the meter runs from ±100% (nothing known) to ±20% (shown)
313      const p = isFinite(m.rel) ? Math.max(0.04, Math.min(1, (1 - Math.min(m.rel, 1)) / (1 - SHOW_WITHIN))) : 0.04
314      const note = `shows at ±20% · ${m.more ? `about ${comma(m.more)} more responses` : 'needs more responses'}`
315      // the text sits at the right on a backing of the card's colour, as wide as the text and a 16px gap:
316      // the track runs under it and stops short of it, however wide the frame is drawn
317      const noteW = tw(note, 11.5, true) * 1.05, gapW = 16
318      const room = Math.max(0.2, 1 - (noteW + gapW) / width)
319      const my = y + 11
320      s += `<rect x="0" y="${my - 3}" width="100%" height="6" rx="3" style="fill:${C.dim}" fill-opacity="0.14"/>`
321        + `<rect x="0" y="${my - 3}" width="${pct(room * p)}" height="6" rx="3" style="fill:${C.over}"/>`
322        + at(1, `<rect x="${-(noteW + gapW).toFixed(1)}" y="${my - 8}" width="${(noteW + gapW).toFixed(1)}" height="16" style="fill:${C.card}"/>`
323          + t(0, my + 4, note, { size: 11.5, mono: true, fill: C.dim, anchor: 'end' }))
324      y = my + 9
325    }
326  })
327  if (!l.models.length) {
328    s += t(0, 16, 'No responses recorded yet. Costs are learned from the responses your sessions get.', { size: 12.5, fill: C.dim })
329    y = 24
330  }
331  return drawing(s, y + 4, pal)
332}
333
334/** This week's points: a bar split by model and effort, each model's efforts as chips, and what isn't split or wasn't recorded. */
335export function modelsSpend(sp: Spend, width: number, pal: Palettes = DEFAULT_PALETTES): string {
336  let s = '', y = 14
337  const head = `This week · ${Math.round(sp.week)}% of limit used`, sub = 'points of the weekly limit, by model and effort'
338  s += t(0, y, head, { size: LABEL, weight: 500, mono: true, fill: C.dim })
339  if (head.length * LABEL * 0.6 + tw(sub, 12) + 16 <= width) s += t('100%', y, sub, { size: 12, fill: C.dim, anchor: 'end' })
340  else { y += 17; s += t(0, y, sub, { size: 12, fill: C.dim }) }
341  y += 10
342  const total = Math.max(sp.week, sp.models.reduce((a, m) => a + m.pts, 0) + sp.unsplit + sp.away, 0.0001)
343  const segs = [
344    ...sp.models.flatMap(m => m.effort.map(([e, v]) => ({ fill: colorOf(m.cost.family), o: shade(e), v }))),
345    ...(sp.unsplit ? [{ fill: C.over, o: 0.45, v: sp.unsplit }] : []),
346  ]
347  let f = 0, bar = `<rect x="0" y="0" width="100%" height="22" style="fill:${C.dim}" fill-opacity="0.12"/>`
348  for (const g of segs) {
349    bar += `<rect x="${pct(f)}" y="0" width="${pct(g.v / total)}" height="22" style="fill:${g.fill}" fill-opacity="${g.o}"/>`
350    if (f > 0) bar += `<line x1="${pct(f)}" x2="${pct(f)}" y1="0" y2="22" style="stroke:${C.card}"/>`
351    f += g.v / total
352  }
353  if (sp.away) bar += `<rect x="${pct(f)}" y="0" width="${pct(sp.away / total)}" height="22" fill="url(#away)"/>` + (f > 0 ? `<line x1="${pct(f)}" x2="${pct(f)}" y1="0" y2="22" style="stroke:${C.card}"/>` : '')
354  s += `<defs><pattern id="away" width="5" height="22" patternUnits="userSpaceOnUse"><rect width="2" height="22" style="fill:${C.dim}" fill-opacity="0.55"/></pattern>`
355    + `<clipPath id="stack"><rect x="0" y="0" width="100%" height="22" rx="5"/></clipPath></defs>`
356    + `<svg x="0" y="${y}" width="100%" height="22" overflow="hidden"><g clip-path="url(#stack)">${bar}</g></svg>`
357  y += 22 + 8
358
359  // each model: its points, then a chip per effort, largest first
360  for (const m of sp.models) {
361    s += rule(y)
362    const hy = y + 20
363    s += dot(0, hy - 4.5, colorOf(m.cost.family)) + t(17, hy, m.cost.name, { size: 13.5, weight: 500 }) + t('100%', hy, one(m.pts), { size: 13, weight: 500, mono: true, anchor: 'end' })
364    let cx = 17, cy = hy + 9
365    for (const [e, v] of m.effort) {
366      const a = one(v), share = `${Math.round(v / (m.pts || 1) * 100)}%`
367      const w = 8 + 8 + 6 + tw(e, 12.5) + 6 + tw(a, 12, true) + 6 + tw(share, 11.5, true) + 8
368      if (cx > 17 && cx + w > width) { cx = 17; cy += 28 }
369      let x = cx + 8
370      s += `<rect x="${cx.toFixed(1)}" y="${cy}" width="${w.toFixed(1)}" height="22" rx="5" style="fill:${C.dim}" fill-opacity="0.12"/>`
371      s += dot(x, cy + 11, colorOf(m.cost.family), 8, shade(e)); x += 14
372      s += t(x, cy + 15.5, e, { size: 12.5 }); x += tw(e, 12.5) + 6
373      s += t(x, cy + 15.5, a, { size: 12, weight: 500, mono: true }); x += tw(a, 12, true) + 6
374      s += t(x, cy + 15.5, share, { size: 11.5, mono: true, fill: C.dim })
375      cx += w + 6
376    }
377    y = cy + 22 + 7
378  }
379
380  // what isn't split by model: side by side when they fit, else one under the other
381  const legend = [
382    ...(sp.unsplit ? [{ label: 'Not split yet', v: sp.unsplit, sw: dot(0, 0, C.over, 10, 0.45) }] : []),
383    { label: 'Not recorded', v: sp.away, sw: `<rect x="0" y="-5" width="10" height="10" rx="3" fill="url(#away)" style="stroke:${C.dim}" stroke-opacity="0.55"/>` },
384  ]
385  const cols = legend.length > 1 && width >= 2 * 150 + 14 ? 2 : 1
386  if (sp.models.length) s += rule(y)
387  y += 6
388  legend.forEach((g, i) => {
389    const col = i % cols, row = Math.floor(i / cols), ly = y + 14 + row * 22
390    const x0 = col / cols, x1 = (col + 1) / cols
391    s += at(x0, `<g transform="translate(${col ? 7 : 0},${ly - 4.5})">${g.sw}</g>` + t(col ? 24 : 17, ly, g.label, { size: 13, fill: C.dim }))
392    s += at(x1, t(col < cols - 1 ? -7 : 0, ly, one(g.v), { size: 13, weight: 500, mono: true, fill: C.dim, anchor: 'end' }))
393  })
394  y += 14 + Math.ceil(legend.length / cols) * 22 - 14
395
396  if (!sp.models.length) {
397    const note = `No model’s cost is known yet, so this week can’t be split. The ${one(sp.unsplit)} points recorded here will split once a model’s figure shows.`
398    const lines = wrapAt(note, width - 26, 13)
399    const H = lines.length * 19 + 16
400    y += 14
401    const dash = `style="stroke:${C.dim}" stroke-opacity="0.45" stroke-dasharray="4 4" fill="none"`
402    s += `<rect x="0.5" y="${y + 0.5}" width="99.9%" height="${H}" rx="8" ${dash}/>`
403      + lines.map((ln, i) => t(12, y + 22 + i * 19, ln, { size: 13, fill: C.dim })).join('')
404    y += H + 2
405  }
406  return drawing(s, y + 6, pal)
407}
408
409/** Text broken into lines that fit `width` at `size`. */
410function wrapAt(s: string, width: number, size: number): string[] {
411  const lines: string[] = []
412  for (const word of s.split(' ')) {
413    const last = lines[lines.length - 1]
414    if (last !== undefined && tw(`${last} ${word}`, size) <= width) lines[lines.length - 1] = `${last} ${word}`
415    else lines.push(word)
416  }
417  return lines
418}
419
420/** Steps older than the readings are kept are dropped, and repeats merged. */
421export function mergeSteps(lists: ReadonlyArray<readonly RangeStep[]>, now: number): RangeStep[] {
422  const seen = new Set<string>(), out: RangeStep[] = []
423  for (const list of lists) {
424    for (const s of list) {
425      const id = `${s[0]}:${s[1]}:${s[3]}`
426      if (s[0] < now - KEEP || seen.has(id)) continue
427      seen.add(id)
428      out.push(s)
429    }
430  }
431  return out.sort((a, b) => a[0] - b[0])
432}
433
types/index.d.ts 35 lines
1/** One rate-limit reading: when, which window (0 = 5-hour, 1 = weekly), % used, reset time (ms). */
2export type RangeReading = [t: number, kind: 0 | 1, pct: number, resetsAt: number]
3
4/** A stretch of time a session on this computer was open and taking readings: [from, to] in ms. */
5export type RangeWatch = [from: number, to: number]
6
7/**
8 * One model response seen here, subagents' included: when, the model's id, the effort it was asked for
9 * ('' for none), its tokens weighted by that model's price ratios, and 1 when a subagent made it.
10 */
11export type RangeStep = [t: number, model: string, effort: string, units: number, sub: 0 | 1]
12
13/**
14 * What the weekly average covers (a window of n units, or since the reset), whether reset countdowns show
15 * the finer unit, and the model the others' costs compare to (a model id; absent, the default).
16 */
17export type RangeSettings = { mode: 'window' | 'reset'; n: number; unit: 'h' | 'd' | 'w'; fine: boolean; baseline?: string }
18
19/** The colours the card draws with, as hex. */
20export type Palette = {
21  card: string; veil: string; fg: string; dim: string; off: string
22  barTop: string; barBottom: string; limit: string; over: string; under: string; bad: string
23  /** A colour per model family. */
24  fable: string; opus: string; sonnet: string; haiku: string
25}
26
27/** The card's palettes for a dark and a light appearance, derived from the theme, and which one the theme itself is. */
28export type Palettes = { dark: Palette; light: Palette; own: 'dark' | 'light' }
29
30declare module 'claude-code' {
31  interface PluginState {
32    'token-range-monitor': { readings: RangeReading[]; seen: RangeWatch[]; steps: RangeStep[]; settings: RangeSettings; tick: number; palettes: Palettes }
33  }
34}
35