diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92ee683..29db64e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: - run: pnpm run test:coverage - name: upload coverage report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage path: coverage @@ -82,16 +82,6 @@ jobs: echo "::error::src/catalog-data.ts is out of date — run 'pnpm run catalog:shim -- --write' and commit the result." exit 1 } - # The Go plan's per-model dollar allowance, from OpenCode's own docs. It - # moves when THEY move — the page says limits "may change as we learn from - # early usage and feedback" — and a stale table shows a wrong dollar figure - # that looks authoritative, which is worse than showing none. - - name: is src/go-limits-data.ts still current? - run: | - pnpm run limits:shim || { - echo "::error::src/go-limits-data.ts is out of date — run 'pnpm run limits:shim -- --write' and commit the result." - exit 1 - } # The live gateway contract: the only job that talks to OpenCode itself, so a # vendor payload change fails here rather than in a user's meter. diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index ed408f0..0aed4e3 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -22,21 +22,4 @@ jobs: - uses: googleapis/release-please-action@v5 with: release-type: node - # What the generated CHANGELOG shows, and in what order. Visible: - # what a USER gets (features, fixes, performance) plus documentation. - # Hidden: the maintenance noise (chores, CI, tests, build, refactors) - # that a reader of a release note has no use for — it stays in the - # commit log, which is where a maintainer looks for it. - changelog-sections: | - [ - { "type": "feat", "section": "Features" }, - { "type": "fix", "section": "Fixes" }, - { "type": "perf", "section": "Performance" }, - { "type": "docs", "section": "Documentation" }, - { "type": "refactor", "section": "Refactors", "hidden": true }, - { "type": "test", "section": "Tests", "hidden": true }, - { "type": "build", "section": "Build", "hidden": true }, - { "type": "ci", "section": "CI", "hidden": true }, - { "type": "chore", "section": "Chores", "hidden": true } - ] token: ${{ secrets.RELEASE_PLEASE_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ff7a26..ecf2b0b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,7 +93,7 @@ jobs: grep -qx 'package/lib/client.js' /tmp/verify/entries.txt \ || { echo "::error::tarball is missing lib/client.js"; exit 1; } - name: upload the release tarball - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: release-tarball path: dist diff --git a/.gitignore b/.gitignore index c0f65b2..8c33bb8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,3 @@ coverage/ # Agent workspace: session notes and scratch data, not source. .workbuddy-ai/ - -# Agent instructions: private notes, never published. -AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..20cd8e4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,392 @@ +--- +tags: + - dsh + - plugin + - opencode + - cordis +status: note +aliases: + - dsh-opencode + - dsh-opencode-patch +--- + +# `dsh-opencode-patch` — OpenCode on DeepSeek Harness + +> [!info] Summary DSH host plugin (`dsh-opencode-patch` on npm, published with the `@viztor/dsh-opencode-patch` and `@viztor/dsh-opencode` scoped aliases from the same tree; repo `viztor/dsh-opencode-patch`) that keeps OpenCode Zen free-tier models working inside DeepSeek Harness: deterministic `ses_…` session affinity, gateway origin-header restoration, `read`/`bash` tool-schema fallback, a models.dev-backed model catalog with SWR refresh, and a composer meter showing Go quota, Zen overflow, session spend and the active model's rate. Standards reference: [[OBSIDIAN]] (`~/dev/OBSIDIAN.md`). + +## How it works + +1. **Turn scope** — `apply()` hooks `llm/stream` for configured providers, derives a stable `ses_<12hex><14base62>` ID per DSH session (`openCodeSessionIdFor`, SHA-256), and carries it in `AsyncLocalStorage` across the streamed turn (`withStore`). +2. **Fetch patch** — `patchFetch()` intercepts only OpenCode traffic (`isOpenCodeRequest`: `opencode.ai/zen` URL or matching provider in turn state). It always sets `x-opencode-session`, optionally restores `User-Agent` / `x-opencode-client` / `x-opencode-project`, and injects fallback `read`+`bash` schemas into free-tier `/responses` bodies. Non-OpenCode requests return via the original fetch untouched. +3. **Settings UI** — `src/settings-page.tsx` builds `lib/client.js`, contributing the OpenCode Patch card under DSH Settings → Plugins: **8 fields in 3 sections** (Gateway Requests / Models & Free Tier / Quota Meter) — the behaviour toggles plus the `keySource` credential policy. The other 8 schema knobs are **config-only** (see `CONFIG_ONLY_FIELDS`), and the row config (`cordis.patch.yml`) is their reference. + +## Package vs component (do not conflate) + +- **npm package** `dsh-opencode-patch`: the installable unit (host `main` + `lib/client.js`); the `@viztor/dsh-opencode-patch` and `@viztor/dsh-opencode` scoped aliases are published from the same tree. The host resolves a row to `node_modules/`, so the row's `name` must equal `dsh-opencode-patch` exactly. +- **cordis row**: one _instance_ of the package. `id` (`dsh-opencode-patch`) is the instance id and doubles as the settings namespace the client card binds (with a fallback to the legacy namespace `dsh-opencode`). One package can back N rows with different ids/configs — the card binds the default `dsh-opencode-patch` row (single-row assumption; a second row would need its own NS binding). +- **plugin `name` export** (`src/index.ts`): the component identity (log lines, service scoping). Matches the default row id by convention only. +- **client slot key** (`PKG` in `src/settings-page.tsx`): bundle-level page key, always the npm package name. + +## Why the gateway markers and the free-tier marker exist + +Two knobs look redundant until you know what they cover — both are config-only for that reason. A third, `usageProviderMarkers`, _was_ redundant and has been removed: see below. + +**`gatewayUrls`** is consulted in exactly one place (`isOpenCodeRequest`) and is checked _before_ the turn state. Two kinds of request need it: one that arrives with **no active turn state** (the patch runs deep in the adapter path, and not every gateway call happens inside a turn) and one whose **provider id we do not list** (a custom relay, mirror, or self-hosted gateway). The provider list cannot cover either, because it is matched against turn state that may not exist. + +Known gap: `isModelsListingUrl` still hardcodes `opencode.ai/zen`, so a custom gateway gets header injection but **not** model enrichment. Fixing it means threading `gatewayUrls` into that predicate — it is called from `patchFetch`, where the config is already in hand. + +**`freeModelMarker`** exists because **the gateway really does treat free tier differently**. `tool-fallback.ts`: "The OpenCode Zen gateway rejects free-tier `/responses` bodies that lack `read` and `bash` in `tools`, while DSH deliberately does not send them." The plugin rewrites the body to carry both schemas — and only when they are genuinely missing, so a body that already declares them passes through byte-identical. The marker (default `"free"`, `"*"` for every model) is how we detect "free tier", because a model row carries **no capability flag** — the only signal is the model id. It is config-only because `"free"` tracks the vendor's ids and `"*"` is the escape hatch if the rule ever widens to paid models. + +### The meter's registration: declare services with `ctx.inject` + +The composer-dock meter depends on two cross-plugin services: `slots` (to register the entry) and `modelDirectories` (to know the active provider). **They must be declared with `ctx.inject([...], scope => …)`**, which is the host's own idiom — `ui-model-selection` does exactly this for the same pair: + +```ts +ctx.inject(['slots', 'modelDirectories'], (scope: ClientContext) => { … }) +``` + +Reading `ctx.modelDirectories` straight off the **root** context is not guaranteed, and because every access in that path is optional (`?.`) the failure is **silent**: `directoryFor` never resolved, the injector returned `null`, and the meter simply never mounted — with no error anywhere. That is how it shipped broken. `settings-page.tsx` now registers through `ctx.inject` and falls back to the root context only when the assembly has no `inject` at all. A regression test pins it: a root context carrying _neither_ service must still produce a working injector, because both arrive on the injected scope. + +General rule: when reaching for another plugin's service, declare it. An optional read off the root context turns a wiring mistake into a missing feature. + +It gated _whether the meter renders_ for the active provider, and its default was `["opencode-go", "opencode"]` — the same list as `providers`, reversed. They are the same set by construction: a route we do not claim carries no OpenCode headers, so it has no quota to report either. The meter now reads **`providers`** (client-side, from the served settings snapshot, falling back to `DEFAULT_PROVIDERS`), and the pill's prop is named `meterProviders` so the source is obvious. + +`DEFAULT_PROVIDERS` therefore moved to `config-values.ts` — the client bundle needs the default and may not import `config.ts` (schemastery). This is the same pattern as `usageKeyEnv`: a row setting that asked the user to restate a decision the composition already owns. Both are gone; prefer removing such a knob over documenting it. + +## Repo map + +Host bundle (`lib/index.mjs`) — a thin `apply` barrel over small modules: + +- `src/index.ts` — public barrel: re-exports identity, `apply`, and every module's surface. No `as`, arrow consts, sync Promise wrappers (ALS-safe by design). +- `src/identity.ts` · `src/lifecycle.ts` — the component/package name constants, and the `apply()` composition: one installer per side effect (rename notice, usage service, fetch patch, stream hook, model discovery) in load-bearing order. +- `src/config.ts` — schemastery `Config` schema + `resolveConfig`; every default lives once in `CONFIG_DEFAULTS`. +- `src/config-values.ts` · `src/guards.ts` — dependency-free readers and type guards. **The only host modules the client bundle may import** (never `config.ts`/schemastery). +- `src/session.ts` · `src/turn-store.ts` — `ses_<12hex><14base62>` hashing (`OPENCODE_SESSION_ID` override) and the ALS turn store. +- `src/stream-hook.ts` · `src/fetch-patch.ts` · `src/tool-fallback.ts` — the `llm/stream` hook, the fetch interceptor, and the free-tier `read`/`bash` fallback. +- `src/key-capture.ts` — what a credential _is_: header extraction, placeholder rejection, tier classification and the capture store. Split from `go-discovery.ts`, which keeps the plan side (endpoint, credential reference, and the policy ordering the two sources against each other). +- `src/go-discovery.ts` · `src/usage.ts` · `src/usage-contract.ts` — credential/base-URL precedence, the usage Host service, and the shared `GoUsage` shape. +- `src/catalog-data.ts` — the static Go/Zen model shim: per-model specs, per-million-token rates, and the provider-scoped retirement list. Pure data, no behavior, and **generated** by `scripts/regenerate-catalog-shim.ts` — never hand-edited, because a hand-patched field once mis-routed nine models on every cold start. `pnpm run catalog:shim` reports staleness; `--write` rewrites it. +- `src/models-catalog.ts` — parses `models.dev`, revalidates the catalog (SWR), and enriches gateway `/models` listings and discovery feeds. Re-exports the `catalog-data.ts` surface. Both sit behind `enrichModels`. +- `src/models-discovery.ts` — provider-aware decoration for `ctx.llm.discoverModels` answers on claimed OpenCode routes. It preserves adapter rows, appends missing canonical rows, and omits provider-retired rows when `enrichModels` is on. +- `src/responses-routes.ts` · `src/responses-provider.ts` — the protocol table read off the vendor's per-model SDK, and the in-process mount of the host's own `llm-pi-ai` that serves the routes it implies. The mount is the piece with four host contracts to survive; read its header before touching it. +- `src/session-cost.ts` — per-turn token/dollar accounting from `llm/stream` usage events, priced with catalog rates. Tracks the ACTIVE model so a mid-session switch reprices without discarding spend. +- `src/cordis-context.ts` · `src/debug.ts` — typed ctx/remote/slots interfaces and JSONL stream debug logging. + +Web client bundle (`lib/client.js`): + +- `src/settings-page.tsx` — bundle entry (`vp pack`): wiring only — `ClientContext`, `apply`, scope validation, the settings store, slot registration, quota-pill props; re-exports `SPECS`. Guard unknown scope with `isSettingsFormScope`, never assert. +- `src/settings-card.tsx` · `src/settings-field-shell.tsx` · `src/settings-boolean-field.tsx` · `src/settings-choice-field.tsx` — the configuration card (one control per `CARD_FIELDS` entry) and its controls: one shared row shell (label / override badge / reset / message) plus a boolean toggle and an enum `` and its `fields.module.css` classes (`css.field`, `css.reset`, `css.hint`, `css.invalid`, …) are private to `ui-primitives`. `settings-field-shell.tsx` is therefore a structural clone in inline styles, mirroring `SettingsValueField`'s markup and its "an invalid draft replaces the hint" rule. +- The enum control — a native ``** (`ui-settings-models`'s `ProviderEditor` protocol picker), down to naming the empty option so a screen reader does not announce a choice with no identity. `settings-choice-field.tsx` mirrors that. + +**Styling rule — use the host's tokens, never invent one.** The host's only variable family is `--dsw-*` (`--dsw-alias-label-*`, `--dsw-alias-state-*`, `--dsw-alias-border-l1…l4`, `--dsw-alias-bg-layer-*`, `--dsw-radius-*`). An invented name such as `--color-fg-subtle` resolves to nothing, so its hard-coded fallback renders instead and the control ignores the theme entirely — dark-theme greys inside a light theme. That is exactly how the enum control first shipped, reading as a bare OS dropdown in the middle of the design system. `fields.module.css` is the reference for the frame, and the metrics matter as much as the colours: a select sitting beside `SettingsValueField` rows must match `.input` (34px, `--dsw-alias-bg-layer-3`, 13px, `0.5px solid --dsw-alias-border-l4`), not a text field's own look. Tests in `settings-choice-field.test.tsx` and `settings-field-shell.test.tsx` assert `--dsw-alias-` is present and `--color-` absent, so it cannot silently regress. + +- The list draft conversion — the platform ships no list spec, so `settings-fields.ts` supplies one. + +`plugins.bundle.config` is rendered with `{ view }` only — the host-owned `form` (state + mutate) is passed to `plugins.item` and `plugins.row.config`, **not** to bundle config — so the card owns its scope and `SettingsFormModel` itself. If the host ever ships a boolean or enum field, delete the matching file here and render that instead. + +Tests — 482 deterministic cases in 28 files; polling helper instead of sleeps; each file restores `globalThis.fetch`/env in `afterEach` (the hook must live in every file, not just the old monolith). `pnpm run test:coverage` enforces a ratchet at **95.5 / 90.9 / 94.1 / 95.5** (statements / branches / functions / lines) — it sits AT the measurement, so it fails only when coverage drops: + +- Host behavior split by concern: `session` · `config` · `fetch-patch` · `lifecycle` · `manifest` · `usage` · `catalog` (26) · `session-cost` · `models-discovery`. +- Host units asserted directly, because every other module narrows through them: `guards` (12) · `config-values` (15) · `cordis-context` (11) · `debug` (5). Each case pins the shapes the unit must REJECT as well as the ones it accepts — an over-accepting guard mis-shapes a host object silently. +- Routing: `responses-routes` (10) split table · `responses-provider` (26) the mount — and the stand-in host it runs against **reproduces all four collisions**, so four more of those cases assert the stand-in REFUSES the shapes the old mount passed. +- Client: `settings-page` (25) card + register · `settings-field-shell` (7) row chrome · `settings-boolean-field` (3) toggle · `settings-choice-field` (7) enum · `usage-pill` (20) gating + copy/failure parsing · `usage-pill-mount` (13) **the pill's poll loop, retry and dismissal, really mounted** · `usage-panel` (13) trigger + panel · `client-bundle` (4) bundle boundary. +- The half of the meter that was untested: `go-discovery` (61) credential policy · `usage-service` (30) + `usage-contract` (19, 100%) the Host service and its parsers · `tool-fallback` (25) + `turn-store` (12) the free-tier rewrite and the ALS store. +- `test/test-helpers.ts` — shared fixtures: mock streams, capture fetch, predicates, `createMockContext`. +- `test/primitives-stub.tsx` — stand-in for the host UI kit; keep it behaviourally faithful to the real primitives (trimmed drafts, empty clears). + +Supporting files: + +- `scripts/name-client-bundle.ts` — renames `vp pack`'s `.cjs` output to `lib/client.js` (DSH loader requires `.js`). +- `scripts/regenerate-catalog-shim.ts` — writes `src/catalog-data.ts` from models.dev through the plugin's own parser. Runs offline with `--from `, exits 1 when the shim is stale, rewrites with `--write`. +- `vitest.e2e.config.ts` · `test/e2e/` — the opt-in end-to-end suite (`pnpm run test:e2e`), collected only by that config so `pnpm test` stays offline and deterministic. `opencode-live.e2e.ts` asserts the live `/models` enrichment and `/usage` payload shapes; `patched-fetch-headers.e2e.ts` proves the outgoing header set over a real socket; **`protocol-routing.e2e.ts` asks the gateway which endpoints actually recognise each shipped model** — the one thing a stub cannot do, because the unit tests read the very mapping they are meant to check. All gated on `OPENCODE_E2E=1`, and each keyed block skips without its key — which is why the CI `e2e` job is green on fork PRs. +- `cordis.patch.yml` — default plugin row (`id: dsh-opencode-patch`); header comments are the headless-config reference. +- `scripts/check.ts` — CI/release gate: lib freshness, peer ranges, harness surface contracts, secret scan, consumer install+load, workflow guards, identity/title consistency, client budget. `scripts/publish-scoped.ts` — publishes/mirrors the scoped aliases with idempotent skip-if-exists guards. +- `.github/workflows/` — `ci.yml` has three jobs: **check** (push/PR/schedule — check + test + build + `scripts/check.ts` + the coverage ratchet, uploading the report), **catalog** (regenerates `src/catalog-data.ts` against models.dev and fails when it is stale), **e2e** (the live gateway). `release.yml` (tag `v*.*.*`) queues per tag instead of cancelling — see the release notes below — packs and hashes the tarball, verifies, then OIDC-publishes the primary + both scoped aliases and confirms every target is readable. `dependabot.yml` keeps both ecosystems current, because the release path is actions and cannot be exercised until it matters. +- `README.md` consumer docs · `CONTRIBUTING.md` dev conventions + release · `CHANGELOG.md` per-version record (release-please-owned; do not hand-edit). + +## Commands & policies + +```sh +pnpm install # install dependencies +pnpm run build # vp pack -> lib/index.mjs + lib/index.d.mts + lib/client.js +pnpm run check # zero *errors* required; zero warnings is the goal (no debt) +pnpm run test # deterministic offline tests, fully green required +pnpm run test:coverage # the same run with the coverage ratchet enforced +pnpm run catalog:shim # regenerate src/catalog-data.ts from models.dev +pnpm run test:e2e # opt-in live gateway suite; no-op unless OPENCODE_E2E=1 +``` + +- Host code needs a **restart**: the base bundle ships `hmr root: []` (config watches only) and a `link:` package resolves through `node_modules` (ignored), so the host never re-imports `lib/index.mjs` after boot — restart `dsh web` after every `pnpm run build`, or the profile runs the old module and `dsh-settings` serves a stale/absent config schema. `lib/client.js` is re-served per page load, so a browser refresh suffices for client-only changes. +- Release: conventional commits on `main` → release-please opens the version + `CHANGELOG.md` PR → merging it tags, and `release.yml` publishes via OIDC (primary + scoped aliases). Never hand-edit `CHANGELOG.md`. +- DSH Web profile wires the build: `~/.dsh/profiles/web/package.json` deps + `bundles` use `dsh-opencode-patch` (`link:../../../dev/dsh-opencode` only for local dev). +- Hygiene: never hardcode `ses_…`/keys in src/tests/git; `lib/` gitignored; `OPENCODE_SESSION_ID` env override only. +- **Client bundle budget**: `lib/client.js` must stay under 64 KiB (`scripts/check.ts`); it currently sits at ~58.5 KB with **~7 KB of headroom**, so the gate is no longer a live constraint — it went from 604 bytes to 7 KB the moment the config-only knobs stopped shipping their copy. Prefer platform primitives over hand-rolled controls (swapping our inline-styled reset ` - + {triggerLabel} + + )} + ); -/** - * The session-spend row, and the one row here that is ALWAYS drawn. - * - * It used to be omitted whenever the accumulator held nothing for the session, - * which reads as "this feature is gone" rather than "no turns yet" — and that - * state is reachable on every host restart, because the accumulator lives in - * the Host process and counts only OpenCode-route turns. A row that cannot - * distinguish the two has to say which. - */ -const SpendRow = ({ - t, - usage, -}: { - t: (key: string) => string; - usage: GoUsage | undefined; -}): React.ReactElement => { - const session = usage?.session; - return ( -
-
- {t("sessionSpend")} - {/* - The SAME shape either way: which model, then what it costs. The free - case used to drop the model entirely and print a bare sentence, so the - one row that said "$0.00" was also the one row that could not say what - you are paying for — and the id it fell back to - (`muse-spark-1.3-contributor-free`) said "free" a third time. - */} - - {session === undefined - ? t("sessionSpendEmpty") - : `${session.activeModelName ?? session.activeModel ?? ""} · ${ - session.freeModel === true - ? t("freeModel") - : (session.activeRateFormatted ?? "") - }`} - -
- - {/* - THIS plane's spend, not the session's all-planes total. The Zen row - once read $1.44 of Go allowance spend against a FREE model, because - the accumulator folds every turn of the session into one number and - the panel answered for a balance it was not reading. - */} - {session === undefined - ? "—" - : (session.planeCostFormatted ?? session.costFormatted)} - -
- ); -}; - /** The hover/click panel: quota breakdown, spend, Zen overflow and actions. */ export interface UsagePanelProps { badgeText: string; + clampedPercent: number; failure: UsageFailure | null; + headline: string; isLimited: boolean; isZen: boolean; locale?: string; @@ -218,18 +116,21 @@ export interface UsagePanelProps { onMouseLeave: () => void; refreshing: boolean; retry: () => void; + ringColor: string; + showUsagePrice: boolean; t: (key: string) => string; - /** The account's name — `OpenCode Go` / `OpenCode Zen`, stable across polls. */ - title: string; /** Epoch ms of the last successful read, or `null` while loading. */ updatedAt: number | null; usage: GoUsage | undefined; zenCardCredit: string; + zenCardDesc: string; } export const UsagePanel = ({ badgeText, + clampedPercent, failure, + headline, isLimited, isZen, locale, @@ -237,121 +138,87 @@ export const UsagePanel = ({ onMouseLeave, refreshing, retry, + ringColor, + showUsagePrice, t, - title, updatedAt, usage, zenCardCredit, + zenCardDesc, }: UsagePanelProps): React.ReactElement => (
- {/* - Header. The badge carries the billing model — `Pay-as-you-go` on Zen, - `Go Plan` / the limit notice on Go — so no second line repeats it. - */} + {/* Header */}
-
{title}
- {/* - The host's own chip, not a look-alike: a hand-rolled span here used the - HOVER fill token as its resting background, so it sat permanently lit. - `danger` is the host's tone for a limit; `neutral` is a plain label. - */} - {/* An empty label draws an empty chip, which is a frame around nothing. - The Go panel has no badge to show until the plan is limited. */} - {badgeText.length > 0 && ( - {badgeText} - )} +
+
{headline}
+ {isZen && ( +
{t("zenPaygDesc")}
+ )} +
+ + {badgeText} +
{!isZen && ( <> + {/* Primary Accent Progress Bar */} +
+
+
+ {/* Breakdown Section */} - {/* No window rows when Go's quota could not be read. The three figures - would be a type floor, not a measurement — and they rendered as three - confident 0% rows all counting down from <1m. The overflow row below - still shows: that inference came from the 403 and is honest. */} - {usage !== undefined && usage.quotaUnavailable !== true && ( + {usage !== undefined && (
{/* One row per window; the rate-limited badge now appears on every window that is limited, not only the monthly one. */} {BREAKDOWN_WINDOWS.map((entry) => { const window: UsageWindow = usage[entry.key]; - // Computed once: the shape decides the grammar below, and calling - // the formatter twice could straddle a boundary between the two - // reads. - const reset = formatRelativeReset(window.resetsAt, locale); return (
-
-
- - {/* - The kit's dot, driven by the state the window IS — - done / warning / error — not by a colour string this - file used to inline. Its CSS maps the same three - states to the same `--dsw-alias-state-*` tokens the - hand-rolled 6px circle carried, so the look is - unchanged and the theme owns it again. - */} - - {t(entry.labelKey)} - - {/* - The reset used to own its own line below the bar, which - made every window THREE blocks deep and the three windows - nine lines tall — and the last reset sat flush against the - session spend with nothing between them. It belongs on - the label row: label left, reset and percent right, one - line per window plus the bar that gives the number its - shape. - */} - - {/* - Grammar follows the SHAPE. A duration is a suffix - (`1h 11m后重置`), an absolute instant is a prefix - (`重置于 11月7日 08:55`), and a window that has already - rolled over says so instead of claiming a countdown. - One prefix for all three produced `重置于 1h 11m` — - "resets at 1h 11m". - */} - {resetLabel(reset, t)} - {window.status === "rate-limited" && ( - - {t("usageLimited")} - - )} - - - {window.percent}% - -
- {/* - The bar is the shape the number takes. A bare "42%" is an - abstraction; a track two pixels tall makes the share - legible at a glance and gives the three windows a common - ruler — which is what the quota cards used to provide, - before they became rows. - */} -
-
+ + -
+ {t(entry.labelKey)} + + + {window.percent}% + +
+
+ + Resets {formatRelativeReset(window.resetsAt, locale)} + + {window.status === "rate-limited" && ( + + {t("usageLimited")} + + )}
); @@ -359,70 +226,69 @@ export const UsagePanel = ({
)} - {/* - What the percentage is a percentage OF. The three windows are shares of a - per-model monthly dollar allowance, and that allowance is the one figure the - live payload cannot supply — `/usage` takes no model parameter, so its - percentages are the ACCOUNT's and multiplying them by this would be a number - with no referent. So this row states the TOTAL, for both tiers: the plan is - not discoverable (`/limits`, `/plan`, `/subscription` all 404), and a panel - that guessed one would misstate the money by 2-3x. - */} - {usage?.allowance !== undefined && ( -
-
- - {t("monthlyAllowance")} - - {/* WHICH model — the allowance is per model, so the figure is - meaningless without it. */} - - {usage.session?.activeModelName ?? usage.allowance.model} - + {/* Divider */} +
+ + {/* Balance Cards (Image 2 pattern) */} + {usage !== undefined && ( + <> +
Quota Overview
+
+ {BREAKDOWN_WINDOWS.map((entry) => { + const window: UsageWindow = usage[entry.key]; + const limited = window.status === "rate-limited"; + return ( +
+ + {entry.cardName} + + + {window.percent}% + + + {formatRelativeReset(window.resetsAt, locale)} + +
+ ); + })}
- - {t("goTier")} {formatUsd(usage.allowance.go)} · {t("goPlusTier")}{" "} - {formatUsd(usage.allowance.goPlus)} - -
+ )} )} {/* Session spend & active-model rate. Priced on the Host from models.dev rates, so the client never ships the catalog. */} - {/* - Called, not rendered: every component in this file is a pure function of - its props with no hooks, so tests invoke them directly and walk the tree — - and an element of a component type hides its own children from a walk that - never mounts. `` would put the row out of reach of exactly the - assertions that pin it. - */} - {SpendRow({ t, usage })} - - {/* - The Zen card answers "where does an over-limit Go request get billed?" - — so it belongs on the GO panel only. On a Zen route you are already - paying per token, and there is no balance to report: OpenCode exposes no - balance endpoint at all, so the row could only restate the badge. - */} - {!isZen && usage?.zenOverflow === true && ( -
-
- - {t("zenCredit")} - {/* The billing rule is a paragraph about a mechanism, so it lives - behind this affordance instead of in the panel: the panel is a - gauge, and four lines of prose made the note the loudest thing - on it. Hovering explains; the row stays one line. */} - - - - - + {showUsagePrice && usage?.session !== undefined && ( +
+
+ {t("sessionSpend")} + + {usage.session.includedInPlan === true + ? t("includedInPlan") + : `${usage.session.activeModel ?? ""} · ${usage.session.activeRateFormatted ?? ""}`}
- {zenCardCredit} + + {usage.session.costFormatted} + +
+ )} + + {/* Attached Zen Overflow Card */} + {(isZen || usage?.zenOverflow === true) && ( +
+
+ {t("zenCredit")} + {zenCardDesc} +
+ {zenCardCredit}
)} @@ -435,75 +301,41 @@ export const UsagePanel = ({ {/* Failure Alert */} {failure !== null && (
- {/* A rejected credential is not a failed refresh, so it does not borrow - the refresh copy: the heading tells the user WHICH state they are in - and what to do, and the raw HTTP message stays as the detail. */} - - {failure.reason === "auth" - ? t("usageAuthRejected") - : t("usageRefreshFailed")} - + {t("usageRefreshFailed")}

{failure.message ?? t("usageUnavailable")}

)} - {/* - One row, three jobs: WHEN this reading was taken, a control to take a new - one, and the console. The refresh is an icon rather than a labelled button - because "更新于 06:46 PM" beside it already says what it does — a second - label spelling that out was noise on a row this short. - */} + {/* Footer with updated timestamp & retry */}
- {/* The control comes AFTER the value, not before it. Leading with the - icon made the cell read as a lone button with a caption beside it, and - the 20px hit box around a 14px glyph plus a 5px gap left a hole where - the sentence should be. A value followed by the thing that refreshes it - is the same reading as any "as of" field. */} - - {/* Nothing at all until the first read lands. A "Loading usage..." cell - is a state that exists for a moment and then disappears, so the row - changes width and the footer jumps on every mount — a blip from - showing progress nobody needs. The refresh button already carries the - affordance; the timestamp is the only thing here worth reading. */} + {updatedAt === null - ? "" + ? t("usageLoading") : `${t("usageLastUpdated")} ${new Date(updatedAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })}`} - - {/* Every link lives in the footer now. `升级套餐` used to have its own row, - between a divider and the spend block, where it read as a section - header rather than the answer to the windows above it — and it put a - second action in a second place on a 320px panel, so the panel was one - row taller for it. Beside 控制台 they read as what they are: actions. - Still two links because they are two destinations — opencode.ai/go is - the plan page, /console is the console SPA, both probed, neither - reachable from the other. */} - {panelActions(isZen).map((action) => ( - - {t(action.labelKey)} - - ))} - - {t(isZen ? "usageTopUp" : "usageConsole")} + {refreshing ? t("usageRefreshing") : t("usageRetry")} + +
+ {/* + The meter says a limit was hit; these say what to do about it. + Both open in a new tab so the console is not lost, and both carry + rel="noreferrer noopener" because the target is a third party. + */} +
diff --git a/src/usage-pill.tsx b/src/usage-pill.tsx index 21b12ee..82ba09e 100644 --- a/src/usage-pill.tsx +++ b/src/usage-pill.tsx @@ -16,12 +16,11 @@ import React, { import { DEFAULT_PROVIDERS } from "./config-values.ts"; import type { GoUsage } from "./usage-contract.ts"; -import { goQuotaTooltip, UsagePanel, UsageTrigger } from "./usage-panel.tsx"; +import { UsagePanel, UsageTrigger } from "./usage-panel.tsx"; import { - CIRCUMFERENCE, describeUsage, getAffectingWindow, - getWindowColorFor, + getWindowColor, isZenProvider, matchesAny, parseFailure, @@ -51,90 +50,37 @@ export interface ModelDirectoryState { pending?: DirectorySelection; } -/** - * A STABLE snapshot, not a fresh object per call. - * - * `useSyncExternalStore` compares by identity, so a getter that builds a new - * object every time reports a change on every read and React re-renders forever - * — "Maximum update depth exceeded" (React error #185), which is how the first - * version of this fallback failed. - */ -const NO_DIRECTORY_STATE: ModelDirectoryState = {}; - -const NO_DIRECTORY: SnapshotStore = { - getSnapshot: () => NO_DIRECTORY_STATE, - subscribe: () => () => { - // Nothing to unsubscribe from. - }, -}; - export interface UsagePillProps { - /** - * The session's model-directory store. - * - * Optional because absence is a real state: the injector hands over no props - * when the Host has no model directory or no usage service, and the Host reads - * `hooks` off whatever it returns — so the pill must render from nothing - * rather than the entry throwing inside the renderer. - */ - directory?: SnapshotStore; + directory: SnapshotStore; getLocale?: () => string; /** * Deprecated: model markers are no longer used for gating. Kept optional for backward compatibility. */ modelMarkers?: readonly string[]; - /** Why the meter has no directory; set only on the diagnostic path. */ - reason?: string; /** * Provider routes the meter is shown for — the same list the host claims * traffic for. Defaults to the stock routes when absent or empty. */ meterProviders?: readonly string[]; - /** - * Read the quota, and optionally name the model the picker is on so the Host - * can price the rate for THAT model — the snapshot alone describes the model - * that ran the last turn. - */ - readUsage: (provider?: string, model?: string) => Promise; + readUsage: (provider?: string) => Promise; /** Whether to show accumulated session spend and the active model's rate. */ + showUsagePrice?: boolean; t: (key: string) => string; } -/** - * One full turn of the refresh glyph, and the minimum a click stays busy. - * - * These two must agree: the CSS animates `dsh-oc-spin` for 0.9s per cycle, and - * the manual refresh holds its state open for at least this long, so the button - * always completes a turn instead of flickering. A test pins the number against - * the keyframes, because the two drifting apart is exactly how the glyph went - * from "a full cycle" back to "an unreadable flicker". - */ -export const MIN_FEEDBACK_MS = 900; - -/** - * Whether the reader has asked for less motion. - * - * Read at call time, not at module load: the query can change between renders, - * and a cached answer would pin the first preference the reader ever had. - */ -const reducedMotion = (): boolean => - globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; - const noop = (): void => { /* no-op */ }; interface ActiveUsageProps extends Omit { - /** The model the picker is on, or `undefined` before a selection is saved. */ - model?: string; provider?: string; } const ActiveUsage = ({ getLocale, - model, provider, readUsage, + showUsagePrice = true, t, }: ActiveUsageProps): React.ReactElement | null => { const [snapshot, setSnapshot] = useState<{ @@ -165,10 +111,9 @@ const ActiveUsage = ({ return; } busy = true; - const startedAt = Date.now(); setRefreshing(true); try { - const value = await readUsage(provider, model); + const value = await readUsage(provider); if (alive) { setSnapshot({ reader: readUsage, @@ -193,22 +138,6 @@ const ActiveUsage = ({ } finally { busy = false; if (alive) { - // A click must show AT LEAST one full turn of the refresh glyph. The - // spin already exists (rotate 360deg, 0.9s, infinite) but the read is - // usually faster than that, so the icon flickered for ~100ms and - // completed no cycle at all — the click looked like it did nothing. - // - // Only a MANUAL refresh is padded. The 60s poll is invisible, and - // holding its state open would just delay the next tick. Skipped - // entirely under prefers-reduced-motion, where a full turn is motion - // the reader has opted out of — the data still lands, just without the - // animation to wait for. - const remaining = MIN_FEEDBACK_MS - (Date.now() - startedAt); - if (manual && remaining > 0 && !reducedMotion()) { - await new Promise((resolve) => { - setTimeout(resolve, remaining); - }); - } setRefreshing(false); } } @@ -233,7 +162,7 @@ const ActiveUsage = ({ clearInterval(timer); document.removeEventListener("visibilitychange", onVisible); }; - }, [readUsage, provider, model]); + }, [readUsage]); useEffect(() => { if (!open) { @@ -261,16 +190,13 @@ const ActiveUsage = ({ }; }, [open]); - /** - * Hovering no longer opens the panel — it only cancels a pending close, so - * moving from the trigger onto the panel keeps it open. The panel is opened by - * a click, and hovering the trigger shows a `Tooltip` instead. - */ const handleMouseEnter = (): void => { if (hoverTimer.current !== null) { clearTimeout(hoverTimer.current); - hoverTimer.current = null; } + hoverTimer.current = setTimeout(() => { + setOpen(true); + }, 120); }; const handleMouseLeave = (): void => { @@ -299,36 +225,16 @@ const ActiveUsage = ({ const affecting = usage === undefined ? undefined : getAffectingWindow(usage); const isLimited = affecting?.window.status === "rate-limited"; const displayPercent = affecting?.window.percent ?? 0; - // A free model has no allowance to run out of: the plan's limit says nothing - // about what the user is actually spending, which is zero. Red would read as - // "you are out of money" against a bill that cannot be charged, so the ring - // goes hollow instead — the track colour, the empty state. - // A free model has nothing to run out of, so its ring is hollow rather than - // red. (Zen needs no clause here: it draws no ring at all — a gauge with no - // measurement is decoration.) - const isFree = usage?.session?.freeModel === true; - // `undefined` affecting means the first read is still in flight; success is - // the neutral assumption, and the ring refills the moment data lands. - const ringColor = isFree - ? "var(--dsw-alias-label-tertiary)" - : getWindowColorFor(affecting); + const ringColor = + affecting === undefined + ? "var(--dsw-alias-state-success-primary)" + : getWindowColor(affecting.window); - const strokeDasharray = isFree - ? `0 ${CIRCUMFERENCE}` - : ringGeometry(displayPercent).strokeDasharray; + const { clampedPercent, strokeDasharray } = ringGeometry(displayPercent); - // Empty, never an ellipsis. "…" is a promise that a number is coming, and on - // a poll that cannot answer it the pill flickers … / 42% / … / 42% — which - // reads as a glitch, not as progress. The ring already says there is a meter; - // a failed read says "!" because that IS information. - let triggerLabel = ""; + let triggerLabel = "…"; if (usage !== undefined) { - // The percentage is the GO plan's window, and the Zen route does not bill - // against it: printing it beside a per-token bill states a fact about a - // plan this route never touches. Zen's number is the spend, which the - // trigger prefers above; with no session record yet it shows NOTHING rather - // than a Go figure. - triggerLabel = isZen ? "" : `${displayPercent}%`; + triggerLabel = `${displayPercent}%`; } else if (failure !== null) { triggerLabel = "!"; } @@ -336,30 +242,13 @@ const ActiveUsage = ({ const locale = getLocale?.(); // Wording lives in `usage-ui.ts` so it can be unit-tested without React. - // The hover label is the SAME string the panel opens under: no second - // composition, no keys an older served dictionary has not heard of. - const { badgeText, title, tooltip, zenCardCredit } = describeUsage( + const { badgeText, headline, zenCardCredit, zenCardDesc } = describeUsage( usage, affecting, isZen, t ); - // A Zen hover answers the question its number raises, and that number is the - // spend — so the label leads with the price. With no record to price it falls - // back to the billing model, which is the only other true thing to say. - const zenSpend = - usage?.session === undefined - ? undefined - : (usage.session.planeCostFormatted ?? usage.session.costFormatted); - const zenTooltip = - zenSpend === undefined - ? tooltip - : `${t("sessionSpend")} ${zenSpend} · ${t("zenPaygBadge")}`; - // Go lists every window: the trigger prints one number, and the next question - // is always "and the other two?". - const tooltipLabel = isZen ? zenTooltip : goQuotaTooltip(t, usage); - return ( {STYLES} - {/* Hover explains; the click opens. */} {open && ( { retry.current(); }} + ringColor={ringColor} + showUsagePrice={showUsagePrice} t={t} - title={title} updatedAt={current === null ? null : current.updatedAt} usage={usage} zenCardCredit={zenCardCredit} + zenCardDesc={zenCardDesc} /> )} @@ -416,31 +308,15 @@ export const UsagePill = ({ directory, meterProviders, modelMarkers: _modelMarkers, - reason: _reason, ...props }: UsagePillProps): React.ReactElement | null => { - // A fallback rather than an early return: `useSyncExternalStore` is a hook, so - // the call has to happen either way. - const store = directory ?? NO_DIRECTORY; const state = useSyncExternalStore( - store.subscribe, - store.getSnapshot, - store.getSnapshot + directory.subscribe, + directory.getSnapshot, + directory.getSnapshot ); - // No directory means the injector had nothing to hand over — the Host has no - // model directory for this session, or no usage service. Rendering nothing is - // the honest state, and it is safe to return here: this is the component's - // only hook. - if (directory === undefined) { - return null; - } - const provider = state?.current?.provider ?? state?.pending?.provider ?? ""; - // The selection, so the Host can price the rate for what the NEXT turn runs - // rather than for whatever ran last. A pending pick wins over the saved one — - // it is the model the user is looking at. - const model = state?.pending?.model ?? state?.current?.model ?? undefined; // The settings scope passes the claimed routes at inject time; an absent or // empty list falls back to the stock ones, so direct callers (and older // injected props) keep the default gate. @@ -457,5 +333,5 @@ export const UsagePill = ({ return null; } - return ; + return ; }; diff --git a/src/usage-ui.ts b/src/usage-ui.ts index ad2c8dc..bf812ab 100644 --- a/src/usage-ui.ts +++ b/src/usage-ui.ts @@ -39,38 +39,8 @@ export const ringGeometry = ( * `AGENTS.md` → "OpenCode endpoints" records the probed surface behind that. */ export const GO_PLAN_URL = "https://opencode.ai/go"; -/** - * The console, which is also where a pay-as-you-go account is topped up. - * - * One URL for both, and deliberately so: it is the only destination verified to - * exist. Every path under `/console/` answers 200 because the console is a - * single-page app, so a probe cannot distinguish a real `/billing` route from a - * catch-all — and a top-up link that 404s in front of a user is worse than one - * that opens the page where top-up lives. - */ -export const CONSOLE_URL = "https://opencode.ai/console"; - -/** One action the panel can offer, as data so the two popovers share a body. */ -export interface PanelAction { - href: string; - labelKey: string; -} - -/** - * What to DO about the quota, rendered inside the GO layer — next to the windows - * it acts on, not parked under the shared footer. - * - * One link, not two. The limits doc went: the panel already names every window - * with its share and its reset time, so the document explained a list the user - * is looking at. A link that restates what is on screen is the same redundancy - * as a card that restates the badge — the doc is one click from the console for - * anyone who wants the policy. - * - * Zen has nothing to add: its single action IS the console on the footer row, - * labelled 充值 because that is what a pay-as-you-go user wants from it. - */ -export const panelActions = (isZen: boolean): readonly PanelAction[] => - isZen ? [] : [{ href: GO_PLAN_URL, labelKey: "usageUpgradePlan" }]; +export const GO_CONSOLE_URL = "https://opencode.ai/console"; +export const GO_LIMITS_DOC_URL = "https://opencode.ai/docs/go/"; /** * Whether a provider route or model id contains any configured marker, @@ -104,63 +74,29 @@ export const STYLES = ` display: inline-flex; min-width: 0; vertical-align: middle; - /* The trigger carries side padding for its hit area, and the dock adds its own - gap — stacked, they read as one wide space beside the model selector. Pull - the BOX back by part of that padding so the visual edge sits at the dock's - rhythm. The hit area is untouched: shrinking the padding instead would trade - accessibility for spacing, which is the wrong trade. */ - margin-right: -6px; - /* Hug the content. A slot wrapper may stretch its child, and a stretched box - makes the pill's hover tint wider than the thing inside it — an edge with - nothing in it. */ - flex: none; -} - -/* - * The trigger follows the host's own composer pills: a translucent fill rather - * than a bare glyph on the bar. DSH pairs its ContextMeter ring with a filled - * pill ("176M tok · 缓存命中 97%"), and an unfilled trigger reads as unfinished - * next to it. - */ +} + .dsh-oc-usage-trigger { border: 0; - /* - * No fill at rest: the composer's own controls are ghost, and a tinted pill - * beside the model selector reads as a chip with a frame of its own. The hover - * tint is the affordance — it is what says the ring is a button. - */ background: transparent; color: var(--dsw-alias-label-secondary, currentColor); font: inherit; font-size: 12px; font-variant-numeric: tabular-nums; - /* Trimmed from 3px 9px: the box read as a fat edge around a 12px label. The - * target is still ~18px tall and ~48px wide, which is a comfortable hit area - * — this trims the edge, it does not remove the target. */ - padding: 2px 7px; - /* --dsw-radius-sm (8px), and the RATIO is the reason — not the token. - * - * The host's own controls (Button, Input, SegmentedControl) all declare - * --dsw-radius-md, and this trigger first copied them. That is right at their - * height: 12px on a ~28px control is 43% of it, which reads as a rounded - * rect. This trigger is ~20px tall, so the same 12px clamps to 10px — 50% of - * the box, which IS a stadium, and the owner read it as too round. - * - * Same host ladder, one step down: 8px on 20px restores the host's own - * ratio. The value comes from the theme; only the rung changes. */ - border-radius: var(--dsw-radius-sm, 8px); + padding: 3px 6px; + border-radius: 6px; cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 5px; - transition: background 0.15s ease, color 0.15s ease; + transition: background 0.15s ease, opacity 0.15s ease; user-select: none; } .dsh-oc-usage-trigger:hover, .dsh-oc-usage-trigger:focus-visible { - background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 7%, transparent)); + background: color-mix(in srgb, currentColor 8%, transparent); color: var(--dsw-alias-label-primary, currentColor); } @@ -182,70 +118,25 @@ export const STYLES = ` transition: stroke-dasharray 0.3s ease, stroke 0.2s ease; } -/* - * Host rules for a floating surface: no border AND an elevation shadow, never - * both a border and a shadow (docs/web-styling.md), radius from the panel token, - * background from the menu material so it matches every other popover. - */ .dsh-oc-usage-panel { position: absolute; bottom: calc(100% + 8px); right: 0; z-index: 1100; - width: 320px; + width: 310px; max-width: calc(100vw - 24px); max-height: 80vh; overflow-y: auto; box-sizing: border-box; - padding: 14px 16px; - border: 0; - /* --dsw-radius-lg, 16px — which is where this STARTED, before two rounds of - * "read the host's values" made it worse. The right reference is - * MenuSurface.module.css, because this panel IS menu material: its .surface and - * .backing rules take --dsw-radius-lg, and .compact takes --dsw-radius-md. - * (Braces cannot appear in this comment — the tests extract a rule with a - * brace-free match, so one here truncates every assertion in this file.) - * * The 28px --dsw-radius-panel belongs to the dockkit FLOAT, which is a - * position:fixed panel docked to the screen edge with an opaque - * --dsw-alias-bg-layer-2 and no backdrop. Different material, different radius — - * see the note on choosing the reference. */ - border-radius: var(--dsw-radius-lg, 16px); - /* The host's own menu material, and the one line that decides whether this - * reads as part of the harness. --dsw-menu-surface-fill is TRANSLUCENT - * (#f8f9fa94 light, #43454a73 dark) so the blur behind it shows through. - * - * This used to stack --dsw-specific-menu (#f8f9faf0 — 94% opaque) over - * --dsw-alias-bg-layer-2 (--dsw-static-neutral-bluish-00: #fff, fully - * opaque). Two real tokens, correct-looking names, and the sum was solid - * white: the only popover in the composer that did not look like one. The - * tokens were checked and the RESULT was not — a fallback chain cannot be - * verified by reading the name. --dsw-specific-menu is the *specific* - * (higher-emphasis) menu; a plain floating surface wants the surface fill. - */ - background: var(--dsw-menu-surface-fill, Canvas); - backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%)); - /* The stroke is NOT declared here, and never needed to be. Hardcoding one - * level broke the other theme — which is why the owner's first instinct - * ("should we use border-l4?") was RIGHT and two rounds of reasoning talked - * them out of it. - * - * The theme picks it per theme, off the attribute the host's own Menu sets: - * body -> border-l4 - * body[data-ds-dark-theme] [data-menu-material] -> border-l3 - * There is NO light-theme [data-menu-material] rule, so l3 is a DARK value - * that reads like a global one. Grep it without its selector and you get the - * wrong answer in the light theme — the theme every screenshot here is in. - * The panel is menu material, so it carries the attribute and takes whatever - * the theme says; setting the variable here would pin one theme's answer and - * silently break the other's. */ - /* --dsw-elevation-prominent, and this was briefly "panel" because a token by - * that name exists. The harness's OWN floating panel — the dockkit float, the - * same class of thing in this same dock — reads - * box-shadow: var(--dsw-elevation-prominent) - * so prominent is the surface elevation, and elevation-panel is a token nobody - * consumes. Reverted after reading the consumer instead of the name, which is - * the mistake this file has now paid for twice. */ - box-shadow: var(--dsw-elevation-prominent, 0 12px 36px rgba(0, 0, 0, 0.28)); + padding: 14px; + border-radius: 14px; + background-color: Canvas; + background-image: + linear-gradient(var(--dsw-specific-menu, transparent), var(--dsw-specific-menu, transparent)), + linear-gradient(var(--dsw-alias-bg-layer-2, Canvas), var(--dsw-alias-bg-layer-2, Canvas)); + backdrop-filter: var(--dsw-menu-backdrop-filter, blur(20px)); + border: 1px solid color-mix(in srgb, currentColor 14%, transparent); + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35); color: var(--dsw-alias-label-primary, CanvasText); font-size: 12px; line-height: 1.5; @@ -264,13 +155,11 @@ export const STYLES = ` } } -/* Header: title left, the one big number right — the host's own popover shape. */ .dsh-oc-usage-header { display: flex; - align-items: baseline; + align-items: center; justify-content: space-between; - gap: 12px; - margin-bottom: 10px; + margin-bottom: 8px; } .dsh-oc-usage-headline { @@ -279,269 +168,244 @@ export const STYLES = ` letter-spacing: -0.01em; } +.dsh-oc-usage-figures { + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--dsw-alias-label-secondary, currentColor); +} + +.dsh-oc-usage-badge { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in srgb, currentColor 10%, transparent); +} + +.dsh-oc-usage-badge.dsh-oc-badge-limited { + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 15%, transparent); + color: var(--dsw-alias-state-error-primary); +} + +.dsh-oc-usage-bar-track { + background: color-mix(in srgb, currentColor 10%, transparent); + border-radius: 999px; + height: 5px; + overflow: hidden; + margin-bottom: 12px; +} + +.dsh-oc-usage-bar-fill { + height: 100%; + border-radius: 999px; + transition: width 0.3s ease, background-color 0.2s ease; +} + .dsh-oc-usage-breakdown { display: flex; flex-direction: column; - gap: 10px; + gap: 9px; margin-top: 6px; - /* The windows are quota; the rows below are money. Without this the last - reset sat flush against the session spend and the two read as one list. */ - margin-bottom: 14px; } .dsh-oc-usage-row { display: flex; - align-items: baseline; - gap: 10px; + align-items: center; + justify-content: space-between; } -/* The kit's dot keeps a 10px SLOT but paints a 6px core inside it, so 2px of - every side is empty. A 7px gap therefore read as ~9px beside a 6px circle — - the dot looked detached from its label, like a marker hanging at the row's - front. 4px makes the optical gap match the circle. */ .dsh-oc-usage-row-left { display: inline-flex; align-items: center; - gap: 4px; - flex: 1 1 auto; - min-width: 0; + gap: 6px; font-size: 12px; - color: var(--dsw-alias-label-secondary, currentColor); } -/* Tertiary, and never competing with the percent for the right edge. */ -.dsh-oc-usage-row-reset { - flex: none; - font-size: 11px; - color: var(--dsw-alias-label-tertiary, currentColor); +.dsh-oc-usage-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; } .dsh-oc-usage-row-right { - flex: none; font-variant-numeric: tabular-nums; font-weight: 600; font-size: 12px; - color: var(--dsw-alias-label-primary, currentColor); } -/* The bar is the shape the number takes: one track per window, the fill the - window's own colour. Two pixels — a progress bar that is also a ruler. - It spans the WHOLE row, starting under the state dot: the 13px indent that - used to align it with the label left the dot hanging outside the row's own - bounds, like a list marker, and cost the ruler the width it exists to use. */ -.dsh-oc-usage-bar { - height: 2px; - margin-top: 4px; - border-radius: var(--dsw-radius-full, 999px); - background: var(--dsw-alias-border-l4, currentColor); - overflow: hidden; -} - -.dsh-oc-usage-bar-fill { - height: 100%; - border-radius: inherit; - transition: width 0.3s ease; -} - -/* Styling lives in the sheet, not inline: an ad-hoc style prop here would be - the only one surviving in the panel, and a second limited-state tone tomorrow - would either duplicate it or diverge from it. */ -.dsh-oc-usage-limited { - margin-left: 6px; - color: var(--dsw-alias-state-error-primary); -} - -/* The footer carries the timestamp AND every action now. The upgrade-plan link had its own - row once, behind a divider, which made the panel a row taller and put a second - action in a second place. Links keep the host's own language — the colour is - from its MarkdownText stylesheet — because unstyled anchors ran together into - one sentence. See AGENTS.md, "The meter's panel". */ -.dsh-oc-usage-footer { +.dsh-oc-usage-subrow { display: flex; align-items: center; justify-content: space-between; - gap: 12px; font-size: 11px; - color: var(--dsw-alias-label-tertiary, currentColor); - padding-top: 4px; + opacity: 0.65; + margin-top: 1px; + padding-left: 13px; } -.dsh-oc-usage-console { - color: var(--dsw-alias-link, currentColor); - font-weight: 500; - text-decoration: none; - white-space: nowrap; +.dsh-oc-usage-divider { + height: 1px; + background: color-mix(in srgb, currentColor 10%, transparent); + margin: 12px 0 10px; } -.dsh-oc-usage-console:hover, -.dsh-oc-usage-console:focus-visible { - text-decoration: underline dotted; - text-underline-offset: 3px; +.dsh-oc-usage-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.6; + margin-bottom: 8px; } -.dsh-oc-usage-console:focus-visible { - outline: none; - border-radius: var(--dsw-radius-xs, 4px); - box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); +.dsh-oc-usage-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-bottom: 10px; } -.dsh-oc-usage-updated { - display: inline-flex; - align-items: center; - /* Tight: the button's own 20px box already carries the breathing room, and - the gap was on top of it. The target stays 20px — padding is not what makes - this row look empty, and shrinking the hit area to close a gap would trade - accessibility for a pixel. */ - gap: 1px; - min-width: 0; +.dsh-oc-usage-card { + padding: 8px 7px; + border-radius: 8px; + background: color-mix(in srgb, currentColor 5%, transparent); + border: 1px solid color-mix(in srgb, currentColor 8%, transparent); + display: flex; + flex-direction: column; + gap: 2px; } -.dsh-oc-usage-refresh { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - margin: -4px 0; - padding: 0; - border: 0; - border-radius: var(--dsw-radius-xs, 4px); - background: transparent; - color: var(--dsw-alias-label-tertiary, currentColor); - cursor: pointer; - transition: - background 0.15s ease, - color 0.15s ease; +.dsh-oc-usage-card.dsh-oc-card-limited { + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); + border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 25%, transparent); } -.dsh-oc-usage-refresh:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 10%, transparent)); - color: var(--dsw-alias-label-primary, currentColor); +.dsh-oc-usage-card-name { + font-size: 10px; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.dsh-oc-usage-refresh:focus-visible { - outline: none; - box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); +.dsh-oc-usage-card-percent { + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; } -/* The control is busy mid-read; the glyph turns rather than disappearing. */ -.dsh-oc-usage-refresh:disabled { - cursor: default; +.dsh-oc-usage-card-reset { + font-size: 10px; opacity: 0.6; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.dsh-oc-usage-refresh:disabled .dsh-oc-usage-refresh-icon { - animation: dsh-oc-spin 0.9s linear infinite; -} - -@keyframes dsh-oc-spin { - to { - transform: rotate(360deg); - } -} - -.dsh-oc-usage-console { - color: var(--dsw-alias-link, currentColor); +.dsh-oc-usage-footer { + display: flex; + align-items: center; + justify-content: space-between; font-size: 11px; - font-weight: 500; - text-decoration: none; -} - -.dsh-oc-usage-console:hover, -.dsh-oc-usage-console:focus-visible { - text-decoration: underline dotted; - text-underline-offset: 3px; + opacity: 0.7; + padding-top: 4px; } -/* - * A failure is the one thing here the user can act on, so unlike every other row - * it IS a callout — same shape as the overflow notice above, in the error tone. - * This class rendered with no rule at all until now: a bare
with a

, - * which meant UA margins, no colour and no emphasis on the one message that - * needed it. - */ -.dsh-oc-usage-warning { - padding: 7px 9px; - border-radius: var(--dsw-radius-sm, 8px); - background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); - color: var(--dsw-alias-state-error-primary); +.dsh-oc-usage-retry { + border: 1px solid color-mix(in srgb, currentColor 20%, transparent); + border-radius: 6px; + padding: 2px 7px; + background: transparent; + color: inherit; + font: inherit; font-size: 11px; - line-height: 1.45; - margin-top: 8px; + cursor: pointer; + transition: background 0.15s ease; } -.dsh-oc-usage-warning strong { +.dsh-oc-usage-retry:hover:not(:disabled) { + background: color-mix(in srgb, currentColor 10%, transparent); } -/* The detail line is a

, whose UA margins would double the padding. */ -.dsh-oc-usage-warning p { - margin: 2px 0 0; - color: var(--dsw-alias-label-secondary, currentColor); +.dsh-oc-usage-retry:disabled { + opacity: 0.4; + cursor: default; } .dsh-oc-usage-zen-notice { font-size: 11px; - line-height: 1.45; - padding: 7px 9px; - border-radius: var(--dsw-radius-sm, 8px); - background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); - color: var(--dsw-alias-label-secondary, inherit); + line-height: 1.4; + padding: 6px 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, #d97706) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary, #d97706) 25%, transparent); + color: inherit; + opacity: 0.95; margin-top: 4px; } -/* - * A detail row — label, sub-label, value. NOT a card: the panel is already a - * floating surface, and a second background inside it draws two nested frames - * around one line of text. The row reads as one of the breakdown's own rows, - * which is what it is. - */ -.dsh-oc-usage-detail { +.dsh-oc-zen-card { + padding: 8px 10px; + border-radius: 8px; + background: color-mix(in srgb, currentColor 6%, transparent); + border: 1px solid color-mix(in srgb, currentColor 10%, transparent); display: flex; align-items: center; justify-content: space-between; - gap: 10px; -} - -/* The first detail is spaced by whatever precedes it — the header's own margin - on Zen, the divider on Go — so only a STACKED one needs a gap of its own. */ -.dsh-oc-usage-detail + .dsh-oc-usage-detail { - margin-top: 10px; + margin-top: 8px; } -.dsh-oc-usage-detail-left { +.dsh-oc-zen-card-left { display: flex; flex-direction: column; gap: 1px; - min-width: 0; } -.dsh-oc-usage-detail-title { +.dsh-oc-zen-card-title { font-size: 11px; - color: var(--dsw-alias-label-primary, currentColor); + font-weight: 600; + opacity: 0.85; } -.dsh-oc-usage-detail-desc { +.dsh-oc-zen-card-desc { font-size: 10px; - color: var(--dsw-alias-label-tertiary, currentColor); + opacity: 0.6; +} + +.dsh-oc-zen-card-credit { + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--dsw-alias-state-success-primary); } -/* The info affordance that carries the billing rule: a control, not - decoration, so it takes the host's label token and says it is hoverable. */ -.dsh-oc-usage-info { +.dsh-oc-zen-pill { display: inline-flex; align-items: center; - margin-left: 4px; - color: var(--dsw-alias-label-tertiary, currentColor); - cursor: help; - vertical-align: middle; + gap: 4px; } -/* Same metrics as a breakdown row's value, so the rows line up as one list. */ -.dsh-oc-usage-detail-value { - font-size: 12px; - font-variant-numeric: tabular-nums; - color: var(--dsw-alias-label-primary, currentColor); - white-space: nowrap; +/* The "what do I do about this" row. Links, not buttons: both navigate away. */ +.dsh-oc-usage-links { + display: flex; + gap: 12px; + padding-top: 6px; + border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); + font-size: 11px; +} + +.dsh-oc-usage-links a { + color: var(--dsw-alias-link, currentColor); + text-decoration: none; +} + +.dsh-oc-usage-links a:hover { + text-decoration: underline; } `; @@ -550,286 +414,86 @@ export const STYLES = ` // DOM outside the component leaks a permanent `