diff --git a/AGENTS.md b/AGENTS.md index e12bb66..47e4586 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,11 @@ the LICENSE, `build.sh`, `tools/`) is repo tooling that stays out of the bundle. - `src/lib/oauth.js` — shared OAuth/API constants (client id, endpoints, scopes, headers) and text codecs, imported by both `usageClient.js` and `prefs.js` so the values are defined once. No shell imports. +- `src/lib/usageModel.js` — pure data-shaping: turns the usage payload into the + ordered list of windows the popup renders (`normalizeWindows`, preferring the + self-describing `limits[]` array and falling back to the legacy flat keys) and + the extra-usage money block (`normalizeSpend`). No GI or shell imports, so it + is unit-testable under plain `node` (see `tools/test-usageModel.mjs`). - `src/stylesheet.css` — `cu-*` classes for the indicator and popup. - `src/icons/` — panel icon (`claude-spark.svg`) and popup logo (`octopus.png`). @@ -42,17 +47,27 @@ the LICENSE, `build.sh`, `tools/`) is repo tooling that stays out of the bundle. `dist/.shell-extension.zip`. Accepts an optional `-major`/`-minor`/ `-patch` flag that bumps `version-name` (semver) in `metadata.json` and increments the integer `version` before packing. -- `tools/poll.js` — standalone validator, run from the repo root: - `gjs -m tools/poll.js`. +- `tools/poll.js` — standalone validator that hits the live API and prints the + normalised windows + spend, run from the repo root: `gjs -m tools/poll.js`. +- `tools/test-usageModel.mjs` — pure-`node` unit tests for `usageModel.js` + (no network, no GI): `node tools/test-usageModel.mjs`. ## Data sources - Tier: `~/.claude/.credentials.json` (`claudeAiOauth.subscriptionType` / `rateLimitTier`), confirmed via `GET https://api.anthropic.com/api/oauth/profile`. -- Limits: `GET https://api.anthropic.com/api/oauth/usage` returns `five_hour`, - `seven_day`, `seven_day_sonnet` (each `utilization` % + `resets_at`) and - `extra_usage`. Required headers: `Authorization: Bearer `, - `anthropic-beta: oauth-2025-04-20`, `anthropic-version: 2023-06-01`. +- Limits: `GET https://api.anthropic.com/api/oauth/usage`. The current shape is + a self-describing `limits[]` array — each entry has `kind` + (`session`/`weekly_all`/`weekly_scoped`), `group` (`session`/`weekly`), + `percent`, `severity` (`normal`/`warning`/`critical`), `resets_at`, + `is_active`, and an optional `scope.model.display_name` naming a per-model + window (e.g. Fable). Money now comes as a structured `spend` object + (`used`/`limit` as `{amount_minor, currency, exponent}` + `percent` + + `severity`). The older flat keys (`five_hour`, `seven_day`, + `seven_day_` with `utilization` %, plus `extra_usage`) are still parsed + as a fallback in `usageModel.js`. Required headers: + `Authorization: Bearer `, `anthropic-beta: oauth-2025-04-20`, + `anthropic-version: 2023-06-01`. - Refresh: `POST https://platform.claude.com/v1/oauth/token` with `grant_type=refresh_token` and the public Claude Code `client_id`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b9af9..5d5152c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +- Per-model usage windows. Anthropic's usage endpoint now reports its windows in + a self-describing `limits[]` array that includes model-scoped limits (e.g. a + weekly Fable window). The popup renders one meter per reported window + dynamically, so any current or future model the API breaks out shows up + automatically instead of being silently dropped. +- A "Worst active limit" option for *Panel reflects*, so the top-bar gauge can + surface whichever limit is most severe right now — a maxed-out per-model + window reaches the panel even when the 5-hour and 7-day totals are calm. + +### Changed +- Gauge colors are now floored at the severity the API reports for each window: + the existing burn-consequence coloring still applies, but a window the API + flags as warning/critical never reads calmer than that. +- The extra-usage line now uses the structured `spend` object (authoritative + amount, limit, percentage, and severity) when present, falling back to the + older `extra_usage` field — and scales it by the API's own decimal places + instead of assuming cents. It tints amber/red with the spend severity. + ## 1.1.2 - 2026-07-10 ### Changed diff --git a/src/extension.js b/src/extension.js index d280502..6630560 100644 --- a/src/extension.js +++ b/src/extension.js @@ -12,6 +12,7 @@ import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; import {UsageClient, UsageError} from './lib/usageClient.js'; +import {normalizeWindows, normalizeSpend} from './lib/usageModel.js'; const TRACK_WIDTH = 300; const USAGE_SETTINGS_URL = 'https://claude.ai/settings/usage'; @@ -59,9 +60,6 @@ function colorRgb(c) { return [c.red / scale, c.green / scale, c.blue / scale]; } -const FIVE_HOUR_SECONDS = 5 * 3600; -const SEVEN_DAY_SECONDS = 7 * 24 * 3600; - // Collapse refreshes that land closer together than this. Opening the popup // triggers a refresh, and so does the poll timer; without a floor the two can // fire back-to-back and the second request is rate-limited (429) by the API. @@ -182,13 +180,6 @@ function tierLabel(subscriptionType, rateLimitTier) { return m ? `${base} ${m[1]}x` : base; } -// Friendly name for a per-model usage window key suffix (seven_day_). -function modelLabel(name) { - const known = {opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku', oauth_apps: 'OAuth Apps'}; - return known[name] ?? - name.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); -} - function relativeReset(iso) { const target = Date.parse(iso); if (Number.isNaN(target)) @@ -264,6 +255,12 @@ class Meter { this._caption.visible = !!caption; } + // Update the meter's title in place (a reused meter can change label, e.g. + // if the API renames a scoped window). + setName(name) { + this._name.text = name; + } + setMuted() { this._pct.text = '—'; this._fill.set_width(0); @@ -389,7 +386,12 @@ class ClaudeUsageIndicator extends PanelMenu.Button { this._cancellable = new Gio.Cancellable(); this._lastUsage = null; this._lastFetchMs = 0; - this._perModelMeters = new Map(); + // key (from the usage model) -> Meter, so meters are reused across polls + // and torn down only when the API stops reporting that window. + this._meters = new Map(); + // Normalised windows from the last render, cached for the panel selector + // and the between-poll countdown. + this._windows = []; this._meterBindings = []; this._countdownTimer = null; @@ -490,15 +492,11 @@ class ClaudeUsageIndicator extends PanelMenu.Button { header.add_child(this._pill); root.add_child(header); - // limits section + // limits section — one meter per window the API reports (5-hour, 7-day, + // and any per-model windows like Fable), built dynamically on render. this._sectionLabel(root, 'Usage limits'); - this._fiveHour = new Meter('5-hour window'); - this._sevenDay = new Meter('7-day window'); - root.add_child(this._fiveHour.root); - root.add_child(this._sevenDay.root); - // Per-model 7-day meters are added here on demand. - this._perModelBox = new St.BoxLayout({vertical: true}); - root.add_child(this._perModelBox); + this._metersBox = new St.BoxLayout({vertical: true}); + root.add_child(this._metersBox); this._extra = wrapLabel(new St.Label({text: '', style_class: 'cu-extra'})); root.add_child(this._extra); @@ -602,56 +600,37 @@ class ClaudeUsageIndicator extends PanelMenu.Button { this._panelTier.text = this._pill.text.split(' ')[0]; } - // Reset the binding list each render so the countdown re-applies from - // exactly the windows now on screen (per-model meters come and go). + // Build the meter list from the normalised windows. The model prefers + // the API's self-describing limits[] array (which now carries per-model + // windows like Fable) and falls back to the legacy flat keys. + this._windows = normalizeWindows(usage); this._meterBindings = []; - this._bindWindow(this._fiveHour, usage.five_hour, FIVE_HOUR_SECONDS); - this._bindWindow(this._sevenDay, usage.seven_day, SEVEN_DAY_SECONDS); - - // Per-model 7-day windows arrive as seven_day_; render one meter - // per non-null entry and drop any that the API stops reporting. const seen = new Set(); - for (const key of Object.keys(usage)) { - const m = /^seven_day_(.+)$/.exec(key); - const win = usage[key]; - if (!m || !win) - continue; - seen.add(key); - let meter = this._perModelMeters.get(key); + for (const w of this._windows) { + seen.add(w.key); + let meter = this._meters.get(w.key); if (!meter) { - meter = new Meter(`7-day ${modelLabel(m[1])}`); - this._perModelBox.add_child(meter.root); - this._perModelMeters.set(key, meter); + meter = new Meter(w.label); + this._metersBox.add_child(meter.root); + this._meters.set(w.key, meter); + } else { + meter.setName(w.label); } - this._bindWindow(meter, win, SEVEN_DAY_SECONDS); + this._bindWindow(meter, w); } - for (const [key, meter] of this._perModelMeters) { + // Keep the on-screen order matching the window order. + this._windows.forEach((w, i) => { + this._metersBox.set_child_at_index(this._meters.get(w.key).root, i); + }); + // Drop meters for windows the API stopped reporting. + for (const [key, meter] of this._meters) { if (!seen.has(key)) { meter.destroy(); - this._perModelMeters.delete(key); + this._meters.delete(key); } } - const xu = usage.extra_usage; - if (xu && xu.is_enabled) { - const cur = xu.currency || ''; - // NOTE: the units of used_credits and monthly_limit are not - // confirmed against a live extra_usage payload. We scale both the - // same way (treating them as minor units, e.g. cents) so the two - // numbers are at least consistent; the previous code scaled only - // monthly_limit, which could not be right for both. Verify against - // real data and adjust the divisor if needed. - const money = v => Number.isFinite(v) ? `${cur} ${(v / 100).toFixed(2)}`.trim() : null; - const used = money(Number(xu.used_credits)); - const limit = money(Number(xu.monthly_limit)); - const parts = [used ?? `${cur} 0.00`.trim()]; - if (limit && Number(xu.monthly_limit) > 0) - parts.push(limit); - this._extra.visible = true; - this._extra.text = `Extra usage: ${parts.join(' / ')}`; - } else { - this._extra.visible = false; - } + this._renderSpend(usage); this._renderPanel(); this._scheduleCountdown(); @@ -660,20 +639,39 @@ class ClaudeUsageIndicator extends PanelMenu.Button { this._updated.text = `Updated ${now.format('%H:%M:%S')}`; } - // Pairs a meter with its window so the live countdown can re-render the - // caption between polls without another network round-trip. - _bindWindow(meter, win, total) { - this._meterBindings.push({meter, win, total}); - this._applyWindow(meter, win, total); + // Renders the "extra usage" line from the normalised spend block (the new + // structured `spend` object, or the legacy `extra_usage` fallback), colour- + // ing it by the API's severity. + _renderSpend(usage) { + const spend = normalizeSpend(usage); + if (!spend) { + this._extra.visible = false; + this._extra.style_class = 'cu-extra'; + return; + } + const parts = [spend.used, spend.limit].filter(Boolean); + let text = `Extra usage: ${parts.join(' / ')}`; + if (spend.percent !== null) + text += ` (${spend.percent}%)`; + this._extra.text = text; + this._extra.style_class = `cu-extra ${levelClass(spend.level)}`; + this._extra.visible = true; + } + + // Pairs a meter with its normalised window so the live countdown can + // re-render the caption between polls without another network round-trip. + _bindWindow(meter, w) { + this._meterBindings.push({meter, w}); + this._applyWindow(meter, w); } // Soonest reset across all on-screen windows, in seconds, or null if none. _soonestResetSeconds() { let soonest = null; - for (const {win} of this._meterBindings) { - if (!win?.resets_at) + for (const {w} of this._meterBindings) { + if (!w?.resetsAt) continue; - const t = Date.parse(win.resets_at); + const t = Date.parse(w.resetsAt); if (Number.isNaN(t)) continue; const rem = (t - Date.now()) / 1000; @@ -706,56 +704,74 @@ class ClaudeUsageIndicator extends PanelMenu.Button { _refreshCountdowns() { if (!this._lastUsage) return; - for (const {meter, win, total} of this._meterBindings) - this._applyWindow(meter, win, total); + for (const {meter, w} of this._meterBindings) + this._applyWindow(meter, w); this._renderPanel(); } - // Renders a meter from a usage window. The color reflects the consequence - // of the current burn (see windowLevel): red only when you're out of - // headroom now or would be locked out for a meaningful stretch; amber for a - // near-reset overrun or a rising trend. The caption explains it in words. - _applyWindow(meter, win, totalSeconds) { - if (!win) { + // Renders a meter from a normalised usage window. The color reflects the + // consequence of the current burn (see windowLevel): red only when you're + // out of headroom now or would be locked out for a meaningful stretch; + // amber for a near-reset overrun or a rising trend. It is floored at the + // API's own severity, so a window the API flags as warning/critical never + // reads calmer than the API says. The caption explains it in words. + _applyWindow(meter, w) { + if (!w || !Number.isFinite(w.utilization)) { meter.setMuted(); return; } - const util = win.utilization; - const level = windowLevel(util, win.resets_at, totalSeconds); - let caption = win.resets_at ? relativeReset(win.resets_at) + const util = w.utilization; + const level = maxLevel(windowLevel(util, w.resetsAt, w.totalSeconds), w.apiLevel); + let caption = w.resetsAt ? relativeReset(w.resetsAt) : (util > 0 ? '' : 'not used yet'); - const note = projectionNote(util, win.resets_at, totalSeconds); + const note = projectionNote(util, w.resetsAt, w.totalSeconds); if (note) caption = caption ? `${caption} · ${note}` : note; meter.setValue(util, caption, level); } - // Which usage window the panel reflects, per the panel-window preference. + // Final level for a normalised window: the computed burn consequence, + // floored at the API's severity. + _windowLevel(w) { + return maxLevel(windowLevel(w.utilization, w.resetsAt, w.totalSeconds), w.apiLevel); + } + + // The worst window to surface in the panel: the highest severity among the + // active limits (or all of them if none are marked active), breaking ties by + // utilization. This is how a 100% scoped window (e.g. Fable) reaches the bar + // even when the session and weekly totals are calm. + _worstWindow(windows) { + const active = windows.filter(w => w.isActive); + const pool = active.length ? active : windows; + const score = w => LEVEL_RANK[this._windowLevel(w)] * 1000 + (Number(w.utilization) || 0); + return pool.reduce((best, w) => (score(w) > score(best) ? w : best)); + } + + // Which normalised usage window the panel reflects, per the panel-window + // preference. _panelWindow() { - const u = this._lastUsage; - if (!u) + const windows = this._windows; + if (!windows || !windows.length) return null; switch (this._settings.get_string('panel-window')) { case 'seven-day': - return {win: u.seven_day, total: SEVEN_DAY_SECONDS}; - case 'max': { - const fu = u.five_hour?.utilization ?? -1; - const su = u.seven_day?.utilization ?? -1; - return su > fu - ? {win: u.seven_day, total: SEVEN_DAY_SECONDS} - : {win: u.five_hour, total: FIVE_HOUR_SECONDS}; - } + return windows.find(w => w.role === 'weekly') ?? windows[0]; + case 'worst': + return this._worstWindow(windows); + case 'max': + return windows.reduce((best, w) => + (Number(w.utilization) || 0) > (Number(best.utilization) || 0) ? w : best); case 'five-hour': default: - return {win: u.five_hour, total: FIVE_HOUR_SECONDS}; + return windows.find(w => w.role === 'session') ?? windows[0]; } } _renderPanel() { const sel = this._panelWindow(); - if (!sel || !sel.win) { + if (!sel || !Number.isFinite(sel.utilization)) { this._panelPct.text = '—'; this._panelPct.style_class = 'cu-panel-pct'; this._ring.setUnknown(); @@ -763,11 +779,11 @@ class ClaudeUsageIndicator extends PanelMenu.Button { this._panelReset.text = ''; return; } - const util = sel.win.utilization; - const level = windowLevel(util, sel.win.resets_at, sel.total); + const util = sel.utilization; + const level = this._windowLevel(sel); this._panelPct.text = `${Math.round(util)}%`; this._panelPct.style_class = `cu-panel-pct ${levelClass(level)}`; - this._panelReset.text = sel.win.resets_at ? compactReset(sel.win.resets_at) : ''; + this._panelReset.text = sel.resetsAt ? compactReset(sel.resetsAt) : ''; this._ring.setValue(util, level); this._panelBar.setValue(util, level); } @@ -822,18 +838,15 @@ class ClaudeUsageIndicator extends PanelMenu.Button { // Tear down the gauge/meter helpers and release their references; the // actors themselves also go with super.destroy(), but releasing here // keeps ownership explicit. - this._fiveHour?.destroy(); - this._sevenDay?.destroy(); - for (const meter of this._perModelMeters.values()) + for (const meter of this._meters.values()) meter.destroy(); - this._perModelMeters.clear(); + this._meters.clear(); this._panelBar?.destroy(); - this._fiveHour = null; - this._sevenDay = null; this._panelBar = null; this._ring = null; this._panelReset = null; this._meterBindings = []; + this._windows = []; this._lastUsage = null; this._client = null; diff --git a/src/lib/usageModel.js b/src/lib/usageModel.js new file mode 100644 index 0000000..01b6374 --- /dev/null +++ b/src/lib/usageModel.js @@ -0,0 +1,214 @@ +// Pure data-shaping for the usage endpoint: turns the API payload into the +// ordered list of windows the popup renders, and normalises the spend / extra- +// usage money block. No GI or shell imports, so it runs under plain `node` and +// `gjs` and is unit-testable in isolation (see tools/poll.js and the tests). +// +// The endpoint recently moved its per-window data out of the flat top-level +// keys (`five_hour`, `seven_day`, `seven_day_`) and into a single self- +// describing `limits[]` array, where each entry carries its own `kind`, +// `group`, `percent`, `severity`, `resets_at`, and an optional `scope` naming a +// specific model (e.g. Fable) or surface. We prefer that array when present and +// fall back to the legacy keys for older responses / accounts. + +// Window length (seconds) by limit `group`, for the burn-rate projection. +const FIVE_HOUR_SECONDS = 5 * 3600; +const SEVEN_DAY_SECONDS = 7 * 24 * 3600; +export const GROUP_SECONDS = {session: FIVE_HOUR_SECONDS, weekly: SEVEN_DAY_SECONDS}; + +// Map the API's severity string to the extension's internal level. Unknown or +// missing severities are treated as calm ('ok') so a new value never trips the +// gauge red on its own — the computed burn model still colours it. +export function apiSeverityLevel(sev) { + switch (sev) { + case 'critical': + return 'crit'; + case 'warning': + return 'warn'; + default: // 'normal', unknown, or missing + return 'ok'; + } +} + +// Title-case an API token like "oauth_apps" or "weekly_scoped" → "Oauth Apps". +function humanizeToken(s) { + return String(s ?? '') + .split(/[_\s]+/) + .filter(Boolean) + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +// Friendly names for known scoped-model / suffix tokens; anything else is +// title-cased so a model the API adds later still reads sensibly. +const KNOWN_MODEL = {opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku', oauth_apps: 'OAuth Apps'}; +export function modelLabel(name) { + return KNOWN_MODEL[name] ?? humanizeToken(name); +} + +// Display label for a `limits[]` entry. +export function limitLabel(entry) { + const model = entry?.scope?.model?.display_name; + const surface = entry?.scope?.surface; + let base; + switch (entry?.kind) { + case 'session': + base = '5-hour'; + break; + case 'weekly_all': + base = '7-day (all models)'; + break; + case 'weekly_scoped': + base = model ? `7-day ${model}` : '7-day (scoped)'; + break; + default: + base = humanizeToken(entry?.kind || entry?.group || 'usage'); + if (model) + base += ` ${model}`; + } + if (surface) + base += ` · ${humanizeToken(surface)}`; + return base; +} + +// Stable identity for a `limits[]` entry so a meter can be reused across polls +// and torn down only when the API stops reporting that window. +export function limitKey(entry) { + const parts = ['limit', entry?.kind ?? '?', entry?.group ?? '?']; + const model = entry?.scope?.model?.display_name ?? entry?.scope?.model?.id; + if (model) + parts.push(model); + if (entry?.scope?.surface) + parts.push(entry.scope.surface); + return parts.join(':'); +} + +// Coarse role, so the panel-window selector can find "the session window" or +// "the weekly window" without caring which API shape produced it. +function limitRole(entry) { + if (entry?.kind === 'session' || entry?.group === 'session') + return 'session'; + if (entry?.kind === 'weekly_all') + return 'weekly'; + if (entry?.kind === 'weekly_scoped') + return 'scoped'; + if (entry?.group === 'weekly') + return 'weekly'; + return 'other'; +} + +const ROLE_ORDER = {session: 0, weekly: 1, scoped: 2, other: 3}; + +// A normalised usage window, independent of which API shape it came from: +// {key, label, role, utilization, resetsAt, totalSeconds, apiLevel, isActive, order} +function fromLimit(entry) { + const role = limitRole(entry); + const totalSeconds = GROUP_SECONDS[entry.group] ?? + (role === 'scoped' || role === 'weekly' ? SEVEN_DAY_SECONDS + : role === 'session' ? FIVE_HOUR_SECONDS : null); + return { + key: limitKey(entry), + label: limitLabel(entry), + role, + utilization: Number(entry.percent), + resetsAt: entry.resets_at ?? null, + totalSeconds, + apiLevel: apiSeverityLevel(entry.severity), + isActive: !!entry.is_active, + order: ROLE_ORDER[role], + }; +} + +function legacyWindow(key, label, role, win, totalSeconds) { + return { + key: `legacy:${key}`, + label, + role, + utilization: Number(win.utilization), + resetsAt: win.resets_at ?? null, + totalSeconds, + apiLevel: 'ok', + isActive: false, + order: ROLE_ORDER[role], + }; +} + +// The ordered list of windows to display. Prefers the self-describing +// `limits[]` array; falls back to the legacy flat keys (`five_hour`, +// `seven_day`, `seven_day_`) for older API responses or accounts that +// still return them. Entries without a numeric utilization are dropped. +export function normalizeWindows(usage) { + const limits = usage?.limits; + if (Array.isArray(limits) && limits.length) { + const out = limits + .filter(l => l && l.percent != null && Number.isFinite(Number(l.percent))) + .map(fromLimit); + if (out.length) { + out.sort((a, b) => a.order - b.order); + return out; + } + } + + const out = []; + if (usage?.five_hour) + out.push(legacyWindow('five_hour', '5-hour', 'session', usage.five_hour, FIVE_HOUR_SECONDS)); + if (usage?.seven_day) + out.push(legacyWindow('seven_day', '7-day', 'weekly', usage.seven_day, SEVEN_DAY_SECONDS)); + for (const key of Object.keys(usage ?? {})) { + const m = /^seven_day_(.+)$/.exec(key); + const win = usage[key]; + if (m && win) + out.push(legacyWindow(key, `7-day ${modelLabel(m[1])}`, 'scoped', win, SEVEN_DAY_SECONDS)); + } + return out; +} + +// Format a structured minor-unit money amount ({amount_minor, currency, +// exponent}) as e.g. "USD 416.54". Returns null when the amount is missing. +function formatMinor(m) { + if (!m || !Number.isFinite(Number(m.amount_minor))) + return null; + const exp = Number.isFinite(Number(m.exponent)) ? Number(m.exponent) : 2; + const value = Number(m.amount_minor) / Math.pow(10, exp); + const cur = m.currency ? `${m.currency} ` : ''; + return `${cur}${value.toFixed(exp)}`; +} + +// Normalise the "extra usage" money block. Prefers the structured `spend` +// object (authoritative minor-unit amounts + severity); falls back to the older +// `extra_usage` shape, now scaling by its own `decimal_places` instead of a +// hard-coded /100. Returns {used, limit, percent, level} or null when there is +// nothing to show. +export function normalizeSpend(usage) { + const spend = usage?.spend; + if (spend && spend.enabled) { + const used = formatMinor(spend.used); + const limit = formatMinor(spend.limit) ?? formatMinor(spend.cap?.money) ?? formatMinor(spend.cap?.credits); + if (used || limit) { + return { + used: used ?? null, + limit: limit ?? null, + percent: Number.isFinite(Number(spend.percent)) ? Number(spend.percent) : null, + level: apiSeverityLevel(spend.severity), + }; + } + } + + const xu = usage?.extra_usage; + if (xu && xu.is_enabled) { + const places = Number.isFinite(Number(xu.decimal_places)) ? Number(xu.decimal_places) : 2; + const div = Math.pow(10, places); + const cur = xu.currency ? `${xu.currency} ` : ''; + const money = v => Number.isFinite(v) ? `${cur}${(v / div).toFixed(places)}` : null; + const used = money(Number(xu.used_credits)); + const limit = Number(xu.monthly_limit) > 0 ? money(Number(xu.monthly_limit)) : null; + if (used || limit) { + return { + used: used ?? `${cur}${(0).toFixed(places)}`, + limit, + percent: Number.isFinite(Number(xu.utilization)) ? Math.round(Number(xu.utilization)) : null, + level: 'ok', + }; + } + } + return null; +} diff --git a/src/prefs.js b/src/prefs.js index 122dcbb..d4f3f7c 100644 --- a/src/prefs.js +++ b/src/prefs.js @@ -99,7 +99,8 @@ export default class ClaudeUsagePreferences extends ExtensionPreferences { windows.append('5-hour window'); windows.append('7-day window'); windows.append('Most constrained'); - const windowKeys = ['five-hour', 'seven-day', 'max']; + windows.append('Worst active limit'); + const windowKeys = ['five-hour', 'seven-day', 'max', 'worst']; const windowRow = new Adw.ComboRow({ title: 'Panel reflects', diff --git a/src/schemas/org.gnome.shell.extensions.claude-usage.gschema.xml b/src/schemas/org.gnome.shell.extensions.claude-usage.gschema.xml index 33204f2..36ba5e8 100644 --- a/src/schemas/org.gnome.shell.extensions.claude-usage.gschema.xml +++ b/src/schemas/org.gnome.shell.extensions.claude-usage.gschema.xml @@ -37,10 +37,11 @@ + "five-hour" Panel usage window - Which usage window the panel ring and percentage reflect. + Which usage window the panel ring and percentage reflect: the 5-hour window, the 7-day window, the one with the highest utilization (max), or the worst active limit by severity (worst) — the latter surfaces a maxed-out per-model window such as Fable. diff --git a/src/stylesheet.css b/src/stylesheet.css index 1f30278..7295f9b 100644 --- a/src/stylesheet.css +++ b/src/stylesheet.css @@ -101,6 +101,8 @@ color: rgba(128, 128, 128, 1.0); margin: 2px 0 4px 0; } +.cu-extra.cu-warn { color: #ffa348; } +.cu-extra.cu-crit { color: #ff6b6b; } .cu-error { font-size: 9.5pt; color: #ff9c8a; diff --git a/tools/poll.js b/tools/poll.js index aca7094..69e0ecb 100644 --- a/tools/poll.js +++ b/tools/poll.js @@ -3,6 +3,7 @@ // gjs -m tools/poll.js (run from the repository root) import GLib from 'gi://GLib'; import {UsageClient} from '../src/lib/usageClient.js'; +import {normalizeWindows, normalizeSpend} from '../src/lib/usageModel.js'; const loop = GLib.MainLoop.new(null, false); @@ -18,16 +19,24 @@ async function run() { print(' rate tier:', profile.organization?.rate_limit_tier); const usage = await client.fetchUsage(); - print('\nusage windows:'); - for (const key of ['five_hour', 'seven_day', 'seven_day_opus', 'seven_day_sonnet']) { - const w = usage[key]; - if (w) - print(` ${key}: ${w.utilization}% resets ${w.resets_at}`); - else - print(` ${key}: (null)`); + + // Raw top-level keys, so it is obvious which shape the API is returning. + print('\nraw usage keys:', Object.keys(usage).join(', ')); + print(` limits[]: ${Array.isArray(usage.limits) ? usage.limits.length : '(none)'} entr${usage.limits?.length === 1 ? 'y' : 'ies'}`); + + // Normalised windows — exactly what the extension renders. + print('\nusage windows (normalised):'); + for (const w of normalizeWindows(usage)) { + const active = w.isActive ? ' [active]' : ''; + const sev = w.apiLevel !== 'ok' ? ` api:${w.apiLevel}` : ''; + print(` ${w.label}: ${Math.round(w.utilization)}% resets ${w.resetsAt ?? '(n/a)'}${sev}${active}`); + } + + const spend = normalizeSpend(usage); + if (spend) { + const pct = spend.percent !== null ? ` (${spend.percent}%)` : ''; + print(`\nextra usage: ${[spend.used, spend.limit].filter(Boolean).join(' / ')}${pct} [${spend.level}]`); } - if (usage.extra_usage) - print(` extra_usage: ${usage.extra_usage.used_credits}/${usage.extra_usage.monthly_limit} ${usage.extra_usage.currency}`); } run() diff --git a/tools/test-usageModel.mjs b/tools/test-usageModel.mjs new file mode 100644 index 0000000..a274405 --- /dev/null +++ b/tools/test-usageModel.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// Unit tests for the pure usage model. Runs under plain node (no GI): +// node tools/test-usageModel.mjs +import assert from 'node:assert/strict'; +import { + normalizeWindows, normalizeSpend, apiSeverityLevel, limitLabel, limitKey, +} from '../src/lib/usageModel.js'; + +let passed = 0; +const test = (name, fn) => { + fn(); + passed++; + console.log(` ok ${name}`); +}; + +// --- severity mapping --- +test('apiSeverityLevel maps the three known values + falls back to ok', () => { + assert.equal(apiSeverityLevel('critical'), 'crit'); + assert.equal(apiSeverityLevel('warning'), 'warn'); + assert.equal(apiSeverityLevel('normal'), 'ok'); + assert.equal(apiSeverityLevel('something-new'), 'ok'); + assert.equal(apiSeverityLevel(undefined), 'ok'); +}); + +// --- new limits[] shape (real payload, model scoped to Fable at 100%) --- +const live = { + five_hour: {utilization: 8.0, resets_at: '2026-07-13T07:40:00+00:00'}, + seven_day: {utilization: 64.0, resets_at: '2026-07-15T14:00:00+00:00'}, + seven_day_opus: null, + tangelo: null, + limits: [ + {kind: 'session', group: 'session', percent: 8, severity: 'normal', resets_at: '2026-07-13T07:40:00+00:00', scope: null, is_active: false}, + {kind: 'weekly_all', group: 'weekly', percent: 64, severity: 'normal', resets_at: '2026-07-15T14:00:00+00:00', scope: null, is_active: false}, + {kind: 'weekly_scoped', group: 'weekly', percent: 100, severity: 'critical', resets_at: '2026-07-15T14:00:00+00:00', scope: {model: {id: null, display_name: 'Fable'}, surface: null}, is_active: true}, + ], + spend: { + used: {amount_minor: 41654, currency: 'USD', exponent: 2}, + limit: {amount_minor: 50000, currency: 'USD', exponent: 2}, + percent: 83, severity: 'warning', enabled: true, + }, + extra_usage: {is_enabled: true, monthly_limit: 50000, used_credits: 41654.0, utilization: 83.308, currency: 'USD', decimal_places: 2}, +}; + +test('normalizeWindows prefers limits[] and surfaces the scoped Fable window', () => { + const ws = normalizeWindows(live); + assert.equal(ws.length, 3, 'three windows'); + // ordered: session, weekly_all, weekly_scoped + assert.deepEqual(ws.map(w => w.role), ['session', 'weekly', 'scoped']); + assert.deepEqual(ws.map(w => w.label), ['5-hour', '7-day (all models)', '7-day Fable']); + const fable = ws[2]; + assert.equal(fable.utilization, 100); + assert.equal(fable.apiLevel, 'crit'); + assert.equal(fable.isActive, true); + assert.equal(fable.totalSeconds, 7 * 24 * 3600); + assert.equal(fable.resetsAt, '2026-07-15T14:00:00+00:00'); + // stable, model-qualified key + assert.equal(fable.key, 'limit:weekly_scoped:weekly:Fable'); +}); + +test('limits[] entries without a numeric percent are dropped', () => { + const ws = normalizeWindows({limits: [ + {kind: 'session', group: 'session', percent: 5, severity: 'normal'}, + {kind: 'weekly_scoped', group: 'weekly', percent: null, severity: 'normal', scope: {model: {display_name: 'Ghost'}}}, + ]}); + assert.equal(ws.length, 1); + assert.equal(ws[0].role, 'session'); +}); + +// --- legacy fallback --- +test('normalizeWindows falls back to legacy keys when limits[] is absent', () => { + const ws = normalizeWindows({ + five_hour: {utilization: 12, resets_at: 'a'}, + seven_day: {utilization: 40, resets_at: 'b'}, + seven_day_sonnet: {utilization: 22, resets_at: 'c'}, + seven_day_opus: null, + }); + assert.deepEqual(ws.map(w => w.label), ['5-hour', '7-day', '7-day Sonnet']); + assert.deepEqual(ws.map(w => w.role), ['session', 'weekly', 'scoped']); + assert.equal(ws.every(w => w.apiLevel === 'ok'), true); + assert.equal(ws[0].key, 'legacy:five_hour'); +}); + +test('empty limits[] falls back rather than rendering nothing', () => { + const ws = normalizeWindows({limits: [], five_hour: {utilization: 3, resets_at: 'x'}}); + assert.equal(ws.length, 1); + assert.equal(ws[0].role, 'session'); +}); + +test('empty/garbage usage yields no windows without throwing', () => { + assert.deepEqual(normalizeWindows(null), []); + assert.deepEqual(normalizeWindows({}), []); +}); + +// --- labels & keys for unknown future models --- +test('unknown scoped models are humanised, not dropped', () => { + const e = {kind: 'weekly_scoped', group: 'weekly', percent: 1, scope: {model: {display_name: 'Cinder Cove'}, surface: 'api'}}; + assert.equal(limitLabel(e), '7-day Cinder Cove · Api'); + assert.equal(limitKey(e), 'limit:weekly_scoped:weekly:Cinder Cove:api'); +}); + +// --- spend / extra_usage --- +test('normalizeSpend prefers the structured spend object', () => { + const s = normalizeSpend(live); + assert.equal(s.used, 'USD 416.54'); + assert.equal(s.limit, 'USD 500.00'); + assert.equal(s.percent, 83); + assert.equal(s.level, 'warn'); +}); + +test('normalizeSpend falls back to extra_usage scaled by decimal_places', () => { + const s = normalizeSpend({extra_usage: {is_enabled: true, monthly_limit: 50000, used_credits: 41654, utilization: 83.3, currency: 'USD', decimal_places: 2}}); + assert.equal(s.used, 'USD 416.54'); + assert.equal(s.limit, 'USD 500.00'); + assert.equal(s.percent, 83); + assert.equal(s.level, 'ok'); +}); + +test('normalizeSpend returns null when spend is disabled and no extra_usage', () => { + assert.equal(normalizeSpend({spend: {enabled: false}}), null); + assert.equal(normalizeSpend({}), null); +}); + +console.log(`\n${passed} tests passed`);