diff --git a/.github/social-preview.png b/.github/social-preview.png new file mode 100644 index 0000000..a212827 Binary files /dev/null and b/.github/social-preview.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60dba5c..32f9da7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + node-version-file: .nvmrc cache: npm - name: Install dependencies diff --git a/AGENTS.md b/AGENTS.md index 3a7bc51..da6bb72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ The repository is **public** on GitHub, MIT licensed, and published to both the Two independently compiled worlds that only talk through `postMessage`: 1. **Extension host** (`src/`, `tsconfig.json`, `module: node16`) - - `services/ChutesApiClient.ts` — authenticated `fetch` against `https://api.chutes.ai`, 15 s abort timeout per request. `/users/me/subscription_usage` and `/users/me/quotas` are required; `/pricing`, `/users/me/quota_usage/me`, `/users/me/quota_usage/{chute_id}`, `/invocations/stats/llm`, `/users/me` are optional and fail soft. + - `services/ChutesApiClient.ts` — authenticated `fetch` against `https://api.chutes.ai`, 15 s abort timeout per request. `/users/me/subscription_usage` and `/users/me/quotas` are required; `/users/me/quota_usage/me`, `/users/me/quota_usage/{chute_id}`, `/invocations/stats/llm`, and `/users/me` are optional and fail soft. Per-chute fallbacks run with bounded concurrency. - `services/normalize.ts` — defensive normalization of loose API payloads into `DashboardData`, plus the compact status bar summary. All API shape tolerance lives here. - `services/SecretStore.ts` — the only place the API key is read or written, always through `vscode.SecretStorage`. - `services/externalLinks.ts` — https-only allowlist (`https://chutes.ai`) for anything the webview asks the host to open. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ebc433..5579991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +- Refreshed the public project presentation with a current dashboard screenshot rendered from the real webview using synthetic values, a custom GitHub social preview, clearer installation links, and a more user-focused README introduction. +- Cleared the `fast-uri` and `undici` development dependency advisories with an npm-managed lockfile refresh. +- Stopped persisting dashboard account snapshots in webview state. Only the Plan Limits collapsed preference is retained, and the cache schema is now version 3. +- Removed the unrelated `/pricing` request. Plan reference prices and PAYG discounts are clearly identified as public references, while account-specific limits are shown only when returned for the current plan; unavailable values use `--`. +- Tightened host-to-webview projections and runtime validators so quota rows and unknown fields cannot cross the message boundary. +- Improved accessibility with contrast-safe light-theme accents, live sync announcements, truthful progressbar values, semantic section headings, and text labels for low or exhausted PAYG credit. +- Limited per-chute quota fallback requests to five concurrent calls and pinned CI to the exact Node.js version in `.nvmrc`. + ## 0.5.4 — 2026-08-01 - Stopped sending the per-model quota rows to the dashboard webview. They are only used by the extension host to derive the daily window, so every state message and the state the webview persists are now smaller. No visible change to the dashboard. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e21c27..b031e79 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for your interest in improving `Chutes Usage Monitor`. ## Prerequisites -- Node.js 22 or newer +- Node.js 22.17.0 (the exact version in `.nvmrc`) - `npm` - Visual Studio Code diff --git a/README.md b/README.md index 2f82522..9bd134b 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,16 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Sponsor](https://img.shields.io/badge/Sponsor-TheStreamCode-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/TheStreamCode) -Monitor Chutes subscription usage, rolling limits, and request quotas directly inside VS Code. +Keep Chutes usage visible while you code. The sidebar shows subscription spend, rolling limits, daily requests, and pay-as-you-go credit without leaving VS Code. + +[Install from the VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=mikesoft.chutes-usage-vscode) · [Install from Open VSX](https://open-vsx.org/extension/mikesoft/chutes-usage-vscode) · [Read the user guide](docs/user-guide.md) This is an unofficial third-party extension and is not affiliated with or endorsed by Chutes. ![Chutes Usage Monitor dashboard](media/screenshot-chutes-usage.png) +_Current dashboard rendered with synthetic, illustrative values._ + ## Requirements - VS Code `1.103.0` or newer (or a compatible editor that installs from Open VSX) @@ -31,28 +35,14 @@ After installation: 2. Run `Chutes Usage Monitor: Set API Key`. 3. Open the `Chutes Usage Monitor` view from the Activity Bar. -## Features - -- Sidebar dashboard aligned with the chutes.ai look: pure-black on dark themes, pill buttons, mint pill badges, headline-violet section titles, generous whitespace -- Theme-aware: backgrounds, foregrounds, borders, and description text follow VS Code theme tokens; works with Light, Dark, and High Contrast themes -- Plan snapshot, per-window usage cards with progress bars, and a Plan Limits reference that highlights your current tier -- PAYG credit tile showing your pay-as-you-go account balance, with amber/red warnings when the balance runs low or reaches zero -- Compact status bar summary with severity-aware codicon and background color; full detail in the tooltip -- Secure API key storage through VS Code `SecretStorage` -- Manual refresh command for immediate sync -- Automatic refresh on a configurable timer, when the dashboard becomes visible again, and when the VS Code window regains focus -- Live `refresh in Ns` countdown driven by your `refreshIntervalSeconds` setting -- Cached state restore on side-panel reopen — no `Loading…` flash, no flicker on every tick -- Accessible by default: ARIA progressbar/alert/status semantics, `prefers-reduced-motion` support, and visible focus rings -- Hardened webview CSP with a per-load nonce and scoped `font-src`/`img-src` - -## Latest Changes - -- `0.5.4` trims the dashboard state message to the fields the webview renders, removes dead status bar and webview code, syncs the citation metadata, and expands the project documentation. -- `0.5.3` hardens webview message and cache handling, resolves the remaining CodeQL findings, adds stricter local and CI quality gates, improves cross-platform cleanup, and live-validates the current Chutes API integration. -- `0.5.2` improves legal documentation, trademark notices, third-party terms references, and project metadata. +## What You Get -See the [changelog](CHANGELOG.md) for the complete release history. +- A focused sidebar dashboard for billing-cycle, rolling four-hour, and daily request usage +- Plan context and PAYG credit at a glance, with visible low- and no-credit warnings +- An optional status bar summary for quick checks without opening the dashboard +- Manual and automatic refresh when the timer fires, the view reopens, or VS Code regains focus +- Native Light, Dark, and High Contrast theme support with keyboard and screen-reader accessibility +- Read-only account access, secure API-key storage, no local usage history, and a locked-down webview ## Commands @@ -78,7 +68,7 @@ The sidebar dashboard includes: - a compact header with sync state and actions - a plan snapshot with the most relevant subscription figures, including your pay-as-you-go credit balance - stacked usage cards optimized for narrow Activity Bar layouts -- a `Plan Limits` reference section that uses pricing data when available without exposing a raw quota payload table +- a `Plan Limits` reference section with public plan prices and PAYG discounts; current-plan limits come from your account and unavailable values display as `--` The dashboard refreshes when you run the refresh command, on the configured refresh interval, when the dashboard becomes visible again, and when VS Code regains window focus. @@ -96,11 +86,20 @@ Settings changes for refresh interval and status bar visibility apply immediatel - Your Chutes API key is stored using VS Code `SecretStorage`. - The extension uses the key only to request your own usage data. - The extension does not keep a local history of usage data. +- The webview persists only whether the Plan Limits section is collapsed, never an account snapshot. - On uninstall, the extension performs best-effort cleanup of its local extension storage. +## Latest Changes + +- `Unreleased` clears dependency advisories, removes persisted usage snapshots and the unrelated pricing request, tightens the webview boundary, and improves accessibility. +- `0.5.4` trims dashboard state, removes dead code, synchronizes release metadata, and expands the project documentation. +- `0.5.3` strengthens webview security, repository quality gates, cross-platform cleanup, and API compatibility. + +See the [changelog](CHANGELOG.md) for the complete release history and the [latest GitHub release](https://github.com/TheStreamCode/chutes-usage-vscode/releases/latest) for downloadable artifacts. + ## Development -Requires Node.js `22` (see `.nvmrc`) and `npm`. The lockfile is authoritative — do not switch package manager. +Requires Node.js `22.17.0` (see `.nvmrc`) and `npm`. The lockfile is authoritative — do not switch package manager. ```bash npm ci # install the locked dependencies @@ -135,6 +134,8 @@ out/ build output (generated, not committed) - [User guide](docs/user-guide.md) - [Troubleshooting](docs/troubleshooting.md) - [Documentation index](docs/README.md) +- [Questions and community support](https://github.com/TheStreamCode/chutes-usage-vscode/discussions) +- [Bug reports and feature requests](https://github.com/TheStreamCode/chutes-usage-vscode/issues) - [Contributing](CONTRIBUTING.md) - [Support](SUPPORT.md) - [Security](SECURITY.md) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 85e743d..8ec1383 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -68,10 +68,10 @@ The extension currently relies on these endpoints for user-facing usage data: - `GET /users/me/quota_usage/me` - `GET /users/me/quota_usage/{chute_id}` - `GET /invocations/stats/llm` -- `GET /pricing` - `GET /users/me` Some endpoints are used as fallbacks when the primary payload is incomplete or delayed. +The Plan Limits link opens the public `https://chutes.ai/pricing` page; the extension does not fetch or parse that page as account data. ## API Compatibility diff --git a/docs/user-guide.md b/docs/user-guide.md index 12a79fc..2116ece 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -29,7 +29,7 @@ It includes: - a header with connection status and actions - a plan snapshot with key subscription details, including your pay-as-you-go credit balance - stacked usage cards for the main billing windows -- a `Plan Limits` section with monthly cap, daily request, burst, and PAYG discount references, using live pricing data when available +- a `Plan Limits` section with public plan prices and PAYG discounts; monthly, daily, and burst limits are filled only for your current plan when the account API provides them Depending on the current state, the dashboard shows onboarding, loading, ready, or error content. @@ -67,7 +67,7 @@ The extension refreshes data in three ways: Use `chutesUsageVscode.refreshIntervalSeconds` to control the refresh interval. -When you reopen the dashboard after a recent refresh, the latest snapshot is shown immediately and then refreshed again in the background. +While the extension host remains active, reopening the dashboard can reuse its in-memory snapshot before refreshing. The webview itself persists only whether the Plan Limits section is collapsed and never stores usage or account values. ## Usage Data Shown @@ -77,8 +77,8 @@ Depending on the API responses available for your account, the extension can dis - 4-hour rolling window usage - daily request usage - plan information such as subscription price or caps when available -- your pay-as-you-go credit balance (the account credit that funds requests beyond your subscription caps), highlighted in amber when it runs low and in red when it reaches zero -- a built-in subscription limits reference based on pricing data when available, with packaged Plus and Pro defaults as a fallback +- your pay-as-you-go credit balance (the account credit that funds requests beyond your subscription caps), with visible `Low credit` or `No credit` text and warning colors when applicable +- a Plus and Pro reference using public prices and PAYG discounts; other limits show `--` unless they are verified from the current account ## Managing Your API Key diff --git a/media/screenshot-chutes-usage.png b/media/screenshot-chutes-usage.png index 12511b3..e671a87 100644 Binary files a/media/screenshot-chutes-usage.png and b/media/screenshot-chutes-usage.png differ diff --git a/package-lock.json b/package-lock.json index f0f961b..48e9bf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1937,9 +1937,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -4046,9 +4046,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { diff --git a/src/services/ChutesApiClient.ts b/src/services/ChutesApiClient.ts index 273f5e1..bba34e8 100644 --- a/src/services/ChutesApiClient.ts +++ b/src/services/ChutesApiClient.ts @@ -1,15 +1,16 @@ import { API_BASE_URL } from '../constants' import type { JsonContainer, JsonObject } from '../types' +const QUOTA_USAGE_CONCURRENCY = 5 + export class ChutesApiClient { public constructor(private readonly apiKey: string) {} // Fetch all user-facing dashboard endpoints needed for the first extension version. - public async getDashboardPayload(): Promise<{ subscriptionUsage: JsonObject; quotas: JsonContainer; quotaUsageMe: JsonContainer | null; quotaUsageFallback: JsonContainer | null; invocationStatsLlm: JsonContainer | null; pricing: JsonContainer | null; me: JsonContainer | null }> { - const [subscriptionUsage, quotas, pricing, quotaUsageMe, invocationStatsLlm, me] = await Promise.all([ + public async getDashboardPayload(): Promise<{ subscriptionUsage: JsonObject; quotas: JsonContainer; quotaUsageMe: JsonContainer | null; quotaUsageFallback: JsonContainer | null; invocationStatsLlm: JsonContainer | null; me: JsonContainer | null }> { + const [subscriptionUsage, quotas, quotaUsageMe, invocationStatsLlm, me] = await Promise.all([ this.getJsonContainer('/users/me/subscription_usage'), this.getJsonContainer('/users/me/quotas'), - this.getJsonContainer('/pricing').catch(() => null), this.getJsonContainer('/users/me/quota_usage/me').catch(() => null), this.getJsonContainer('/invocations/stats/llm').catch(() => null), this.getJsonContainer('/users/me').catch(() => null) @@ -20,7 +21,7 @@ export class ChutesApiClient { throw new Error('Unexpected API response shape for /users/me/subscription_usage') } - return { subscriptionUsage, quotas, quotaUsageMe, quotaUsageFallback, invocationStatsLlm, pricing, me } + return { subscriptionUsage, quotas, quotaUsageMe, quotaUsageFallback, invocationStatsLlm, me } } // Execute one authenticated GET request and return a JSON object or array payload. @@ -60,11 +61,11 @@ export class ChutesApiClient { return null } - const entries = await Promise.all(chuteIds.map(async (chuteId) => { + const entries = await mapWithConcurrency(chuteIds, QUOTA_USAGE_CONCURRENCY, async (chuteId) => { const path = `/users/me/quota_usage/${encodePathSegment(chuteId)}` const payload = await this.getJsonContainer(path).catch(() => null) return payload === null ? null : [chuteId, payload] as const - })) + }) const validEntries = entries.filter((entry): entry is readonly [string, JsonContainer] => entry !== null) if (validEntries.length === 0) { @@ -75,6 +76,25 @@ export class ChutesApiClient { } } +async function mapWithConcurrency(items: readonly T[], concurrency: number, mapper: (item: T) => Promise): Promise { + const results = new Array(items.length) + let nextIndex = 0 + const workerCount = Math.min(concurrency, items.length) + + const workers = Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex++ + const item = items[index] + if (item !== undefined) { + results[index] = await mapper(item) + } + } + }) + + await Promise.all(workers) + return results +} + function isJsonObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/src/services/normalize.ts b/src/services/normalize.ts index f325dab..52c065c 100644 --- a/src/services/normalize.ts +++ b/src/services/normalize.ts @@ -6,7 +6,6 @@ export function normalizeDashboardData( quotaUsagePayload: JsonContainer | null = null, quotaUsageMePayload: JsonContainer | null = null, invocationStatsPayload: JsonContainer | null = null, - pricingPayload: JsonContainer | null = null, mePayload: JsonContainer | null = null ): DashboardData { const usageSource = unwrapUsagePayload(subscriptionUsage) @@ -15,12 +14,13 @@ export function normalizeDashboardData( const quotaUsageMe = normalizeQuotaUsage(quotaUsageMePayload) const invocationStats = normalizeInvocationStats(invocationStatsPayload) const windows = normalizeUsageWindows(usageSource, quotas, quotaUsageFallback, quotaUsageMe, invocationStats) + const plan = normalizePlan(usageSource, windows) return { windows, quotas, - plan: normalizePlan(usageSource, windows), - planLimits: normalizePlanLimits(pricingPayload), + plan, + planLimits: normalizePlanLimits(plan), paygCreditUsd: normalizePaygCredit(mePayload) } } @@ -393,74 +393,38 @@ function getPlanNameFromMonthlyPrice(monthlyPrice: number): string | null { return null } -function normalizePlanLimits(payload: JsonContainer | null): PlanLimitEntry[] { - const items = getPlanLimitItems(payload) - const normalized = items - .map((item) => asObject(item)) - .filter((item): item is JsonObject => item !== null) - .map((item) => { - const name = asString(item.name) ?? asString(item.plan_name) ?? asString(item.label) - const monthlyPrice = asNumber(item.monthly_price) ?? asNumber(item.monthlyPriceUsd) - const monthlyCap = asNumber(item.monthly_cap_usd) ?? asNumber(item.monthlyCapUsd) - const dailyLimit = asNumber(item.daily_request_limit) ?? asNumber(item.dailyRequestLimit) - const fourHourCap = asNumber(item.four_hour_cap_usd) ?? asNumber(item.fourHourCapUsd) - const discount = asNumber(item.payg_discount_percent) ?? asNumber(item.paygDiscountPercent) - - if (name === null || monthlyPrice === null || monthlyCap === null || dailyLimit === null || fourHourCap === null || discount === null) { - return null - } - - if (isRetiredBasePlan(name, monthlyPrice)) { - return null - } - - return { - name, - priceLabel: `${formatUsd(monthlyPrice)}/mo`, - monthlyCapLabel: formatUsd(monthlyCap), - dailyRequestLimitLabel: formatInteger(dailyLimit), - fourHourCapLabel: formatUsd(fourHourCap), - paygDiscountLabel: `${Math.round(discount)}%` - } satisfies PlanLimitEntry - }) - .filter((item): item is PlanLimitEntry => item !== null) - - return normalized.length > 0 ? normalized : getDefaultPlanLimits() -} - -function getPlanLimitItems(payload: JsonContainer | null): JsonArray { - if (payload === null) { - return [] - } - - if (Array.isArray(payload)) { - return payload - } +function normalizePlanLimits(plan: PlanInfo | null): PlanLimitEntry[] { + // Public price and discount references verified on 2026-08-08. Exact limits + // are never inferred for another tier; only current-account values may fill them. + const references = [ + { name: 'Plus', monthlyPriceUsd: 10, paygDiscountPercent: 6 }, + { name: 'Pro', monthlyPriceUsd: 20, paygDiscountPercent: 10 } + ] - if (Array.isArray(payload.items)) { - return payload.items - } + const entries = references.map((reference) => { + const isCurrent = plan?.planName?.toLowerCase() === reference.name.toLowerCase() + return toPlanLimitEntry(reference.name, isCurrent ? plan : null, reference.monthlyPriceUsd, reference.paygDiscountPercent) + }) - if (Array.isArray(payload.plans)) { - return payload.plans + if (plan?.planName && !references.some((reference) => reference.name.toLowerCase() === plan.planName?.toLowerCase())) { + entries.unshift(toPlanLimitEntry(plan.planName, plan, null, null)) } - if (Array.isArray(payload.pricing)) { - return payload.pricing - } - - return [] + return entries } -function isRetiredBasePlan(name: string, monthlyPrice: number): boolean { - return name.toLowerCase() === 'base' || monthlyPrice === 3 -} +function toPlanLimitEntry(name: string, plan: PlanInfo | null, referenceMonthlyPrice: number | null, referenceDiscount: number | null): PlanLimitEntry { + const monthlyPrice = plan?.monthlyPriceUsd ?? referenceMonthlyPrice + const paygDiscount = plan?.paygDiscountPercent ?? referenceDiscount -function getDefaultPlanLimits(): PlanLimitEntry[] { - return [ - { name: 'Plus', priceLabel: '$10/mo', monthlyCapLabel: '$50', dailyRequestLimitLabel: '2,000', fourHourCapLabel: '$4.17', paygDiscountLabel: '6%' }, - { name: 'Pro', priceLabel: '$20/mo', monthlyCapLabel: '$100', dailyRequestLimitLabel: '5,000', fourHourCapLabel: '$8.33', paygDiscountLabel: '10%' } - ] + return { + name, + priceLabel: monthlyPrice === null ? '--' : `${formatUsd(monthlyPrice)}/mo`, + monthlyCapLabel: plan?.monthlyCapUsd === null || plan?.monthlyCapUsd === undefined ? '--' : formatUsd(plan.monthlyCapUsd), + dailyRequestLimitLabel: plan?.dailyRequestLimit === null || plan?.dailyRequestLimit === undefined ? '--' : formatInteger(plan.dailyRequestLimit), + fourHourCapLabel: plan?.fourHourCapUsd === null || plan?.fourHourCapUsd === undefined ? '--' : formatUsd(plan.fourHourCapUsd), + paygDiscountLabel: paygDiscount === null ? '--' : `${Math.round(paygDiscount)}%` + } } function formatUsd(value: number): string { diff --git a/src/state/DashboardStore.ts b/src/state/DashboardStore.ts index 0ae549e..1db2844 100644 --- a/src/state/DashboardStore.ts +++ b/src/state/DashboardStore.ts @@ -59,7 +59,6 @@ export class DashboardStore { payload.quotaUsageFallback, payload.quotaUsageMe, payload.invocationStatsLlm, - payload.pricing, payload.me ) diff --git a/src/state/webviewState.ts b/src/state/webviewState.ts index 9e7feec..f0968cb 100644 --- a/src/state/webviewState.ts +++ b/src/state/webviewState.ts @@ -1,13 +1,22 @@ -import type { DashboardState } from '../types' +import type { DashboardState, WebviewDashboardState } from '../types' // Send the webview only what the dashboard renders. The per-model `quotas` rows // are consumed by the extension host to derive the daily window and are never -// read by the webview, so they stay out of every `postMessage` payload and out -// of the state the webview persists through `vscode.setState`. -export function toWebviewState(state: DashboardState): DashboardState { - if (state.data === null || state.data.quotas.length === 0) { - return state +// read by the webview, so project an exact boundary type rather than forwarding +// the wider extension-host state object. +export function toWebviewState(state: DashboardState): WebviewDashboardState { + return { + connectionState: state.connectionState, + connected: state.connected, + lastUpdatedAt: state.lastUpdatedAt, + data: state.data === null + ? null + : { + windows: state.data.windows, + plan: state.data.plan, + planLimits: state.data.planLimits, + paygCreditUsd: state.data.paygCreditUsd + }, + errorMessage: state.errorMessage } - - return { ...state, data: { ...state.data, quotas: [] } } } diff --git a/src/test/ChutesApiClient.test.ts b/src/test/ChutesApiClient.test.ts index 0d0ba19..b30f37d 100644 --- a/src/test/ChutesApiClient.test.ts +++ b/src/test/ChutesApiClient.test.ts @@ -40,10 +40,6 @@ test('fetches fallback quota usage through documented per-chute endpoints when a return jsonResponse([]) } - if (url.endsWith('/pricing')) { - return jsonResponse([]) - } - if (url.endsWith('/users/me')) { return jsonResponse({ balance: 12.34 }) } @@ -63,6 +59,7 @@ test('fetches fallback quota usage through documented per-chute endpoints when a assert.ok(requestedUrls.some((url) => url.endsWith('/users/me/quota_usage/my-chute'))) assert.ok(requestedUrls.some((url) => url.endsWith('/users/me/quota_usage/me'))) assert.ok(requestedUrls.some((url) => url.endsWith('/invocations/stats/llm'))) + assert.ok(!requestedUrls.some((url) => url.endsWith('/pricing'))) assert.ok(!requestedUrls.some((url) => url.endsWith('/users/me/quota_usage/*'))) } finally { globalThis.fetch = originalFetch @@ -93,10 +90,6 @@ test('skips per-chute fallback quota usage when aggregate usage is available', a return jsonResponse([]) } - if (url.endsWith('/pricing')) { - return jsonResponse([]) - } - if (url.endsWith('/users/me')) { return jsonResponse({ balance: 12.34 }) } @@ -139,10 +132,6 @@ test('treats numeric string aggregate quota usage as available', async () => { return jsonResponse([]) } - if (url.endsWith('/pricing')) { - return jsonResponse([]) - } - if (url.endsWith('/users/me')) { return jsonResponse({ balance: 12.34 }) } @@ -177,10 +166,6 @@ test('returns null fallback quota usage when quota rows have no chute ids', asyn }) } - if (url.endsWith('/pricing')) { - return jsonResponse([]) - } - if (url.endsWith('/users/me/quota_usage/me')) { return jsonResponse({ used: 0, quota: 5000 }) } @@ -232,10 +217,6 @@ test('prefers quota_usage me and llm stats in the dashboard payload', async () = ]) } - if (url.endsWith('/pricing')) { - return jsonResponse([]) - } - if (url.endsWith('/users/me')) { return jsonResponse({ balance: 12.34 }) } @@ -260,6 +241,56 @@ test('prefers quota_usage me and llm stats in the dashboard payload', async () = } }) +test('limits per-chute fallback requests to five concurrent calls', async () => { + const originalFetch = globalThis.fetch + let inFlight = 0 + let maxInFlight = 0 + const chuteIds = Array.from({ length: 12 }, (_, index) => `chute-${index}`) + + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + + if (url.endsWith('/users/me/subscription_usage')) { + return jsonResponse({ subscription: true, monthly_price: 20 }) + } + + if (url.endsWith('/users/me/quotas')) { + return jsonResponse(chuteIds.map((chuteId) => ({ chute_id: chuteId, quota: 100 }))) + } + + if (url.endsWith('/users/me/quota_usage/me')) { + return jsonResponse({}) + } + + if (url.endsWith('/invocations/stats/llm')) { + return jsonResponse([]) + } + + if (url.endsWith('/users/me')) { + return jsonResponse({ balance: 12.34 }) + } + + if (url.includes('/users/me/quota_usage/chute-')) { + inFlight++ + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise((resolve) => setTimeout(resolve, 5)) + inFlight-- + return jsonResponse({ used: 1, quota: 100 }) + } + + throw new Error(`Unexpected URL ${url}`) + }) as typeof fetch + + try { + const payload = await new ChutesApiClient('test-key').getDashboardPayload() + + assert.equal(Object.keys(payload.quotaUsageFallback ?? {}).length, chuteIds.length) + assert.equal(maxInFlight, 5) + } finally { + globalThis.fetch = originalFetch + } +}) + // Build a minimal JSON response for fetch-based client tests. function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { diff --git a/src/test/DashboardStore.test.ts b/src/test/DashboardStore.test.ts index 465ce3f..60a4120 100644 --- a/src/test/DashboardStore.test.ts +++ b/src/test/DashboardStore.test.ts @@ -116,7 +116,6 @@ function createDashboardPayload({ monthlyPriceUsd, billingUsedUsd }: { monthlyPr quotaUsageMe: null, quotaUsageFallback: null, invocationStatsLlm: null, - pricing: null, me: null } } diff --git a/src/test/normalize.test.ts b/src/test/normalize.test.ts index 08e0759..de7396e 100644 --- a/src/test/normalize.test.ts +++ b/src/test/normalize.test.ts @@ -55,13 +55,13 @@ test('normalizes known subscription usage and quotas for a pro account', () => { }) test('extracts the PAYG credit balance from the /users/me payload', () => { - const result = normalizeDashboardData({}, [], null, null, null, null, { balance: 12.34 }) + const result = normalizeDashboardData({}, [], null, null, null, { balance: 12.34 }) assert.equal(result.paygCreditUsd, 12.34) }) test('returns null PAYG credit when the /users/me payload is missing or empty', () => { assert.equal(normalizeDashboardData({}, []).paygCreditUsd, null) - assert.equal(normalizeDashboardData({}, [], null, null, null, null, {}).paygCreditUsd, null) + assert.equal(normalizeDashboardData({}, [], null, null, null, {}).paygCreditUsd, null) }) test('reads PAYG credit from documented fallback balance fields', () => { @@ -72,33 +72,17 @@ test('reads PAYG credit from documented fallback balance fields', () => { assert.equal(normalizePaygCredit(null), null) }) -test('normalizes plan limits from pricing payload when available', () => { - const result = normalizeDashboardData({}, [], null, null, null, [ - { - name: 'Base', - monthly_price: 3, - monthly_cap_usd: 15, - daily_request_limit: 300, - four_hour_cap_usd: 1.25, - payg_discount_percent: 3 - }, - { +test('uses account data only for the current plan limits', () => { + const result = normalizeDashboardData({ + plan: { name: 'Plus', monthly_price: 10, monthly_cap_usd: 50, daily_request_limit: 2000, four_hour_cap_usd: 4.17, payg_discount_percent: 6 - }, - { - name: 'Pro', - monthly_price: 20, - monthly_cap_usd: 100, - daily_request_limit: 5000, - four_hour_cap_usd: 8.33, - payg_discount_percent: 10 } - ]) + }, []) assert.deepEqual(result.planLimits, [ { @@ -112,9 +96,9 @@ test('normalizes plan limits from pricing payload when available', () => { { name: 'Pro', priceLabel: '$20/mo', - monthlyCapLabel: '$100', - dailyRequestLimitLabel: '5,000', - fourHourCapLabel: '$8.33', + monthlyCapLabel: '--', + dailyRequestLimitLabel: '--', + fourHourCapLabel: '--', paygDiscountLabel: '10%' } ]) @@ -124,6 +108,25 @@ test('defaults plan limits to the current Plus and Pro subscription tiers', () = const result = normalizeDashboardData({}, []) assert.deepEqual(result.planLimits.map((limit) => limit.name), ['Plus', 'Pro']) + assert.deepEqual(result.planLimits.map((limit) => limit.monthlyCapLabel), ['--', '--']) +}) + +test('prepends an unrecognized current plan using only account-provided values', () => { + const result = normalizeDashboardData({ + plan: { + name: 'Enterprise', + monthly_cap_usd: 250 + } + }, []) + + assert.deepEqual(result.planLimits[0], { + name: 'Enterprise', + priceLabel: '--', + monthlyCapLabel: '$250', + dailyRequestLimitLabel: '--', + fourHourCapLabel: '--', + paygDiscountLabel: '--' + }) }) test('normalizes quotas when the API returns a top-level array', () => { diff --git a/src/test/webviewBootstrap.test.ts b/src/test/webviewBootstrap.test.ts index 4e6b7c4..1103934 100644 --- a/src/test/webviewBootstrap.test.ts +++ b/src/test/webviewBootstrap.test.ts @@ -27,12 +27,18 @@ test('loads the webview bootstrap as an ES module script with a CSP nonce', () = assert.match(providerJs, /img-src/) }) -test('validates extension messages and cached state before rendering', () => { +test('validates extension messages and cached display preferences before rendering', () => { assert.match(mainJs, /isStateMessage\(event\.data\)/) assert.match(mainJs, /isCachedPayload\(raw\)/) assert.match(messagesJs, /Number\.isFinite/) }) +test('rejects unknown fields in actions sent back to the extension host', () => { + assert.match(providerJs, /Object\.keys\(message\)\.length === 1/) + assert.match(providerJs, /Object\.keys\(message\)\.length === 2/) + assert.match(providerJs, /Object\.hasOwn\(message, 'href'\)/) +}) + test('uses explicit DOM properties instead of a generic attribute sink', () => { assert.doesNotMatch(domJs, /setAttribute\(/) assert.doesNotMatch(domJs, /\.data\s*=/) @@ -55,17 +61,28 @@ test('renders the dashboard without ever wiping innerHTML on state updates', () assert.equal(replaceChildrenCount, 1, 'mount() should call parent.replaceChildren exactly once') }) -test('persists state with vscode.setState/getState to avoid Loading flashes on reopen', () => { +test('persists only non-sensitive display preferences in webview state', () => { assert.match(mainJs, /vscode\.getState\(/) assert.match(mainJs, /vscode\.setState\(/) - assert.match(mainJs, /SCHEMA_VERSION/) + assert.match(mainJs, /SCHEMA_VERSION\s*=\s*3/) + assert.match(mainJs, /writeCached\(\{ version: SCHEMA_VERSION, planLimitsCollapsed \}\)/) + assert.doesNotMatch(mainJs, /writeCached\(\{[^}]*\bstate:/s) }) test('exposes ARIA progressbar semantics on metric cards', () => { assert.match(mainJs, /progressbar/) assert.match(mainJs, /ariaValueMin/) assert.match(mainJs, /ariaValueMax/) - assert.match(mainJs, /ariaValueNow/) + assert.match(mainJs, /setAriaValueNow/) + assert.match(mainJs, /setAriaValueText/) + assert.match(domJs, /removeAttribute\('aria-valuenow'\)/) +}) + +test('announces live status and exposes non-colour PAYG warnings', () => { + assert.match(mainJs, /role: 'status', ariaLive: 'polite', ariaAtomic: true/) + assert.match(mainJs, /Low credit/) + assert.match(mainJs, /No credit/) + assert.match(mainJs, /el\('h2', \{ className: 'section-title' \}/) }) test('migrates styles to VS Code theme tokens with chutes accents', () => { diff --git a/src/test/webviewMessages.test.ts b/src/test/webviewMessages.test.ts index 29fc041..6351bd6 100644 --- a/src/test/webviewMessages.test.ts +++ b/src/test/webviewMessages.test.ts @@ -30,6 +30,43 @@ test('accepts a complete dashboard state message', () => { assert.equal(isStateMessage({ type: 'state', state: readyState, refreshIntervalMs: 60_000 }), true) }) +test('rejects unknown fields at every webview message boundary', () => { + assert.equal(isStateMessage({ type: 'state', state: readyState, unexpected: true }), false) + assert.equal(isStateMessage({ type: 'state', state: { ...readyState, quotas: [] } }), false) + assert.equal(isStateMessage({ + type: 'state', + state: { ...readyState, data: { ...readyState.data, quotas: [] } } + }), false) + assert.equal(isStateMessage({ + type: 'state', + state: { + ...readyState, + data: { + ...readyState.data, + windows: [{ ...readyState.data.windows[0], unexpected: true }] + } + } + }), false) + assert.equal(isStateMessage({ + type: 'state', + state: { + ...readyState, + data: { + ...readyState.data, + plan: { + planName: 'Pro', + monthlyPriceUsd: 20, + monthlyCapUsd: 100, + fourHourCapUsd: 8.33, + dailyRequestLimit: 5000, + paygDiscountPercent: 10, + unexpected: true + } + } + } + }), false) +}) + test('rejects malformed dashboard values before rendering them', () => { const malformed = structuredClone(readyState) malformed.data.windows[0].used = Number.NaN @@ -40,7 +77,8 @@ test('rejects malformed dashboard values before rendering them', () => { }) test('validates cached payloads before restoring webview state', () => { - assert.equal(isCachedPayload({ version: 2, state: readyState, planLimitsCollapsed: false }), true) - assert.equal(isCachedPayload({ version: 2, state: readyState, planLimitsCollapsed: 'false' }), false) - assert.equal(isCachedPayload({ version: 2, state: { ...readyState, data: { windows: [] } }, planLimitsCollapsed: false }), false) + assert.equal(isCachedPayload({ version: 3, planLimitsCollapsed: false }), true) + assert.equal(isCachedPayload({ version: 3, planLimitsCollapsed: 'false' }), false) + assert.equal(isCachedPayload({ version: 3, planLimitsCollapsed: false, unexpected: true }), false) + assert.equal(isCachedPayload({ version: 2, state: readyState, planLimitsCollapsed: false }), false) }) diff --git a/src/test/webviewPresentation.test.ts b/src/test/webviewPresentation.test.ts index 37e4dd9..8fa5544 100644 --- a/src/test/webviewPresentation.test.ts +++ b/src/test/webviewPresentation.test.ts @@ -1,8 +1,8 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { formatResetLabel, getHeaderPresentation } from '../../webview/presentation' -import type { DashboardState } from '../types' +import { formatResetLabel, getHeaderPresentation, getProgressPresentation } from '../../webview/presentation' +import type { DashboardState, UsageWindow } from '../types' test('keeps key management actions available when the dashboard is in error with stale data', () => { const presentation = getHeaderPresentation(createState({ @@ -93,6 +93,50 @@ test('keeps explanatory reset labels readable', () => { assert.equal(formatResetLabel('Possible sync delay'), 'Possible sync delay') }) +test('announces a known progress value with its verified usage and limit', () => { + const presentation = getProgressPresentation(createWindow({ + used: 21.2, + limit: 50, + percentUsed: 42.4 + })) + + assert.deepEqual(presentation, { + percentUsed: 42.4, + ariaValueText: '42% used; $21.20 of $50.00' + }) +}) + +test('omits the numeric progress value when the percentage is unknown', () => { + assert.deepEqual(getProgressPresentation(createWindow({ + unit: 'requests', + used: 3, + limit: null, + percentUsed: null + })), { + percentUsed: null, + ariaValueText: '3 used; limit unavailable' + }) + + assert.equal(getProgressPresentation(createWindow()).ariaValueText, 'Usage unavailable') +}) + +test('announces an unlimited request quota without inventing a percentage', () => { + assert.deepEqual(getProgressPresentation(createWindow({ + unit: 'requests', + used: 8, + limit: 0, + percentUsed: null + })), { + percentUsed: null, + ariaValueText: '8 used; unlimited request quota' + }) +}) + +test('clamps progress percentages to the ARIA range', () => { + assert.equal(getProgressPresentation(createWindow({ percentUsed: 140 })).percentUsed, 100) + assert.equal(getProgressPresentation(createWindow({ percentUsed: -10 })).percentUsed, 0) +}) + // Build a minimal dashboard state for presentation tests. function createState(overrides: Partial = {}): DashboardState { return { @@ -104,3 +148,18 @@ function createState(overrides: Partial = {}): DashboardState { ...overrides } } + +function createWindow(overrides: Partial = {}): UsageWindow { + return { + id: 'billing', + kind: 'billing-cycle', + label: 'Billing Cycle Cap', + unit: 'usd', + used: null, + limit: null, + remaining: null, + percentUsed: null, + resetLabel: null, + ...overrides + } +} diff --git a/src/test/webviewState.test.ts b/src/test/webviewState.test.ts index d8ba2a6..59f0e29 100644 --- a/src/test/webviewState.test.ts +++ b/src/test/webviewState.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import { toWebviewState } from '../state/webviewState' import type { DashboardState } from '../types' -test('keeps states without dashboard data untouched', () => { +test('projects states without dashboard data to the exact webview shape', () => { const state: DashboardState = { connectionState: 'missing-key', connected: false, @@ -13,7 +13,8 @@ test('keeps states without dashboard data untouched', () => { errorMessage: null } - assert.equal(toWebviewState(state), state) + assert.deepEqual(toWebviewState(state), state) + assert.notEqual(toWebviewState(state), state) }) test('drops the per-model quota rows the webview never renders', () => { @@ -23,7 +24,7 @@ test('drops the per-model quota rows the webview never renders', () => { const projected = toWebviewState(state) - assert.deepEqual(projected.data?.quotas, []) + assert.equal(projected.data === null ? false : 'quotas' in projected.data, false) assert.equal(projected.connectionState, state.connectionState) assert.equal(projected.lastUpdatedAt, state.lastUpdatedAt) assert.deepEqual(projected.data?.windows, state.data?.windows) @@ -32,9 +33,12 @@ test('drops the per-model quota rows the webview never renders', () => { assert.equal(projected.data?.paygCreditUsd, state.data?.paygCreditUsd) }) -test('does not copy the state when there is no quota payload to strip', () => { +test('does not expose a quotas property even when the host list is empty', () => { const state = createReadyState([]) - assert.equal(toWebviewState(state), state) + const projected = toWebviewState(state) + + assert.equal(projected.data === null ? false : 'quotas' in projected.data, false) + assert.notEqual(projected, state) }) test('leaves the extension-host state object unmodified', () => { diff --git a/src/types.ts b/src/types.ts index 39575c5..ba765ef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,6 +55,13 @@ export interface DashboardData { paygCreditUsd: number | null } +export interface WebviewDashboardData { + windows: UsageWindow[] + plan: PlanInfo | null + planLimits: PlanLimitEntry[] + paygCreditUsd: number | null +} + export interface QuotaUsageSummary { used: number | null quota: number | null @@ -75,9 +82,17 @@ export interface DashboardState { errorMessage: string | null } +export interface WebviewDashboardState { + connectionState: ConnectionState + connected: boolean + lastUpdatedAt: string | null + data: WebviewDashboardData | null + errorMessage: string | null +} + export interface WebviewStateMessage { type: 'state' - state: DashboardState + state: WebviewDashboardState refreshIntervalMs?: number } diff --git a/src/views/ChutesWebviewProvider.ts b/src/views/ChutesWebviewProvider.ts index c3989c8..d741864 100644 --- a/src/views/ChutesWebviewProvider.ts +++ b/src/views/ChutesWebviewProvider.ts @@ -125,9 +125,11 @@ function isWebviewActionMessage(message: unknown): message is WebviewActionMessa case 'refresh': case 'setApiKey': case 'removeApiKey': - return true + return Object.keys(message).length === 1 case 'openExternal': return typeof candidate.href === 'string' + && Object.keys(message).length === 2 + && Object.hasOwn(message, 'href') default: return false } diff --git a/webview/dom.ts b/webview/dom.ts index 3f2bed3..ab73eab 100644 --- a/webview/dom.ts +++ b/webview/dom.ts @@ -8,10 +8,12 @@ type ElProps = { ariaLabel?: string ariaHidden?: boolean ariaLive?: 'off' | 'polite' | 'assertive' + ariaAtomic?: boolean ariaExpanded?: boolean ariaValueMin?: number ariaValueMax?: number ariaValueNow?: number + ariaValueText?: string dataKind?: string } & Partial> @@ -30,10 +32,12 @@ export function el( if (props.ariaLabel !== undefined) node.ariaLabel = props.ariaLabel if (props.ariaHidden !== undefined) node.ariaHidden = String(props.ariaHidden) if (props.ariaLive !== undefined) node.ariaLive = props.ariaLive + if (props.ariaAtomic !== undefined) node.ariaAtomic = String(props.ariaAtomic) if (props.ariaExpanded !== undefined) node.ariaExpanded = String(props.ariaExpanded) if (props.ariaValueMin !== undefined) node.ariaValueMin = String(props.ariaValueMin) if (props.ariaValueMax !== undefined) node.ariaValueMax = String(props.ariaValueMax) if (props.ariaValueNow !== undefined) node.ariaValueNow = String(props.ariaValueNow) + if (props.ariaValueText !== undefined) node.ariaValueText = props.ariaValueText if (props.dataKind !== undefined) node.dataset.kind = props.dataKind if (tag === 'button' && props.type !== undefined) { (node as HTMLButtonElement).type = props.type as 'button' | 'reset' | 'submit' @@ -70,11 +74,20 @@ export function setAriaExpanded(element: HTMLElement, value: boolean): void { if (element.ariaExpanded !== stringValue) element.ariaExpanded = stringValue } -export function setAriaValueNow(element: HTMLElement, value: number): void { +export function setAriaValueNow(element: HTMLElement, value: number | null): void { + if (value === null) { + if (element.ariaValueNow !== null) element.removeAttribute('aria-valuenow') + return + } + const stringValue = String(value) if (element.ariaValueNow !== stringValue) element.ariaValueNow = stringValue } +export function setAriaValueText(element: HTMLElement, value: string): void { + if (element.ariaValueText !== value) element.ariaValueText = value +} + export function setStyleVar(element: HTMLElement, name: string, value: string): void { if (element.style.getPropertyValue(name) !== value) element.style.setProperty(name, value) } diff --git a/webview/main.ts b/webview/main.ts index 65f2d4f..2d8378b 100644 --- a/webview/main.ts +++ b/webview/main.ts @@ -1,5 +1,5 @@ -import { formatResetLabel, getHeaderPresentation, type HeaderPresentation } from './presentation.js' -import { cls, codicon, el, setAriaExpanded, setAriaLabel, setAriaValueNow, setStyleVar, setText, toggle, txt } from './dom.js' +import { formatResetLabel, getHeaderPresentation, getProgressPresentation, type HeaderPresentation } from './presentation.js' +import { cls, codicon, el, setAriaExpanded, setAriaLabel, setAriaValueNow, setAriaValueText, setStyleVar, setText, toggle, txt } from './dom.js' import { isCachedPayload, isStateMessage } from './messages.js' import type { ActionType, CachedPayload, DashboardState, PlanInfo, PlanLimitEntry, UsageWindow, UsageWindowKind } from './types.js' @@ -9,7 +9,7 @@ declare function acquireVsCodeApi(): { getState: () => unknown } -const SCHEMA_VERSION = 2 +const SCHEMA_VERSION = 3 const DEFAULT_REFRESH_INTERVAL_MS = 60_000 const KNOWN_KINDS: UsageWindowKind[] = ['billing-cycle', 'rolling-4h', 'daily-requests', 'weekly'] // PAYG credit tile turns amber below this USD threshold, and red at/under zero. @@ -38,6 +38,8 @@ interface PlanStatRefs { root: HTMLElement label: Text value: Text + statusBadge: HTMLSpanElement + statusText: Text } interface TierRefs { @@ -90,7 +92,7 @@ interface DomRefs { toggleBtn: HTMLButtonElement chevron: HTMLElement body: HTMLElement - fiveXDesc: Text + description: Text tiersWrap: HTMLElement tiers: TierRefs[] cachedTierCount: number @@ -111,14 +113,10 @@ if (app) { if (cached) { planLimitsCollapsed = cached.planLimitsCollapsed } + writeCached({ version: SCHEMA_VERSION, planLimitsCollapsed }) const refs = mount(app) - - if (cached) { - update(refs, cached.state, { fromCache: true }) - } else { - update(refs, initialState(), { fromCache: true }) - } + update(refs, initialState(), { initialRender: true }) window.addEventListener('message', (event: MessageEvent) => { if (!isStateMessage(event.data)) return @@ -127,11 +125,7 @@ if (app) { refreshIntervalMs = message.refreshIntervalMs } const incoming = message.state - update(refs, incoming, { fromCache: false }) - - if (incoming.connectionState !== 'loading' || incoming.data !== null) { - writeCached({ version: SCHEMA_VERSION, state: incoming, planLimitsCollapsed }) - } + update(refs, incoming, { initialRender: false }) }) // Pause the next-refresh ticker when the webview is hidden, clear it on unload. @@ -185,7 +179,12 @@ function mount(parent: HTMLElement): DomRefs { // ---- Header ---- const dot = el('span', { className: 'status-dot', ariaHidden: true }) const statusText = txt('Connecting…') - const statusLine = el('div', { className: 'status-line' }, dot, statusText) + const statusLine = el( + 'div', + { className: 'status-line', role: 'status', ariaLive: 'polite', ariaAtomic: true }, + dot, + statusText + ) const title = el('h1', { className: 'brand-title' }, 'Chutes ', el('span', { className: 'brand-divider' }, '/'), ' Usage') const brand = el('div', { className: 'header-brand' }, title, statusLine) @@ -271,7 +270,7 @@ function mount(parent: HTMLElement): DomRefs { const planSummaryRoot = el( 'section', { className: 'plan-panel' }, - el('div', { className: 'section-title' }, 'Plan snapshot'), + el('h2', { className: 'section-title' }, 'Plan snapshot'), planGrid, metaWrap ) @@ -287,7 +286,14 @@ function mount(parent: HTMLElement): DomRefs { } // ---- Plan Limits ---- - const fiveXDesc = txt('Subscriptions include 5× the monthly price in pay-as-you-go equivalent usage.') + const planLimitsDescription = txt('Plan prices and PAYG discounts are public references. Limits for your current plan come from your account when available; -- means unavailable.') + const pricingLink = el('a', { className: 'plan-limits-link' }, 'View current pricing ', codicon('link-external')) + pricingLink.href = 'https://chutes.ai/pricing' + pricingLink.rel = 'noopener noreferrer' + pricingLink.addEventListener('click', (event) => { + event.preventDefault() + dispatchAction('openExternal', 'https://chutes.ai/pricing') + }) const tiersWrap = el('div', { className: 'plan-limits-tiers', id: 'plan-limits-tiers' }) const chevron = codicon('chevron-down') cls(chevron, 'plan-limits-chevron', true) @@ -311,12 +317,13 @@ function mount(parent: HTMLElement): DomRefs { const planLimitsBody = el( 'div', { className: 'plan-limits-body' }, - el('p', { className: 'plan-limits-intro' }, fiveXDesc), + el('p', { className: 'plan-limits-intro' }, planLimitsDescription, txt(' '), pricingLink), tiersWrap, buildFootnotes() ) - const planLimitsRoot = el('section', { className: 'plan-limits-section' }, toggleBtn, planLimitsBody) + const planLimitsHeading = el('h2', { className: 'plan-limits-heading' }, toggleBtn) + const planLimitsRoot = el('section', { className: 'plan-limits-section' }, planLimitsHeading, planLimitsBody) // ---- Footer ---- const updatedText = txt('') @@ -379,7 +386,7 @@ function mount(parent: HTMLElement): DomRefs { toggleBtn, chevron, body: planLimitsBody, - fiveXDesc, + description: planLimitsDescription, tiersWrap, tiers: [], cachedTierCount: 0 @@ -481,13 +488,15 @@ function buildFootnotes(): HTMLElement { function createPlanStat(initialLabel: string, initialValue: string): PlanStatRefs { const labelNode = txt(initialLabel) const valueNode = txt(initialValue) + const statusText = txt('') + const statusBadge = el('span', { className: 'plan-stat-status', hidden: true }, statusText) const root = el( 'div', { className: 'plan-stat', role: 'group', ariaLabel: `${initialLabel}: ${initialValue}` }, el('div', { className: 'plan-stat-label' }, labelNode), - el('div', { className: 'plan-stat-value' }, valueNode) + el('div', { className: 'plan-stat-value-row' }, el('div', { className: 'plan-stat-value' }, valueNode), statusBadge) ) - return { root, label: labelNode, value: valueNode } + return { root, label: labelNode, value: valueNode, statusBadge, statusText } } // Update a plan-stat tile value and keep its screen-reader label in sync. @@ -497,9 +506,21 @@ function setPlanStat(ref: PlanStatRefs, label: string, value: string): void { } // Flag the PAYG credit tile when the balance is running low or exhausted. -function applyPaygCreditState(root: HTMLElement, credit: number | null): void { - cls(root, 'is-crit', credit !== null && credit <= 0) - cls(root, 'is-warn', credit !== null && credit > 0 && credit < PAYG_CREDIT_WARN_USD) +function applyPaygCreditState(ref: PlanStatRefs, credit: number | null): void { + const exhausted = credit !== null && credit <= 0 + const low = credit !== null && credit > 0 && credit < PAYG_CREDIT_WARN_USD + const status = exhausted ? 'No credit' : low ? 'Low credit' : null + + cls(ref.root, 'is-crit', exhausted) + cls(ref.root, 'is-warn', low) + cls(ref.statusBadge, 'is-crit', exhausted) + cls(ref.statusBadge, 'is-warn', low) + toggle(ref.statusBadge, status !== null) + setText(ref.statusText, status ?? '') + + if (status !== null) { + setAriaLabel(ref.root, `PAYG credit: ${formatValue(credit, 'usd')}, ${status}`) + } } function createMetricCard(kind: UsageWindowKind): MetricCardRefs { @@ -524,7 +545,7 @@ function createMetricCard(kind: UsageWindowKind): MetricCardRefs { role: 'progressbar', ariaValueMin: 0, ariaValueMax: 100, - ariaValueNow: 0 + ariaValueText: 'Usage unavailable' }, fill ) as HTMLDivElement @@ -620,14 +641,12 @@ function applyCollapsedState(button: HTMLButtonElement, body: HTMLElement, chevr } function persistCollapsed(): void { - const cached = readCached() - const base = cached ?? { version: SCHEMA_VERSION, state: initialState(), planLimitsCollapsed } - writeCached({ ...base, planLimitsCollapsed }) + writeCached({ version: SCHEMA_VERSION, planLimitsCollapsed }) } -function update(refs: DomRefs, state: DashboardState, options: { fromCache: boolean }): void { +function update(refs: DomRefs, state: DashboardState, options: { initialRender: boolean }): void { const presentation = getHeaderPresentation(state) - applyHeader(refs, presentation, state, options.fromCache) + applyHeader(refs, presentation, state, options.initialRender) applyMode(refs, state) if (state.data) { @@ -636,15 +655,15 @@ function update(refs: DomRefs, state: DashboardState, options: { fromCache: bool applyPlanLimits(refs, state.data.planLimits, state.data.plan?.planName ?? null) } - applyFooter(refs, state, options.fromCache) + applyFooter(refs, state) - if (!options.fromCache && state.connectionState !== 'loading') { + if (!options.initialRender && state.connectionState !== 'loading') { lastStateReceivedAt = Date.now() } refreshNextRefreshLabel(refs) } -function applyHeader(refs: DomRefs, presentation: HeaderPresentation, state: DashboardState, fromCache: boolean): void { +function applyHeader(refs: DomRefs, presentation: HeaderPresentation, state: DashboardState, initialRender: boolean): void { setText(refs.header.statusText, presentation.statusText) toggle(refs.header.dot, presentation.showDot) cls(refs.header.dot, 'tone-live', presentation.tone === 'live') @@ -655,10 +674,9 @@ function applyHeader(refs: DomRefs, presentation: HeaderPresentation, state: Das setText(refs.header.setKeyLabel, presentation.keyActionLabel === 'Set Key' ? 'Set key' : 'Replace key') refs.header.removeBtn.disabled = presentation.removeDisabled - // When rendering from cache the previous state may have been "loading"; keep - // the refresh button enabled so the user can retry without waiting for the - // first fresh message from the extension host. - const isLiveLoading = !fromCache && state.connectionState === 'loading' + // Keep refresh available during the initial placeholder render. Once the + // extension host posts state, disable it only for an active refresh. + const isLiveLoading = !initialRender && state.connectionState === 'loading' refs.header.refreshBtn.disabled = isLiveLoading cls(refs.header.refreshBtn, 'is-busy', isLiveLoading) } @@ -680,7 +698,7 @@ function applyMode(refs: DomRefs, state: DashboardState): void { if (showStaleBanner) { const message = state.errorMessage && state.errorMessage.length > 0 ? state.errorMessage - : 'Last sync failed. Showing cached data.' + : 'Last sync failed. Showing the last available data.' setText(refs.staleBanner.text, ` ${message}`) } @@ -704,7 +722,7 @@ function applyPlanSummary(refs: DomRefs, plan: PlanInfo | null, windows: UsageWi setPlanStat(refs.planSummary.fourHLimit, '4h limit', formatValue(plan?.fourHourCapUsd ?? rolling?.limit ?? null, 'usd')) setPlanStat(refs.planSummary.dailyLimit, 'Daily limit', formatRequestsLimitValue(plan?.dailyRequestLimit ?? daily?.limit ?? null)) setPlanStat(refs.planSummary.paygCredit, 'PAYG credit', formatValue(paygCreditUsd, 'usd')) - applyPaygCreditState(refs.planSummary.paygCredit.root, paygCreditUsd) + applyPaygCreditState(refs.planSummary.paygCredit, paygCreditUsd) const monthlyPrice = plan?.monthlyPriceUsd ?? null const monthlyCap = plan?.monthlyCapUsd ?? null @@ -742,14 +760,16 @@ function applyMetrics(refs: DomRefs, windows: UsageWindow[]): void { cls(card.root, 'is-stale', isStale) toggle(card.staleBadge, isStale) - const pct = Math.max(0, Math.min(window.percentUsed ?? 0, 100)) - setStyleVar(card.progress.fill, '--progress-w', `${pct}%`) - setAriaValueNow(card.progress.container, Math.round(pct)) + const progress = getProgressPresentation(window) + const pct = progress.percentUsed + setStyleVar(card.progress.fill, '--progress-w', `${pct ?? 0}%`) + setAriaValueNow(card.progress.container, pct === null ? null : Math.round(pct)) + setAriaValueText(card.progress.container, progress.ariaValueText) setAriaLabel(card.progress.container, `${window.label} usage`) cls(card.progress.fill, 'progress-violet', window.kind === 'rolling-4h') - cls(card.progress.fill, 'progress-warn', window.kind !== 'rolling-4h' && pct >= 75 && pct < 90) - cls(card.progress.fill, 'progress-crit', window.kind !== 'rolling-4h' && pct >= 90) + cls(card.progress.fill, 'progress-warn', window.kind !== 'rolling-4h' && pct !== null && pct >= 75 && pct < 90) + cls(card.progress.fill, 'progress-crit', window.kind !== 'rolling-4h' && pct !== null && pct >= 90) } for (const [kind, card] of refs.metricsGrid.cards) { @@ -761,13 +781,7 @@ function applyMetrics(refs: DomRefs, windows: UsageWindow[]): void { function applyPlanLimits(refs: DomRefs, planLimits: PlanLimitEntry[], currentPlanName: string | null): void { ensureTiers(refs, planLimits.length) - - const examples = planLimits.slice(0, 3).map((p) => `${p.name} gets ${p.monthlyCapLabel}`) - if (examples.length > 0) { - setText(refs.planLimits.fiveXDesc, `Subscriptions include 5× the monthly price in pay-as-you-go equivalent. Examples: ${examples.join(', ')}.`) - } else { - setText(refs.planLimits.fiveXDesc, 'Subscriptions include 5× the monthly price in pay-as-you-go equivalent usage.') - } + setText(refs.planLimits.description, 'Plan prices and PAYG discounts are public references. Limits for your current plan come from your account when available; -- means unavailable.') const lowerCurrent = currentPlanName?.toLowerCase() ?? null @@ -791,10 +805,10 @@ function applyPlanLimits(refs: DomRefs, planLimits: PlanLimitEntry[], currentPla }) } -function applyFooter(refs: DomRefs, state: DashboardState, fromCache: boolean): void { +function applyFooter(refs: DomRefs, state: DashboardState): void { if (state.lastUpdatedAt) { const time = new Date(state.lastUpdatedAt).toLocaleTimeString() - setText(refs.footer.updatedText, fromCache ? `Cached · ${time}` : `Updated ${time}`) + setText(refs.footer.updatedText, `Updated ${time}`) } else { setText(refs.footer.updatedText, '') } diff --git a/webview/messages.ts b/webview/messages.ts index b0eae9e..1832b23 100644 --- a/webview/messages.ts +++ b/webview/messages.ts @@ -7,7 +7,10 @@ const WINDOW_STATUSES = new Set(['trusted', 'stale', 'unknown']) const DATA_SOURCES = new Set(['quota-usage-me', 'quota-usage-fallback', 'subscription-usage', 'quotas', 'unknown']) export function isStateMessage(value: unknown): value is StateMessage { - if (!isObject(value) || value.type !== 'state' || !isDashboardState(value.state)) { + if (!isObject(value) + || !hasOnlyKeys(value, ['type', 'state', 'refreshIntervalMs']) + || value.type !== 'state' + || !isDashboardState(value.state)) { return false } @@ -16,13 +19,14 @@ export function isStateMessage(value: unknown): value is StateMessage { export function isCachedPayload(value: unknown): value is CachedPayload { return isObject(value) + && hasOnlyKeys(value, ['version', 'planLimitsCollapsed']) && Number.isInteger(value.version) && typeof value.planLimitsCollapsed === 'boolean' - && isDashboardState(value.state) } function isDashboardState(value: unknown): value is DashboardState { if (!isObject(value) + || !hasOnlyKeys(value, ['connectionState', 'connected', 'lastUpdatedAt', 'data', 'errorMessage']) || typeof value.connectionState !== 'string' || !CONNECTION_STATES.has(value.connectionState) || typeof value.connected !== 'boolean' @@ -36,6 +40,7 @@ function isDashboardState(value: unknown): value is DashboardState { } return isObject(value.data) + && hasOnlyKeys(value.data, ['windows', 'plan', 'planLimits', 'paygCreditUsd']) && Array.isArray(value.data.windows) && value.data.windows.every(isUsageWindow) && (value.data.plan === null || isPlanInfo(value.data.plan)) @@ -46,6 +51,7 @@ function isDashboardState(value: unknown): value is DashboardState { function isUsageWindow(value: unknown): value is UsageWindow { if (!isObject(value) + || !hasOnlyKeys(value, ['id', 'kind', 'label', 'unit', 'used', 'limit', 'remaining', 'percentUsed', 'resetLabel', 'status', 'dataSource']) || typeof value.id !== 'string' || typeof value.kind !== 'string' || !WINDOW_KINDS.has(value.kind) @@ -70,6 +76,7 @@ function isUsageWindow(value: unknown): value is UsageWindow { function isPlanInfo(value: unknown): value is PlanInfo { return isObject(value) + && hasOnlyKeys(value, ['planName', 'monthlyPriceUsd', 'monthlyCapUsd', 'fourHourCapUsd', 'dailyRequestLimit', 'paygDiscountPercent']) && isNullableString(value.planName) && isNullableFiniteNumber(value.monthlyPriceUsd) && isNullableFiniteNumber(value.monthlyCapUsd) @@ -80,6 +87,7 @@ function isPlanInfo(value: unknown): value is PlanInfo { function isPlanLimitEntry(value: unknown): value is PlanLimitEntry { return isObject(value) + && hasOnlyKeys(value, ['name', 'priceLabel', 'monthlyCapLabel', 'dailyRequestLimitLabel', 'fourHourCapLabel', 'paygDiscountLabel']) && typeof value.name === 'string' && typeof value.priceLabel === 'string' && typeof value.monthlyCapLabel === 'string' @@ -92,6 +100,11 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +function hasOnlyKeys(value: Record, allowedKeys: readonly string[]): boolean { + const allowed = new Set(allowedKeys) + return Object.keys(value).every((key) => allowed.has(key)) +} + function isNullableString(value: unknown): value is string | null { return value === null || typeof value === 'string' } diff --git a/webview/presentation.ts b/webview/presentation.ts index 70078a4..5bc37df 100644 --- a/webview/presentation.ts +++ b/webview/presentation.ts @@ -1,4 +1,4 @@ -import type { DashboardState } from './types.js' +import type { DashboardState, UsageWindow } from './types.js' export type HeaderPresentation = { statusText: string @@ -8,6 +8,44 @@ export type HeaderPresentation = { tone: 'idle' | 'live' | 'warn' | 'error' } +export type ProgressPresentation = { + percentUsed: number | null + ariaValueText: string +} + +export function getProgressPresentation(window: UsageWindow): ProgressPresentation { + const percentUsed = window.percentUsed === null + ? null + : Math.max(0, Math.min(window.percentUsed, 100)) + + if (percentUsed === null) { + if (window.unit === 'requests' && window.limit === 0) { + return { + percentUsed: null, + ariaValueText: window.used === null + ? 'Unlimited request quota' + : `${formatProgressValue(window.used, window.unit)} used; unlimited request quota` + } + } + + return { + percentUsed: null, + ariaValueText: window.used === null + ? 'Usage unavailable' + : `${formatProgressValue(window.used, window.unit)} used; limit unavailable` + } + } + + const limit = window.limit === 0 && window.unit === 'requests' + ? 'Unlimited' + : formatProgressValue(window.limit, window.unit) + + return { + percentUsed, + ariaValueText: `${Math.round(percentUsed)}% used; ${formatProgressValue(window.used, window.unit)} of ${limit}` + } +} + export function formatResetLabel( value: string | null, formatAbsoluteTime: (value: string) => string = defaultFormatResetTime @@ -85,6 +123,12 @@ function buildPrefix(planName: string | null | undefined, monthlyPrice: number | return '' } +function formatProgressValue(value: number | null, unit: UsageWindow['unit']): string { + if (value === null) return '—' + if (unit === 'requests') return Math.round(value).toLocaleString('en-US') + return `$${value.toFixed(2)}` +} + function defaultFormatTime(value: string): string { return new Date(value).toLocaleTimeString() } diff --git a/webview/styles.css b/webview/styles.css index 24758e3..b2f9cd2 100644 --- a/webview/styles.css +++ b/webview/styles.css @@ -7,11 +7,11 @@ } :root { - --chutes-accent-mint: #6ef2b1; - --chutes-accent-violet: #7c7bff; - --chutes-headline-violet: #a78bfa; - --chutes-warn: #f59e0b; - --chutes-crit: #ef4444; + --chutes-accent-mint: var(--vscode-charts-green, #1f8f5f); + --chutes-accent-violet: var(--vscode-charts-purple, #7568e8); + --chutes-headline-violet: var(--vscode-textLink-foreground, #7568e8); + --chutes-warn: var(--vscode-notificationsWarningIcon-foreground, #b86e00); + --chutes-crit: var(--vscode-errorForeground, #d83b3b); --radius-pill: 999px; --radius-card: 12px; @@ -32,6 +32,14 @@ --card-border-strong: var(--vscode-focusBorder, color-mix(in srgb, var(--vscode-foreground) 24%, transparent)); } +body.vscode-light { + --chutes-accent-mint: #0b6b3a; + --chutes-accent-violet: #4736b3; + --chutes-headline-violet: #5b21b6; + --chutes-warn: #7c3e00; + --chutes-crit: #b42318; +} + body.vscode-dark { --app-bg: #000; --panel-bg: #0a0a0c; @@ -43,9 +51,11 @@ body.vscode-dark { body.vscode-high-contrast, body.vscode-high-contrast-light { - --chutes-accent-mint: var(--vscode-charts-green, #6ef2b1); - --chutes-accent-violet: var(--vscode-charts-purple, #7c7bff); - --chutes-headline-violet: var(--vscode-charts-purple, #a78bfa); + --chutes-accent-mint: var(--vscode-charts-green, currentColor); + --chutes-accent-violet: var(--vscode-charts-purple, currentColor); + --chutes-headline-violet: var(--vscode-charts-purple, currentColor); + --chutes-warn: var(--vscode-editorWarning-foreground, currentColor); + --chutes-crit: var(--vscode-errorForeground, currentColor); --card-border: var(--vscode-contrastBorder, currentColor); --card-border-strong: var(--vscode-contrastActiveBorder, currentColor); } @@ -111,7 +121,6 @@ body { color: var(--chutes-headline-violet); font-weight: 400; margin: 0 2px; - opacity: 0.85; } .status-line { @@ -339,6 +348,32 @@ body { text-overflow: ellipsis; } +.plan-stat-value-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.plan-stat-status { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border: 1px solid currentColor; + border-radius: var(--radius-pill); + font-size: 10px; + font-weight: 600; + line-height: 1.4; +} + +.plan-stat-status.is-warn { + color: var(--chutes-warn); +} + +.plan-stat-status.is-crit { + color: var(--chutes-crit); +} + .plan-meta { margin-top: var(--gap-md); font-size: 11px; @@ -371,7 +406,7 @@ body { } .metric-card.is-stale { - opacity: 0.78; + border-style: dashed; } .metric-header { @@ -482,6 +517,11 @@ body { padding-top: var(--gap-lg); } +.plan-limits-heading { + margin: 0; + font: inherit; +} + .plan-limits-toggle { display: flex; width: 100%; @@ -547,6 +587,22 @@ body { line-height: 1.5; } +.plan-limits-link { + color: var(--vscode-textLink-foreground); + text-decoration: none; + white-space: nowrap; +} + +.plan-limits-link:hover { + color: var(--vscode-textLink-activeForeground, var(--vscode-textLink-foreground)); + text-decoration: underline; +} + +.plan-limits-link:focus-visible { + outline: 2px solid var(--vscode-focusBorder, currentColor); + outline-offset: 2px; +} + .plan-limits-tiers { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); @@ -675,7 +731,6 @@ body { font-size: 10px; color: var(--vscode-descriptionForeground); line-height: 1.5; - opacity: 0.85; } .plan-limits-footnote .codicon { @@ -722,7 +777,6 @@ body { .empty-hint { font-size: 10px; color: var(--vscode-descriptionForeground); - opacity: 0.75; margin-top: var(--gap-md); line-height: 1.5; } @@ -802,10 +856,6 @@ body { white-space: nowrap; } -.footer-next { - opacity: 0.7; -} - .footer-link { color: var(--vscode-descriptionForeground); text-decoration: none; diff --git a/webview/types.ts b/webview/types.ts index f3a5733..c574793 100644 --- a/webview/types.ts +++ b/webview/types.ts @@ -58,6 +58,5 @@ export type ActionType = 'refresh' | 'setApiKey' | 'removeApiKey' | 'openExterna export type CachedPayload = { version: number - state: DashboardState planLimitsCollapsed: boolean }