diff --git a/AGENTS.md b/AGENTS.md index a40500c..c7520a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,9 +38,12 @@ src/wire-shared.ts Shared boundary-validation + Remote-descriptor plumbing for the hand-rolled Typert wire contracts (dependency-free; imported by the wire files so the client can inline it). src/client/index.ts Browser client entry: registers the "Command Code" - settings page (settings.section, id `commandcode`) and - the Models-page provider card - (settings.models.provider-card, key `llm-commandcode`). + settings page (settings.section, id `commandcode`), the + Models-page provider card + (settings.models.provider-card, key `llm-commandcode`), the + plans & quota panel's `main` cell + gated + `sidebar.footer.action` card, and the composer + `conversation.composer.dock` session-cost entry. src/client/settings.ts Settings-page controller (scope + credentials + staged form; React-free so node tests can drive it). src/client/legacy-credentials.ts Pre-0.1.2 ApiProxy-to-current credential face adapter. @@ -53,10 +56,34 @@ src/client/card.tsx The Models-page provider card (keyed-slot component + the SlotMap merge for `settings.models.provider-card` / `settings.models.footer` mirroring upstream 0.1.2). src/client/sessions.ts selectModel friendly-error wrapper (React-free). +src/client/panel.ts Plans & quota panel view model + the shared background + auto-refresh loop (React-free). +src/client/panel-view.tsx Sidebar footer card + center dashboard components. +src/client/panel-copy.ts English copy for the panel (deliberately NOT a locale + namespace — the panel is English on every harness). +src/client/panel-slots.ts SlotMap merge for `main` + `sidebar.footer.action`. +src/client/panel-styles.ts The panel stylesheet + its `data-plugin-css` id (the + idempotence key `injectPanelCss` selects on). +src/client/prices.ts Price-table controller over `commandcode/prices` (cached, + bounded transient retries, manual retry, rebind reload). +src/client/session-cost.ts Session-cost calculation + copy (React-free). +src/client/session-cost-view.tsx The dock entry that feeds the injected cost. +src/client/session-cost-display.ts DOM injection into the harness's token-usage + pill and usage dialog (browser only). +src/client/session-cost-slots.ts SlotMap merge for `conversation.composer.dock`. +src/cost-facts.ts JSON-only billing facts shared by the Host projection and + the browser readout (groups, peak/hour rule, pricing key). +src/cost-projection.ts Durable `commandCodeCost` session projection: folds each + request's model, attempt time and prompt band so the + readout prices history instead of cumulative totals. src/client/version.ts Plugin version for the settings-page footer (package.json import, inlined at build). src/client/update.ts Update hint: throttled npm-registry `latest` check + tolerant semver compare (React-free, storage/fetch/time seams); the page footer links to releases when newer. +src/model-prices.ts Vendored per-token price table (input/output/cache-read/ + cache-write, peak overrides) + the catalog-id → pricing-slug + join, served to the browser through `commandcode/prices`. + Generated rows; see the dsh-commandcode-upstream skill. src/login.ts Host half of the browser login: loopback callback server mirroring `command-code login` (POST /callback, state token, whoami validation) → storeKey seam. @@ -97,6 +124,20 @@ tests/login-client.test.ts login-panel controller poll lifecycle. tests/client.test.ts selectModel friendly-error rewrite tests (real envelope shape). tests/client-boot.test.ts client-boot integration tests (real apply() against a DSH 0.1.2 client assembly; settings page + provider card). +tests/panel.test.ts plans & quota projection + auto-refresh loop tests. +tests/model-prices.test.ts price table ↔ catalog join tests (fails when a + catalog model has no price). +tests/session-cost.test.ts session-cost calculation tests (peak/off-peak, a + missing rate, the invisible-when-unpriceable rules). +tests/session-cost-display.test.ts DOM-injection tests for the pill and the + usage dialog, driven through the real class and its + `doc`/`observe` seams against a fake DOM (confirmation, + self-heal, hide/restore, disposal). +tests/cost-projection.test.ts durable cost-fact fold tests against the real + projection registry (v1/v2 settlements, retries, history + restore, tier boundaries, free/unpriced subtotals). +tests/prices-client.test.ts price-table controller tests (cache, bounded + transient retries, a Host without the endpoint). tests/package.test.ts package-metadata contract (Harness peers start at rc.1, no dsh-client-runtime). tests/config-schema.test.ts Config credential contract: literal apiKey fields @@ -130,7 +171,13 @@ tsdown.config.ts Build config (tsdown -> lib/, ESM, .d.ts + client.js). leak into a settings document. The retained legacy credential adapter is defensive only; the published peer contract starts at 0.1.2-rc.1. `tests/settings.test.ts` and `tests/legacy-credentials.test.ts` pin these - internal faces. + internal faces. The panel and session-cost slots have PER-SLOT version floors, + not one shared one (see the panel bullet below): the composer dock and the + sidebar foot have existed since 0.1.1-rc.2, the keyed `main` seat arrives in + 0.1.5-alpha.2, and `layout.selectPanel` in 0.1.5-rc.1. Never restate that as + "the panel needs 0.1.5" — that is true of the `main` seat only, and the + sidebar seat exists everywhere, which is why the footer registration is gated + on the layout seam instead of on a slot declaration. - **Isolated package install**: pnpm 10 auto-installs the package's DSH peers when a desktop marketplace prepares a fresh generation. Keep `@deepseek-ai/dsh-invariants` as an explicit `^0.1.2-rc.1` peer matching @@ -175,11 +222,135 @@ tsdown.config.ts Build config (tsdown -> lib/, ESM, .d.ts + client.js). - Web search: `POST {apiBase}/alpha/web-search` — body `{ query, numResults, allowedDomains?, blockedDomains? }` → `{ results: [{ title, url, snippet }] }`; same `Authorization: Bearer ` + `x-command-code-version` as generate. - Defaults: `apiBase = https://api.commandcode.ai`, `COMMAND_CODE_CLI_VERSION = '1.53.1'`. - **API key resolution order** (in `src/index.ts`): `config.apiKey` → credential ref `apiKeyEnv` (default `COMMANDCODE_API_KEY`, via the dsh credentials seam) → launch environment → official CLI auth file `~/.commandcode/auth.json`. **pi/OMP auth files are intentionally NOT scanned** — keep it that way. -- **Multi-account rotation** (`src/accounts.ts` + the adapter's connect loop): the top-level key forms the `default` slot; `Config.accounts` (`[{ label, apiKeyEnv | apiKey }]`) adds more, in rotation order. Rotation is **passive**: a key is marked only on a real pre-stream rejection (429 → `unknown` cooldown, 401 → `disabled`), and the adapter's `rotateApiKey` hook re-sends the same request with the next account's key (safe: nothing streamed, the body is account-independent, `threadId` random per request — mid-stream failures NEVER rotate). When every account is marked, the pool probes `/alpha/billing/credits` per key (`probeFiveHourWindow`) to revive reset windows, else throws `RATE_LIMIT` naming the earliest `resetAt` (all-401 → `INVALID_CREDENTIAL`). State is keyed by API key, not slot — shared credentials share one mark. **Manual selection**: `Config.activeAccount` (a slot id) pins the serving account via the pool's `preferredId` seam + `selectActiveAccount()` (shared with the usage view's active badge); a pinned-but-exhausted or unknown id falls back to rotation order. **Model routing**: `Config.modelAccountRules` (`[{ models: string[], account }]`) lists catalog model ids per account slot; the request's model reaches key resolution (`resolveApiKey(connection, model)`), the pool's `modelAccountRules` seam re-reads rules per resolution, and `matchModelRule()`/`selectAccountForModel()` serve the routed account before preferred/rotation — an unusable routed account falls back, so the router is a hint, never a hard gate. The rules editor's model list comes from a Host-side `commandcode/models` Remote (the FULL adapter catalog via `listModels(…, { unfiltered: true })`, sorted), so the browser never calls the Command Code API; `SettingsPageApi.models` is optional, so legacy transports degrade to the empty-catalog state. Extra-account slot ids are the credential reference itself (`COMMANDCODE_API_KEY_2`, …) so a stored selection survives list reorders/removals; only literal-only composition entries keep positional `account-N` ids. The settings page edits `activeAccount` through the generic section-field machinery (a `` bound to a text field) and `modelAccountRules` through its own rules card (staged rows like `accounts`, one `modelAccountRules` write). The picker's billing-access cache is per key. The usage Remote result is `CommandCodeAccountsReport` (`{ accounts: [...] }`); host and client ship in one bundle, so wire-shape changes need no migration — only synced edits in `src/usage-wire.ts`, `src/usage-remote.ts`, `src/client/usage.ts`, and `src/commands.ts`. **`report.credits` distinguishes "reported" from "zero", and that distinction is load-bearing**: `monthlyReported` is optional-tri-state (`undefined` = a pre-field Host, read as "assume reported"; explicit `false` = the endpoint omitted the balance, which the panel renders as `—` and never as a consumed quota), and `fiveHour`/`weekly` are OPTIONAL members that are absent when the endpoint reported no such window — a present window with `cap: 0` means uncapped spend and keeps its row, an absent one keeps no row at all. Reading an absent balance as `0` is what turned a transient `/alpha/billing/credits` failure into a confident "100% used, quota exhausted"; the official CLI gates its own meter on the credits payload being present (`hasCreditsInfo`) and computes no depletion percentage without it. The wire schema validates a PRESENT `monthlyReported` as a boolean, so a malformed frame is rejected rather than coerced. - **Web search (`src/web-search.ts` + the optional `web` seam)**: the model-facing `web_search` tool (from `@deepseek-ai/dsh-tool-web`) is served by a `CommandCodeSearchProvider` registered as `commandcode` on `ctx.web` — same `Authorization: Bearer ` + `x-command-code-version` chain, same `apiBase`, so DSH's web search needs NO separate key/endpoint config (unlike `dsh-web-search-deepseek`, which needs its own Anthropic-compatible base). It POSTs `{ query, numResults, allowedDomains?, blockedDomains? }` to `/alpha/web-search` and maps `{ title, url, snippet }` → `WebSearchSource`. Registration rides `ctx.inject(['web'], ...)` exactly like `commands`/`typert`: the provider is registered only when the profile mounts the web service, and the fiber never activates otherwise (this stays an LLM-provider-only plugin without web). The pool's `resolveKey()` (rotation + auth-file revived) is reused, so search benefits from the same multi-account selection; the search endpoint is account-independent so no mid-flight rotation happens. **Selection**: whether the `commandcode` provider WINS over the shipped `deepseek-official` (or a sibling search plugin's pin, e.g. modsearch's `searchProvider: modsearch`) is `Config.webSearch` (default on). The web seam has NO public runtime selector, so the plugin writes its private `searchProviderId` field (read per call by `web.search()`) via `applyCommandCodeSearchSelection()` in `src/web-search.ts` — applied at boot AND on every settings change (the `installSection` `onChange` hook), and restored on fiber unload. The tracked `CommandCodeSearchSelection` remembers the displaced backend id, so toggle-off (and unload) hands the selection back to it — it NEVER forces the factory default, because that is what silenced sibling plugins with Command Code search off (issue #26); a fresh boot straight into `webSearch: false` leaves the field untouched. Re-enables keep the original `displaced` (the field holds our own id then, which must not overwrite the memory), and a field already reading `commandcode` at first touch means "nothing to restore". That write depends on the runtime shape (a plain writable property, not `#private`); the durable alternative is the boot-time `searchProvider: commandcode` cordis patch. The legacy `selectCommandCodeSearchProvider()` stays exported for compatibility but always restores the factory default on disable — new code must not use it. `dsh-web` is a `^0.1.2-rc.1` peer (kept external in tsdown); `tests/web-search.test.ts` pins the wire body, header, result mapping, the `WEB_ABORTED`/`WEB_PROVIDER_CREDENTIAL_MISSING`/`WEB_PROVIDER_ERROR` taxonomy, the selection-field handoff (sibling-pin restore, re-enable memory, unload path via the real host `apply()`), and the legacy rewrite. - **StreamChunk contract** (dsh-llm): each block starts with `block-start`, deltas by `index`, ends with `block-end`; `usage` before `finish`; nothing after `finish`. Tool-call `arguments` are raw JSON strings. Historical reasoning blocks are replayed on BOTH transports for tool-loop continuity — as a `{ type: 'reasoning', text }` assistant part on `/alpha/generate` (the official CLI's shape) and as `reasoning_content` on `/provider/v1/chat/completions` (see the wire-protocol bullet; issue #34). Only tool calls with a paired tool result are replayed on both transports. **Tool-result images** (`read_image` returns text + a nested `image` block): neither wire can hold an image inside a tool result — the CLI's `tool-result.output` is text-only (the official CLI's own `toV2ToolOutput` filters out everything but text) and Chat Completions forbids non-text `role: 'tool'` content — so `toolResultMedia()` splits each result and both converters emit the bytes in a user message immediately after the tool message, led by the `Attached image(s) from tool result:` note (the shape `@deepseek-ai/dsh-llm-deepseek` uses). Deduplicated by attachment id per result; an image-only result gets a `(image returned; see the attached image)` tool text instead of an empty string; a result without a paired call drops its images with the result. Never flatten a tool result with `blockText` alone again — that is issue #30. The `hasImageContent` gate (model Vision capability + attachment seam) already recurses into tool results, so these images ride the same `readImage` resolver user attachments use. - **Errors**: throw `LlmError` with stable codes. 401 → `INVALID_CREDENTIAL`; 429 → `RATE_LIMIT`; other HTTP → `PROVIDER_HTTP_ERROR` (403 body's `error.code`, e.g. `MODEL_NOT_IN_PLAN`, is parsed into the message). Unsupported options (`stop`) and image input throw `UNSUPPORTED_OPTION` / `UNSUPPORTED_CONTENT` rather than silently dropping. - **Adapter is cordis-free** by design: `src/adapter.ts` takes a per-request `options()` thunk + `resolveApiKey()` from the plugin entry, so settings changes reach the next request without re-registration. It also accepts an injectable `fetchImpl` for tests. +- **Plans & quota panel + composer session cost (ported from PR #36)**: two + client surfaces using Host usage and durable request-cost facts. (1) The sidebar footer + card (`sidebar.footer.action`, order 1 — directly above Settings) and the + dashboard it opens in the layout's keyed `main` slot both render one + projection, `buildPanelView()` in `src/client/panel.ts`: plan, the 5-hour and + weekly windows with their own spend/limits, monthly credits derived the CLI's + way (`limit - remaining`), and the purchased/free balances. The projections + fetch nothing themselves: both read the usage controller's snapshot, and + `startPanelAutoRefresh()` is a refcounted 2-minute tick that calls + `usage.refresh()` while mounted. The Host report determines configuration, + including composition literals and CLI-auth fallback; browser credential + references must never gate this read. Unreported credit fields stay dashes. + (2) The composer readout (`conversation.composer.dock`, id + `commandcode-session-cost` — never the shipped `stats` id, which would REPLACE + the harness's token/cache-hit/throughput cell) renders NO surface of its own: + `src/client/session-cost-display.ts` injects the amount into the shipped pill + and a price per row into the shipped usage dialog, matched POSITIONALLY (the + labels are the `chat` locale's, so they are never read) and confirmed by the + token count each row must be showing. `buildSessionCostView()` owns every + number and string: it prices only `commandcode` sessions, never invents a + cache-write rate the pricing page omits (those tokens are reported as + unpriced and the total stays a floor), and returns `undefined` — no pill at + all — for no usage, no table, an unknown model, all-zero buckets, or a session + whose EVERY billed token is unpriced (the guard is "unpriced tokens and no + priced spend", never "the total rounds to zero": a real sub-cent session keeps + its `<$0.0001` bound). The dialog row for cache-write tokens is hidden only + when their rate is missing; when it is published the row stays, because its + cost is already inside the total and hiding it makes the rows unable to + explain the figure above them. Both surfaces are English by construction + (`panel-copy.ts`, `SESSION_COST_COPY`) rather than through `ctx.locale`; that + is a deliberate, documented limitation. + **Version floors are PER SLOT, and getting this wrong ships a dead button.** + Measured across 0.1.1-rc.2 … 0.1.5-rc.2: `sidebar.footer.action` and + `conversation.composer.dock` are declared AND rendered in every one of those + releases; the keyed `main` seat arrives in 0.1.5-alpha.2 and + `layout.selectPanel` in 0.1.5-rc.1. So "the panel needs 0.1.5" is true of the + `main` seat ONLY — on an older engine the sidebar seat still exists, so an + ungated `slots.inject` there would render a card that silently does nothing + when clicked. The footer registration is therefore gated on + `ctx.inject(['layout'], …)` plus a `typeof selectPanel === 'function'` check + (`src/client/index.ts`), and the dashboard cell needs no gate because + registering against an undeclared slot is a no-op by construction. The DOM + anchors (`[data-composer-stats]`, `[data-session-stats-usage]`) are the one + genuinely 0.1.5-alpha.1 marker. `tests/client-boot.test.ts` pins all of this by + modelling the declaration set and the layout seam separately. + The three slot declarations are re-stated locally + (`panel-slots.ts`, `session-cost-slots.ts`) and must stay structurally + identical to upstream's, exactly like the Models-card merge in `card.tsx`. +- **Durable session cost facts** (`src/cost-projection.ts`): optional reflective + `sessionProjections` registration folds `request/header` model selection and + `step/start` / `llm/retry-started` timestamps alongside v1 usage chunks and v2 + assistant message/attempt stream settlements. Samples replace within an + attempt; retries add. Rate-equivalent requests aggregate into bounded groups, + using all prompt token buckets for each request's context tier. The pricing + fingerprint versions checkpoints and guards the client table; schema/fold + changes must bump the fingerprint seed. Restoring/forking uses the Host log, + not browser memory. Match all buckets against `tokenUsage` before decorating. + Never price cumulative usage with `modelSelection.lastUsed` or current time. + Missing projection means hidden cost; missing rates/other-provider usage + produce a labeled subtotal. Published-rate estimates are not provider invoices. + **KNOWN LIMITATION — one fold rule cannot serve both log generations.** The + replacement semantics here are the 0.1.5 token-meter's (`llm/retry-started` + closes the replacement slot, so a retried attempt ADDS). The 0.1.2/0.1.3-era + fold instead replaced on `(turn, step)` alone and never handled that event + (`dsh-client-connection`'s fixture projection is exactly that rule). On those + engines the two folds therefore disagree by the retried attempt's tokens, and + because the client requires per-bucket EQUALITY against `tokenUsage` + (`src/client/session-cost.ts`), a session that retried there loses the readout + entirely rather than showing a wrong figure. Retries are routine on this route + (429 plus the near-unbounded retry policy) and 0.1.2-rc.1/0.1.3 are declared + compatible in `package.json`, so this is a real gap, not a theoretical one. + Closing it means recording which fold rule wrote a group (or versioning the + projection per engine generation). Until then, do NOT "fix" the equality gate + by loosening it: that gate is what keeps a mismatched fold from being priced. +- **Settings usage card availability** (`usageCardState()` in + `src/client/usage.ts`): the card's auto-fetch, refresh button and "no key" + hint derive from the HOST report's `entry.configured`, never from a browser + credential reference. A composition literal (`Config.apiKey`) is a stripped + secret and the official CLI auth file is not in the credentials store, so both + are invisible to the browser while the Host happily serves requests with them + — `state.anyAccountConfigured` must not gate this card or the post-save + refresh trigger (`src/client/index.ts`). `shouldRefresh` is true only in + `status === 'idle'`, so a failed fetch never becomes an automatic request + loop, and the button stays enabled for a manual retry. +- **Price table Remote (`commandcode/prices`)**: `src/model-prices.ts` vendors + the official per-token rates and serves them Host-side over the SAME + `commandcodeUsage` service and one combined contribution (report + catalog + + prices + login). Rows are keyed by CATALOG id wherever the two namespaces + reconcile (the page drops vendor prefixes and sometimes inserts a hyphen — + `priceSlugCandidates()` generates the plausible slugs and takes the first hit), + and a row no catalog model claims is still served under its slug; free models + are served explicitly at zero with `free: true`. The peak windows travel WITH + the table (`peakHours`), so the browser prices against this snapshot's schedule + instead of restating it; the model-independent half of that rule is + `isPeakPricingHour()` in `capabilities.ts`, which `peakPricingState()` now + delegates to. `CommandCodePricesController` (`src/client/prices.ts`) is a + cache with three bounded transient retries (1/2/4 seconds), and both the namespace member and `UsageRemote.prices` are + OPTIONAL because the Host and bundle can be a cross-version pair — a Host + without the endpoint lands in a permanent "no prices" state instead of + throwing. `tests/model-prices.test.ts` fails whenever a catalog model has no + price row (free models are exempt, since they are served explicitly at zero), + which is the visible decision point when upstream adds a model. + **Syncing the table is a script, not a hand edit**: `node + scripts/sync-model-prices.mjs` re-reads the page's embedded model JSON, asserts + it still duplicates its base rates into `offPeak` and that peak ≥ off-peak, + carries every `contextTiers` band including its inclusive input-token bound, + CROSS-CHECKS each rewritten row against the page's own rendered table, + and rewrites only the `MODEL_PRICE_ROWS` literal. `--check` reports drift + without writing (exit 1 on drift, exit 2 when the page could not be read, so a + network failure never reads as "up to date"). The cross-check is the point: a + row can be internally consistent and still be the wrong row, which is exactly + how `deepseek-v4-flash-vision-exp` shipped ~47% high. Two documented upstream + inconsistencies to leave alone rather than "fix" in the table: the four GPT + rows publish a literal `cacheWriteCost: 0` in the JSON while the rendered + column shows `—` (the table stays faithful to the machine-readable source), + and the Mon–Fri rule is restated in the browser because only the WINDOWS + travel with the table. The live copies are `isPeakPricingHour()` in + `capabilities.ts` (Host picker labels) and `peakHour()` in `cost-facts.ts` + (the readout's path, imported by the client bundle) — a weekday-rule change + is a two-place edit across those two. `isPeakHour()` in + `src/client/session-cost.ts` is a leftover third copy that nothing calls: + only `tests/session-cost.test.ts` references it, and it is dead-code-eliminated + out of `lib/client.js`. Delete it (and that one test) rather than re-wiring it. + - **Usage Remote (`commandcode/report`)**: the settings page's account card fetches the usage report Host-side through the Typert Gateway — the browser never holds the API key. Host: `src/usage-remote.ts` registers a diff --git a/CHANGELOG.md b/CHANGELOG.md index c2aaf0b..d5ef458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **A plans & quota card in the sidebar and a dashboard behind it.** The bottom of the sidebar now carries a Command Code card — directly above Settings — showing the serving account's plan and both quota windows (5-hour and weekly) with their spend and limits. Clicking it opens a dashboard in the center column with the plan badge and billing period, the two windows as progress bars with reset times, monthly credit consumption, and the purchased/free credit balances. Both surfaces are driven by the same Host-side usage report the settings page's account card already uses, so no key ever reaches the browser, and the panel refreshes itself in the background on a 2-minute tick while it is on screen. The card is English by construction rather than following the harness language. It needs a harness whose layout can select a center panel — dsh 0.1.5 (rc.1) or newer: on an older engine the card is not registered at all, because a button that opens nothing is worse than no button, and every other screen is unaffected. Ported from [#36](https://github.com/Mars-Sea/dsh-commandcode-provider/pull/36) by [@xer-on](https://github.com/xer-on). + +- **The composer now shows a published-rate estimate for the current session.** Under the input, next to the harness's own token counter, a session served by Command Code gains an amount (for example `1.2M tokens · Cache hit 87% · ≈$0.02`), and the token-usage dialog the counter opens gains a price per row (uncached input, completion, cache read). The figures are computed from the session's own token buckets against Command Code's published per-token rates, including the peak/off-peak halves of the hourly-priced DeepSeek models. The readout decorates the harness's counter instead of replacing it, so the existing token/cache-hit/throughput figures and their translations stay exactly as they are. Three rules keep it honest: only Command Code sessions are priced, a model with no published cache-write rate says so rather than guessing a multiplier, and a session with nothing to price (no usage yet, an unknown model) renders no figure at all instead of a misleading `$0.00`. Ported from [#36](https://github.com/Mars-Sea/dsh-commandcode-provider/pull/36) by [@xer-on](https://github.com/xer-on). + +### Changed +- **The model price table is served to the browser over a new `commandcode/prices` Remote.** The official per-token rates (input, output, cache-read, cache-write where published, plus the peak-hour override for the hourly-priced models) are vendored Host-side and reach the composer through the existing `commandcodeUsage` service, so the browser bundle carries no copy that could drift and a price row reaches an open page without a rebuild. A price row whose catalog name differs from the pricing page's slug (a vendor prefix, a dated suffix) is matched automatically, and a model the page does not price renders no cost rather than a guess. The table is pinned against the catalog by `tests/model-prices.test.ts`, which fails when a catalog model has no price — the visible decision point when upstream adds a model. It is refreshed with `node scripts/sync-model-prices.mjs`, which re-reads the page, cross-checks the embedded rates against the page's own rendered table and rewrites only the rows (`--check` reports drift without writing). + +### Fixed +- **Free usage mixed with unpriced usage no longer displays a zero subtotal.** Purely free sessions retain `Free`, while partial sessions need positive priced spend before a figure is shown. +- **The settings usage card also resolves availability from the Host.** Literal and CLI-auth keys can fetch and refresh usage without a browser credential reference. The slash command omits its balance ratio when either input is unreported or the denominator is zero, and displays a single percent sign for known ratios. + +- **Session estimates preserve each request's model and time across reloads and model switches.** An optional Host projection replays durable usage, including failed attempts and retry replacement rules; all eight context-tiered price rows retain their bands. Missing history hides the estimate, and unpriced or other-provider usage is labeled as a subtotal. The result uses the installed published-rate snapshot, not the provider invoice. +- **Quota refresh resolves credentials on the Host**, including literal config keys and CLI-auth fallback. Omitted purchased/free balances remain unknown instead of appearing as zero. +- **Transient price-table failures retry automatically** three times with bounded backoff. Host rebinds invalidate stale results; manual refresh remains available after retries are exhausted. + +- **A session cost on a model with no published cache-write rate no longer shows `$0.00`.** If every billed token was one the price table has no rate for, the total was zero for lack of a rate rather than for lack of spending, and the readout announced a confident `$0.00`. It now renders nothing, exactly as it already did for an unknown model. Relatedly, a cache-write row whose rate IS published is no longer hidden from the usage dialog: its cost was already part of the total above it, so dropping the row left the visible figures unable to explain the amount. + +- **The plans panel no longer reports a failed billing call as a fully consumed quota.** The credits endpoint is fetched in parallel with the plan and can fail on its own; the monthly balance then arrived as an absent number, which the panel read as `0` and turned into "100% used, quota exhausted" — with purchased/free tiles showing balances that were never fetched. Unreported figures now render as a dash, and the quota bar is simply not drawn until both halves of the ratio are known. Only the quota windows the endpoint actually reported get a row, so an unlimited plan no longer shows two fabricated 0%-of-limit meters. + +- **The hourly-priced DeepSeek V4 Flash Vision (exp) is priced at its published rate.** The table carried `$0.22 / $0.66 / $0.003`, which is ~47% above the official `$0.15 / $0.60 / $0.003` (peak `$0.30 / $1.20`). Every session cost and per-row price for that model was overstated; the row now matches the page and `scripts/sync-model-prices.mjs` cross-checks the table against the page's rendered rates so the same class of error is caught at sync time. + ## [0.10.6] - 2026-09-12 ### Fixed diff --git a/README.md b/README.md index 44ff7a9..f62f3d4 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Unofficial [DeepSeek Harness](https://deepseek-harness.github.io/deepseek-harnes - **Plan-aware picker** — models above your subscription tier are hidden by default (toggleable); an optional **Model allowlist** keeps only your favorites in the picker. - **Reasoning-effort support** — models with selectable reasoning effort levels expose them in the picker. - **Image input** — Vision-capable models accept images. +- **Plans & quota panel** — a Command Code card at the bottom of the sidebar (directly above Settings) shows the serving account's plan and its 5-hour and weekly windows; clicking it opens a dashboard with the billing period, both windows as progress bars with reset times, monthly credit consumption, and the purchased/free balances. Needs dsh 0.1.5 (rc.1) or newer, and reads in English on every harness language. +- **Session cost estimate** — published-rate estimates (`≈`) beside the composer token counter and in its usage dialog, using each request's model, request time and context tier from durable session history. Model switches and viewing the session later do not reprice earlier requests. Mixed-provider or unpriced usage shows a labeled subtotal (`≥`); missing history or wholly unpriceable usage stays hidden. These are estimates from the installed price snapshot, not provider invoices. Also English on every harness language. - **Web search** — the dsh `web_search` tool is backed by the Command Code Provider API (`/alpha/web-search`) with the same key/endpoint as chat, so no separate search key or base URL is needed. See [Web search](#web-search). See [Screenshots](#screenshots) below for what the UI looks like. diff --git a/README.zh-CN.md b/README.zh-CN.md index 301eb07..7987689 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,6 +27,8 @@ - **按套餐过滤**:默认隐藏超出订阅套餐的模型,可一键关闭;「模型白名单」可进一步只保留常用模型。 - **推理强度支持**:支持推理强度的模型可在选择器中选择档位。 - **图片输入**:Vision 模型支持发送图片。 +- **套餐与配额面板**:侧边栏底部(Settings 正上方)新增 Command Code 卡片,显示当前服务账号的套餐与 5 小时 / 每周两个配额窗口;点击后在中间栏打开面板,包含计费周期、两个窗口的进度条与重置时间、月度额度消耗,以及已购买 / 赠送余额。需要 dsh 0.1.5(rc.1)或更高版本;无论 Harness 语言如何,该面板固定为英文。 +- **会话费用估算**:在输入框下方的 token 计数旁及用量对话框中显示估算金额(`≈`),根据持久化历史中每次请求的模型、请求时间和上下文阶梯分别计价。切换模型或稍后查看不会重新定价之前的请求。混合供应商或缺少费率时显示已定价部分的小计(`≥`);缺少历史事实或完全无法定价时不显示金额。结果基于插件内的价格快照,不等同于供应商账单。同样固定为英文。 - **联网搜索**:dsh 的 `web_search` 工具由 Command Code Provider API(`/alpha/web-search`)承载,复用聊天同一个 key 与端点,无需单独配置搜索 key 或地址。详见[联网搜索](#联网搜索)。 ## 安装 diff --git a/lib/client.js b/lib/client.js index 199a00f..9a22a9a 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1225,7 +1225,7 @@ window.__ModuleLoader__.load({ } //#endregion //#region src/client/usage.ts - const IDLE = { + const IDLE$1 = { status: "idle", report: void 0, error: void 0, @@ -1239,7 +1239,7 @@ window.__ModuleLoader__.load({ var CommandCodeUsageController = class { remote; listeners = /* @__PURE__ */ new Set(); - current = IDLE; + current = IDLE$1; generation = 0; inFlight = false; disposed = false; @@ -1341,6 +1341,166 @@ window.__ModuleLoader__.load({ if (ms <= 0) return ""; return new Date(ms).toLocaleString(); } + /** Host facts drive the usage card, including keys invisible to browser credentials. */ + function usageCardState(usage) { + const accounts = usage.report?.accounts ?? []; + return { + loading: usage.status === "loading", + shouldRefresh: usage.status === "idle", + noKey: accounts.length > 0 && !accounts.some((account) => account.configured) + }; + } + //#endregion + //#region src/client/prices.ts + const IDLE = { + status: "idle", + table: void 0, + error: void 0 + }; + const RETRY_TIMER = { + set(callback, ms) { + const handle = setTimeout(callback, ms); + handle.unref?.(); + return handle; + }, + clear(handle) { + clearTimeout(handle); + } + }; + /** + * Cache over the `commandcode/prices` Remote, with a bounded transient-retry + * budget. Public API mirrors {@link CommandCodeUsageController}: `state()`, + * `subscribe`, and `ensure()`; `reload()` additionally drops the cache and + * restarts the budget when the Host namespace rebinds. + * + * Not a one-shot: the last fetch is cached until `reload()`, a transient + * failure is retried three times at 1/2/4 s (the delays are bounded, not a + * poll), and a MANUAL `ensure()` still fetches after that budget is spent. A + * PERMANENT failure — the Host serving no such endpoint — is terminal for the + * binding and is only cleared by `reload()`. + */ + var CommandCodePricesController = class { + remote; + listeners = /* @__PURE__ */ new Set(); + current = IDLE; + generation = 0; + inFlight = false; + disposed = false; + retryHandle; + attempts = 0; + permanent = false; + timer; + constructor(remote, timer = RETRY_TIMER) { + this.timer = timer; + this.remote = remote; + } + /** Release every subscription. Idempotent; in-flight results are dropped. */ + dispose() { + this.disposed = true; + this.clearRetry(); + this.listeners.clear(); + } + /** Subscribe to state projections. @returns the disposer. */ + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + /** The current state face. */ + state() { + return this.current; + } + /** + * Fetch the table unless it is already loaded, permanent, or in flight — and + * unless a bounded retry already owns the next attempt. + * + * The only callers are the client entry: `reload()` when the Remote namespace + * lands or rebinds, and a manual refresh. Nothing in the composer calls it, so + * a mounted readout does not trigger a fetch of its own — it renders whatever + * the table's state currently is. + */ + ensure() { + if (this.disposed || this.permanent || this.inFlight || this.current.status === "ready") return; + this.clearRetry(); + const call = this.remote.prices; + if (typeof call !== "function") { + this.permanent = true; + this.publish({ + status: "error", + table: void 0, + error: "the Host serves no commandcode/prices endpoint" + }); + return; + } + const generation = this.generation; + this.inFlight = true; + this.attempts += 1; + this.publish({ + status: "loading", + table: this.current.table, + error: void 0 + }); + let request; + try { + request = call.call(this.remote); + } catch (error) { + request = Promise.reject(error); + } + request.then((result) => { + if (this.disposed || generation !== this.generation) return; + this.inFlight = false; + if (result.ok) { + this.publish({ + status: "ready", + table: result.value, + error: void 0 + }); + return; + } + this.permanent = result.error.permanent === true; + this.publish({ + status: "error", + table: void 0, + error: result.error.message + }); + this.scheduleRetry(); + }, (error) => { + if (this.disposed || generation !== this.generation) return; + this.inFlight = false; + this.publish({ + status: "error", + table: void 0, + error: error instanceof Error ? error.message : String(error) + }); + this.scheduleRetry(); + }); + } + /** Rebinding a Host invalidates cached prices and restarts the bounded retry budget. */ + reload() { + if (this.disposed) return; + this.generation += 1; + this.inFlight = false; + this.clearRetry(); + this.permanent = false; + this.attempts = 0; + this.current = IDLE; + this.ensure(); + } + clearRetry() { + if (this.retryHandle !== void 0) this.timer.clear(this.retryHandle); + this.retryHandle = void 0; + } + scheduleRetry() { + if (this.disposed || this.permanent || this.attempts >= 4) return; + this.retryHandle = this.timer.set(() => { + this.retryHandle = void 0; + this.ensure(); + }, 1e3 * 2 ** (this.attempts - 1)); + } + publish(next) { + this.current = next; + for (const listener of this.listeners) listener(); + } + }; //#endregion //#region src/client/login.ts /** How often a live attempt is polled. */ @@ -1680,7 +1840,16 @@ window.__ModuleLoader__.load({ */ const { reject: reject$1, record: record$1, stringField: stringField$1, numberField, booleanField } = makeBoundaryValidator("commandcode/report result:"); /** Validate one window-limit block (`fiveHour` / `weekly`). */ + /** + * Parse one quota window, or undefined when the frame carries no such window. + * + * The distinction is load-bearing at this boundary: an ABSENT window means the + * billing endpoint reported none (an unlimited plan), while a present block with + * `cap: 0` means uncapped spend that was really reported. Collapsing the two + * would draw a zeroed quota row for an account that has no such limit. + */ function windowLimit(value, field) { + if (value === void 0) return void 0; const source = record$1(value, field); return { used: numberField(source, "used", `${field}.used`), @@ -1728,13 +1897,18 @@ window.__ModuleLoader__.load({ } if (source.credits !== void 0) { const credits = record$1(source.credits, "credits"); - report.credits = { + const parsed = { monthlyCredits: numberField(credits, "monthlyCredits", "credits.monthlyCredits"), purchasedCredits: numberField(credits, "purchasedCredits", "credits.purchasedCredits"), - freeCredits: numberField(credits, "freeCredits", "credits.freeCredits"), - fiveHour: windowLimit(credits.fiveHour, "credits.fiveHour"), - weekly: windowLimit(credits.weekly, "credits.weekly") + freeCredits: numberField(credits, "freeCredits", "credits.freeCredits") }; + if (credits.monthlyReported !== void 0) parsed.monthlyReported = booleanField(credits, "monthlyReported", "credits.monthlyReported"); + for (const flag of ["purchasedReported", "freeReported"]) if (credits[flag] !== void 0) parsed[flag] = booleanField(credits, flag, `credits.${flag}`); + const fiveHour = windowLimit(credits.fiveHour, "credits.fiveHour"); + const weekly = windowLimit(credits.weekly, "credits.weekly"); + if (fiveHour !== void 0) parsed.fiveHour = fiveHour; + if (weekly !== void 0) parsed.weekly = weekly; + report.credits = parsed; } if (source.plan !== void 0) { const plan = record$1(source.plan, "plan"); @@ -1803,6 +1977,72 @@ window.__ModuleLoader__.load({ package: USAGE_REMOTE_PACKAGE, descriptors: [makeRemoteDescriptor(MODELS_ENDPOINT, "models", `${USAGE_REMOTE_PACKAGE}#CommandCodeCatalog`, { parse: parseCatalog })] }; + /** Canonical `/` endpoint of the price-table Remote. */ + const PRICES_ENDPOINT = "commandcode/prices"; + /** + * The shared read/validate helpers for the price-table endpoint — its own + * instance so price boundary errors name `commandcode/prices`. + */ + const { reject: priceReject, record: priceRecord, stringField: priceString, numberField: priceNumber, booleanField: priceBoolean } = makeBoundaryValidator("commandcode/prices result:"); + /** Parse one rate block (`rates`, or a model's `peak` override). */ + function parseRates(source, field) { + const rates = { + inputCost: priceNumber(source, "inputCost", `${field}.inputCost`), + outputCost: priceNumber(source, "outputCost", `${field}.outputCost`), + cacheReadCost: priceNumber(source, "cacheReadCost", `${field}.cacheReadCost`) + }; + if (source.cacheWriteCost !== void 0) rates.cacheWriteCost = priceNumber(source, "cacheWriteCost", `${field}.cacheWriteCost`); + return rates; + } + /** Parse one untrusted boundary value into a {@link CommandCodeModelPrice}. */ + function parseModelPrice(value) { + const source = priceRecord(value, "model"); + const price = { + id: priceString(source, "id", "model.id"), + slug: priceString(source, "slug", "model.slug"), + ...parseRates(source, "model") + }; + if (source.peak !== void 0) price.peak = parseRates(priceRecord(source.peak, "model.peak"), "model.peak"); + if (source.contextTiers !== void 0) { + if (!Array.isArray(source.contextTiers) || source.contextTiers.length === 0) priceReject("contextTiers"); + let previous = 0; + price.contextTiers = source.contextTiers.map((value, index, tiers) => { + const tier = priceRecord(value, "contextTier"); + const out = parseRates(tier, "contextTier"); + if (tier.maxContext !== void 0) { + const max = priceNumber(tier, "maxContext", "contextTier.maxContext"); + if (!Number.isSafeInteger(max) || max <= previous || index === tiers.length - 1) priceReject("contextTier.maxContext"); + previous = max; + out.maxContext = max; + } else if (index !== tiers.length - 1) priceReject("contextTier.maxContext"); + return out; + }); + } + if (source.free !== void 0) price.free = priceBoolean(source, "free", "model.free"); + return price; + } + /** Parse the wire result into a {@link CommandCodePriceTable}. */ + function parsePriceTable(value) { + const source = priceRecord(value, "result"); + const models = source.models; + if (!Array.isArray(models)) priceReject("models"); + const peakHours = source.peakHours; + if (!Array.isArray(peakHours)) priceReject("peakHours"); + return { + models: models.map(parseModelPrice), + peakHours: peakHours.map((window) => { + if (!Array.isArray(window) || window.length !== 2) priceReject("peakHours[]"); + const [start, end] = window; + if (typeof start !== "number" || typeof end !== "number") priceReject("peakHours[]"); + return [start, end]; + }) + }; + } + /** The Client-face contribution for the price-table endpoint. */ + const PRICES_REMOTE_CONTRIBUTION = { + package: USAGE_REMOTE_PACKAGE, + descriptors: [makeRemoteDescriptor(PRICES_ENDPOINT, "prices", `${USAGE_REMOTE_PACKAGE}#CommandCodePriceTable`, { parse: parsePriceTable })] + }; //#endregion //#region src/login-wire.ts /** The canonical endpoint paths of the three login Remotes. */ @@ -2554,7 +2794,8 @@ window.__ModuleLoader__.load({ }); } /** One window-limit row: label, used/cap, a fill bar, and the reset time. */ - function UsageWindow({ label, limit: { used, cap, exceeded, resetAt }, t }) { + function UsageWindow({ label, limit, t }) { + const { used, cap, exceeded, resetAt } = limit; const ratio = windowRatio(used, cap); const reset = formatResetAt(resetAt); return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { @@ -2717,29 +2958,29 @@ window.__ModuleLoader__.load({ children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageStat, { label: t("usageMonthly"), - value: formatMoney(credits.monthlyCredits) + value: credits.monthlyReported === false ? "—" : formatMoney(credits.monthlyCredits) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageStat, { label: t("usagePurchased"), - value: formatMoney(credits.purchasedCredits) + value: credits.purchasedReported === false ? "—" : formatMoney(credits.purchasedCredits) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageStat, { label: t("usageFree"), - value: formatMoney(credits.freeCredits) + value: credits.freeReported === false ? "—" : formatMoney(credits.freeCredits) }) ] }) : null, - credits !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + credits?.fiveHour !== void 0 || credits?.weekly !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { className: "cc-usageWindows", - children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageWindow, { + children: [credits.fiveHour !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageWindow, { label: t("usageFiveHour"), limit: credits.fiveHour, t - }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageWindow, { + }) : null, credits.weekly !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageWindow, { label: t("usageWeekly"), limit: credits.weekly, t - })] + }) : null] }) : null, showPeriod || showPartial || fetchedAt !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { className: "cc-usageMeta", @@ -2800,15 +3041,11 @@ window.__ModuleLoader__.load({ * management card are hidden here immediately. Data arrives through the * `commandcode/report` Remote; the API keys never leave the Host. */ - function UsageCard({ t, usage, apiKeyConfigured, removingIds, removableIds, canManage, onRefresh, onRemoveAccount }) { + function UsageCard({ t, usage, removingIds, removableIds, canManage, onRefresh, onRemoveAccount }) { + const { loading, shouldRefresh, noKey } = usageCardState(usage); (0, react.useEffect)(() => { - if (apiKeyConfigured && usage.status === "idle") onRefresh(); - }, [ - apiKeyConfigured, - usage.status, - onRefresh - ]); - const loading = usage.status === "loading"; + if (shouldRefresh) onRefresh(); + }, [shouldRefresh, onRefresh]); const report = usage.report; const [locallyRemoved, setLocallyRemoved] = (0, react.useState)([]); (0, react.useEffect)(() => { @@ -2844,17 +3081,17 @@ window.__ModuleLoader__.load({ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { type: "button", className: "cc-usageRefresh", - disabled: loading || !apiKeyConfigured, + disabled: loading, onClick: onRefresh, children: loading ? t("usageRefreshing") : t("usageRefresh") }) ] }), - !apiKeyConfigured ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { + noKey ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { className: "cc-usageHint", children: t("usageNoKey") }) : null, - apiKeyConfigured && report === void 0 && loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { + report === void 0 && loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { className: "cc-usageHint", children: t("usageLoading") }) : null, @@ -3399,7 +3636,6 @@ window.__ModuleLoader__.load({ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageCard, { t, usage, - apiKeyConfigured: state.anyAccountConfigured, removingIds: state.accountsRemoving, removableIds: state.accounts.map((account) => account.id), canManage: state.writable, @@ -3535,7 +3771,7 @@ window.__ModuleLoader__.load({ return null; } /** The closed-panel style: the outlet stays mounted as the detection anchor. */ - const HIDDEN_STYLE = { display: "none" }; + const HIDDEN_STYLE$1 = { display: "none" }; /** * Decide the card's posture from one settings snapshot. Pure: the component * subscribes once and passes the snapshot in, so hook order never depends @@ -3655,7 +3891,7 @@ window.__ModuleLoader__.load({ ref: rootRef, className: "cc-providerCard", "data-cc-models-card": "true", - style: editorOpen ? void 0 : HIDDEN_STYLE, + style: editorOpen ? void 0 : HIDDEN_STYLE$1, children: [ editorOpen && mode.kind === "registration" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { className: "cc-hint", @@ -3728,172 +3964,1976 @@ window.__ModuleLoader__.load({ }); } //#endregion - //#region src/client/locales.ts - const zh = { + //#region src/client/panel-copy.ts + /** The panel's literal string table. */ + const PANEL_COPY = { nav: "Command Code", - title: "Command Code", - intro: "配置 Command Code Provider 连接。API 密钥仅保存在本机凭据服务中,不会回显;其他字段写入用户设置,下次请求即生效。", - apiKey: "API 密钥", - apiKeyHint: "在 commandcode.ai 控制台创建。留空保存不会覆盖已存储的密钥。", - apiKeySet: "已配置", - apiKeyUnset: "未配置", - apiKeyLocked: "密钥由只读来源提供", - apiBase: "API 地址", - apiBaseHint: "默认 https://api.commandcode.ai,一般无需修改。", - workingDir: "工作目录", - workingDirHint: "可选。留空时使用占位符显示的进程工作目录;仅在需要固定路径时填写。", - requestTimeoutMs: "请求超时(毫秒)", - requestTimeoutMsHint: "等待响应首个字节的超时;默认 60000。", - streamIdleTimeoutMs: "流空闲超时(毫秒)", - streamIdleTimeoutMsHint: "生成流停滞多久视为断连;默认 300000(长思考模型可静默数分钟,默认值刻意放宽)。", - advancedSettings: "高级设置", - advancedSettingsHint: "API 地址、工作目录、超时与模型过滤等不常修改的选项。", - advancedOverriddenOne: "已自定义 1 项", - advancedOverriddenMany: "已自定义 {count} 项", - advancedInvalid: "高级设置中有未填好的数字,请展开修正后再保存。", - filterModelsByPlan: "隐藏套餐外模型", - filterModelsByPlanHint: "开启后,模型选择器只列出当前套餐可用的模型;账户持有按需余额时会显示全部。", - webSearch: "用 Command Code 承载联网搜索", - webSearchHint: "开启后,dsh 的 web_search 工具由 Command Code 承担(复用同一个 API key 与地址),并优先于其他搜索后端;关闭则把选择权交还给之前的后端(如 modsearch),而不是强制回退到 DeepSeek 搜索。", - accountsTitle: "多账户轮换", - accountsHint: "当前账户达到用量限额(429)或密钥失效(401)时,请求自动切换到下一个账户;全部耗尽时会提示最早的重置时间。", - accountAdd: "添加账户", - accountRemove: "移除", - accountLabel: "账户备注名", - accountKey: "API 密钥", - accountKeyHint: "该账户的 API 密钥。留空保存不会覆盖已存储的密钥。", - accountDefault: "默认账户", - activeAccount: "当前使用账户", - activeAccountAuto: "自动(第一个可用账户)", - activeAccountHint: "手动指定优先使用的账户,保存后下次请求即生效;所选账户耗尽时仍会自动切换到其他可用账户。", - rulesTitle: "按模型切换账户", - rulesHint: "选择模型并路由到某个账户(可多选)。命中规则的模型且该账户可用时优先使用;账户耗尽或密钥失效时仍自动回落到其他账户。规则按列表顺序匹配,第一条命中生效。", - rulesEmpty: "尚未配置规则。", - rulesCatalogFailed: "模型目录获取失败,暂时无法选择模型;已保存的规则仍会生效。", - ruleAdd: "添加规则", - ruleRemove: "移除", - ruleModel: "模型", - ruleModelPick: "选择模型…", - ruleModelCount: "已选 {count} 个模型", - ruleAccount: "目标账户", - ruleHint: "从下拉列表勾选要路由的模型(可多选),再选择目标账户。", - modelSearchPlaceholder: "搜索模型…", - modelSearchEmpty: "没有匹配的模型。", - modelStale: "已下架", - visibleModelsTitle: "模型白名单", - visibleModelsHint: "勾选要保留的模型,模型选择器就只列出这些;一个都不勾选时则显示全部模型。保存后,下次打开模型选择器生效。", - visibleModelsPick: "选择要保留的模型…", - visibleModelsCount: "已选 {count} 个模型", - visibleModelsShowAll: "显示全部", - visibleModelsStaleHint: "有 {count} 个已选模型在目录中找不到了(可能已下架),不影响其他模型;可清理或保留。", - visibleModelsCleanStale: "清理失效({count})", - overridden: "已覆盖", - reset: "重置", - invalidNumber: "无效数字", - numberTooSmall: "不能小于 1(毫秒)", - numberTooLarge: "超出允许上限(2147483647 毫秒)", - readOnly: "当前配置为只读。", - unsaved: "未保存", - save: "保存", - saving: "保存中", - saved: "已保存 ✓", - saveFailed: "保存失败,请重试。", - discard: "放弃", - cancel: "取消", - show: "显示", - hide: "隐藏", - usageTitle: "账户用量", - usageRefresh: "刷新", - usageRefreshing: "刷新中…", - usageLoading: "正在获取账户用量…", - usageNoKey: "配置 API 密钥后,这里会显示账户的用量与额度状态。", - usageError: "用量获取失败", - usageRequests: "请求", - usageFailed: "失败", - usageSuccessRate: "成功率", - usageCost: "花费", - usageTokens: "Token", - usageTokensIn: "入", - usageTokensOut: "出", - usageMonthly: "月额度", - usagePurchased: "已购", - usageFree: "赠送", - usageFiveHour: "5 小时窗口", - usageWeekly: "每周窗口", - usageExceeded: "已超限", - usageReset: "重置于", - usagePartial: "部分端点数据不可用", - usageKeyClear: "清除已存密钥", - usageKeyClearStaged: "将清除(保存后生效)", - usageUndoKeyClear: "撤销清除", - usageKeyInvalid: "API 密钥无效或已过期", - usageKeyInvalidHint: "服务端拒绝了全部请求(401)。请检查该账户配置的密钥,或到 commandcode.ai 控制台重新生成。", - usageServiceUnavailable: "Command Code 服务暂时不可用", - usageServiceUnavailableHint: "服务端返回了错误(5xx),稍后点击刷新重试。", - usageNetworkError: "无法连接 Command Code 服务", - usageNetworkHint: "所有请求都没有到达服务端。请检查网络连接或 API 地址设置。", - usageUpdated: "更新于", - usagePeriodEnd: "账期截止", - usageActive: "当前使用", - usageCooldown: "限额冷却中", - usageInvalidKey: "密钥无效", - usageUnconfigured: "该账户尚未配置 API 密钥。", - updateAvailable: "可更新", - updateHint: "已发布新版本,点击查看发布说明;更新插件后刷新本页,提示会自动消失。", - loginTitle: "通过官方登录获取密钥", - loginHintIdle: "不想手动创建密钥?点击登录后浏览器会打开 commandcode.ai 授权页,完成后密钥自动写入本机凭据服务,下次请求即生效。", - loginButton: "登录 Command Code", - loginStarting: "正在启动本地回调服务…", - loginWaiting: "等待在浏览器中完成授权…", - loginOpenLink: "打开授权页面 ↗", - loginCancel: "取消登录", - loginSuccess: "已登录为", - loginUnavailable: "此环境暂不支持登录流程,请手动粘贴密钥。", - loginDenied: "授权被拒绝。可重试,或手动粘贴密钥。", - loginTimeout: "等待超时:未在窗口期内收到授权回调,请重试。", - loginInvalidKey: "获取到的密钥未通过校验(401),请重试或手动粘贴。", - loginNetwork: "无法连接 Command Code 服务校验密钥,请检查网络后重试。", - loginStoreFailed: "密钥无法写入本机凭据服务,请手动粘贴。", - loginCancelled: "登录已取消。", - loginFailedGeneric: "登录失败,请重试或手动粘贴密钥。", - cardTitle: "Command Code", - cardRouteActive: "已启用", - cardLoadingHint: "正在读取 Command Code 配置…", - cardRegistrationHint: "此卡片随 Command Code 插件注册,需要较新版本的 DeepSeek Harness 才会显示完整内容。" + subtitle: "Plans, credits and quota windows", + refresh: "Refresh", + refreshing: "Refreshing…", + loading: "Loading account usage…", + noKey: "No API key configured", + noKeyHint: "Paste a key — or sign in — under Settings → Command Code, then refresh.", + plan: "Plan", + credits: "Credits", + limits: "Quota windows", + usage: "Usage", + monthly: "Monthly", + monthlyLimit: "Monthly limit", + monthlyUsed: "Monthly used", + remaining: "Remaining", + purchased: "Purchased", + free: "Free", + fiveHour: "5-hour window", + weekly: "Weekly window", + fiveHourShort: "5-hour", + weeklyShort: "Weekly", + windowUnlimited: "unlimited", + exceeded: "Exceeded", + exhausted: "Used up", + resets: "Resets", + requests: "Requests", + failed: "failed", + successRate: "Success rate", + spend: "Spend", + tokens: "Tokens", + tokensIn: "in", + tokensOut: "out", + periodEnds: "Period ends", + updated: "Updated", + partial: "Some endpoint data unavailable", + active: "Active", + coolingDown: "Cooling down", + invalidKey: "Invalid key", + unconfigured: "Not configured", + unavailable: "No data", + errorInvalidKey: "API key invalid or expired", + errorInvalidKeyHint: "The server rejected every request (401). Check the key for this account, or generate a new one in the commandcode.ai console.", + errorServiceUnavailable: "The Command Code service is temporarily unavailable", + errorServiceUnavailableHint: "The server returned errors (5xx). Try Refresh again in a moment.", + errorNetwork: "Could not reach the Command Code service", + errorNetworkHint: "No request reached the server. Check your network connection or the API base setting.", + errorGeneric: "Could not fetch account usage" }; - const en = { - nav: "Command Code", - title: "Command Code", - intro: "Configure the Command Code Provider connection. The API key is stored only in the local credential service and never echoed; other fields are written to user settings and take effect on the next request.", - apiKey: "API key", - apiKeyHint: "Create one in the commandcode.ai console. Saving with this field blank keeps the stored key.", - apiKeySet: "Configured", - apiKeyUnset: "Not configured", - apiKeyLocked: "Key provided by a read-only source", - apiBase: "API base URL", - apiBaseHint: "Defaults to https://api.commandcode.ai; usually leave as-is.", - workingDir: "Working directory", - workingDirHint: "Optional. Leave blank to use the process cwd shown as the placeholder; fill in only to pin a specific path.", - requestTimeoutMs: "Request timeout (ms)", - requestTimeoutMsHint: "Time to wait for the first response byte; default 60000.", - streamIdleTimeoutMs: "Stream idle timeout (ms)", - streamIdleTimeoutMsHint: "How long a stalled stream is treated as dead; default 300000 (deliberately generous — long-thinking models can stay silent for minutes).", - advancedSettings: "Advanced", - advancedSettingsHint: "Rarely touched options: API base URL, working directory, timeouts, and model filtering.", - advancedOverriddenOne: "1 customized", - advancedOverriddenMany: "{count} customized", - advancedInvalid: "A number in Advanced settings is not ready to save; expand to fix it.", - filterModelsByPlan: "Hide out-of-plan models", - filterModelsByPlanHint: "When on, the model picker lists only models your subscription includes; any on-demand credit balance shows the full catalog.", - webSearch: "Serve dsh web search with Command Code", - webSearchHint: "When on, the model-facing web_search tool is backed by Command Code (same API key and base URL as chat), winning over other search backends. Off hands the selection back to the previous backend (e.g. modsearch) instead of forcing the shipped DeepSeek search.", - accountsTitle: "Account rotation", - accountsHint: "When the active account hits its usage limit (429) or its key fails (401), requests switch to the next account; when every account is exhausted the error names the earliest window reset.", - accountAdd: "Add account", - accountRemove: "Remove", - accountLabel: "Account label", - accountKey: "API key", - accountKeyHint: "This account’s API key. Saving with the field blank keeps the stored key.", + /** Look up one panel string. The fallback key keeps a bad call visible, never blank. */ + function panelText(key) { + return PANEL_COPY[key] ?? key; + } + //#endregion + //#region src/client/panel.ts + /** `$1.23`, and `$0.0123` only when the amount is too small for cents to show it. */ + function money(value) { + if (value === 0) return formatMoney(0); + return Math.abs(value) < .01 ? formatMoneyExact(value) : formatMoney(value); + } + /** + * What a figure shows when the endpoint that carries it did not report one. + * A dash, never a zero: the panel exists to state what the account has spent, + * so "we were not told" has to look different from "nothing". + */ + const UNREPORTED = "—"; + /** Local reset time; empty when the endpoint reported none. */ + function resetText(ms) { + if (ms <= 0) return ""; + return new Date(ms).toLocaleString(); + } + /** Local short date; empty when unset. */ + function dateText(ms) { + if (ms <= 0) return ""; + return new Date(ms).toLocaleDateString(); + } + /** Local time-of-day; empty when unset. */ + function timeText(ms) { + if (ms <= 0) return ""; + return new Date(ms).toLocaleTimeString(); + } + /** + * Consumption as a percentage, NOT clamped at 100 — an over-quota window + * reports its real overshoot (150%), which is what the printed figure should + * say. Bar widths clamp separately via {@link barPercent}. + */ + function rawPercent(used, cap) { + if (cap <= 0) return 0; + return Math.round(used / cap * 100); + } + /** A percentage usable as a CSS width, clamped into [0, 100]. */ + function barPercent(percent) { + return Math.min(100, Math.max(0, percent)); + } + /** Build one quota-window view. */ + function windowView(label, limit) { + const percent = rawPercent(limit.used, limit.cap); + const capped = limit.cap > 0; + return { + label, + value: capped ? `${money(limit.used)} / ${money(limit.cap)}` : money(limit.used), + capped, + percent, + barPercent: barPercent(percent), + exceeded: limit.exceeded, + resetsAt: resetText(limit.resetAt) + }; + } + /** + * Build the monthly credit view from the two endpoints that carry it. + * + * The limit is the PLAN's credit total, not the billing endpoint's + * `monthlyCredits` — that field is a remaining balance (see + * {@link PanelMonthlyView}). Unknown plans (no `monthlyCredits` on the plan + * record) therefore still show their balances, just without a ratio. + * + * The trap this guards: a MISSING balance is not a consumed one. The billing + * endpoint is one of four the report fetches in parallel, so it fails on its + * own while the plan still arrives — and reading the absent balance as 0 would + * turn that partial failure into `limit - 0 = limit`, i.e. a confident "100% + * used, quota exhausted" for an account that may have spent nothing. The + * official CLI draws no meter unless the credits payload is present at all + * (`hasCreditsInfo`), so "unreported" is the upstream-faithful reading here + * too. Every derived figure stays a placeholder until the balance reported. + */ + function monthlyView(report) { + const credits = report.credits; + const plan = report.plan; + if (credits === void 0 && plan === void 0) return void 0; + const limitValue = plan?.monthlyCredits ?? null; + const remainingReported = credits !== void 0 && credits.monthlyReported !== false; + const remaining = remainingReported ? Math.max(0, credits.monthlyCredits) : 0; + const known = limitValue !== null && limitValue > 0 && remainingReported; + const used = known ? Math.max(0, limitValue - remaining) : 0; + const percent = known ? rawPercent(used, limitValue) : 0; + return { + known, + limit: limitValue !== null ? money(limitValue) : UNREPORTED, + used: known ? money(used) : UNREPORTED, + remaining: remainingReported ? money(remaining) : UNREPORTED, + purchased: credits !== void 0 && credits.purchasedReported !== false ? money(Math.max(0, credits.purchasedCredits)) : UNREPORTED, + free: credits !== void 0 && credits.freeReported !== false ? money(Math.max(0, credits.freeCredits)) : UNREPORTED, + percent, + barPercent: barPercent(percent), + exhausted: known && remaining <= 0, + periodEnds: dateText(plan?.currentPeriodEnd ?? 0) + }; + } + /** Build one account's view. */ + function accountView(entry) { + const { report } = entry; + const account = report.account; + const plan = report.plan; + const credits = report.credits; + const usage = report.usage; + const stats = []; + if (usage !== void 0) { + stats.push({ + label: "requests", + value: String(usage.completedCount), + sub: `${usage.failedCount} ${panelText("failed")}` + }); + stats.push({ + label: "successRate", + value: `${formatSuccessRate(usage.successRate)}%`, + sub: "" + }); + stats.push({ + label: "spend", + value: formatMoneyExact(usage.totalCost), + sub: `${formatMoney(usage.totalCredits)} credits` + }); + stats.push({ + label: "tokens", + value: formatTokensCompact(usage.totalTokensIn + usage.totalTokensOut), + sub: `${formatTokensCompact(usage.totalTokensIn)} ${panelText("tokensIn")} / ${formatTokensCompact(usage.totalTokensOut)} ${panelText("tokensOut")}` + }); + } + const windows = []; + if (credits?.fiveHour !== void 0) windows.push(windowView("fiveHour", credits.fiveHour)); + if (credits?.weekly !== void 0) windows.push(windowView("weekly", credits.weekly)); + let mark; + if (entry.active) mark = "active"; + else if (entry.mark === "invalid-credential") mark = "invalidKey"; + else if (entry.cooldownUntil > 0 || entry.mark === "rate-limit") mark = "coolingDown"; + return { + id: entry.id, + label: entry.label, + owner: account === void 0 ? "" : account.userName || account.name, + planName: plan?.name ?? "", + planStatus: plan !== void 0 && plan.status !== "" && plan.status !== "active" ? plan.status : "", + mark, + cooldownUntil: entry.cooldownUntil > 0 ? resetText(entry.cooldownUntil) : "", + unconfigured: !entry.configured, + periodEnds: dateText(plan?.currentPeriodEnd ?? 0), + active: entry.active, + monthly: monthlyView(report), + windows, + stats + }; + } + /** The report-level failure box, or undefined when the report is usable. */ + function failureView(state) { + const blocked = state.report?.accounts.find((entry) => entry.report.blocked !== void 0)?.report.blocked; + if (blocked === "invalid-key") return { + title: "errorInvalidKey", + hint: "errorInvalidKeyHint", + detail: "" + }; + if (blocked === "service-unavailable") return { + title: "errorServiceUnavailable", + hint: "errorServiceUnavailableHint", + detail: "" + }; + if (blocked === "network") return { + title: "errorNetwork", + hint: "errorNetworkHint", + detail: "" + }; + if (state.status === "error" && state.report === void 0) return { + title: "errorGeneric", + hint: "errorGeneric", + detail: state.error ?? "" + }; + } + /** + * Every panel string, resolved once per projection. One object with all keys + * (rather than per-field lookups in the components) keeps the copy table and + * the render sites in lockstep: a key cannot be read from `text` unless + * {@link PANEL_COPY} declares it. + */ + function panelStrings() { + const keys = Object.keys(PANEL_COPY); + const out = {}; + for (const key of keys) out[key] = panelText(key); + return out; + } + /** + * Project the shared usage snapshot into the panel's render tree. + * + * Deduplication matches the settings card: hand-edited settings can name one + * credential twice, and removal staging hides an account before the post-save + * refresh lands. + */ + function buildPanelView(input) { + const { usage } = input; + const hidden = new Set(input.removingIds ?? []); + const seen = /* @__PURE__ */ new Set(); + const entries = (usage.report?.accounts ?? []).filter((entry) => { + if (hidden.has(entry.id)) return false; + if (seen.has(entry.id)) return false; + seen.add(entry.id); + return true; + }); + const accounts = entries.map(accountView); + const selectedEntry = entries.find((entry) => entry.active) ?? entries[0]; + const selectedView = accounts.find((view) => view.id === selectedEntry?.id); + const footerBars = []; + const windowBars = [["fiveHourShort", selectedEntry?.report.credits?.fiveHour], ["weeklyShort", selectedEntry?.report.credits?.weekly]]; + for (const [label, window] of windowBars) { + if (window === void 0 || window.cap <= 0) continue; + const percent = rawPercent(window.used, window.cap); + footerBars.push({ + label, + percent: `${percent}%`, + barPercent: barPercent(percent), + warn: window.exceeded, + detail: `${money(window.used)} / ${money(window.cap)}` + }); + } + const totalCost = selectedEntry?.report.usage?.totalCost; + const cost = totalCost === void 0 ? "" : money(totalCost); + let status = ""; + if (entries.length > 0 && !entries.some((entry) => entry.configured)) status = panelText("unconfigured"); + else if (selectedView?.mark !== void 0 && selectedView.mark !== "active") status = panelText(selectedView.mark); + else if (selectedEntry === void 0) status = panelText("unavailable"); + const planName = selectedView !== void 0 && selectedView.planName !== "" ? selectedView.planName : panelText("nav"); + const titleParts = planName === panelText("nav") ? [planName] : [panelText("nav"), planName]; + for (const bar of footerBars) { + const figures = bar.detail === "" ? "" : ` ${bar.detail}`; + titleParts.push(`${panelText(bar.label)}${figures} (${bar.percent})`); + } + if (cost !== "") titleParts.push(`${panelText("spend")} ${cost}`); + return { + text: panelStrings(), + planName, + status, + footerBars, + cost, + footTitle: titleParts.join(" · "), + accounts, + selectedId: selectedEntry?.id, + selected: selectedView, + loading: usage.status === "loading" && usage.report === void 0, + noKey: entries.length > 0 && !entries.some((entry) => entry.configured), + failure: failureView(usage), + staleError: usage.status === "error" && usage.report !== void 0 ? usage.error ?? "" : void 0, + partial: usage.report?.accounts.some((entry) => entry.report.failures.length > 0 && entry.report.blocked === void 0) ?? false ? "partial" : void 0, + updatedAt: timeText(usage.fetchedAt ?? 0) + }; + } + /** + * How often the panel re-reads the report while a surface is mounted. The + * quota windows move slowly and one report costs four upstream calls, so this + * is a background freshness tick, not a live meter. + */ + const PANEL_AUTO_REFRESH_MS = 12e4; + /** The default timer (the client bundle runs in a browser; node tests inject one). */ + const REAL_TIMER = { + set: (callback, ms) => setTimeout(callback, ms), + clear: (handle) => clearTimeout(handle) + }; + /** Live mount count of {@link startPanelAutoRefresh}. */ + let references = 0; + /** Owner ticket of the running loop, if any. */ + let activeTicket; + /** Ticket handed to the next starter. */ + let ticketSeq = 0; + /** Pending timer handle of the running loop. */ + let handle; + /** + * Start the shared auto refresh. One fetch when the surface appears (the + * sidebar row is the point of the panel — it must be current, not wait for a + * click), then a tick every {@link PANEL_AUTO_REFRESH_MS} while a surface + * stays mounted. + * + * Reference-counted: the sidebar entry and the dashboard can be mounted at + * once, so only the first start fetches and only the last stop halts the loop. + * Every tick goes through `usage.refresh()`, which already collapses a + * concurrent fetch onto the in-flight one — a tick never double-fetches + * against the settings page's own refresh. + * + * @param usage - the shared usage controller. + * @param isConfigured - whether a credential exists right now (re-read per tick). + * @param timer - timer seam for tests. + * @returns the disposer that drops this surface's reference. + */ + function startPanelAutoRefresh(usage, isConfigured, timer = REAL_TIMER) { + references += 1; + const ticket = ++ticketSeq; + const tick = () => { + if (activeTicket !== ticket) return; + if (isConfigured()) usage.refresh(); + handle = timer.set(tick, PANEL_AUTO_REFRESH_MS); + }; + if (activeTicket === void 0) { + activeTicket = ticket; + handle = timer.set(tick, PANEL_AUTO_REFRESH_MS); + if (isConfigured()) usage.refresh(); + } + let stopped = false; + return () => { + if (stopped) return; + stopped = true; + references -= 1; + if (references > 0) return; + activeTicket = void 0; + if (handle !== void 0) { + timer.clear(handle); + handle = void 0; + } + }; + } + //#endregion + //#region src/client/panel-view.tsx + /** + * React components for the Command Code plans & quota panel (browser half): + * the sidebar footer card and the dashboard it opens in the center column. + * + * Both render one {@link PanelView} projected by `./panel.ts` — no fact is + * derived here. Strings arrive as keys into `view.text`, and every one of them + * is English, because the view is built from `./panel-copy.ts` rather than the + * harness `ctx.locale` namespace (which follows the user's language and would + * render this panel in Chinese on a Chinese harness — the thing this surface + * exists to avoid). + * + * The footer card is the panel's home: the sidebar shell renders it in the foot + * area directly above the Settings seat, so each quota window's own spend and + * limit — the 5-hour and the weekly one — are on screen without opening + * anything. Clicking it selects the `main` panel this file also renders — and + * unlike a `sidebar.panellist` row, whose button chrome and label the SHELL + * owns, this entry owns its whole surface and therefore calls `open()` itself. + * + * Styles ride the stylesheet `./panel-styles.ts` returns, injected once by + * the client entry; classes are `ccp-` prefixed to stay clear of the settings + * page's `cc-` set. + * + * @module dsh-commandcode-provider/client/panel-view + */ + /** The panel's view, recomputed from both seats on every notification. */ + function usePanelView(props) { + const usage = props.useCommandCodeUsage((state) => state); + const settings = props.useCommandCodeSettings((state) => state); + return buildPanelView({ + usage, + apiKeyConfigured: settings.anyAccountConfigured, + removingIds: settings.accountsRemoving + }); + } + /** + * The quota ring. One glyph serves the rail button, the footer card's top row + * and the dashboard header: a faint track plus an arc whose sweep is the + * consumption, drawn from 12 o'clock. Circumference 2πr = 45.55 at r = 7.25. + */ + function Ring({ percent, warn, size }) { + const clamped = Math.min(100, Math.max(0, percent)); + const circumference = 45.55; + const dashoffset = Math.round(circumference * (1 - clamped / 100) * 1e3) / 1e3; + return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-glyph", + "aria-hidden": "true", + children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", { + viewBox: "0 0 20 20", + width: size, + height: size, + focusable: "false", + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", { + cx: "10", + cy: "10", + r: "7.25", + fill: "none", + stroke: "currentColor", + strokeWidth: "1.5", + opacity: "0.4" + }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", { + cx: "10", + cy: "10", + r: "7.25", + fill: "none", + stroke: warn ? "var(--dsw-alias-state-error-primary)" : "currentColor", + strokeWidth: "2.5", + strokeLinecap: "round", + strokeDasharray: String(circumference), + strokeDashoffset: String(dashoffset), + transform: "rotate(-90 10 10)" + })] + }) + }); + } + /** One labelled bar. `compact` drops the reset line; the footer draws its own. */ + function QuotaBar({ label, value, percent, barPercent, warn, resetsAt, resetsLabel }) { + const clamped = Math.min(100, Math.max(0, barPercent)); + return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "ccp-window", + children: [ + /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "ccp-windowHead", + children: [ + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-windowLabel", + children: label + }), + warn !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-warnTag", + children: warn + }) : null, + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "ccp-spacer" }), + value !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-windowValue", + children: value + }) : null, + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-windowPct", + children: percent + }) + ] + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { + className: "ccp-bar", + role: "progressbar", + "aria-label": label, + "aria-valuemin": 0, + "aria-valuemax": 100, + "aria-valuenow": clamped, + children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { + className: warn !== "" ? "ccp-barFill ccp-barFillWarn" : "ccp-barFill", + style: { width: `${clamped}%` } + }) + }), + resetsAt !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { + className: "ccp-windowReset", + children: [ + resetsLabel, + " ", + resetsAt + ] + }) : null + ] + }); + } + /** A labelled figure. */ + function Tile({ label, value, sub }) { + return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "ccp-tile", + children: [ + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-tileLabel", + children: label + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-tileValue", + children: value + }), + sub !== void 0 && sub !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-tileSub", + children: sub + }) : null + ] + }); + } + /** One usage tile. */ + function StatTile({ stat, label }) { + return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tile, { + label, + value: stat.value, + sub: stat.sub + }); + } + /** The rotation/credential badge line for one account. */ + function markText(account, text) { + if (account.mark === void 0) return ""; + if (account.mark === "coolingDown" && account.cooldownUntil !== "") return `${text("coolingDown")} · ${account.cooldownUntil}`; + return text(account.mark); + } + /** `Default account` → `D`; used for the card's monogram chip. */ + function initial(label) { + const trimmed = label.trim(); + return trimmed === "" ? "?" : trimmed[0].toUpperCase(); + } + /** + * One account's full report: the monthly limit/usage bar, the two quota + * windows, then the credit and usage totals. + */ + function AccountCard({ account, view }) { + const text = (key) => view.text[key] ?? key; + const mark = markText(account, text); + const monthly = account.monthly; + return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { + className: "ccp-card", + "aria-label": account.label, + children: [ + /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", { + className: "ccp-cardHead", + children: [ + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-avatar", + "aria-hidden": "true", + children: initial(account.label) + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { + className: "ccp-cardIdentity", + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-cardTitle", + children: account.label + }), account.owner !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-cardOwner", + children: account.owner + }) : null] + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "ccp-spacer" }), + account.planName !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-badge", + children: account.planName + }) : null, + account.planStatus !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-badge ccp-badgeWarn", + children: account.planStatus + }) : null, + mark !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: account.mark === "invalidKey" ? "ccp-badge ccp-badgeError" : "ccp-badgeMuted", + children: mark + }) : null, + account.periodEnds !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { + className: "ccp-meta", + children: [ + text("periodEnds"), + " ", + account.periodEnds + ] + }) : null + ] + }), + account.unconfigured ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { + className: "ccp-hint", + children: text("unconfigured") + }) : null, + monthly !== void 0 && monthly.known ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(QuotaBar, { + label: text("monthly"), + value: `${monthly.used} / ${monthly.limit}`, + percent: `${monthly.percent}%`, + barPercent: monthly.barPercent, + warn: monthly.exhausted ? text("exhausted") : "", + resetsAt: "", + resetsLabel: "" + }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "ccp-planRow", + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-fieldLabel", + children: text("plan") + }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-planName", + children: account.planName !== "" ? account.planName : text("unavailable") + })] + }), + monthly !== void 0 && monthly.remaining !== "—" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "ccp-planRow", + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-fieldLabel", + children: text("remaining") + }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-planName", + children: monthly.remaining + })] + }) : null, + account.windows.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { + className: "ccp-windows", + children: account.windows.map((window) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(QuotaBar, { + label: text(window.label), + value: window.value, + percent: window.capped ? `${window.percent}%` : text("windowUnlimited"), + barPercent: window.barPercent, + warn: window.exceeded ? text("exceeded") : "", + resetsAt: window.resetsAt, + resetsLabel: text("resets") + }, window.label)) + }) : null, + monthly !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "ccp-block", + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", { + className: "ccp-blockTitle", + children: text("credits") + }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "ccp-tiles", + children: [ + /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tile, { + label: text("monthlyLimit"), + value: monthly.limit + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tile, { + label: text("monthlyUsed"), + value: monthly.used + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tile, { + label: text("remaining"), + value: monthly.remaining + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tile, { + label: text("purchased"), + value: monthly.purchased + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tile, { + label: text("free"), + value: monthly.free + }) + ] + })] + }) : null, + account.stats.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "ccp-block", + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", { + className: "ccp-blockTitle", + children: text("usage") + }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { + className: "ccp-tiles", + children: account.stats.map((stat) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatTile, { + stat, + label: text(stat.label) + }, stat.label)) + })] + }) : null + ] + }); + } + /** + * The center-column dashboard, registered into the layout's keyed `main` slot + * under the same id the footer card selects, so the two are one navigation + * entry: the card shows the plan, the monthly bar and the quota window; the + * panel shows everything, including the per-account breakdown. + */ + function CommandCodePanel(props) { + const view = usePanelView(props); + const text = (key) => view.text[key] ?? key; + const [selectedId, setSelectedId] = (0, react.useState)(void 0); + const startAutoRefresh = props.startAutoRefresh; + (0, react.useEffect)(() => startAutoRefresh(), [startAutoRefresh]); + const accounts = view.accounts; + const selected = accounts.find((account) => account.id === selectedId) ?? view.selected ?? accounts[0]; + return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { + className: "ccp-main", + role: "region", + "aria-label": text("nav"), + children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "ccp-mainInner", + children: [ + /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", { + className: "ccp-header", + children: [ + /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "ccp-headerText", + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", { + className: "ccp-title", + children: text("nav") + }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { + className: "ccp-subtitle", + children: text("subtitle") + })] + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "ccp-spacer" }), + view.updatedAt !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { + className: "ccp-meta", + children: [ + text("updated"), + " ", + view.updatedAt + ] + }) : null, + /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, { + variant: "ghost", + size: "sm", + disabled: view.loading, + onClick: () => props.refresh(), + children: view.loading ? text("refreshing") : text("refresh") + }) + ] + }), + view.noKey ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "ccp-notice", + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { + className: "ccp-noticeTitle", + children: text("noKey") + }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { + className: "ccp-noticeHint", + children: text("noKeyHint") + })] + }) : null, + view.failure !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "ccp-notice ccp-noticeError", + role: "alert", + children: [ + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { + className: "ccp-noticeTitle", + children: text(view.failure.title) + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { + className: "ccp-noticeHint", + children: text(view.failure.hint) + }), + view.failure.detail !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { + className: "ccp-noticeDetail", + children: view.failure.detail + }) : null + ] + }) : null, + !view.noKey && view.failure === void 0 && accounts.length === 0 && view.loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { + className: "ccp-hint", + children: text("loading") + }) : null, + view.staleError !== void 0 && view.staleError !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { + className: "ccp-hint", + role: "status", + children: [ + text("errorGeneric"), + " — ", + view.staleError + ] + }) : null, + accounts.length > 1 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { + className: "ccp-tabs", + role: "tablist", + "aria-label": text("nav"), + children: accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { + type: "button", + role: "tab", + "aria-selected": selected?.id === account.id, + className: selected?.id === account.id ? "ccp-tab ccp-tabActive" : "ccp-tab", + onClick: () => setSelectedId(account.id), + children: account.label + }, account.id)) + }) : null, + selected !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountCard, { + account: selected, + view + }) : null, + view.partial !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { + className: "ccp-hint", + children: text(view.partial) + }) : null + ] + }) + }); + } + /** + * The sidebar footer card, registered into `sidebar.footer.action` — the list + * the shell renders in the sidebar's foot area directly ABOVE the Settings + * seat, so the panel reads as a bottom-pinned sibling of Settings rather than + * a global panel icon at the top of the column. + * + * The shell wraps nothing here, so this component owns the surface: the + * button, its chrome and its accessible name. In the expanded column it draws + * the title row, then one block per quota window (5-hour, then weekly) — the + * window's own spend and limit (`$1.32 / $6.00`), its percentage and its bar — + * and nothing else: the card's figures are the two windows the account runs + * into, so the period total stays in the tooltip rather than taking a third + * line. In the 56px rail it collapses to a 36px icon button carrying the ring, + * matching the shell's own rail geometry. `wide` comes from the shell as an + * owner prop — unlike the old `sidebar.panellist` row, this slot really does + * supply it. + */ + function CommandCodeFooterEntry(props) { + const view = usePanelView(props); + const startAutoRefresh = props.startAutoRefresh; + (0, react.useEffect)(() => startAutoRefresh(), [startAutoRefresh]); + const text = (key) => view.text[key] ?? key; + const headline = view.footerBars[0]; + const title = view.footTitle; + if (!props.wide) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { + type: "button", + className: "ccp-railButton", + "aria-label": title, + title, + onClick: () => props.open(), + children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Ring, { + percent: headline?.barPercent ?? 0, + warn: headline?.warn ?? false, + size: 18 + }) + }); + return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", { + type: "button", + className: "ccp-foot", + "aria-label": title, + title, + onClick: () => props.open(), + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { + className: "ccp-footTop", + children: [ + /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Ring, { + percent: headline?.barPercent ?? 0, + warn: headline?.warn ?? false, + size: 16 + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-footName", + children: text("nav") + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "ccp-spacer" }), + view.status !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-badgeMuted", + children: view.status + }) : view.planName !== text("nav") ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-badge", + children: view.planName + }) : null + ] + }), view.footerBars.map((bar) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { + className: "ccp-footRow", + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { + className: "ccp-footHead", + children: [ + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-footLabel", + children: text(bar.label) + }), + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "ccp-spacer" }), + bar.detail !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-footAmount", + children: bar.detail + }) : null, + /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-footPct", + children: bar.percent + }) + ] + }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: "ccp-footBar", + children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + className: bar.warn ? "ccp-footFill ccp-footFillWarn" : "ccp-footFill", + style: { width: `${bar.barPercent}%` } + }) + })] + }, bar.label))] + }); + } + //#endregion + //#region src/cost-facts.ts + const zeroCostTokens = () => ({ + uncachedInputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0 + }); + const COST_TOKEN_KEYS = [ + "uncachedInputTokens", + "outputTokens", + "cacheReadTokens", + "cacheWriteTokens" + ]; + function peakHour(at, windows) { + const time = new Date(at); + return time.getUTCDay() !== 0 && time.getUTCDay() !== 6 && windows.some(([start, end]) => time.getUTCHours() >= start && time.getUTCHours() < end); + } + /** + * All prompt billing buckets determine a request's band, never cumulative session input. + * + * A tiered model returns the matching band and does NOT then apply `peak`: the + * page publishes those two dimensions independently, no row carries both today, + * and the generator would report a tiered row on every sync — so the combination + * becoming real is visible before it reaches a user. This ordering is a latent + * choice, not a verified upstream rule. + */ + function requestRates(price, at, contextTokens, table) { + const tier = price.contextTiers?.find((tier) => tier.maxContext === void 0 || contextTokens <= tier.maxContext); + if (tier !== void 0) return tier; + return price.peak !== void 0 && at !== null && peakHour(at, table.peakHours) ? price.peak : price; + } + /** Cache version and wire guard: changing bands/rates must refold historical groups. */ + function pricingKey(table) { + const rates = (r) => [ + r.inputCost, + r.outputCost, + r.cacheReadCost, + r.cacheWriteCost ?? null + ]; + const canonical = JSON.stringify([ + "commandCodeCost-v1", + table.peakHours, + table.models.map((p) => [ + p.id, + p.slug, + p.free === true, + rates(p), + p.peak ? rates(p.peak) : null, + p.contextTiers?.map((t) => [t.maxContext ?? null, rates(t)]) ?? null + ]) + ]); + let hash = 2166136261; + for (const c of canonical) hash = Math.imul(hash ^ c.charCodeAt(0), 16777619) >>> 0; + return hash; + } + //#endregion + //#region src/client/session-cost.ts + /** + * Session-cost view model for the composer readout. + * + * Deliberately JSX-free and React-free, mirroring `./panel.ts`: it turns the + * session's token accounting plus the Host's price table into one presentation + * value AND the exact text the two surfaces of the harness's token-usage UI + * receive — the amount appended to the shipped pill, and the rows appended to + * the shipped usage dialog. Node tests therefore drive the whole calculation, + * and every user-visible string, without a DOM. + * + * The figures it prices are the session's DURABLE cumulative buckets, which is + * what the composer's own "N tokens" pill reads too (`tokenUsage`), so the two + * surfaces can never disagree about how much was used. Dollars are computed + * here because Command Code publishes per-token rates and bills against + * dollar-denominated windows; the account's own reported `totalCost` is a + * billing PERIOD figure, not this session's. + * + * Three rules are load-bearing: + * + * 1. **Only Command Code usage is priced.** A session served by another + * provider must render nothing, never a Command Code estimate. + * 2. **A missing rate is never invented.** The pricing page publishes + * input/output/cache-read rates for every model but a cache-WRITE rate for + * only some, so unpriced cache-write tokens are surfaced as such rather than + * charged at a guessed multiple of the input rate. + * 3. **Unpriceable means invisible.** No usage, no model, no table, an unknown + * model, or all-zero buckets renders nothing at all — a confident `$0.00` + * would be a lie, and this module never returns one. + * + * @module dsh-commandcode-provider/client/session-cost + */ + /** Tokens per published rate unit — the pricing page quotes USD per million. */ + const TOKENS_PER_RATE_UNIT = 1e6; + /** The provider route whose usage this surface prices. */ + const COMMANDCODE_PROVIDER = "commandcode"; + /** + * The English copy for this surface. + * + * A plain constant, NOT the `settings.commandcode` locale namespace: like the + * plans & quota panel, the readout stays English on a Chinese harness. Do not + * route it through `ctx.locale`. + */ + const SESSION_COST_COPY = { + /** Shown instead of an amount when the model costs nothing on every plan. */ + free: "Free", + /** + * The separator the readout prefixes itself with, so the cost reads as the + * last item of the token-usage pill's text run rather than a control beside + * it. Rendered with the same colour and margins the shipped pill uses + * between its own items. + */ + separator: "·", + /** The heading the tooltip leads with. */ + panelTitle: "Session cost", + /** Marks an estimate from published rates rather than an invoice. */ + approximate: "≈", + /** Tooltip line for the unpriced cache-write tokens. */ + unpricedCacheWrite: "cache write tokens have no published rate", + /** Tooltip line naming the rate half in force. */ + peakRates: "peak rates", + /** Tooltip line naming the rate half in force. */ + offPeakRates: "off-peak rates", + /** Tooltip line explaining the approximate marker. */ + approximateNote: "estimate from published rates, not the provider invoice", + /** Row/tooltip label for uncached prompt tokens. */ + uncachedInput: "uncached input", + /** Row/tooltip label for completion tokens. */ + output: "output", + /** Row/tooltip label for cache-served input tokens. */ + cacheRead: "cache read", + /** Row/tooltip label for cache-written input tokens. */ + cacheWrite: "cache write" + }; + /** A finite, non-negative count — anything else reads as absent. */ + function count(value) { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0; + } + /** + * Index a price table for lookup. Rows are keyed by catalog id and by pricing + * slug, both exact and lowercased, because a session reports a catalog id while + * a row no catalog model claims is served under the page's slug. + */ + function indexTable(table) { + const index = /* @__PURE__ */ new Map(); + for (const price of table.models) for (const key of [price.id, price.slug]) { + if (typeof key !== "string" || key === "") continue; + if (!index.has(key)) index.set(key, price); + const lower = key.toLowerCase(); + if (!index.has(lower)) index.set(lower, price); + } + return index; + } + function costOf(usage, rates, free) { + if (free) return { + uncachedInput: 0, + cacheRead: 0, + cacheWrite: 0, + output: 0, + total: 0, + unpricedCacheWriteTokens: 0 + }; + const perUnit = (tokens, rate) => tokens * rate / TOKENS_PER_RATE_UNIT; + const uncachedInput = perUnit(count(usage.uncachedInputTokens), rates.inputCost); + const output = perUnit(count(usage.outputTokens), rates.outputCost); + const cacheRead = perUnit(count(usage.cacheReadTokens), rates.cacheReadCost); + const cacheWriteTokens = count(usage.cacheWriteTokens); + const cacheWrite = rates.cacheWriteCost === void 0 ? void 0 : perUnit(cacheWriteTokens, rates.cacheWriteCost); + return { + uncachedInput, + cacheRead, + cacheWrite, + output, + total: uncachedInput + output + cacheRead + (cacheWrite ?? 0), + unpricedCacheWriteTokens: rates.cacheWriteCost === void 0 ? cacheWriteTokens : 0 + }; + } + /** + * Amount text for a session cost. + * + * The panel's `money()` convention (2 decimals, 4 below a cent) is right for + * billing windows, but a live session total starts far below a cent, where + * `toFixed(4)` would print a flat `$0.0000` — which reads as broken rather than + * as small. So a total under $0.0001 is stated as a bound instead. + */ + function sessionCostAmount(total) { + if (!Number.isFinite(total) || total <= 0) return formatMoney(0); + if (total < 1e-4) return `<$0.0001`; + return total < .01 ? formatMoneyExact(total) : formatMoney(total); + } + /** One `label value` clause of the tooltip. */ + function clause(label, tokens) { + return tokens > 0 ? `${label} ${formatTokensCompact(tokens)}` : void 0; + } + /** + * Build the composer readout, or undefined when there is nothing honest to show. + * + * Undefined is the correct answer for a session with no usage yet, a session + * another provider served, a model the price table does not know, and a table + * that has not landed — the pill simply is not there. + */ + function buildSessionCostView(input) { + const { usage, table, facts } = input; + if (!usage || !table || !facts || facts.pricingKey !== pricingKey(table)) return void 0; + const totals = zeroCostTokens(); + const index = indexTable(table); + const breakdown = { + uncachedInput: 0, + cacheRead: 0, + cacheWrite: 0, + output: 0, + total: 0, + unpricedCacheWriteTokens: 0 + }; + let free = true; + let peak = false; + let partial = false; + let priced = false; + const notes = []; + for (const group of facts.groups) { + for (const key of COST_TOKEN_KEYS) totals[key] += group.tokens[key]; + if (group.provider !== COMMANDCODE_PROVIDER) { + partial = true; + continue; + } + const price = index.get(group.model); + if (!price) { + partial = true; + continue; + } + const isFree = price.free === true; + free &&= isFree; + if (price.peak && group.at === null) { + partial = true; + notes.push("request time unavailable; off-peak lower bound"); + } + const rates = requestRates(price, group.at, group.contextTokens, table); + const part = costOf(group.tokens, rates, isFree); + priced ||= isFree || part.total > 0; + for (const key of [ + "uncachedInput", + "cacheRead", + "output", + "total", + "unpricedCacheWriteTokens" + ]) breakdown[key] += part[key]; + breakdown.cacheWrite = (breakdown.cacheWrite ?? 0) + (part.cacheWrite ?? 0); + if (price.peak) { + const isPeak = group.at !== null && peakHour(group.at, table.peakHours); + peak ||= isPeak; + notes.push(isPeak ? SESSION_COST_COPY.peakRates : SESSION_COST_COPY.offPeakRates); + } + } + if (COST_TOKEN_KEYS.some((key) => totals[key] !== count(usage[key])) || !priced) return void 0; + const { total, unpricedCacheWriteTokens } = breakdown; + partial ||= unpricedCacheWriteTokens > 0; + free &&= !partial; + if (partial) notes.push("Command Code priced subtotal only; unpriced or other-provider usage excluded"); + notes.push(SESSION_COST_COPY.approximateNote); + const uncachedInput = totals.uncachedInputTokens; + const output = totals.outputTokens; + const cacheRead = totals.cacheReadTokens; + const cacheWrite = totals.cacheWriteTokens; + if (uncachedInput === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) return void 0; + if (!free && total <= 0) return void 0; + const value = free ? SESSION_COST_COPY.free : `${partial ? "≥" : SESSION_COST_COPY.approximate}${sessionCostAmount(total)}`; + const money = (amount) => amount === void 0 ? void 0 : sessionCostAmount(amount); + const rows = [ + { + key: "uncachedInput", + label: SESSION_COST_COPY.uncachedInput, + tokens: uncachedInput, + costText: money(breakdown.uncachedInput) + }, + { + key: "cacheRead", + label: SESSION_COST_COPY.cacheRead, + tokens: cacheRead, + costText: money(breakdown.cacheRead) + }, + { + key: "cacheWrite", + label: SESSION_COST_COPY.cacheWrite, + tokens: cacheWrite, + costText: unpricedCacheWriteTokens === cacheWrite && cacheWrite > 0 ? void 0 : money(breakdown.cacheWrite) + }, + { + key: "output", + label: SESSION_COST_COPY.output, + tokens: output, + costText: money(breakdown.output) + } + ]; + if (unpricedCacheWriteTokens > 0) notes.push(SESSION_COST_COPY.unpricedCacheWrite); + return { + total, + value, + title: [ + `${SESSION_COST_COPY.panelTitle} ${free ? SESSION_COST_COPY.free : sessionCostAmount(total)}`, + clause(SESSION_COST_COPY.uncachedInput, uncachedInput), + clause(SESSION_COST_COPY.output, output), + clause(SESSION_COST_COPY.cacheRead, cacheRead), + clause(SESSION_COST_COPY.cacheWrite, cacheWrite), + ...notes + ].filter((part) => part !== void 0).join(" · "), + rows, + notes: [...new Set(notes)], + free, + peak, + unpricedCacheWriteTokens, + approximate: !free + }; + } + /** Split the appended run into the two nodes the display creates. */ + function sessionCostPillRun(view) { + return { + separator: SESSION_COST_COPY.separator, + value: view.value + }; + } + /** + * Decorate the harness's usage dialog, row by row. + * + * The row sequence mirrors the shipped component's conditions exactly — the + * cache-hit row exists while there is billed prompt input, the cache-write row + * while those tokens are non-zero — because the display layer matches what it + * finds positionally. A shape it cannot confirm is a shape it does not touch. + * + * A free model decorates nothing: every row's cost is zero by definition, the + * pill already says `Free`, and a column of `$0.00` would be noise. A bucket + * whose rate the page does not publish is likewise left unfilled rather than + * filled with an invented number. + */ + function sessionCostRowDecorations(view) { + const bucket = (key) => view.rows.find((row) => row.key === key)?.tokens ?? 0; + const price = (key) => { + if (view.free) return void 0; + return view.rows.find((row) => row.key === key)?.costText; + }; + const uncachedInput = bucket("uncachedInput"); + const cacheRead = bucket("cacheRead"); + const cacheWrite = bucket("cacheWrite"); + const output = bucket("output"); + const plan = []; + if (uncachedInput + cacheRead + cacheWrite > 0) plan.push({ + row: "cacheHit", + tokens: void 0, + amount: void 0, + hidden: false + }); + plan.push({ + row: "uncachedInput", + tokens: uncachedInput, + amount: price("uncachedInput"), + hidden: false + }); + plan.push({ + row: "cacheRead", + tokens: cacheRead, + amount: price("cacheRead"), + hidden: false + }); + if (cacheWrite !== 0) { + const amount = price("cacheWrite"); + plan.push({ + row: "cacheWrite", + tokens: cacheWrite, + amount, + hidden: amount === void 0 + }); + } + plan.push({ + row: "output", + tokens: output, + amount: price("output"), + hidden: false + }); + return plan; + } + //#endregion + //#region src/client/session-cost-display.ts + /** The shipped composer stats row (dsh-client-ui-chat `StatsPills`). */ + const STATS_ROOT = "[data-composer-stats]"; + /** + * The shipped token-usage dialog's `
`. Unique in the whole of + * `dsh-client-ui-chat`: the per-message turn-usage panel has its own dialog with + * different markup, so the composer's is unambiguous. + */ + const USAGE_DIALOG = "[data-session-stats-usage]"; + /** + * The usage dialog's trigger. Both pills announce a dialog, but the token one is + * rendered LAST — and when no step carries timing the time pill is not a button + * at all, so "last" still selects the token pill in both cases. + */ + const DIALOG_TRIGGER = "button[aria-haspopup=\"dialog\"]"; + /** + * Stable id for the hidden node the appended cost is described by. The pill's + * own `aria-label` is computed by the harness on every render and cannot be + * extended, so a description is how the cost reaches assistive technology. + */ + const A11Y_ID = "dsh-commandcode-session-cost"; + /** How many consecutive misses before the missing anchor is worth a console line. */ + const MISSES_BEFORE_WARNING = 3; + /** + * The default observer: `childList` on `body`, and deliberately NOT `subtree`, so + * streaming text inside the page never wakes it while the panel the harness + * portals straight onto `body` does. + */ + const observeBodyChildren = (target, listener) => { + if (typeof MutationObserver === "undefined") return void 0; + const observer = new MutationObserver(listener); + observer.observe(target, { childList: true }); + return () => observer.disconnect(); + }; + /** + * Owns the nodes injected into the shipped token-usage UI. + * + * One instance per mounted entry. `sync()` is cheap and idempotent: it re-reads + * both targets on every call, which is what lets it self-heal when React + * remounts the row or replaces the dialog, and it rewrites a node only when its + * text actually changed. + */ + var SessionCostDisplay = class { + doc; + scope; + observe; + view = void 0; + /** Detaches the observer installed by `start()`. */ + detach = void 0; + /** The button we appended into, while it is still connected. */ + pillHost = void 0; + /** Our appended root, and the two nodes whose text changes. */ + pillRoot = void 0; + pillValue = void 0; + pillA11y = void 0; + /** Whether WE set `aria-describedby` on the button (so only we take it back). */ + described = false; + /** The dialog we decorated, the price nodes we own, and the cells we hid. */ + dialogHost = void 0; + dialogPrices = /* @__PURE__ */ new Map(); + dialogHidden = /* @__PURE__ */ new Set(); + pillMisses = 0; + constructor(options) { + this.doc = options.doc; + this.scope = options.scope; + this.observe = options.observe ?? observeBodyChildren; + } + /** Begin watching for the shipped usage dialog opening and closing. */ + start() { + if (this.detach !== void 0) return; + const body = this.doc.body; + if (body === null || body === void 0) return; + this.detach = this.observe(body, () => this.apply()); + } + /** + * Hand the display the current figure. + * + * Undefined — nothing priceable about this session — removes everything this + * module injected, leaving both shipped surfaces exactly as they ship. + */ + sync(view) { + this.view = view; + this.apply(); + } + /** Remove every injected node and stop observing. Safe to call twice. */ + dispose() { + this.detach?.(); + this.detach = void 0; + this.removePill(); + this.clearDialog(); + this.view = void 0; + } + /** Re-apply the last known view to whatever both targets are right now. */ + apply() { + this.applyPill(); + this.applyDialog(); + } + applyPill() { + const view = this.view; + if (view === void 0) { + this.removePill(); + return; + } + if (this.pillHost?.isConnected !== true) { + const button = this.resolvePillButton(); + if (button === null) { + this.pillMisses += 1; + if (this.pillMisses === MISSES_BEFORE_WARNING) console.warn(`[dsh-commandcode-provider] no ${STATS_ROOT} row to append the session cost to; the figure stays in the usage dialog only`); + return; + } + this.pillMisses = 0; + this.removePill(); + this.buildPill(button, sessionCostPillRun(view)); + } + if (this.pillValue !== void 0) { + const amount = sessionCostPillRun(view).value; + if (this.pillValue.textContent !== amount) this.pillValue.textContent = amount; + } + if (this.pillRoot !== void 0) { + if (this.pillRoot.title !== view.title) this.pillRoot.title = view.title; + const approximate = view.approximate ? "true" : null; + if (this.pillRoot.getAttribute("data-approximate") !== approximate) { + if (approximate === null) this.pillRoot.removeAttribute("data-approximate"); + else this.pillRoot.setAttribute("data-approximate", approximate); + } + } + if (this.pillA11y !== void 0) { + const described = `${SESSION_COST_COPY.panelTitle} ${view.value}`; + if (this.pillA11y.textContent !== described) this.pillA11y.textContent = described; + } + } + /** The shipped token pill, scoped to this entry's own composer. */ + resolvePillButton() { + const root = (this.scope() ?? this.doc).querySelector(STATS_ROOT); + if (root === null) return null; + const triggers = root.querySelectorAll(DIALOG_TRIGGER); + return triggers.length === 0 ? null : triggers[triggers.length - 1] ?? null; + } + /** + * Create and append the cost run. The button's child list is static + * (`[svg, label]`), so appending once is enough for it to stay last. + */ + buildPill(button, run) { + const doc = this.doc; + const root = doc.createElement("span"); + root.setAttribute("data-composer-session-cost", ""); + const separator = doc.createElement("span"); + separator.textContent = run.separator; + separator.setAttribute("aria-hidden", "true"); + separator.style.color = "var(--dsw-alias-separator-primary)"; + separator.style.margin = "0 6px 0 0"; + const value = doc.createElement("span"); + value.textContent = run.value; + value.style.fontWeight = "500"; + const a11y = doc.createElement("span"); + a11y.id = A11Y_ID; + a11y.style.display = "none"; + root.appendChild(separator); + root.appendChild(value); + root.appendChild(a11y); + button.appendChild(root); + if (!button.hasAttribute("aria-describedby")) { + button.setAttribute("aria-describedby", A11Y_ID); + this.described = true; + } + this.pillHost = button; + this.pillRoot = root; + this.pillValue = value; + this.pillA11y = a11y; + } + removePill() { + if (this.pillRoot !== void 0 && this.pillRoot.parentNode !== null) this.pillRoot.parentNode.removeChild(this.pillRoot); + if (this.described) { + this.pillHost?.removeAttribute("aria-describedby"); + this.described = false; + } + this.pillHost = void 0; + this.pillRoot = void 0; + this.pillValue = void 0; + this.pillA11y = void 0; + } + applyDialog() { + const view = this.view; + const host = view === void 0 ? null : this.resolveDialog(); + if (host === null || view === void 0) { + this.clearDialog(); + return; + } + this.pruneDialog(host); + const plan = sessionCostRowDecorations(view); + const pairs = dialogPairs(host); + if (!dialogShapeMatches(pairs, plan, this.dialogPrices)) { + this.clearDialog(); + return; + } + const hidden = /* @__PURE__ */ new Set(); + for (const [index, row] of plan.entries()) { + const pair = pairs[index]; + if (pair === void 0) continue; + const span = this.dialogPrices.get(pair.dd); + if (row.hidden) { + if (pair.dt.style.display !== "none") pair.dt.style.display = "none"; + if (pair.dd.style.display !== "none") pair.dd.style.display = "none"; + hidden.add(pair.dt); + hidden.add(pair.dd); + this.removeDialogPrice(pair.dd, span); + continue; + } + if (pair.dt.style.display === "none") pair.dt.style.display = ""; + if (pair.dd.style.display === "none") pair.dd.style.display = ""; + if (row.amount === void 0) { + this.removeDialogPrice(pair.dd, span); + continue; + } + const price = span ?? this.createDialogPrice(pair.dd, row.row); + if (price.parentNode !== pair.dd) pair.dd.appendChild(price); + if (price.textContent !== row.amount) price.textContent = row.amount; + } + for (const cell of [...this.dialogHidden]) { + if (hidden.has(cell)) continue; + if (cell.style.display === "none") cell.style.display = ""; + this.dialogHidden.delete(cell); + } + for (const cell of hidden) this.dialogHidden.add(cell); + this.dialogHost = host; + } + /** Forget the cells and prices a re-rendered dialog took with it. */ + pruneDialog(host) { + for (const [dd, price] of [...this.dialogPrices]) { + if (dd.parentNode === host) continue; + if (price.parentNode !== null) price.parentNode.removeChild(price); + this.dialogPrices.delete(dd); + } + for (const cell of [...this.dialogHidden]) { + if (cell.parentNode === host) continue; + if (cell.style.display === "none") cell.style.display = ""; + this.dialogHidden.delete(cell); + } + } + /** The price node for one shipped value cell, appended as its last child. */ + createDialogPrice(dd, row) { + const price = this.doc.createElement("span"); + price.setAttribute("data-session-cost-price", row); + price.style.marginLeft = "6px"; + price.style.display = "inline-block"; + price.style.minWidth = "56px"; + price.style.textAlign = "right"; + price.style.fontWeight = "500"; + dd.appendChild(price); + this.dialogPrices.set(dd, price); + return price; + } + removeDialogPrice(dd, price) { + if (price === void 0) return; + if (price.parentNode !== null) price.parentNode.removeChild(price); + this.dialogPrices.delete(dd); + } + /** + * The shipped usage dialog, or null while it is closed. + * + * The dialog is portaled onto `body`, so unlike the pill it cannot be scoped + * from this entry; DSH renders one composer, and the attribute is unique in + * the chat client, so a document-level lookup is exact. Should a future build + * mount two composers at once, both dialogs would describe whichever session + * this entry belongs to — noted rather than defended against. + */ + resolveDialog() { + if (this.dialogHost?.isConnected === true) return this.dialogHost; + this.clearDialog(); + return this.doc.querySelector(USAGE_DIALOG); + } + /** Give the dialog back: every price removed, every hidden row restored. */ + clearDialog() { + for (const price of this.dialogPrices.values()) if (price.parentNode !== null) price.parentNode.removeChild(price); + this.dialogPrices.clear(); + for (const cell of this.dialogHidden) if (cell.style.display === "none") cell.style.display = ""; + this.dialogHidden.clear(); + this.dialogHost = void 0; + } + }; + /** The shipped dialog's `dt`/`dd` pairs, in document order. */ + function dialogPairs(host) { + const pairs = []; + let label = null; + for (const node of Array.from(host.childNodes)) { + const tag = tagNameOf(node); + if (tag === "DT") label = node; + else if (tag === "DD" && label !== null) { + pairs.push({ + dt: label, + dd: node + }); + label = null; + } + } + return pairs; + } + /** `tagName` upper-cased, or an empty string for a non-element node. */ + function tagNameOf(node) { + const tag = node.tagName; + return typeof tag === "string" ? tag.toUpperCase() : ""; + } + /** + * Whether the dialog really holds the rows this session's buckets predict. + * + * The shipped labels belong to the `chat` locale — they are `Uncached input` in + * English and something else entirely in Chinese — so rows are matched by + * POSITION, and this is what makes that safe: the row count must agree, the + * cache-hit row must be the percentage it is, and every other value must carry + * exactly the token count of the bucket predicted for it. A mismatch means the + * dialog is not what this view describes, and nothing is decorated. + */ + function dialogShapeMatches(pairs, plan, prices) { + if (pairs.length !== plan.length) return false; + return plan.every((row, index) => { + const pair = pairs[index]; + if (pair === void 0) return false; + const text = hostValueText(pair.dd, prices.get(pair.dd)); + if (row.tokens === void 0) return text.includes("%"); + return digitsOf(text) === String(row.tokens); + }); + } + /** + * A value cell's own text, without the price this module appended to it — the + * digits of `3,206,544 tok` are the host's, the digits of `$0.07` are ours. + */ + function hostValueText(dd, price) { + let text = ""; + for (const node of Array.from(dd.childNodes)) { + if (node === price) continue; + text += node.textContent ?? ""; + } + return text; + } + /** Every digit of a display string, so locale grouping cannot break the match. */ + function digitsOf(text) { + return text.replace(/\D/g, ""); + } + //#endregion + //#region src/client/session-cost-view.tsx + /** The dock outlet this entry renders inside, i.e. what it can scope itself from. */ + const DOCK_ANCHOR = "[data-slot=\"conversation.composer.dock\"]"; + /** Module-level constant so the marker's `style` prop never diffs. */ + const HIDDEN_STYLE = { display: "none" }; + /** Log a missing seat once per page, so a silent no-op stays diagnosable. */ + let warnedMissingSeat = false; + /** + * The composer's session-cost entry. Guards the two seats before rendering the + * mount: a dsh that does not supply the projection seat to dock occupants gets + * an absent cost and one console line, never a render crash — which the renderer + * would answer by abdicating the entry with no visible trace. + */ + function CommandCodeSessionCost(props) { + if (typeof props.useProjection !== "function" || typeof props.useCommandCodePrices !== "function") { + if (!warnedMissingSeat) { + warnedMissingSeat = true; + console.error("[dsh-commandcode-provider] the composer does not supply the projection/hook seats the session-cost readout needs; the readout stays hidden"); + } + return null; + } + return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionCostEntry, { ...props }); + } + /** The mount: one injection lifetime, one hidden marker. Every hook lives here. */ + function SessionCostEntry(props) { + const markerRef = (0, react.useRef)(null); + const displayRef = (0, react.useRef)(null); + const prices = props.useCommandCodePrices((state) => state); + const view = buildSessionCostView({ + usage: props.useProjection("tokenUsage"), + selection: props.useProjection("modelSelection"), + facts: props.useProjection("commandCodeCost"), + table: prices.table, + now: Date.now() + }); + (0, react.useEffect)(() => { + if (typeof document === "undefined") return; + const display = new SessionCostDisplay({ + doc: document, + scope: () => markerRef.current?.closest(DOCK_ANCHOR)?.parentElement ?? null + }); + displayRef.current = display; + display.start(); + return () => { + displayRef.current = null; + display.dispose(); + }; + }, []); + (0, react.useEffect)(() => { + displayRef.current?.sync(view); + }); + if (view === void 0) return null; + return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + ref: markerRef, + style: HIDDEN_STYLE, + "data-ccp-session-cost-anchor": "" + }); + } + //#endregion + //#region src/client/panel-styles.ts + /** + * Stylesheet for the Command Code plans & quota panel (the sidebar footer card + * and the dashboard it opens). + * + * Returned as a string rather than injected here so the modules stay free of + * DOM side effects at import time — the client entry installs it once, keyed + * by the same `data-plugin-css` attribute the settings-page stylesheet uses, + * and removes it again when the plugin's fiber unwinds. + * + * Every colour comes from a harness theme alias with a neutral fallback, so + * the panel follows the active theme (light/dark and any brand pack) without + * hardcoded values. Classes are `ccp-` prefixed to stay clear of the settings + * page's `cc-` set. + * + * @module dsh-commandcode-provider/client/panel-styles + */ + /** + * Stylesheet id (the `data-plugin-css` value that makes injection idempotent). + * + * The package prefix must match the one the settings-page stylesheet uses + * (`injectPageCss` in `./index.ts`) and this package's real name: an id is the + * injection's identity, so a stale fork prefix would let a second copy of the + * plugin inject the same rules twice and would misreport the owner in the DOM. + */ + const PANEL_CSS_ID = "@mars-sea/dsh-commandcode-provider/CommandCodePanel.module.css"; + /** The panel stylesheet. */ + const PANEL_CSS = ` +/* ------------------------------------------------- sidebar footer card */ +/* The shell's foot area renders this list ABOVE the Settings seat, so the card + is the sidebar's bottom-most content. The shell supplies no chrome: the entry + is the button. It is deliberately quiet — a surface that sits beside Settings + should read as part of the column, not as a call to action — with one hover + step and a hairline border. + + The shell's container is a flex ROW whose occupants (this card and ui-cordis's + footer chip) each declare a full-width line and shrink-proof flex, so as a row + it would overflow the column. Both were written for a full-width line, which + is exactly what a column gives them. Matched by the CSS-module class STEM — + never a hashed name — so a dsh that renames it degrades to the shell's own + row rather than breaking. */ +[class*="_footerActions"]{flex-direction:column} +.ccp-foot{box-sizing:border-box;flex:0 0 auto;width:100%;min-width:0;font:inherit;color:var(--dsw-alias-label-secondary);text-align:left;cursor:pointer;background:0 0;border:1px solid transparent;border-radius:10px;flex-direction:column;gap:6px;margin:0 0 4px;padding:8px;display:flex} +.ccp-foot:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l2)} +.ccp-foot:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px} +.ccp-footTop{align-items:center;gap:8px;min-width:0;display:flex} +.ccp-footName{white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;font-size:13px;font-weight:500;line-height:20px} +/* One block per quota window: a head line carrying the window's own spend and + limit, then the FULL-WIDTH bar under it. Stacking the two lets the card show + the dollar figures — the reason this surface exists — without squeezing the + bar into what is left beside them. Mirrors the dashboard's own window block. */ +.ccp-footRow{flex-direction:column;gap:4px;min-width:0;display:flex} +.ccp-footHead{align-items:baseline;gap:8px;min-width:0;display:flex} +.ccp-footLabel{flex:1;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px} +.ccp-footAmount{flex:none;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums;white-space:nowrap} +/* The card's markup must stay PHRASING content — it renders inside the shell's + own button — so these bars are spans, not divs. That makes display:block + load-bearing on BOTH: an inline box ignores width and height outright, so + without it the 5px track still painted (a flex item is blockified by its + container) while the fill collapsed to 0x0 and the bar showed no usage. */ +.ccp-footBar{display:block;background:var(--dsw-alias-bg-layer-2);border-radius:999px;height:5px;overflow:hidden} +.ccp-footFill{display:block;background:var(--dsw-alias-brand-primary);border-radius:999px;height:100%;transition:width .3s ease} +.ccp-footFillWarn{background:var(--dsw-alias-state-error-primary)} +.ccp-footPct{flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:11px;line-height:16px;font-variant-numeric:tabular-nums} + +/* The 56px rail: one icon button on the shell's own rail geometry (36px cell), + so the collapsed column keeps a single 18px glyph like its siblings. */ +.ccp-railButton{box-sizing:border-box;width:36px;height:36px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:1px solid transparent;border-radius:8px;flex:none;justify-content:center;align-items:center;margin:0 0 4px;padding:0;display:inline-flex} +.ccp-railButton:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)} +.ccp-railButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px} + +/* The ring glyph. Sized entirely by its own width/height attribute, so the + footer row, the rail button and the dashboard can each ask for their own. */ +.ccp-glyph{flex:none;justify-content:center;align-items:center;display:inline-flex;color:var(--dsw-alias-brand-primary)} + +/* ------------------------------------------------------------ dashboard */ +/* The center column in the layout frame: fill it, scroll the content column, + and cap the reading width like the harness's own panels. */ +.ccp-main{background:var(--dsw-alias-bg-layer-1);width:100%;height:100%;overflow:auto;display:block} +.ccp-mainInner{max-width:760px;margin:0 auto;padding:24px 20px 40px;flex-direction:column;gap:14px;display:flex;color:var(--dsw-alias-label-primary)} +.ccp-header{align-items:center;gap:10px;display:flex;flex-wrap:wrap} +.ccp-headerText{flex-direction:column;gap:2px;display:flex;min-width:0} +.ccp-title{margin:0;font-size:18px;font-weight:600;line-height:1.4} +.ccp-subtitle{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5} +.ccp-spacer{flex:1} +.ccp-meta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;font-variant-numeric:tabular-nums} +.ccp-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5} + +/* Notices: the no-key guidance, a blocked report, and a stale-data error. */ +.ccp-notice{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:12px 14px;flex-direction:column;gap:4px;display:flex} +.ccp-noticeError{border-color:var(--dsw-alias-state-error-primary)} +.ccp-noticeTitle{margin:0;font-size:13px;font-weight:600;line-height:1.5} +.ccp-noticeError .ccp-noticeTitle{color:var(--dsw-alias-state-error-primary)} +.ccp-noticeHint{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55} +.ccp-noticeDetail{margin:0;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;word-break:break-word} + +.ccp-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:14px;padding:16px 18px;flex-direction:column;gap:16px;display:flex} +.ccp-cardHead{align-items:center;gap:10px;display:flex;flex-wrap:wrap} +.ccp-avatar{flex:none;width:28px;height:28px;color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-module-platform);border-radius:50%;justify-content:center;align-items:center;font-size:12px;font-weight:600;line-height:1;display:inline-flex} +.ccp-cardIdentity{flex-direction:column;gap:1px;min-width:0;display:flex} +.ccp-cardTitle{font-size:13px;font-weight:600;line-height:1.4} +.ccp-cardOwner{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px} +.ccp-block{flex-direction:column;gap:8px;display:flex} +.ccp-blockTitle{margin:0;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:600;line-height:1.5;text-transform:uppercase;letter-spacing:.04em} +.ccp-planRow{align-items:center;gap:8px;display:flex;flex-wrap:wrap} +.ccp-fieldLabel{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5} +.ccp-planName{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:1.5} + +/* Stat tiles: the monthly credits and the usage totals share one grid. */ +.ccp-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px} +.ccp-tile{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:8px;padding:8px 10px;flex-direction:column;gap:2px;display:flex;min-width:0} +.ccp-tileLabel{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5} +.ccp-tileValue{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4;font-variant-numeric:tabular-nums} +.ccp-tileSub{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + +/* Quota bars: the monthly bar and the two windows stack in one column, each a + label row plus the track. */ +.ccp-windows{flex-direction:column;gap:14px;display:flex} +.ccp-window{flex-direction:column;gap:6px;display:flex} +.ccp-windowHead{align-items:baseline;gap:8px;display:flex} +.ccp-windowLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:1.5} +.ccp-windowValue{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5;font-variant-numeric:tabular-nums;white-space:nowrap} +.ccp-windowPct{color:var(--dsw-alias-label-primary);min-width:38px;text-align:right;font-size:12px;font-weight:600;line-height:1.5;font-variant-numeric:tabular-nums} +.ccp-warnTag{white-space:nowrap;background:var(--dsw-alias-state-warn-tertiary,var(--dsw-alias-bg-module-platform));color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-label-secondary));border-radius:999px;padding:0 8px;font-size:11px;font-weight:600;line-height:17px} +.ccp-bar{overflow:hidden;background:var(--dsw-alias-bg-layer-1);border-radius:999px;height:8px} +.ccp-barFill{background:var(--dsw-alias-brand-primary);border-radius:999px;height:100%;transition:width .3s ease} +.ccp-barFillWarn{background:var(--dsw-alias-state-error-primary)} +.ccp-windowReset{color:var(--dsw-alias-label-tertiary);margin:0;font-size:11px;line-height:1.5} + +/* Badges. */ +.ccp-badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-brand-primary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:600;line-height:17px} +.ccp-badgeError{background:transparent;color:var(--dsw-alias-state-error-primary)} +.ccp-badgeWarn{background:var(--dsw-alias-state-warn-tertiary,var(--dsw-alias-bg-module-platform));color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-label-secondary))} +.ccp-badgeMuted{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px;max-width:220px;overflow:hidden;text-overflow:ellipsis} + +/* Account switch: plain buttons, like the settings page's usage carousel. */ +.ccp-tabs{flex-wrap:wrap;gap:6px;display:flex} +.ccp-tab{align-items:center;font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:2px 10px;font-size:12px;line-height:18px;display:inline-flex;gap:6px} +.ccp-tab:hover:not(.ccp-tabActive){color:var(--dsw-alias-label-primary)} +.ccp-tabActive{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-brand-primary)} + +@media (prefers-reduced-motion:reduce){.ccp-footFill,.ccp-barFill{transition:none}} +`; + //#endregion + //#region src/client/locales.ts + const zh = { + nav: "Command Code", + title: "Command Code", + intro: "配置 Command Code Provider 连接。API 密钥仅保存在本机凭据服务中,不会回显;其他字段写入用户设置,下次请求即生效。", + apiKey: "API 密钥", + apiKeyHint: "在 commandcode.ai 控制台创建。留空保存不会覆盖已存储的密钥。", + apiKeySet: "已配置", + apiKeyUnset: "未配置", + apiKeyLocked: "密钥由只读来源提供", + apiBase: "API 地址", + apiBaseHint: "默认 https://api.commandcode.ai,一般无需修改。", + workingDir: "工作目录", + workingDirHint: "可选。留空时使用占位符显示的进程工作目录;仅在需要固定路径时填写。", + requestTimeoutMs: "请求超时(毫秒)", + requestTimeoutMsHint: "等待响应首个字节的超时;默认 60000。", + streamIdleTimeoutMs: "流空闲超时(毫秒)", + streamIdleTimeoutMsHint: "生成流停滞多久视为断连;默认 300000(长思考模型可静默数分钟,默认值刻意放宽)。", + advancedSettings: "高级设置", + advancedSettingsHint: "API 地址、工作目录、超时与模型过滤等不常修改的选项。", + advancedOverriddenOne: "已自定义 1 项", + advancedOverriddenMany: "已自定义 {count} 项", + advancedInvalid: "高级设置中有未填好的数字,请展开修正后再保存。", + filterModelsByPlan: "隐藏套餐外模型", + filterModelsByPlanHint: "开启后,模型选择器只列出当前套餐可用的模型;账户持有按需余额时会显示全部。", + webSearch: "用 Command Code 承载联网搜索", + webSearchHint: "开启后,dsh 的 web_search 工具由 Command Code 承担(复用同一个 API key 与地址),并优先于其他搜索后端;关闭则把选择权交还给之前的后端(如 modsearch),而不是强制回退到 DeepSeek 搜索。", + accountsTitle: "多账户轮换", + accountsHint: "当前账户达到用量限额(429)或密钥失效(401)时,请求自动切换到下一个账户;全部耗尽时会提示最早的重置时间。", + accountAdd: "添加账户", + accountRemove: "移除", + accountLabel: "账户备注名", + accountKey: "API 密钥", + accountKeyHint: "该账户的 API 密钥。留空保存不会覆盖已存储的密钥。", + accountDefault: "默认账户", + activeAccount: "当前使用账户", + activeAccountAuto: "自动(第一个可用账户)", + activeAccountHint: "手动指定优先使用的账户,保存后下次请求即生效;所选账户耗尽时仍会自动切换到其他可用账户。", + rulesTitle: "按模型切换账户", + rulesHint: "选择模型并路由到某个账户(可多选)。命中规则的模型且该账户可用时优先使用;账户耗尽或密钥失效时仍自动回落到其他账户。规则按列表顺序匹配,第一条命中生效。", + rulesEmpty: "尚未配置规则。", + rulesCatalogFailed: "模型目录获取失败,暂时无法选择模型;已保存的规则仍会生效。", + ruleAdd: "添加规则", + ruleRemove: "移除", + ruleModel: "模型", + ruleModelPick: "选择模型…", + ruleModelCount: "已选 {count} 个模型", + ruleAccount: "目标账户", + ruleHint: "从下拉列表勾选要路由的模型(可多选),再选择目标账户。", + modelSearchPlaceholder: "搜索模型…", + modelSearchEmpty: "没有匹配的模型。", + modelStale: "已下架", + visibleModelsTitle: "模型白名单", + visibleModelsHint: "勾选要保留的模型,模型选择器就只列出这些;一个都不勾选时则显示全部模型。保存后,下次打开模型选择器生效。", + visibleModelsPick: "选择要保留的模型…", + visibleModelsCount: "已选 {count} 个模型", + visibleModelsShowAll: "显示全部", + visibleModelsStaleHint: "有 {count} 个已选模型在目录中找不到了(可能已下架),不影响其他模型;可清理或保留。", + visibleModelsCleanStale: "清理失效({count})", + overridden: "已覆盖", + reset: "重置", + invalidNumber: "无效数字", + numberTooSmall: "不能小于 1(毫秒)", + numberTooLarge: "超出允许上限(2147483647 毫秒)", + readOnly: "当前配置为只读。", + unsaved: "未保存", + save: "保存", + saving: "保存中", + saved: "已保存 ✓", + saveFailed: "保存失败,请重试。", + discard: "放弃", + cancel: "取消", + show: "显示", + hide: "隐藏", + usageTitle: "账户用量", + usageRefresh: "刷新", + usageRefreshing: "刷新中…", + usageLoading: "正在获取账户用量…", + usageNoKey: "配置 API 密钥后,这里会显示账户的用量与额度状态。", + usageError: "用量获取失败", + usageRequests: "请求", + usageFailed: "失败", + usageSuccessRate: "成功率", + usageCost: "花费", + usageTokens: "Token", + usageTokensIn: "入", + usageTokensOut: "出", + usageMonthly: "月额度", + usagePurchased: "已购", + usageFree: "赠送", + usageFiveHour: "5 小时窗口", + usageWeekly: "每周窗口", + usageExceeded: "已超限", + usageReset: "重置于", + usagePartial: "部分端点数据不可用", + usageKeyClear: "清除已存密钥", + usageKeyClearStaged: "将清除(保存后生效)", + usageUndoKeyClear: "撤销清除", + usageKeyInvalid: "API 密钥无效或已过期", + usageKeyInvalidHint: "服务端拒绝了全部请求(401)。请检查该账户配置的密钥,或到 commandcode.ai 控制台重新生成。", + usageServiceUnavailable: "Command Code 服务暂时不可用", + usageServiceUnavailableHint: "服务端返回了错误(5xx),稍后点击刷新重试。", + usageNetworkError: "无法连接 Command Code 服务", + usageNetworkHint: "所有请求都没有到达服务端。请检查网络连接或 API 地址设置。", + usageUpdated: "更新于", + usagePeriodEnd: "账期截止", + usageActive: "当前使用", + usageCooldown: "限额冷却中", + usageInvalidKey: "密钥无效", + usageUnconfigured: "该账户尚未配置 API 密钥。", + updateAvailable: "可更新", + updateHint: "已发布新版本,点击查看发布说明;更新插件后刷新本页,提示会自动消失。", + loginTitle: "通过官方登录获取密钥", + loginHintIdle: "不想手动创建密钥?点击登录后浏览器会打开 commandcode.ai 授权页,完成后密钥自动写入本机凭据服务,下次请求即生效。", + loginButton: "登录 Command Code", + loginStarting: "正在启动本地回调服务…", + loginWaiting: "等待在浏览器中完成授权…", + loginOpenLink: "打开授权页面 ↗", + loginCancel: "取消登录", + loginSuccess: "已登录为", + loginUnavailable: "此环境暂不支持登录流程,请手动粘贴密钥。", + loginDenied: "授权被拒绝。可重试,或手动粘贴密钥。", + loginTimeout: "等待超时:未在窗口期内收到授权回调,请重试。", + loginInvalidKey: "获取到的密钥未通过校验(401),请重试或手动粘贴。", + loginNetwork: "无法连接 Command Code 服务校验密钥,请检查网络后重试。", + loginStoreFailed: "密钥无法写入本机凭据服务,请手动粘贴。", + loginCancelled: "登录已取消。", + loginFailedGeneric: "登录失败,请重试或手动粘贴密钥。", + cardTitle: "Command Code", + cardRouteActive: "已启用", + cardLoadingHint: "正在读取 Command Code 配置…", + cardRegistrationHint: "此卡片随 Command Code 插件注册,需要较新版本的 DeepSeek Harness 才会显示完整内容。" + }; + const en = { + nav: "Command Code", + title: "Command Code", + intro: "Configure the Command Code Provider connection. The API key is stored only in the local credential service and never echoed; other fields are written to user settings and take effect on the next request.", + apiKey: "API key", + apiKeyHint: "Create one in the commandcode.ai console. Saving with this field blank keeps the stored key.", + apiKeySet: "Configured", + apiKeyUnset: "Not configured", + apiKeyLocked: "Key provided by a read-only source", + apiBase: "API base URL", + apiBaseHint: "Defaults to https://api.commandcode.ai; usually leave as-is.", + workingDir: "Working directory", + workingDirHint: "Optional. Leave blank to use the process cwd shown as the placeholder; fill in only to pin a specific path.", + requestTimeoutMs: "Request timeout (ms)", + requestTimeoutMsHint: "Time to wait for the first response byte; default 60000.", + streamIdleTimeoutMs: "Stream idle timeout (ms)", + streamIdleTimeoutMsHint: "How long a stalled stream is treated as dead; default 300000 (deliberately generous — long-thinking models can stay silent for minutes).", + advancedSettings: "Advanced", + advancedSettingsHint: "Rarely touched options: API base URL, working directory, timeouts, and model filtering.", + advancedOverriddenOne: "1 customized", + advancedOverriddenMany: "{count} customized", + advancedInvalid: "A number in Advanced settings is not ready to save; expand to fix it.", + filterModelsByPlan: "Hide out-of-plan models", + filterModelsByPlanHint: "When on, the model picker lists only models your subscription includes; any on-demand credit balance shows the full catalog.", + webSearch: "Serve dsh web search with Command Code", + webSearchHint: "When on, the model-facing web_search tool is backed by Command Code (same API key and base URL as chat), winning over other search backends. Off hands the selection back to the previous backend (e.g. modsearch) instead of forcing the shipped DeepSeek search.", + accountsTitle: "Account rotation", + accountsHint: "When the active account hits its usage limit (429) or its key fails (401), requests switch to the next account; when every account is exhausted the error names the earliest window reset.", + accountAdd: "Add account", + accountRemove: "Remove", + accountLabel: "Account label", + accountKey: "API key", + accountKeyHint: "This account’s API key. Saving with the field blank keeps the stored key.", accountDefault: "Default account", activeAccount: "Active account", activeAccountAuto: "Auto (first usable account)", @@ -4134,6 +6174,38 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box document.head.appendChild(tag); } /** + * Install the plans & quota panel's stylesheet and return its disposer, for + * `ctx.effect` to own. Keyed by its own `data-plugin-css` id, so the injection + * is idempotent even if a second surface asks for it later. + */ + function injectPanelCss() { + if (typeof document === "undefined") return () => {}; + if (document.querySelector(`style[data-plugin-css="@mars-sea/dsh-commandcode-provider/CommandCodePanel.module.css"]`) !== null) return () => {}; + const tag = document.createElement("style"); + tag.dataset.plugin = "@mars-sea/dsh-commandcode-provider"; + tag.dataset.pluginCss = PANEL_CSS_ID; + tag.textContent = PANEL_CSS; + document.head.appendChild(tag); + return () => { + tag.remove(); + }; + } + /** + * Plans & quota panel id. It is the layout's `MainPanelId`: one string shared + * by the `sidebar.footer.action` card and the `main` slot cell, so the card + * selects this panel and nothing else. `dsh-client-ui-layout` is not a + * dependency of this bundle (its type is only a brand over `string`), so the + * brand is applied at the call site instead of importing the package. + */ + const PANEL_ID = "commandcode-panel"; + /** + * The composer figure's entry id in `conversation.composer.dock`. Its own id, + * not the shipped `stats` cell's: reusing `stats` would REPLACE the tokens / + * cache-hit / throughput readout rather than inject into it, and that readout + * is the harness's to format (see `./session-cost-display.ts`). + */ + const SESSION_COST_ID = "commandcode-session-cost"; + /** * Client plugin body. Gates on the services shared by both client generations * (`slots`, `locale`, `connection`, `remote`, `settingsScope`). Current clients * mount the page after `remote.credentials` appears; legacy clients mount it @@ -4176,11 +6248,13 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box }), "dsh-commandcode-provider: credential invalidations"); let usageNamespace; let usageMountError; + let pricesController; const contribution = { package: USAGE_REMOTE_CONTRIBUTION.package, descriptors: [ ...USAGE_REMOTE_CONTRIBUTION.descriptors, ...MODELS_REMOTE_CONTRIBUTION.descriptors, + ...PRICES_REMOTE_CONTRIBUTION.descriptors, ...LOGIN_REMOTE_CONTRIBUTION.descriptors ] }; @@ -4196,6 +6270,7 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box ctx.inject(["remote.commandcode"], (namespaceCtx) => { usageNamespace = namespaceCtx.remote.commandcode; controller.refreshCatalog(); + pricesController?.reload(); namespaceCtx.effect(() => () => { usageNamespace = void 0; }, "dsh-commandcode-provider: usage namespace"); @@ -4225,6 +6300,22 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box error: { message: usageMountError ?? "commandcode/models remote is not mounted" } }; return namespace.models(); + }, + prices: async () => { + const namespace = usageNamespace; + if (namespace === void 0) return { + ok: false, + error: { message: usageMountError ?? "commandcode/prices remote is not mounted" } + }; + const call = namespace.prices; + if (typeof call !== "function") return { + ok: false, + error: { + message: "the Host serves no commandcode/prices endpoint", + permanent: true + } + }; + return call.call(namespace); } }; modelsRemote = () => usageRemote.models(); @@ -4232,6 +6323,10 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box ctx.effect(() => () => usageController.dispose(), "dsh-commandcode-provider: usage controller"); const usageStore = createSnapshotStore(usageController.state()); usageController.subscribe(() => usageStore.set(usageController.state())); + pricesController = new CommandCodePricesController(usageRemote); + ctx.effect(() => () => pricesController?.dispose(), "dsh-commandcode-provider: price table"); + const pricesStore = createSnapshotStore(pricesController.state()); + pricesController.subscribe(() => pricesStore.set(pricesController.state())); const loginRemote = { loginBegin: async () => { if (usageNamespace === void 0) return { @@ -4323,6 +6418,54 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box cancelLogin: () => void loginController.cancel() }) }, CommandCodeProviderCard)); + const panelFace = () => ({ + hooks: { + commandCodeUsage: usageStore, + commandCodeSettings: store + }, + refresh: () => { + pricesController?.ensure(); + usageController.refresh(); + }, + startAutoRefresh: () => startPanelAutoRefresh(usageController, () => true), + open: () => { + const layout = ctx.get("layout"); + if (typeof layout?.selectPanel === "function") layout.selectPanel(PANEL_ID); + } + }); + ctx.effect(() => injectPanelCss(), "dsh-commandcode-provider: panel styles"); + try { + ctx.slots.inject("main", () => ctx.slots.register({ + name: "main", + key: PANEL_ID, + inject: panelFace + }, CommandCodePanel)); + } catch (error) { + console.error("[dsh-commandcode-provider] could not register the plans & quota panel:", error); + } + ctx.inject(["layout"], (layoutCtx) => { + if (typeof layoutCtx.get("layout")?.selectPanel !== "function") return; + try { + layoutCtx.slots.inject("sidebar.footer.action", () => layoutCtx.slots.register({ + name: "sidebar.footer.action", + id: PANEL_ID, + order: 1, + inject: panelFace + }, CommandCodeFooterEntry)); + } catch (error) { + console.error("[dsh-commandcode-provider] could not register the sidebar footer card:", error); + } + }); + const sessionCostFace = () => ({ hooks: { commandCodePrices: pricesStore } }); + try { + ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({ + name: "conversation.composer.dock", + id: SESSION_COST_ID, + inject: sessionCostFace + }, CommandCodeSessionCost)); + } catch (error) { + console.error("[dsh-commandcode-provider] could not register the composer session-cost readout:", error); + } } const inject = [ "slots", diff --git a/lib/client.js.map b/lib/client.js.map index 2b10ed7..0660122 100644 --- a/lib/client.js.map +++ b/lib/client.js.map @@ -1 +1 @@ -{"version":3,"file":"client.js","names":["numberField","booleanField","record","stringField","reject","pkg.version","(pkg as { repository?: unknown }).repository","useState","useMemo","Menu","Button","useState","useRef"],"sources":["../src/client/snapshot-store.ts","../src/command-locales.ts","../src/client/sessions.ts","../src/client/settings.ts","../src/client/legacy-credentials.ts","../src/client/usage.ts","../src/client/login.ts","../src/wire-shared.ts","../src/usage-wire.ts","../src/login-wire.ts","../src/client/login-row.tsx","../src/client/model-select.ts","../package.json","../src/client/version.ts","../src/client/update.ts","../src/client/section.tsx","../src/client/card.tsx","../src/client/locales.ts","../src/client/index.ts"],"sourcesContent":["/**\n * A tiny observable snapshot store — the `getSnapshot` / `subscribe` / `set`\n * triple React's `useSyncExternalStore` consumes through the harness slot kit\n * (the host builds each slot's `useFoo(selector)` hook from one of these).\n *\n * Vendored on purpose. DSH 0.1.2 seeds `@deepseek-ai/dsh-client-store` as a\n * platform module, but older Web shells do not and the package is not yet\n * published independently on npm. Inlining the small subset used here avoids\n * a version-specific module request while preserving the slot-hook contract.\n */\n\n/** Mutable observable snapshot consumed by slot hooks. */\nexport interface SnapshotStore {\n getSnapshot(): T\n subscribe(listener: () => void): () => void\n set(value: T): void\n}\n\n/** Notify every subscriber without letting one faulty UI consumer suppress the rest. */\nfunction notifyListeners(listeners: ReadonlySet<() => void>): void {\n for (const listener of listeners) {\n try {\n listener()\n } catch (error: unknown) {\n console.error('[dsh-commandcode-provider] snapshot subscriber failed:', error)\n }\n }\n}\n\n/** Create one observable snapshot store. */\nexport function createSnapshotStore(initial: T): SnapshotStore {\n let snapshot = initial\n const listeners = new Set<() => void>()\n return {\n getSnapshot: () => snapshot,\n subscribe(listener: () => void) {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n },\n set(value: T) {\n if (Object.is(value, snapshot)) return\n snapshot = value\n notifyListeners(listeners)\n },\n }\n}\n","/**\n * Locale copy for the `/commandcode` usage command and the friendly\n * image-gate error rewrite. Distinct from `./client/locales.ts` (the\n * settings-page namespace `settings.commandcode`): the command runs on the\n * Host and has no access to the client's `ctx.locale`, so the dictionaries\n * are exposed as plain constants for direct lookup; the resolver lives in\n * `pickCommandLocale()`. The image-gate wrapper also lives on the client\n * but is reached from a non-React path that has no `t` in scope, so the\n * same dictionaries serve both surfaces.\n *\n * zh is the source of truth for the key set; en must carry the exact same\n * keys — a mismatch is a compile error at the lookup site.\n */\n\n/** Active locale id recognized by the command and the image-gate wrapper. */\nexport type LocaleId = 'zh' | 'en'\n\n/** Dictionary keys used by the `/commandcode` command and the image-gate wrapper. */\nexport type CommandCodeCommandKey =\n | 'title' // top heading of a single-account report\n | 'accountTitle' // per-account heading in the multi-account view\n | 'accountSeparator' // rule between accounts in the multi-account view\n | 'activeBadge' // \"currently serving\" badge\n | 'invalidCredentialBadge' // mark for an account whose key is invalid\n | 'cooldownBadge' // mark for an account in rate-limit cooldown\n | 'rateLimitBadge' // mark when the pool has marked a key rate-limited\n | 'unconfigured' // one-account row when the slot has no key\n | 'blockedInvalidKey' // top-of-report block when the whole account is 401\n | 'blockedServiceUnavailable' // 5xx\n | 'blockedNetwork' // network unreachable\n | 'planLine' // \" 📦 套餐 {name}{status}{period}\"\n | 'planPeriodSuffix' // \" · 账期截止 {date}\" / \" · period ends {date}\"\n | 'usageHeader' // \"── 请求 ─────...\"\n | 'requestsLine' // \" 💬 请求 {n} 次 / 失败 {f} 成功率 {r}%\"\n | 'costLine' // \" 💰 花费 {money} ({credits} credits)\"\n | 'tokensLine' // \" 🔤 Token {in} 入 / {out} 出\"\n | 'creditsHeader' // \"── 信用 ─────...\"\n | 'monthlyLine' // \" 💳 月额度 {monthly} (已购 {purchased} / 赠送 {free})\"\n | 'barLine' // \" └ {bar} {pct}%\"\n | 'windowsHeader' // \"── 窗口用量 ─────...\"\n | 'fiveHourLine' // \" ⏱ 5 小时 {used} / {cap}{warn}\"\n | 'weeklyLine' // \" 📅 每周 {used} / {cap}{warn}\"\n | 'windowBarLine' // \" └ {bar} 重置 {when}\"\n | 'exceededWarning' // the trailing \" ⚠️ 超限!\" / \" ⚠️ exceeded!\"\n | 'resetSuffix' // \"重置 {when}\" (the suffix after the bar)\n | 'partialFailures' // \"⚠️ 部分端点失败: {list}\"\n | 'noData' // \"(no data — check your API key)\"\n | 'errorText' // \"Could not fetch Command Code usage: {message}\"\n | 'imageGate' // image-gate rejection rewrite (with {model})\n\nexport const commandcodeCommand: Record> = {\n zh: {\n title: '📊 Command Code 用量{account}',\n accountTitle: '📊 {label}{badges}',\n accountSeparator: '────────────────────',\n activeBadge: ' ✅ 当前使用',\n invalidCredentialBadge: ' ⛔ 密钥无效',\n cooldownBadge: ' ⏳ 限额冷却中,重置 {when}',\n rateLimitBadge: ' ⏳ 已达限额(等待窗口探测)',\n unconfigured: ' (未配置 API 密钥)',\n blockedInvalidKey:\n '⛔ API 密钥无效或已过期 — 服务端拒绝了全部请求(401),请检查该账户的密钥配置',\n blockedServiceUnavailable:\n '⚠️ Command Code 服务暂时不可用(5xx),稍后重试',\n blockedNetwork:\n '⚠️ 无法连接 Command Code 服务 — 请检查网络或 API 地址',\n planLine: ' 📦 套餐 {name}{status}{period}',\n planPeriodSuffix: ' · 账期截止 {date}',\n usageHeader: '── 请求 ──────────────────────────────',\n requestsLine: ' 💬 请求 {n} 次 / 失败 {f} 成功率 {r}%',\n costLine: ' 💰 花费 {money} ({credits} credits)',\n tokensLine: ' 🔤 Token {in} 入 / {out} 出',\n creditsHeader: '── 信用 ──────────────────────────────',\n monthlyLine: ' 💳 月额度 {monthly} (已购 {purchased} / 赠送 {free})',\n barLine: ' └ {bar} {pct}%',\n windowsHeader: '── 窗口用量 ──────────────────────────',\n fiveHourLine: ' ⏱ 5 小时 {used} / {cap}{warn}',\n weeklyLine: ' 📅 每周 {used} / {cap}{warn}',\n windowBarLine: ' └ {bar} 重置 {when}',\n exceededWarning: ' ⚠️ 超限!',\n resetSuffix: '重置 {when}',\n partialFailures: '⚠️ 部分端点失败: {list}',\n noData: '(无数据 — 请检查 API 密钥)',\n errorText: '获取 Command Code 用量失败:{message}',\n imageGate:\n '当前会话已包含图片,而模型 {model} 不支持图片输入;'\n + '请选择支持图片的模型,或先移除会话中的图片。',\n },\n en: {\n title: '📊 Command Code usage{account}',\n accountTitle: '📊 {label}{badges}',\n accountSeparator: '────────────────────',\n activeBadge: ' ✅ active',\n invalidCredentialBadge: ' ⛔ invalid key',\n cooldownBadge: ' ⏳ cooling down, resets {when}',\n rateLimitBadge: ' ⏳ rate-limited (waiting for window probe)',\n unconfigured: ' (no API key configured)',\n blockedInvalidKey:\n '⛔ API key invalid or expired — the server rejected every request (401); check the key configured for this account',\n blockedServiceUnavailable:\n '⚠️ Command Code service temporarily unavailable (5xx); try again later',\n blockedNetwork:\n '⚠️ could not reach the Command Code service — check your network or the API base setting',\n planLine: ' 📦 Plan {name}{status}{period}',\n planPeriodSuffix: ' · period ends {date}',\n usageHeader: '── Requests ──────────────────────────',\n requestsLine: ' 💬 Requests {n} / failed {f} success rate {r}%',\n costLine: ' 💰 Spend {money} ({credits} credits)',\n tokensLine: ' 🔤 Tokens {in} in / {out} out',\n creditsHeader: '── Credits ───────────────────────────',\n monthlyLine: ' 💳 Monthly {monthly} (purchased {purchased} / free {free})',\n barLine: ' └ {bar} {pct}%',\n windowsHeader: '── Window usage ──────────────────────',\n fiveHourLine: ' ⏱ 5-hour {used} / {cap}{warn}',\n weeklyLine: ' 📅 Weekly {used} / {cap}{warn}',\n windowBarLine: ' └ {bar} resets {when}',\n exceededWarning: ' ⚠️ exceeded!',\n resetSuffix: 'resets {when}',\n partialFailures: '⚠️ some endpoints failed: {list}',\n noData: '(no data — check your API key)',\n errorText: 'Could not fetch Command Code usage: {message}',\n imageGate:\n 'This session already contains images, and model {model} does not accept'\n + ' image input; please select an image-capable model, or remove the'\n + ' images from the session first.',\n },\n}\n\n/**\n * Resolve the active locale for a Host-side command run.\n *\n * Priority: explicit `override` (from `Config.lang`) → `LC_ALL` → `LANG` →\n * the conventional fallback (`'zh'`, matching the existing single-language\n * behavior so unconfigured deployments keep their current output).\n *\n * The values are matched on the leading tag only — `zh_CN.UTF-8`,\n * `zh-Hans`, `zh` all map to `'zh'`; everything starting with `en` maps to\n * `'en'`; anything else falls back to `'zh'` (a non-`en` shell that\n * already has Chinese in the terminal is the closest sensible default;\n * a Western shell that happens to be neither keeps the existing Chinese\n * output rather than swapping to half-translated English).\n */\nexport function pickCommandLocale(\n override: string | undefined,\n env: Readonly> = process.env as Record,\n): LocaleId {\n if (override === 'zh' || override === 'en') return override\n const raw = env.LC_ALL ?? env.LANG ?? ''\n const tag = raw.toLowerCase().split(/[._-]/)[0] ?? ''\n if (tag === 'en') return 'en'\n return 'zh'\n}\n\n/** Look up a key in the active locale, with an internal en fallback. */\nexport function commandCopy(locale: LocaleId, key: CommandCodeCommandKey): string {\n return commandcodeCommand[locale][key] ?? commandcodeCommand.en[key] ?? key\n}\n","/**\n * Friendly-error wrapper for the harness's image-session gate.\n *\n * The host rejects switching to a text-only model while the session already\n * contains images with a `model-unavailable` error\n * (`dsh-host-apiproxy`'s `session.selectModel` handler). That rejection is\n * intentional and cannot be relaxed from the plugin side — the adapter's\n * `inputModalities` is exactly what makes the guard work. What we CAN do is\n * make the error message friendlier: wrap the shared\n * `connection.api.sessions.selectModel` face so a `model-unavailable`\n * rejection shows a clear, actionable hint (with the requested model name)\n * instead of the raw English harness message.\n *\n * The wrapper is deliberately narrow: only the `model-unavailable` code is\n * rewritten, only when the message matches the image-session gate, and only\n * the message text changes — the error code and details pass through\n * untouched so any caller that switches on `error.code` keeps working.\n *\n * The wire types are spelled structurally here (not imported from\n * `@deepseek-ai/dsh-host-apiproxy`) so this client bundle does not drag an\n * extra peer dependency into the package; the shapes are stable and the\n * client build inlines them anyway.\n *\n * The wrapper takes a `getLocale` thunk because it is reached from a\n * non-React path that has no `t` in scope; the supplied thunk reads the\n * active locale at call time (typically `() => ctx.locale.getLocale().active`\n * in the client entry), and the message template lives in the shared\n * `commandcodeCommand` dictionary used by the Host-side `/commandcode`\n * command — the same bilingual surface serves both.\n *\n * This module is deliberately free of React and other client-platform\n * imports so the node test runner can exercise it directly.\n */\n\nimport { commandcodeCommand, type LocaleId } from '../command-locales.ts'\n\n/** The `model-unavailable` error details: provider + model id. */\ninterface ModelUnavailableDetails {\n provider: string\n model: string\n}\n\n/** The narrow slice of the RPC error we need to inspect and rewrite. */\ninterface RpcErrorLike {\n code: string\n message: string\n details?: ModelUnavailableDetails\n}\n\n/**\n * The narrow slice of a unary RPC result we need to inspect and rewrite.\n * The wire shape from `sessions.selectModel` (via `AbstractApiClient.callUnary`)\n * is the full envelope `{ rpcId, result: { ok, error? } }` — the error lives\n * under `result.result`, not at the top level. `RpcResultLike` models that.\n */\ninterface RpcResultLike {\n rpcId: string\n result:\n | { ok: true; value?: unknown }\n | { ok: false; error: RpcErrorLike }\n}\n\n/** One selectModel call: payload in, envelope out. */\ntype SelectModelCall = (\n payload: { sessionId: string; provider: string; model: string; reasoningEffort?: string },\n signal?: AbortSignal,\n) => Promise\n\n/** The shared sessions wire face we wrap. */\ninterface SessionsLike {\n selectModel: SelectModelCall\n}\n\n/** Whether a selectModel rejection is the harness's image-session gate. */\nexport function isImageSessionRejection(\n result: RpcResultLike,\n): result is RpcResultLike & { result: { ok: false; error: RpcErrorLike } } {\n return (\n !result.result.ok &&\n result.result.error.code === 'model-unavailable' &&\n result.result.error.message.includes('does not accept image input')\n )\n}\n\n/** Wrap the shared sessions API so selectModel failures read friendlier. */\nexport function withFriendlyImageError(\n sessions: SessionsLike,\n getLocale: () => LocaleId,\n): SessionsLike {\n const selectModel = sessions.selectModel.bind(sessions)\n return {\n ...sessions,\n selectModel: async (payload, signal) => {\n const result = await selectModel(payload, signal)\n if (!isImageSessionRejection(result)) return result\n const model = result.result.error.details?.model ?? payload.model\n const template = commandcodeCommand[getLocale()].imageGate\n ?? commandcodeCommand.en.imageGate\n return {\n ...result,\n result: {\n ...result.result,\n error: {\n ...result.result.error,\n message: template.replace('{model}', model),\n },\n },\n }\n },\n }\n}\n\n/**\n * The pre-0.1.2 connection handle that exposed the shared session API under\n * `connection.api.sessions`. In 0.1.2 the connection service became a\n * transport/generation handle and model selection moved to `remote.session`,\n * so this legacy field is deliberately optional.\n */\nexport interface ConnectionLike {\n api?: { sessions?: SessionsLike }\n}\n\n/**\n * Install the friendly-error wrapper when a legacy sessions face is present.\n *\n * @returns whether the wrapper was installed. The rewrite is only UX polish;\n * a 0.1.2 connection has no `api.sessions`, and its absence must never block\n * the plugin from mounting its settings, credential, or usage surfaces.\n */\nexport function installFriendlyImageError(\n connection: ConnectionLike,\n getLocale: () => LocaleId,\n): boolean {\n const api = connection.api\n const sessions = api?.sessions\n if (api === undefined || sessions === undefined || typeof sessions.selectModel !== 'function') return false\n api.sessions = withFriendlyImageError(sessions, getLocale)\n return true\n}\n","/**\n * Browser controller for the \"Command Code\" settings page.\n *\n * The page lives at the same settings-nav level as General / Models / Plugins\n * (a `settings.section` entry, id `commandcode`). It exists because the\n * Models page renders an unknown-adapter-family card for the `commandcode`\n * provider and deliberately disables its submit — the API key cannot be\n * configured there. This page owns the connection facts the plugin resolves\n * per request:\n *\n * - API key -> written through the credentials domain under the reference\n * the plugin resolves (`apiKeyEnv`, default\n * `COMMANDCODE_API_KEY`). The literal never rides a response,\n * so the control only reports whether one is configured.\n * - API base -> the `llm-commandcode` settings namespace (`apiBase`), same\n * namespace the Models page card addresses.\n * - Working dir, request/stream timeouts -> the same namespace.\n *\n * The controller mirrors the plugin-card pattern from the harness's own\n * settings UI: it binds the `llm-commandcode` namespace through the\n * `settingsScope` service, keeps a staged draft of edits, and writes them on\n * save through `scope.set` / the credentials domain. The Host stays the\n * single fact source; the snapshot is republished after each accepted write.\n *\n * This module is deliberately free of JSX — it only produces the state face\n * the React component renders.\n */\n\n/** The settings namespace the plugin registers (host half, src/index.ts). */\nexport const COMMANDCODE_NS = 'llm-commandcode'\n/** Default credential reference the plugin resolves when none is named. */\nexport const DEFAULT_API_KEY_REF = 'COMMANDCODE_API_KEY'\n\n/** The settings-scope snapshot fields consumed by this controller. */\nexport interface SettingsScopeSnapshot {\n status: 'loading' | 'ready' | 'unavailable'\n value: T | undefined\n base: unknown\n user: unknown\n revision: number | undefined\n writable: boolean\n mode: 'host' | 'memory'\n}\n\n/** Current settings-scope service face used without importing a browser plugin value. */\nexport interface SettingsScope {\n getSnapshot(): SettingsScopeSnapshot\n subscribe(listener: () => void): () => void\n set(field: string, value: unknown): Promise\n unset(field: string): Promise\n}\n\n/** Result envelope returned by one current Typert Remote call. */\ninterface RemoteResult {\n ok: boolean\n value?: T\n error?: { message: string }\n}\n\n/** Credential facts returned without exposing the credential value. */\ninterface CredentialInfo {\n configured: boolean\n writable: boolean\n}\n\n/** The narrow slice of the wire face this controller needs. */\nexport interface SettingsPageApi {\n /** Credential methods exposed by the current Typert Remote namespace. */\n credentials: {\n describe(refs: string[]): Promise>>\n set(ref: string, value: string): Promise>\n unset(ref: string): Promise>\n }\n /**\n * The model catalog for the settings page's model editors (the\n * routing-rule editor and the visible-models filter; Host-side). Absent\n * on legacy transports without the Remote mount — the editors degrade to\n * the empty-catalog state.\n */\n models?(): Promise>\n}\n\n/** The Host-description observable the page reads the process cwd from. */\nexport interface HostDescriptionSource {\n getSnapshot(): { cwd?: string } | undefined\n subscribe(fn: () => void): () => void\n}\n\n/** One editable text field's staged state (blank = keep stored value). */\nexport interface StagedField {\n /** Live draft text the input shows. */\n text: string\n /** Whether the user explicitly cleared the field (reset to inherited). */\n clear: boolean\n /** Whether the user layer carries this field (marks it overridden). */\n overridden: boolean\n /** Whether the staged draft fails to parse (blocks save). */\n invalid: boolean\n /**\n * Why the draft is invalid — a non-number (`format`) or an out-of-range\n * number (`tooSmall`/`tooLarge`); undefined when valid.\n */\n invalidReason: InvalidReason | undefined\n}\n\n/** Why a staged draft fails validation (drives the per-field error copy). */\nexport type InvalidReason = 'format' | 'tooSmall' | 'tooLarge'\n\n/** One extra account row's staged state (the default account uses `apiKey`). */\nexport interface AccountItemState {\n /** Stable id — the account's credential reference. */\n id: string\n /** Credential reference this account's key lives under. */\n ref: string\n /** Label draft text (the stored/generated label until edited). */\n label: string\n /** The API key draft (write-only; starts blank, never echoes the stored key). */\n keyText: string\n /** Whether a key is stored for this account (Host-reported). */\n configured: boolean\n /** Whether the credentials domain can store the key. */\n writable: boolean\n /** Staged for addition (not yet saved). */\n added: boolean\n /** Staged for key removal on the next save (the stored key is bad/unwanted). */\n clearStaged: boolean\n}\n\n/** One model → account routing rule row's staged state. */\nexport interface RuleItemState {\n /** Stable row id (`rule-N` for stored rows, `new-N` for staged adds). */\n id: string\n /** Model ids the rule routes to the account (multi-select). */\n models: string[]\n /** Account slot id the rule routes matching models to. */\n account: string\n /** Staged for addition (not yet saved). */\n added: boolean\n}\n\n/** One selectable catalog model in the settings page's model editors. */\nexport interface CatalogModelOption {\n id: string\n name: string\n /**\n * Minimum plan-tier key (a Host `KNOWN_PLANS` value), or undefined for\n * models outside the snapshot / older Hosts. Drives the tier headings in\n * the editor dropdowns; absent tiers render unheaded.\n */\n tier?: string\n}\n\n/** The page's full state face, projected from the scope + drafts + credential. */\nexport interface SettingsPageState {\n /** Whether the namespace snapshot is ready. */\n available: boolean\n /** Whether the Host document accepts writes. */\n writable: boolean\n /** Whether the API key is currently configured (Host-reported). */\n apiKeyConfigured: boolean\n /** Whether ANY account (default or extra) has a stored key — gates the usage card. */\n anyAccountConfigured: boolean\n /** Whether the credentials domain can store the key. */\n apiKeyWritable: boolean\n /** The API key draft (write-only; starts blank, never echoes the stored key). */\n apiKey: StagedField\n /** Whether the default account's stored key is staged for removal on the next save. */\n apiKeyClearStaged: boolean\n /** apiBase draft. */\n apiBase: StagedField\n /** workingDir draft. */\n workingDir: StagedField\n /**\n * The working directory a blank `workingDir` resolves to: the Host\n * process cwd (`host.describe().cwd`). Shown as the field's placeholder so\n * the user sees what \"leave it empty\" means — no configuration needed.\n */\n defaultWorkingDir: string | undefined\n /** requestTimeoutMs draft. */\n requestTimeoutMs: StagedField\n /** streamIdleTimeoutMs draft. */\n streamIdleTimeoutMs: StagedField\n /**\n * filterModelsByPlan draft, staged as `'true'`/`'false'`/`''` (unset). The\n * component renders it as a toggle; `''` means \"inherit the default\" (on).\n */\n filterModelsByPlan: StagedField\n /**\n * webSearch draft, staged as `'true'`/`'false'`/`''` (unset). The component\n * renders it as a toggle; `''` means \"inherit the default\" (on — Command Code\n * serves the dsh web_search tool).\n */\n webSearch: StagedField\n /**\n * The manually selected active account, staged as a slot id (`default`\n * or an extra account's credential reference); `''` means \"auto — first\n * usable account\". The component renders it as a select.\n */\n activeAccount: StagedField\n /** Extra accounts (multi-account rotation), in rotation order. */\n accounts: AccountItemState[]\n /** Refs of stored accounts staged for removal (the usage card hides them). */\n accountsRemoving: string[]\n /** Model → account routing rules, in list order (first match wins). */\n rules: RuleItemState[]\n /** Effective visible-model allowlist: staged draft or stored value. Empty = show all. */\n visibleModels: string[]\n /** The catalog the model editors offer (Host-side, empty until loaded). */\n catalogModels: CatalogModelOption[]\n /** Whether the catalog fetch failed (editors fall back to typing). */\n catalogFailed: boolean\n /** Whether any staged edit differs from the stored section. */\n dirty: boolean\n /** Whether a staged numeric field fails to parse (save blocked). */\n invalid: boolean\n /** Whether a save is in flight. */\n saving: boolean\n /** Whether the last save failed (drafts retained for correction). */\n failed: boolean\n /**\n * Monotonic counter bumped once per accepted save. The component watches it\n * to flash the \"Saved ✓\" affordance (timing lives in the component; the\n * controller stays a plain state machine with no timers).\n */\n savedCount: number\n}\n\n/** Parsed outcome of one field's draft. */\ntype Parsed =\n | { kind: 'set'; value: string | number | boolean }\n | { kind: 'clear' }\n | { kind: 'invalid'; reason: InvalidReason }\n\n/** One field's staged draft (internal; the public face adds derived flags). */\ninterface Staged {\n text: string\n clear: boolean\n}\n\n/** A field conversion spec. */\ninterface FieldSpec {\n field: string\n format(value: unknown): string\n parse(text: string): Parsed\n}\n\n/** A free-text field; an empty draft clears it. */\nfunction textField(field: string): FieldSpec {\n return {\n field,\n format: (value) => (typeof value === 'string' ? value : ''),\n parse: (text) => {\n const trimmed = text.trim()\n return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }\n },\n }\n}\n\n/**\n * A numeric field; an empty draft clears it, anything non-numeric blocks\n * save, and an optional inclusive `bounds` range rejects out-of-range values\n * with a specific reason (the Host schema would reject them at save time with\n * only a generic failure — catching it here names the problem while typing).\n * Decimals pass: the Host schema is `z.number()` too, and a fractional\n * millisecond value is harmless even if pointless.\n */\nfunction numberField(field: string, bounds?: { min?: number; max?: number }): FieldSpec {\n return {\n field,\n format: (value) => (typeof value === 'number' ? String(value) : ''),\n parse: (text) => {\n const trimmed = text.trim()\n if (trimmed === '') return { kind: 'clear' }\n const parsed = Number(trimmed)\n if (!Number.isFinite(parsed)) return { kind: 'invalid', reason: 'format' }\n if (bounds?.min !== undefined && parsed < bounds.min) return { kind: 'invalid', reason: 'tooSmall' }\n if (bounds?.max !== undefined && parsed > bounds.max) return { kind: 'invalid', reason: 'tooLarge' }\n return { kind: 'set', value: parsed }\n },\n }\n}\n\n/**\n * A boolean field, staged as the strings `'true'`/`'false'` (an empty draft\n * clears it). The component renders a toggle and only ever stages these two\n * strings; anything else blocks save.\n */\nfunction booleanField(field: string): FieldSpec {\n return {\n field,\n format: (value) => (typeof value === 'boolean' ? String(value) : ''),\n parse: (text) => {\n const trimmed = text.trim()\n if (trimmed === '') return { kind: 'clear' }\n if (trimmed === 'true') return { kind: 'set', value: true }\n if (trimmed === 'false') return { kind: 'set', value: false }\n return { kind: 'invalid', reason: 'format' }\n },\n }\n}\n\n/**\n * Inclusive bounds for the millisecond timeout fields, mirroring the Host\n * Config schema (`z.number().min(1).max(MAX_TIMER_DELAY_MS)` in src/index.ts;\n * `MAX_TIMER_DELAY_MS` is dsh-timeout's 2^31-1 timer ceiling). The client\n * bundle cannot import the node-side package, so the bound is pinned here —\n * the host remains the final gate.\n */\nexport const MIN_TIMEOUT_MS = 1\nexport const MAX_TIMEOUT_MS = 2147483647\n\n/** The fields this page edits inside the `llm-commandcode` namespace. */\nconst SECTION_FIELDS: FieldSpec[] = [\n textField('apiBase'),\n textField('workingDir'),\n numberField('requestTimeoutMs', { min: MIN_TIMEOUT_MS, max: MAX_TIMEOUT_MS }),\n numberField('streamIdleTimeoutMs', { min: MIN_TIMEOUT_MS, max: MAX_TIMEOUT_MS }),\n booleanField('filterModelsByPlan'),\n booleanField('webSearch'),\n textField('activeAccount'),\n]\n\n/** Whether two model-id lists are equal as sets (order-insensitive). */\nfunction sameModels(a: readonly string[], b: readonly string[]): boolean {\n if (a.length !== b.length) return false\n const set = new Set(a)\n return b.every((id) => set.has(id))\n}\n\n/**\n * Order-sensitive content fingerprint of the stored routing rules. Stored rule\n * ids are positional (`rule-`), so equality of this fingerprint across a\n * save is exactly the statement \"no rules write landed and no row shifted\".\n */\nfunction ruleFingerprint(rules: ReadonlyArray<{ models: readonly string[]; account: string }>): string {\n return JSON.stringify(rules.map((rule) => [rule.models, rule.account]))\n}\n\n/**\n * Controller bridging the `llm-commandcode` scope and the credentials domain\n * onto the page. Public API mirrors the harness's CardForm actions, so the\n * component stays thin.\n */\nexport class CommandCodeSettingsController {\n private readonly scope: SettingsScope>\n private readonly api: SettingsPageApi\n private readonly specs = new Map(SECTION_FIELDS.map((spec) => [spec.field, spec]))\n private readonly staged = new Map()\n private readonly listeners = new Set<() => void>()\n private readonly disposers: Array<() => void> = []\n private disposed = false\n private defaultWorkingDir: string | undefined\n /** The credential reference the default account resolves. */\n private credentialRef = DEFAULT_API_KEY_REF\n /** Host-reported configured/writable state per credential reference. */\n private readonly credentialStates = new Map()\n /** Staged account additions (not yet saved). */\n private addedAccounts: Array<{ label: string; ref: string }> = []\n /** Staged removals of stored extra accounts, by credential reference. */\n private readonly removedRefs = new Set()\n /** Staged label drafts, by credential reference. */\n private readonly labelDrafts = new Map()\n /** Staged key drafts, by credential reference (blank = keep stored key). */\n private readonly keyDrafts = new Map()\n /** Credential references staged for removal on the next save. */\n private readonly keyClears = new Set()\n /** Staged model→account routing rules (not yet saved). */\n private addedRules: Array<{ models: string[]; account: string }> = []\n /** Staged edits to stored routing rules, by stored row id. */\n private readonly ruleDrafts = new Map()\n /**\n * Stored routing rule rows staged for removal, by stored row id with a\n * content snapshot. The snapshot makes reconcile content-based: stored\n * row ids are positional (`rule-N`) and shift after any write, so an\n * id-only check would misread a landed removal as pending (and a retry\n * would delete the wrong row).\n */\n private readonly removedRuleIds = new Map()\n /**\n * Fingerprint of the stored routing rules when the last `save()` started;\n * `undefined` outside a save. Lets reconcile distinguish a landed rules\n * write from a save that failed before reaching it.\n */\n private rulesBeforeSave: string | undefined = undefined\n /** Staged visible-model allowlist (undefined = no draft). */\n private visibleModelsDraft: string[] | undefined = undefined\n /** The catalog the model editors offer (Host-side). */\n private catalogModels: CatalogModelOption[] = []\n private catalogFailed = false\n private saving = false\n private failed = false\n private savedCount = 0\n\n /**\n * @param scope - bound scope for the `llm-commandcode` namespace.\n * @param api - credentials wire face.\n * @param hostDescription - the Host-description observable whose `cwd` is\n * shown as the placeholder a blank `workingDir` field resolves to.\n */\n constructor(\n scope: SettingsScope>,\n api: SettingsPageApi,\n hostDescription?: HostDescriptionSource,\n ) {\n this.scope = scope\n this.api = api\n this.disposers.push(scope.subscribe(() => {\n this.recomputeCredentialRef()\n void this.describeAll()\n this.publish()\n }))\n if (hostDescription !== undefined) {\n this.defaultWorkingDir = hostDescription.getSnapshot()?.cwd\n this.disposers.push(hostDescription.subscribe(() => {\n if (this.disposed) return\n const cwd = hostDescription.getSnapshot()?.cwd\n if (cwd !== this.defaultWorkingDir) {\n this.defaultWorkingDir = cwd\n this.publish()\n }\n }))\n }\n this.recomputeCredentialRef()\n void this.describeAll()\n this.refreshCatalog()\n }\n\n /** Release every subscription held on external sources. Idempotent. */\n dispose(): void {\n if (this.disposed) return\n this.disposed = true\n for (const dispose of this.disposers) dispose()\n this.disposers.length = 0\n this.listeners.clear()\n }\n\n /**\n * The credential reference the section names, or the provider default. A\n * user who renamed `apiKeyEnv` in `settings.yaml` (or the composition\n * config) gets a page that addresses the renamed ref instead of silently\n * writing the default — mirroring the Models page's `refFor()`.\n */\n private recomputeCredentialRef(): void {\n const snapshot = this.scope.getSnapshot()\n const named = typeof snapshot.value?.apiKeyEnv === 'string' && snapshot.value.apiKeyEnv.length > 0\n ? snapshot.value.apiKeyEnv\n : DEFAULT_API_KEY_REF\n if (named === this.credentialRef) return\n // Prune the orphaned OLD ref's cached state (renames only move forward;\n // the new ref re-describes on the next describeAll).\n this.credentialStates.delete(this.credentialRef)\n this.credentialRef = named\n }\n\n /** Subscribe to state projections. @returns the disposer. */\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener)\n return () => this.listeners.delete(listener)\n }\n\n /** Build the current page state face. */\n state(): SettingsPageState {\n const snapshot = this.scope.getSnapshot()\n const plan = this.plan()\n const credential = this.credentialStates.get(this.credentialRef)\n const accounts = this.effectiveAccounts()\n return {\n available: snapshot.status === 'ready',\n writable: snapshot.writable,\n apiKeyConfigured: credential?.configured ?? false,\n anyAccountConfigured: (credential?.configured ?? false) || accounts.some((account) => account.configured),\n apiKeyWritable: credential?.writable ?? true,\n apiKey: {\n text: this.staged.get('apiKey')?.text ?? '',\n clear: false,\n overridden: false,\n invalid: false,\n invalidReason: undefined,\n },\n apiKeyClearStaged: this.keyClears.has(this.credentialRef),\n apiBase: this.field('apiBase'),\n workingDir: this.field('workingDir'),\n defaultWorkingDir: this.defaultWorkingDir,\n requestTimeoutMs: this.field('requestTimeoutMs'),\n streamIdleTimeoutMs: this.field('streamIdleTimeoutMs'),\n filterModelsByPlan: this.field('filterModelsByPlan'),\n webSearch: this.field('webSearch'),\n activeAccount: this.field('activeAccount'),\n accounts,\n accountsRemoving: [...this.removedRefs],\n rules: this.effectiveRules(),\n visibleModels: this.effectiveVisibleModels(),\n catalogModels: this.catalogModels,\n catalogFailed: this.catalogFailed,\n dirty: plan.length > 0 || this.accountsDirty() || this.rulesDirty() || this.visibleModelsDirty(),\n invalid: plan.some((item) => item.run === undefined),\n saving: this.saving,\n failed: this.failed,\n savedCount: this.savedCount,\n }\n }\n\n /** Stage a new extra account (saved on the next `save()`). */\n addAccount(): void {\n const used = new Set([\n this.credentialRef,\n ...this.storedExtras().map((extra) => extra.ref),\n ...this.addedAccounts.map((extra) => extra.ref),\n ])\n // New refs derive from the current credential reference's prefix (the\n // same one `this.credentialRef` names), so a renamed apiKeyEnv yields\n // `MY_KEY_2`-style refs consistent with the default slot — never a stray\n // COMMANDCODE_API_KEY_2 that no longer matches the page's reference.\n let n = 2\n while (used.has(`${this.credentialRef}_${n}`)) n += 1\n const index = this.storedExtras().length + this.addedAccounts.length + 2\n this.addedAccounts.push({ label: `Account ${index}`, ref: `${this.credentialRef}_${n}` })\n this.failed = false\n void this.describeAll()\n this.publish()\n }\n\n /** Stage one extra account's removal (or drop an unsaved addition). */\n removeAccount(id: string): void {\n const addedIndex = this.addedAccounts.findIndex((extra) => extra.ref === id)\n if (addedIndex >= 0) this.addedAccounts.splice(addedIndex, 1)\n else this.removedRefs.add(id)\n this.labelDrafts.delete(id)\n this.keyDrafts.delete(id)\n // A pinned active account that is going away must not linger as a ghost\n // selection: stage its clear alongside the removal (the host would fall\n // back to rotation order, but the stored value would be meaningless).\n const stagedActive = this.staged.get('activeAccount')\n const activeValue = stagedActive !== undefined\n ? stagedActive.clear ? '' : stagedActive.text\n : typeof this.sectionValue('activeAccount') === 'string' ? this.sectionValue('activeAccount') as string : ''\n if (activeValue === id) {\n this.staged.set('activeAccount', { text: '', clear: true })\n }\n this.failed = false\n this.publish()\n }\n\n /** Stage one extra account's label draft. */\n editAccountLabel(id: string, text: string): void {\n this.labelDrafts.set(id, text)\n this.failed = false\n this.publish()\n }\n\n /** Stage one extra account's key draft (blank keeps the stored key). */\n editAccountKey(id: string, text: string): void {\n this.keyDrafts.set(id, text)\n // Typing a replacement cancels a staged removal — the two intents are\n // mutually exclusive (replace vs remove), and a staged clear would\n // otherwise silently discard what is being typed.\n this.keyClears.delete(id)\n this.failed = false\n this.publish()\n }\n\n /**\n * Toggle the staged removal of one account's stored key: the next save\n * unsets the credential so the account reports unconfigured and falls back\n * to its other key sources. Only meaningful while a key is actually\n * stored. `target` is `'default'` (the implicit first account) or an extra\n * account's credential reference.\n */\n toggleKeyClear(target: string): void {\n const ref = target === 'default' ? this.credentialRef : target\n if (this.keyClears.has(ref)) {\n this.keyClears.delete(ref)\n } else {\n if (this.credentialStates.get(ref)?.configured !== true) return\n // A staged replacement and a staged removal are mutually exclusive.\n this.keyDrafts.delete(ref)\n if (ref === this.credentialRef) this.staged.delete('apiKey')\n this.keyClears.add(ref)\n }\n this.failed = false\n void this.describeAll()\n this.publish()\n }\n\n /** Stage a new model → account routing rule (saved on the next `save()`). */\n addRule(): void {\n this.addedRules.push({ models: [], account: 'default' })\n this.failed = false\n this.publish()\n }\n\n /** Stage one routing rule's removal (or drop an unsaved addition). */\n removeRule(id: string): void {\n const addedIndex = this.addedRules.findIndex((_, index) => `new-${index}` === id)\n if (addedIndex >= 0) this.addedRules.splice(addedIndex, 1)\n else {\n // Snapshot the row content: stored ids are positional and shift after\n // any write, so reconcile must compare content, not ids.\n const stored = this.storedRules().find((rule) => rule.id === id)\n this.removedRuleIds.set(id, stored === undefined\n ? { models: [], account: '' }\n : { models: [...stored.models], account: stored.account })\n }\n this.ruleDrafts.delete(id)\n this.failed = false\n this.publish()\n }\n\n /** Stage one routing rule's selected model ids (multi-select). */\n editRuleModels(id: string, models: string[]): void {\n const addedIndex = this.addedRules.findIndex((_, index) => `new-${index}` === id)\n if (addedIndex >= 0) {\n this.addedRules[addedIndex] = { ...this.addedRules[addedIndex]!, models }\n } else {\n const current = this.ruleDrafts.get(id) ?? this.storedRules().find((rule) => rule.id === id) ?? { models: [], account: 'default' }\n this.ruleDrafts.set(id, { ...current, models })\n }\n this.failed = false\n this.publish()\n }\n\n /** Stage one routing rule's target account draft. */\n editRuleAccount(id: string, text: string): void {\n const addedIndex = this.addedRules.findIndex((_, index) => `new-${index}` === id)\n if (addedIndex >= 0) {\n this.addedRules[addedIndex] = { ...this.addedRules[addedIndex]!, account: text }\n } else {\n const current = this.ruleDrafts.get(id) ?? this.storedRules().find((rule) => rule.id === id) ?? { models: [], account: 'default' }\n this.ruleDrafts.set(id, { ...current, account: text })\n }\n this.failed = false\n this.publish()\n }\n\n /** Stage one field's draft text. */\n edit(field: string, text: string): void {\n this.staged.set(field, { text, clear: false })\n // Typing a replacement for the default key cancels a staged removal.\n if (field === 'apiKey') this.keyClears.delete(this.credentialRef)\n this.failed = false\n this.publish()\n }\n\n /** Reset one section field to its inherited (composition) value. */\n resetField(field: string): void {\n if (field === 'apiKey') {\n this.staged.delete('apiKey')\n this.failed = false\n this.publish()\n return\n }\n const spec = this.spec(field)\n this.staged.set(field, { text: spec.format(this.baseValue(field)), clear: true })\n this.failed = false\n this.publish()\n }\n\n /** Discard every staged edit. */\n discard(): void {\n if (this.staged.size === 0 && !this.accountsStaged() && !this.rulesStaged() && !this.visibleModelsStaged() && !this.failed) return\n this.staged.clear()\n this.clearAccountStaging()\n this.clearRuleStaging()\n this.clearVisibleModelsStaging()\n this.failed = false\n this.publish()\n }\n\n /**\n * Re-read the Host's credential facts without any staged edit. The browser\n * login stores a key Host-side behind the page's back; the plugin entry\n * calls this when a login lands so the configured/writable badges follow.\n */\n refreshCredentials(): void {\n void this.describeAll()\n }\n\n /** Write every staged edit, then re-read the Host's accepted state. */\n async save(): Promise {\n const plan = this.plan()\n const accountRuns = this.accountPlan()\n const ruleRuns = this.rulesPlan()\n const visibleRuns = this.visibleModelsPlan()\n if ((plan.length === 0 && accountRuns.length === 0 && ruleRuns.length === 0 && visibleRuns.length === 0) || this.saving) return\n const runs: Array<() => Promise> = []\n for (const item of plan) {\n if (item.run === undefined) return\n runs.push(item.run)\n }\n this.saving = true\n this.failed = false\n // Snapshot the stored routing rules before any write: reconcile needs to\n // tell \"the rules write landed (rows shifted)\" from \"the save failed\n // earlier (rows untouched)\" — see reconcileRuleStaging.\n this.rulesBeforeSave = ruleFingerprint(this.storedRules())\n this.publish()\n let landed = true\n // Keys land first so a saved accounts list never names a ref whose key\n // write failed silently; the accounts list itself writes last. Stop at\n // the first failure: running later writes after a failed one would\n // persist a partial state the staged drafts no longer describe. A\n // throwing write counts as a failure too (the scope seam may reject)\n // so the surviving staging is reconciled instead of dropped.\n for (const run of [...runs, ...accountRuns, ...ruleRuns, ...visibleRuns]) {\n let ok = false\n try {\n ok = await run()\n } catch {\n ok = false\n }\n if (!ok) {\n landed = false\n break\n }\n }\n this.saving = false\n this.failed = !landed\n if (landed) {\n this.savedCount += 1\n this.staged.clear()\n this.clearAccountStaging()\n this.clearRuleStaging()\n this.clearVisibleModelsStaging()\n } else {\n // A failed save may still have landed earlier writes (e.g. the accounts\n // list made it while a key write did not). Reconcile the staging with\n // the stored section so a landed account is not simultaneously stored\n // AND staged-for-addition (which a retry would persist twice).\n this.reconcileAccountStaging()\n this.reconcileRuleStaging()\n this.reconcileVisibleModelsStaging()\n }\n this.publish()\n }\n\n /**\n * Drop account staging the stored section already reflects: additions whose\n * ref is now stored, removals whose ref is gone, and label drafts that the\n * stored label proves landed. Key drafts are kept — a landed key write is\n * idempotent on retry, and the draft carries the user's intent when it was\n * the accounts write that failed.\n */\n private reconcileAccountStaging(): void {\n const stored = new Set(this.storedExtras().map((extra) => extra.ref))\n this.addedAccounts = this.addedAccounts.filter((extra) => !stored.has(extra.ref))\n for (const ref of [...this.removedRefs]) {\n if (!stored.has(ref)) this.removedRefs.delete(ref)\n }\n for (const [ref, text] of [...this.labelDrafts]) {\n const storedLabel = this.storedExtras().find((extra) => extra.ref === ref)?.label\n // A label draft is dropped only when the stored section proves it\n // landed. An ABSENT entry is not proof: keys land before the accounts\n // list, so a failed save routinely leaves a staged addition stored\n // nowhere — treating \"not stored\" as \"already applied\" silently threw\n // away the label the user had typed and persisted the auto-generated\n // name on the retry instead.\n if (storedLabel !== undefined && storedLabel === text.trim()) this.labelDrafts.delete(ref)\n }\n // A landed clear already did its job (the Host reports unconfigured);\n // keep only clears that failed so a retry re-attempts them.\n for (const ref of [...this.keyClears]) {\n if (this.credentialStates.get(ref)?.configured !== true) this.keyClears.delete(ref)\n }\n }\n\n // -------------------------------------------------------------------------\n // Internals\n // -------------------------------------------------------------------------\n\n private spec(field: string): FieldSpec {\n const spec = this.specs.get(field)\n if (spec === undefined) throw new Error(`commandcode settings page has no field ${field}`)\n return spec\n }\n\n /** One field's rendered state: draft text, whether it is user-overridden, invalid. */\n private field(field: string): StagedField {\n const spec = this.spec(field)\n const staged = this.staged.get(field)\n if (staged === undefined) {\n return {\n text: spec.format(this.sectionValue(field)),\n clear: false,\n overridden: this.stored(field),\n invalid: false,\n invalidReason: undefined,\n }\n }\n const parsed = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)\n return {\n text: staged.text,\n clear: staged.clear,\n overridden: parsed.kind === 'set',\n invalid: parsed.kind === 'invalid',\n invalidReason: parsed.kind === 'invalid' ? parsed.reason : undefined,\n }\n }\n\n private sectionValue(field: string): unknown {\n return this.scope.getSnapshot().value?.[field]\n }\n\n private baseValue(field: string): unknown {\n const base = this.scope.getSnapshot().base\n return typeof base === 'object' && base !== null && !Array.isArray(base)\n ? (base as Record)[field]\n : undefined\n }\n\n private userLayer(): Record | undefined {\n const user = this.scope.getSnapshot().user\n return typeof user === 'object' && user !== null && !Array.isArray(user)\n ? (user as Record)\n : undefined\n }\n\n private stored(field: string): boolean {\n const user = this.userLayer()\n return user !== undefined && Object.prototype.hasOwnProperty.call(user, field)\n }\n\n /**\n * The writes a save would perform, in staged order. A field whose draft is\n * not a value its spec accepts carries no write (the save refuses).\n */\n private plan(): Array<{ field: string; run: (() => Promise) | undefined }> {\n const plan: Array<{ field: string; run: (() => Promise) | undefined }> = []\n for (const [field, staged] of this.staged) {\n if (field === 'apiKey') {\n const value = staged.text.trim()\n if (value !== '') {\n plan.push({ field, run: () => this.writeKey(value) })\n }\n continue\n }\n const spec = this.spec(field)\n if (staged.clear) {\n if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })\n continue\n }\n if (staged.text === spec.format(this.sectionValue(field))) continue\n const parsed = spec.parse(staged.text)\n if (parsed.kind === 'invalid') plan.push({ field, run: undefined })\n else if (parsed.kind === 'clear') plan.push({ field, run: () => this.clear(field) })\n else plan.push({ field, run: () => this.store(field, parsed.value) })\n }\n return plan\n }\n\n private async clear(field: string): Promise {\n await this.scope.unset(field)\n return !this.stored(field)\n }\n\n private async store(field: string, value: string | number | boolean): Promise {\n await this.scope.set(field, value)\n return this.userLayer()?.[field] === value\n }\n\n /** Write the staged default key, then re-read whether the Host holds it. */\n private async writeKey(value: string): Promise {\n return this.writeKeyTo(this.credentialRef, value)\n }\n\n /** Write one account's key, then re-read the Host's credential states. */\n private async writeKeyTo(ref: string, value: string): Promise {\n try {\n const response = await this.api.credentials.set(ref, value)\n if (!response.ok) return false\n } catch {\n return false\n }\n await this.describeAll()\n return this.credentialStates.get(ref)?.configured ?? false\n }\n\n /** Ask the credentials domain about every reference this page writes. */\n private async describeAll(): Promise {\n const refs = [\n this.credentialRef,\n ...this.storedExtras().map((extra) => extra.ref),\n ...this.addedAccounts.map((extra) => extra.ref),\n ]\n let response: Awaited>\n try {\n response = await this.api.credentials.describe(refs)\n } catch {\n return\n }\n if (!response.ok) return\n let changed = false\n for (const ref of refs) {\n const view = response.value?.[ref]\n const next = {\n configured: view?.configured ?? false,\n writable: view?.writable ?? true,\n }\n const prev = this.credentialStates.get(ref)\n if (prev === undefined || prev.configured !== next.configured || prev.writable !== next.writable) {\n this.credentialStates.set(ref, next)\n changed = true\n }\n }\n if (changed) this.publish()\n }\n\n /**\n * Fetch the model catalog for the settings page's model editors through\n * the Host Remote. Runs once at construction; call again (e.g. from the\n * client entry once the Remote mount lands) to (re)try — a later success\n * clears a prior failure flag so the editors recover without a page reload.\n */\n refreshCatalog(): void {\n const models = this.api.models\n if (models === undefined) {\n this.catalogFailed = true\n this.publish()\n return\n }\n void models().then((response) => {\n if (response.ok && Array.isArray(response.value?.models)) {\n // Defensive per-entry shaping: the Remote result is untrusted at the\n // boundary, and an older Host predates the tier field.\n const shaped: CatalogModelOption[] = []\n for (const model of response.value.models) {\n if (typeof model !== 'object' || model === null) continue\n const entry = model as unknown as Record\n if (typeof entry.id !== 'string' || typeof entry.name !== 'string') continue\n shaped.push({\n id: entry.id,\n name: entry.name,\n ...(typeof entry.tier === 'string' ? { tier: entry.tier } : {}),\n })\n }\n this.catalogModels = shaped\n this.catalogFailed = false\n } else {\n this.catalogFailed = true\n }\n }, () => {\n this.catalogFailed = true\n }).then(() => this.publish())\n }\n\n // -----------------------------------------------------------------------\n // Multi-account staging\n // -----------------------------------------------------------------------\n\n /** The raw `accounts` array of the stored section, verbatim. */\n private rawStoredAccounts(): Array> {\n const raw = this.scope.getSnapshot().value?.accounts\n if (!Array.isArray(raw)) return []\n return raw.filter(\n (entry): entry is Record =>\n typeof entry === 'object' && entry !== null && !Array.isArray(entry),\n )\n }\n\n /**\n * The stored extra accounts from the settings section (`accounts`): the rows\n * this page can address, i.e. the ones carrying a credential reference.\n *\n * Entries the page cannot name are deliberately NOT listed here but are also\n * never dropped — `writeAccounts()` rebuilds the stored list from\n * {@link rawStoredAccounts} and only rewrites the reference-carrying entries\n * it manages (see the note there), so a literal-key entry stays in the\n * document and simply has no row.\n */\n private storedExtras(): Array<{ label: string; ref: string }> {\n const out: Array<{ label: string; ref: string }> = []\n for (const record of this.rawStoredAccounts()) {\n const ref = record.apiKeyEnv\n if (typeof ref !== 'string' || ref === '') continue\n const label = record.label\n out.push({ label: typeof label === 'string' && label !== '' ? label : ref, ref })\n }\n return out\n }\n\n /** Every extra account row: stored (minus staged removals) + staged adds. */\n private effectiveAccounts(): AccountItemState[] {\n const stored = this.storedExtras()\n .filter((extra) => !this.removedRefs.has(extra.ref))\n .map((extra) => ({ ...extra, added: false }))\n const added = this.addedAccounts.map((extra) => ({ ...extra, added: true }))\n return [...stored, ...added].map((extra) => ({\n id: extra.ref,\n ref: extra.ref,\n label: this.labelDrafts.get(extra.ref) ?? extra.label,\n keyText: this.keyDrafts.get(extra.ref) ?? '',\n configured: this.credentialStates.get(extra.ref)?.configured ?? false,\n writable: this.credentialStates.get(extra.ref)?.writable ?? true,\n added: extra.added,\n clearStaged: this.keyClears.has(extra.ref),\n }))\n }\n\n /** Whether any account-level staging (add/remove/label/key/clear) exists. */\n private accountsStaged(): boolean {\n return this.addedAccounts.length > 0\n || this.removedRefs.size > 0\n || this.labelDrafts.size > 0\n || this.keyDrafts.size > 0\n || this.keyClears.size > 0\n }\n\n /** Whether the staged account edits differ from the stored section. */\n private accountsDirty(): boolean {\n if (this.addedAccounts.length > 0 || this.removedRefs.size > 0) return true\n for (const [ref, text] of this.labelDrafts) {\n const base = this.storedExtras().find((extra) => extra.ref === ref)?.label\n if (base !== undefined && text.trim() !== '' && text !== base) return true\n }\n for (const text of this.keyDrafts.values()) {\n if (text.trim() !== '') return true\n }\n // A staged clear is only meaningful while the key is actually stored —\n // staging one against an unconfigured ref is a no-op, not dirt.\n for (const ref of this.keyClears) {\n if (this.credentialStates.get(ref)?.configured === true) return true\n }\n return false\n }\n\n /** Reset every account-level staged edit. */\n private clearAccountStaging(): void {\n this.addedAccounts = []\n this.removedRefs.clear()\n this.labelDrafts.clear()\n this.keyDrafts.clear()\n this.keyClears.clear()\n }\n\n /** Unset one stored credential, then re-read the Host's credential states. */\n private async unsetKey(ref: string): Promise {\n try {\n const response = await this.api.credentials.unset(ref)\n if (!response.ok) return false\n } catch {\n return false\n }\n await this.describeAll()\n return this.credentialStates.get(ref)?.configured !== true\n }\n\n /** The account-level writes a save performs (empty when nothing staged). */\n private accountPlan(): Array<() => Promise> {\n if (!this.accountsDirty()) return []\n const runs: Array<() => Promise> = []\n // Staged removals land first: a cleared credential must be gone before\n // the accounts list write, or a removed row would leave an orphaned\n // secret behind. Removed rows keep their clear (clean removal).\n for (const ref of this.keyClears) {\n if (this.credentialStates.get(ref)?.configured === true) {\n runs.push(() => this.unsetKey(ref))\n }\n }\n for (const [ref, text] of this.keyDrafts) {\n const value = text.trim()\n if (value !== '' && !this.removedRefs.has(ref) && !this.keyClears.has(ref)) {\n runs.push(() => this.writeKeyTo(ref, value))\n }\n }\n runs.push(() => this.writeAccounts())\n return runs\n }\n\n /**\n * Persist the staged accounts list into the settings section.\n *\n * The stored list is the base — NOT a list rebuilt from this page's rows.\n * A composition-config entry may carry a literal `apiKey` (or a shape this\n * page does not know), and the settings layer replaces the whole array, so a\n * rebuilt list would silently delete every entry the page cannot name along\n * with the literal keys of the entries it can. Entries are therefore carried\n * over verbatim and only the reference-carrying rows are rewritten (label\n * draft applied, staged removals dropped, staged additions appended).\n */\n private async writeAccounts(): Promise {\n const removed = this.removedRefs\n const written = new Set()\n const list: Array> = []\n for (const entry of this.rawStoredAccounts()) {\n const ref = entry.apiKeyEnv\n // Not a row this page manages (literal-key or unknown entry): preserve\n // it exactly as stored rather than dropping it with the rewrite.\n if (typeof ref !== 'string' || ref === '') {\n list.push({ ...entry })\n continue\n }\n if (removed.has(ref) || written.has(ref)) continue\n written.add(ref)\n list.push(this.accountEntry(ref, entry))\n }\n // Defensive dedupe by ref: a partially landed earlier save can leave an\n // account both stored and staged-for-addition; never persist duplicates.\n // A staged addition whose ref is already stored is skipped too — the\n // stored entry (with any literal key or unknown field) wins.\n for (const extra of this.addedAccounts) {\n if (written.has(extra.ref)) continue\n written.add(extra.ref)\n list.push(this.accountEntry(extra.ref, extra))\n }\n await this.scope.set('accounts', list)\n // Verify against the same raw entries the write was built from: the\n // page's row view ignores reference-less entries, so comparing it to\n // `list` would report a false failure whenever one is present.\n const after = this.rawStoredAccounts()\n return after.length === list.length\n && list.every((item, index) => after[index]?.apiKeyEnv === item.apiKeyEnv)\n }\n\n /**\n * One written account entry: the stored/added facts plus the label draft.\n * `fallback` contributes the non-managed fields (a stored entry's literal\n * `apiKey`, or any future field) so a rewrite never strips them.\n */\n private accountEntry(\n ref: string,\n fallback: { label: string } | Record,\n ): Record {\n const draft = this.labelDrafts.get(ref)?.trim()\n const base = 'ref' in fallback\n ? { label: (fallback as { label: string }).label }\n : { ...(fallback as Record) }\n const storedLabel = base.label\n return {\n ...base,\n label: draft !== undefined && draft !== ''\n ? draft\n : typeof storedLabel === 'string' && storedLabel !== '' ? storedLabel : ref,\n apiKeyEnv: ref,\n }\n }\n\n // -----------------------------------------------------------------------\n // Model → account routing-rule staging\n // -----------------------------------------------------------------------\n\n /** The stored routing rules from the settings section (`modelAccountRules`). */\n private storedRules(): Array<{ id: string; models: string[]; account: string }> {\n const raw = this.scope.getSnapshot().value?.modelAccountRules\n if (!Array.isArray(raw)) return []\n const out: Array<{ id: string; models: string[]; account: string }> = []\n for (const [index, entry] of raw.entries()) {\n if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) continue\n const record = entry as Record\n const models = record.models\n const account = record.account\n const modelsList = Array.isArray(models) && models.every((m) => typeof m === 'string')\n ? (models as string[]).filter((m) => m !== '')\n : []\n if (modelsList.length === 0) continue\n out.push({\n id: `rule-${index}`,\n models: modelsList,\n account: typeof account === 'string' && account !== '' ? account : 'default',\n })\n }\n return out\n }\n\n /** Every routing rule row: stored (minus staged removals, with drafts) + staged adds. */\n private effectiveRules(): RuleItemState[] {\n const stored = this.storedRules()\n .filter((rule) => !this.removedRuleIds.has(rule.id))\n .map((rule) => {\n const draft = this.ruleDrafts.get(rule.id)\n return {\n id: rule.id,\n models: draft?.models ?? rule.models,\n account: draft?.account ?? rule.account,\n added: false,\n }\n })\n const added = this.addedRules.map((rule, index) => ({\n id: `new-${index}`,\n models: rule.models,\n account: rule.account,\n added: true,\n }))\n return [...stored, ...added]\n }\n\n /** Whether any routing-rule staging (add/remove/edit) exists. */\n private rulesStaged(): boolean {\n return this.addedRules.length > 0 || this.removedRuleIds.size > 0 || this.ruleDrafts.size > 0\n }\n\n /** Whether the staged routing rules differ from the stored section. */\n private rulesDirty(): boolean {\n if (this.addedRules.length > 0 || this.removedRuleIds.size > 0) return true\n for (const [id, draft] of this.ruleDrafts) {\n const base = this.storedRules().find((rule) => rule.id === id)\n if (base === undefined) continue\n if (draft.models.length > 0 && !sameModels(draft.models, base.models)) return true\n if (draft.account !== base.account) return true\n }\n return false\n }\n\n /** Reset every routing-rule staged edit. */\n private clearRuleStaging(): void {\n this.addedRules = []\n this.ruleDrafts.clear()\n this.removedRuleIds.clear()\n }\n\n /**\n * Drop rule staging the stored section already reflects (partial-save\n * retry). Stored row ids are positional (`rule-N`) and shift after any\n * write, so every check here is content-based, never id-based:\n * - landed additions (in `addedRules`, already in stored) are dropped, or\n * a retry would persist them twice;\n * - staged removals whose snapshot row is gone from stored have landed\n * (drop them); a removal whose snapshot still matches a stored row is\n * still pending (keep it).\n */\n private reconcileRuleStaging(): void {\n const stored = this.storedRules()\n this.addedRules = this.addedRules.filter((added) =>\n !stored.some((rule) =>\n sameModels(added.models, rule.models) && added.account === rule.account))\n for (const [id, snapshot] of [...this.removedRuleIds]) {\n const landed = !stored.some((rule) =>\n sameModels(snapshot.models, rule.models) && snapshot.account === rule.account)\n if (landed) this.removedRuleIds.delete(id)\n }\n // Drafts are keyed by positional stored ids, so they survive a failed save\n // exactly as long as the stored list did not change: writes run in order\n // and stop at the first failure, so a failure BEFORE the rules write leaves\n // the stored rows (and their ids) untouched and the drafts still describe\n // them — clearing here would silently revert the user's edit and leave\n // `dirty` false, i.e. nothing to retry. Only a save that actually landed\n // the rules write shifts every positional id, which makes a draft\n // unattributable; matching shifted rows by content would misattribute\n // edits, so those drafts are dropped and the user re-applies the edit.\n if (this.rulesBeforeSave !== undefined && ruleFingerprint(stored) === this.rulesBeforeSave) return\n this.ruleDrafts.clear()\n }\n\n /** The stored visible-model allowlist (`visibleModels`); empty = show all. */\n private storedVisibleModels(): string[] {\n const raw = this.scope.getSnapshot().value?.visibleModels\n if (!Array.isArray(raw)) return []\n return raw.filter((m): m is string => typeof m === 'string' && m !== '')\n }\n\n /** Effective visible-model allowlist: staged draft or stored value. */\n private effectiveVisibleModels(): string[] {\n return this.visibleModelsDraft ?? this.storedVisibleModels()\n }\n\n /** Whether the staged visible-model selection differs from stored. */\n private visibleModelsDirty(): boolean {\n return this.visibleModelsDraft !== undefined\n && !sameModels(this.visibleModelsDraft, this.storedVisibleModels())\n }\n\n /** Whether any visible-model staging exists. */\n private visibleModelsStaged(): boolean {\n return this.visibleModelsDraft !== undefined\n }\n\n /** Reset the visible-model staged edit. */\n private clearVisibleModelsStaging(): void {\n this.visibleModelsDraft = undefined\n }\n\n /** Drop visible-model staging the stored section already reflects. */\n private reconcileVisibleModelsStaging(): void {\n if (this.visibleModelsDraft !== undefined\n && sameModels(this.visibleModelsDraft, this.storedVisibleModels())) {\n this.visibleModelsDraft = undefined\n }\n }\n\n /** The visible-model writes a save performs (empty when nothing staged). */\n private visibleModelsPlan(): Array<() => Promise> {\n if (!this.visibleModelsDirty()) return []\n return [() => this.writeVisibleModels()]\n }\n\n /** Persist the staged visible-model allowlist into the settings section. */\n private async writeVisibleModels(): Promise {\n const list = this.visibleModelsDraft ?? []\n await this.scope.set('visibleModels', list)\n return sameModels(this.storedVisibleModels(), list)\n }\n\n /** Stage the visible-model allowlist (multi-select). */\n editVisibleModels(models: string[]): void {\n this.visibleModelsDraft = [...models]\n this.failed = false\n this.publish()\n }\n\n /** Stage \"show all models\" (clears the allowlist). */\n clearVisibleModels(): void {\n this.visibleModelsDraft = []\n this.failed = false\n this.publish()\n }\n\n /** The routing-rule writes a save performs (empty when nothing staged). */\n private rulesPlan(): Array<() => Promise> {\n if (!this.rulesDirty()) return []\n return [() => this.writeRules()]\n }\n\n /** Persist the staged routing rules into the settings section. */\n private async writeRules(): Promise {\n const base = this.storedRules().filter((rule) => {\n const snapshot = this.removedRuleIds.get(rule.id)\n // Content-based removal: positional ids shift after any write, so a\n // staged removal only filters the row it snapshotted.\n return snapshot === undefined\n || !sameModels(snapshot.models, rule.models)\n || snapshot.account !== rule.account\n })\n const list = [\n ...base.map((rule) => {\n const draft = this.ruleDrafts.get(rule.id)\n return {\n models: draft !== undefined && draft.models.length > 0 ? draft.models : rule.models,\n account: draft?.account !== undefined && draft.account !== '' ? draft.account : rule.account,\n }\n }),\n ...this.addedRules.map((rule) => ({\n models: rule.models,\n account: rule.account,\n })),\n ].filter((rule) => rule.models.length > 0)\n // Defensive dedupe by content: a partially landed earlier save can leave\n // a rule both stored and staged-for-addition; never persist duplicates.\n const seen = new Set()\n const deduped = list.filter((rule) => {\n const key = `${rule.account}\\u0001${[...rule.models].sort().join('\\u0001')}`\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n await this.scope.set('modelAccountRules', deduped)\n const after = this.storedRules()\n return after.length === deduped.length\n && deduped.every((item, index) =>\n after[index] !== undefined\n && sameModels(after[index].models, item.models)\n && after[index].account === item.account)\n }\n\n private publish(): void {\n if (this.disposed) return\n for (const listener of this.listeners) listener()\n }\n}\n","/** Legacy ApiProxy credentials adapter for pre-0.1.2 DSH clients. */\n\nimport type { SettingsPageApi } from './settings.ts'\n\ninterface LegacyFailure {\n message: string\n}\n\ninterface LegacyResponse {\n result:\n | { ok: true; value: T }\n | { ok: false; error: LegacyFailure }\n}\n\n/** The pre-0.1.2 `connection.api.credentials` face. */\nexport interface LegacyCredentialsApi {\n describe(request: { refs: string[] }): Promise\n }>>\n set(request: { ref: string; value: string }): Promise>\n unset(request: { ref: string }): Promise>\n}\n\n/** Convert a legacy credentials ApiProxy into the current settings-page face. */\nexport function adaptLegacyCredentials(\n legacy: LegacyCredentialsApi | undefined,\n): SettingsPageApi | undefined {\n if (legacy === undefined) return undefined\n return {\n credentials: {\n describe: async (refs) => {\n const response = await legacy.describe({ refs })\n return response.result.ok\n ? { ok: true, value: response.result.value.credentials }\n : { ok: false, error: response.result.error }\n },\n set: async (ref, value) => {\n const response = await legacy.set({ ref, value })\n return response.result.ok\n ? { ok: true, value: undefined }\n : { ok: false, error: response.result.error }\n },\n unset: async (ref) => {\n const response = await legacy.unset({ ref })\n return response.result.ok\n ? { ok: true, value: undefined }\n : { ok: false, error: response.result.error }\n },\n },\n }\n}\n","/**\n * Browser controller for the settings page's account-usage card.\n *\n * The card renders the same account/usage/credit facts the `/commandcode`\n * command prints, fetched Host-side through the `commandcode/report` Remote\n * (the browser never holds the API key). This controller owns the fetch\n * lifecycle — idle/loading/ready/error, one in-flight request at a time,\n * stale-response dropping — and the display formatting, so the React\n * component stays a thin renderer and node tests can drive everything.\n *\n * Deliberately JSX-free, mirroring `./settings.ts`.\n *\n * @module dsh-commandcode-provider/client/usage\n */\n\nimport type { CommandCodeAccountsReport, CommandCodeCatalog } from '../usage-wire.ts'\nimport type { CommandCodeLoginStatus } from '../login-wire.ts'\nimport type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'\n\n/**\n * Merge the plugin's Remote endpoints into the harness's typed client Remote\n * surface (the same declaration pattern the harness's generated\n * typert.remote-client files use), so `ctx.remote.commandcode.*()` is typed\n * once each contribution is mounted. The `commandcode` namespace member is\n * declared exactly once (interface merging forbids duplicate members), so\n * this one declaration carries the usage report, the model catalog, AND the\n * login endpoints — the endpoint-level declarations live beside their\n * controllers.\n */\ndeclare module '@deepseek-ai/dsh-typert-protocol' {\n interface TypertRemoteMap {\n 'commandcode/report': () => Promise>\n 'commandcode/models': () => Promise>\n }\n interface TypertRemoteNamespaceMap {\n commandcode: {\n report: () => Promise>\n models: () => Promise>\n loginBegin: () => Promise>\n loginStatus: () => Promise>\n loginCancel: () => Promise>\n }\n }\n}\n\n/** The narrow slice of the mounted Remote this controller calls. */\nexport interface UsageRemote {\n report(): Promise<\n | { ok: true; value: CommandCodeAccountsReport }\n | { ok: false; error: { message: string } }\n >\n models(): Promise<\n | { ok: true; value: CommandCodeCatalog }\n | { ok: false; error: { message: string } }\n >\n}\n\n/** The card's fetch lifecycle. */\nexport type UsageStatus =\n /** Never fetched (no API key configured yet, or not requested). */\n | 'idle'\n /** A fetch is in flight; `report` retains the last good data if any. */\n | 'loading'\n /** The last fetch succeeded. */\n | 'ready'\n /** The last fetch failed (no key, unreachable host, old plugin). */\n | 'error'\n\n/** The card's full state face. */\nexport interface UsagePageState {\n status: UsageStatus\n /** The last successfully fetched report (retained across refetches). */\n report: CommandCodeAccountsReport | undefined\n /** The last failure's message (error status). */\n error: string | undefined\n /** Millis timestamp of the last successful fetch. */\n fetchedAt: number | undefined\n}\n\nconst IDLE: UsagePageState = { status: 'idle', report: undefined, error: undefined, fetchedAt: undefined }\n\n/**\n * Controller bridging the `commandcode/report` Remote onto the card. Public\n * API mirrors {@link CommandCodeSettingsController}: `state()` projections,\n * `subscribe`, and one `refresh()` action.\n */\nexport class CommandCodeUsageController {\n private readonly remote: UsageRemote\n private readonly listeners = new Set<() => void>()\n private current: UsagePageState = IDLE\n private generation = 0\n private inFlight = false\n private disposed = false\n\n constructor(remote: UsageRemote) {\n this.remote = remote\n }\n\n /** Release every subscription. Idempotent; in-flight results are dropped. */\n dispose(): void {\n this.disposed = true\n this.generation += 1\n this.listeners.clear()\n }\n\n /** Subscribe to state projections. @returns the disposer. */\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener)\n return () => this.listeners.delete(listener)\n }\n\n /** The current card state face. */\n state(): UsagePageState {\n return this.current\n }\n\n /**\n * Fetch (or refetch) the report. Concurrent refreshes collapse onto one\n * request; a superseded fetch's late result is dropped, never published.\n */\n async refresh(): Promise {\n if (this.disposed || this.inFlight) return\n const generation = ++this.generation\n this.inFlight = true\n this.current = { ...this.current, status: 'loading', error: undefined }\n this.publish()\n try {\n const response = await this.remote.report()\n if (this.disposed || generation !== this.generation) return\n if (response.ok) {\n this.current = { status: 'ready', report: response.value, error: undefined, fetchedAt: Date.now() }\n } else {\n this.current = { ...this.current, status: 'error', error: response.error.message }\n }\n } catch (error: unknown) {\n if (this.disposed || generation !== this.generation) return\n this.current = {\n ...this.current,\n status: 'error',\n error: error instanceof Error ? error.message : String(error),\n }\n } finally {\n if (generation === this.generation) this.inFlight = false\n }\n this.publish()\n }\n\n private publish(): void {\n if (this.disposed) return\n for (const listener of this.listeners) listener()\n }\n}\n\n// ---------------------------------------------------------------------------\n// Display formatting (shared by the component, covered by node tests)\n// ---------------------------------------------------------------------------\n\n/** Format a dollar amount compactly (2 decimals). */\nexport function formatMoney(value: number): string {\n return `$${value.toFixed(2)}`\n}\n\n/** Format a dollar amount precisely (4 decimals) for small totals. */\nexport function formatMoneyExact(value: number): string {\n return `$${value.toFixed(4)}`\n}\n\n/** Format a large token count compactly (1.9M style). */\nexport function formatTokensCompact(value: number): string {\n if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`\n if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`\n if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`\n return String(value)\n}\n\n/**\n * Format a success-rate percentage (already in percent units, e.g. 99.96):\n * at most two decimals, trailing zeros trimmed — `100` stays `100`, not\n * `100.00`, and the raw upstream float `99.965552876334` becomes `99.97`.\n * The `%` suffix is appended by the caller (the card and the dashboard line\n * both compose it).\n */\nexport function formatSuccessRate(value: number): string {\n return String(Number(value.toFixed(2)))\n}\n\n/** One window's fill ratio in [0, 1]; 0 when uncapped. */\nexport function windowRatio(used: number, cap: number): number {\n if (cap <= 0) return 0\n return Math.max(0, Math.min(1, used / cap))\n}\n\n/** Format a millis timestamp as a local short date-time; empty when unset. */\nexport function formatResetAt(ms: number): string {\n if (ms <= 0) return ''\n return new Date(ms).toLocaleString()\n}\n","/**\n * Browser controller for the settings page's login panel.\n *\n * The panel drives the Host-half browser login (the official\n * `command-code login` loopback dance) through the Typert Gateway: `begin`\n * asks the Host to bind the callback server and returns the Studio URL, the\n * controller polls `loginStatus` once a second while the attempt is live, and\n * `cancel` tears it down. The key itself never crosses to the browser — the\n * Host validates and stores it through the credentials seam.\n *\n * Transport-level failures (no mounted Remote, an older Host without the\n * login endpoints) land in the dedicated `unavailable` phase so the page can\n * point back at manual paste. Deliberately JSX-free, mirroring\n * `./settings.ts` and `./usage.ts`.\n *\n * @module dsh-commandcode-provider/client/login\n */\n\nimport type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'\nimport type { CommandCodeLoginFailureReason, CommandCodeLoginStatus } from '../login-wire.ts'\nimport type { Translate } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SettingsCommandCodeKey } from './locales.ts'\n\n/** The endpoint-level Remote surface this controller calls. */\ndeclare module '@deepseek-ai/dsh-typert-protocol' {\n interface TypertRemoteMap {\n 'commandcode/loginBegin': () => Promise>\n 'commandcode/loginStatus': () => Promise>\n 'commandcode/loginCancel': () => Promise>\n }\n}\n\n/** The narrow slice of the mounted Remote this controller calls. */\nexport interface LoginRemote {\n loginBegin(): Promise\n loginStatus(): Promise\n loginCancel(): Promise\n}\n\n/** One Remote call's outcome as the controller sees it. */\nexport type LoginCallResult =\n | { ok: true; value: CommandCodeLoginStatus }\n | { ok: false; error: { message: string } }\n\n/** The panel's full state face. */\nexport interface LoginPageState {\n /**\n * `idle` — nothing started; `starting` — begin() in flight; `waiting` —\n * the Studio URL is live and polling; `success`/`failed` — terminal;\n * `unavailable` — the Remote itself could not be reached (old Host, mount\n * failure), manual paste is the way.\n */\n phase: 'idle' | 'starting' | 'waiting' | 'success' | 'failed' | 'unavailable'\n /** The Studio authorization URL while `waiting`. */\n authUrl: string | undefined\n /** The account display name on `success`. */\n userName: string | undefined\n /** The key's Studio label on `success`. */\n keyName: string | undefined\n /** The stable failure reason when `failed`. */\n reason: CommandCodeLoginFailureReason | undefined\n /** Secondary failure detail when `failed`/`unavailable`. */\n message: string | undefined\n}\n\n/** How often a live attempt is polled. */\nconst POLL_INTERVAL_MS = 1_000\n\n/**\n * The login panel's fetch/poll lifecycle. One poll loop at a time; a fresh\n * `begin()` supersedes any previous loop via a generation token.\n */\nexport class CommandCodeLoginController {\n private readonly remote: () => LoginRemote | undefined\n private readonly listeners = new Set<() => void>()\n private readonly pollMs: number\n /** Monotonic token; only the latest loop may publish polling results. */\n private generation = 0\n private disposed = false\n\n private phase: LoginPageState['phase'] = 'idle'\n private authUrl: string | undefined\n private userName: string | undefined\n private keyName: string | undefined\n private reason: CommandCodeLoginFailureReason | undefined\n private message: string | undefined\n\n constructor(remote: () => LoginRemote | undefined, pollMs = POLL_INTERVAL_MS) {\n this.remote = remote\n this.pollMs = pollMs\n }\n\n /** Subscribe to state projections. @returns the disposer. */\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener)\n return () => this.listeners.delete(listener)\n }\n\n /** Build the current panel state face. */\n state(): LoginPageState {\n return {\n phase: this.phase,\n authUrl: this.authUrl,\n userName: this.userName,\n keyName: this.keyName,\n reason: this.reason,\n message: this.message,\n }\n }\n\n /** Start (or rejoin) a login attempt and begin polling its status. */\n async begin(): Promise {\n if (this.disposed || this.phase === 'starting' || this.phase === 'waiting') return\n const generation = ++this.generation\n this.set({ phase: 'starting', authUrl: undefined, userName: undefined, keyName: undefined, reason: undefined, message: undefined })\n const remote = this.remote()\n if (remote === undefined) {\n this.set({ phase: 'unavailable', authUrl: undefined, userName: undefined, keyName: undefined, reason: undefined, message: 'login remote is not mounted' })\n return\n }\n let result: LoginCallResult\n try {\n result = await remote.loginBegin()\n } catch (error: unknown) {\n // A transport throw (gateway hiccup) reads the same as a rejected call.\n result = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } }\n }\n if (this.superseded(generation)) return\n if (!result.ok) {\n this.set({ phase: 'unavailable', authUrl: undefined, userName: undefined, keyName: undefined, reason: undefined, message: result.error.message })\n return\n }\n this.apply(result.value)\n if (this.currentPhase === 'waiting') void this.poll(generation)\n }\n\n /** Cancel a waiting attempt. */\n async cancel(): Promise {\n if (this.disposed || (this.phase !== 'starting' && this.phase !== 'waiting')) return\n const generation = ++this.generation\n const remote = this.remote()\n if (remote === undefined) return\n let result: LoginCallResult\n try {\n result = await remote.loginCancel()\n } catch {\n // The Host may be gone; reflect the local cancel regardless.\n this.set({ phase: 'failed', authUrl: undefined, userName: undefined, keyName: undefined, reason: 'cancelled', message: undefined })\n return\n }\n if (this.superseded(generation)) return\n if (result.ok) this.apply(result.value)\n else this.set({ phase: 'failed', authUrl: undefined, userName: undefined, keyName: undefined, reason: 'cancelled', message: undefined })\n }\n\n /** Stop polling and release listeners. Idempotent. */\n dispose(): void {\n if (this.disposed) return\n this.disposed = true\n this.generation += 1\n this.listeners.clear()\n }\n\n // -----------------------------------------------------------------------\n // Internals\n // -----------------------------------------------------------------------\n\n /** Poll until the attempt leaves `waiting` or a newer loop supersedes us. */\n private async poll(generation: number): Promise {\n // A getter read (not the field) so control-flow narrowing across `await`\n // cannot claim the phase is frozen.\n while (!this.disposed && !this.superseded(generation) && this.currentPhase === 'waiting') {\n await sleep(this.pollMs)\n if (this.disposed || this.superseded(generation) || this.currentPhase !== 'waiting') return\n const remote = this.remote()\n if (remote === undefined) {\n this.set({ phase: 'unavailable', authUrl: undefined, userName: undefined, keyName: undefined, reason: undefined, message: 'login remote is not mounted' })\n return\n }\n let result: LoginCallResult\n try {\n result = await remote.loginStatus()\n } catch {\n continue // one missed poll is not an outage; the next tick retries\n }\n if (this.superseded(generation) || this.currentPhase !== 'waiting') return\n if (result.ok) this.apply(result.value)\n }\n }\n\n private get currentPhase(): LoginPageState['phase'] {\n return this.phase\n }\n\n /** Project one Host status onto the panel face. */\n private apply(status: CommandCodeLoginStatus): void {\n const base = { authUrl: undefined, userName: undefined, keyName: undefined, reason: undefined, message: undefined }\n if (status.state === 'waiting') {\n this.set({ ...base, phase: 'waiting', authUrl: status.authUrl })\n return\n }\n if (status.state === 'success') {\n this.set({ ...base, phase: 'success', userName: status.userName, keyName: status.keyName })\n return\n }\n if (status.state === 'failed') {\n this.set({ ...base, phase: 'failed', reason: status.reason, message: status.message })\n return\n }\n // `idle` mid-poll means the Host restarted; the attempt is gone.\n this.set({ ...base, phase: 'failed', reason: 'cancelled', message: 'the login attempt is no longer active' })\n }\n\n /** Replace the whole state face and notify. Explicit over partial patches. */\n private set(state: LoginPageState): void {\n this.phase = state.phase\n this.authUrl = state.authUrl\n this.userName = state.userName\n this.keyName = state.keyName\n this.reason = state.reason\n this.message = state.message\n this.publish()\n }\n\n private superseded(generation: number): boolean {\n return this.disposed || generation !== this.generation\n }\n\n private publish(): void {\n if (this.disposed) return\n for (const listener of [...this.listeners]) listener()\n }\n}\n\nfunction sleep(ms: number): Promise {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n// ---------------------------------------------------------------------------\n// Hint copy for the login panel / card row (JSX-free so node tests drive it)\n// ---------------------------------------------------------------------------\n\n/**\n * The per-reason copy for a failed login attempt. Shared by the settings\n * page's login panel and the Models-page card's login row — the same reasons\n * can surface from either surface.\n */\nexport function loginFailureCopy(\n reason: CommandCodeLoginFailureReason | undefined,\n t: Translate,\n): string {\n if (reason === 'denied') return t('loginDenied')\n if (reason === 'timeout') return t('loginTimeout')\n if (reason === 'invalid-key') return t('loginInvalidKey')\n if (reason === 'network') return t('loginNetwork')\n if (reason === 'unavailable') return t('loginStoreFailed')\n if (reason === 'cancelled') return t('loginCancelled')\n return t('loginFailedGeneric')\n}\n\n/** One login panel row's computed hint: text, class stem, and optional title. */\nexport interface LoginHint {\n text: string\n className: string\n /** Tooltip shown when the row carries secondary failure detail. */\n title: string | undefined\n}\n\n/**\n * The hint text + class for one login panel state, shared by the settings\n * page's `LoginPanel` and the Models-page card's login row so both surfaces\n * can never drift apart. Pure: no timers, no state — the components render it.\n */\nexport function loginHint(\n state: LoginPageState,\n t: Translate,\n): LoginHint {\n if (state.phase === 'starting' || state.phase === 'waiting') {\n return {\n text: t(state.phase === 'starting' ? 'loginStarting' : 'loginWaiting'),\n className: 'cc-hint',\n title: undefined,\n }\n }\n if (state.phase === 'success') {\n const keyName = state.keyName !== undefined && state.keyName !== '' ? ` · ${state.keyName}` : ''\n return {\n text: `${t('loginSuccess')} ${state.userName ?? ''}${keyName}`.trim(),\n className: 'cc-loginDone',\n title: undefined,\n }\n }\n if (state.phase === 'failed') {\n return {\n text: loginFailureCopy(state.reason, t),\n className: 'cc-loginError',\n title: state.message,\n }\n }\n if (state.phase === 'unavailable') {\n return {\n text: `${t('loginUnavailable')} ${state.message ?? ''}`.trim(),\n className: 'cc-loginError',\n title: undefined,\n }\n }\n return { text: t('loginHintIdle'), className: 'cc-hint', title: undefined }\n}\n","/**\n * Shared boundary-validation and Remote-descriptor plumbing for the plugin's\n * hand-rolled Typert wire contracts (`commandcode/report`, `commandcode/models`,\n * `commandcode/login*`).\n *\n * Every Remote this plugin serves crosses the Typert Gateway with a strict\n * result schema: the Host half registers a descriptor against a Cordis service\n * (`src/usage-remote.ts`) and the browser half mounts the matching contribution\n * on `ctx.remote` (`src/client/index.ts`). The wire contract is deliberately\n * dependency-free so the client bundle can inline it — a pure-TS helper module\n * imported by the wire files keeps it that way while removing the schema\n * helpers and descriptor boilerplate they used to duplicate.\n *\n * @module dsh-commandcode-provider/wire-shared\n */\n\nimport type { InvocationDescriptor, TypertSchema } from '@deepseek-ai/dsh-typert-protocol'\n\n/** The npm package identity every contribution and descriptor claims. */\nexport const REMOTE_PACKAGE = '@mars-sea/dsh-commandcode-provider'\n\n/** The Cordis service key the Gateway resolves every Command Code Remote from. */\nexport const REMOTE_SERVICE = 'commandcodeUsage'\n\n/** The wire namespace all Command Code endpoints share. */\nexport const REMOTE_NAMESPACE = 'commandcode'\n\n/** The read/validate helpers one boundary codec needs. */\nexport interface BoundaryValidator {\n /** Reject one boundary value with a field-naming error. */\n reject(field: string): never\n /** Narrow an unknown value to a plain record, or reject. */\n record(value: unknown, field: string): Record\n /** Read one required string field (`field` is the dotted error label). */\n stringField(source: Record, key: string, field: string): string\n /** Read one required finite number field (`field` is the dotted error label). */\n numberField(source: Record, key: string, field: string): number\n /** Read one required boolean field (`field` is the dotted error label). */\n booleanField(source: Record, key: string, field: string): boolean\n}\n\n/**\n * Build the validator helpers one endpoint uses. `prefix` names the\n * endpoint in the rejection message (e.g. `commandcode/report result:`), so\n * each wire file keeps its own diagnostic phrasing while sharing the helper\n * bodies.\n *\n * The helpers return the reject call directly in the failure branch: since\n * `reject` is typed `never`, the ternary's union collapses to the success type\n * without relying on TypeScript's control-flow analysis of a never-returning\n * call (which only recognizes function declarations, not the destructured\n * arrow `reject` callers receive from this factory).\n */\nexport function makeBoundaryValidator(prefix: string): BoundaryValidator {\n const reject = (field: string): never => {\n throw new TypeError(`${prefix} invalid ${field}`)\n }\n const record = (value: unknown, field: string): Record =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n ? value as Record\n : reject(field)\n const stringField = (source: Record, key: string, field: string): string =>\n typeof source[key] === 'string' ? source[key] as string : reject(field)\n const numberField = (source: Record, key: string, field: string): number =>\n typeof source[key] === 'number' && Number.isFinite(source[key] as number)\n ? source[key] as number\n : reject(field)\n const booleanField = (source: Record, key: string, field: string): boolean =>\n typeof source[key] === 'boolean' ? source[key] as boolean : reject(field)\n return { reject, record, stringField, numberField, booleanField }\n}\n\n/**\n * Build one strict invocation descriptor. Every Command Code Remote shares the\n * `commandcode` namespace, the `commandcodeUsage` service, and a strict\n * `mode: 'strict'` result — only the endpoint, method, result type symbol, and\n * schema differ — so the boilerplate lives here once and each endpoint supplies\n * only its own facts.\n */\nexport function makeRemoteDescriptor(\n endpoint: string,\n method: string,\n typeSymbol: string,\n schema: TypertSchema,\n): InvocationDescriptor {\n return {\n id: `${REMOTE_PACKAGE}#${endpoint}`,\n service: REMOTE_SERVICE,\n namespace: REMOTE_NAMESPACE,\n method,\n invocation: { kind: 'direct' },\n parameters: [],\n result: {\n mode: 'strict',\n typeSymbol,\n schema,\n },\n }\n}","/**\n * Wire contract for the Command Code account-usage Remote\n * (`commandcode/report`).\n *\n * The settings page renders the same account/usage/credit facts the\n * `/commandcode` command prints, but the browser never holds the API key —\n * the report must be produced Host-side and cross the Connection RPC carrier.\n * The harness exposes plugin-defined Host methods through the Typert Gateway:\n * the Host half registers a strict invocation descriptor against a Cordis\n * service (`src/usage-remote.ts`), and the browser half mounts the matching\n * Remote contribution on `ctx.remote` (`src/client/index.ts`).\n *\n * This module is the single source both halves share: the result validator\n * (a hand-rolled {@link TypertSchema}, so neither half needs a schema library)\n * and the exact descriptor object, so the endpoint can never drift apart.\n * It is deliberately dependency-free — the client bundle inlines it, and only\n * `import type` edges leave it (erased at build). The boundary-validator\n * helpers and the descriptor boilerplate live in `./wire-shared.ts`, shared\n * with the login wire contract.\n *\n * @module dsh-commandcode-provider/usage-wire\n */\n\nimport type { CommandCodeUsageReport, UsageBlockReason } from './adapter.ts'\n\nexport type { CommandCodeUsageReport, UsageBlockReason }\nimport type { InvocationDescriptor, TypertRemoteContribution, TypertSchema } from '@deepseek-ai/dsh-typert-protocol'\nimport {\n makeBoundaryValidator,\n makeRemoteDescriptor,\n REMOTE_PACKAGE,\n} from './wire-shared.ts'\n\n/** One account's usage entry in the multi-account report. */\nexport interface CommandCodeAccountUsage {\n /** Stable slot id (`default`, `account-2`, …). */\n id: string\n /** Display label (user-provided or generated). */\n label: string\n /** Whether an API key resolved for this account. */\n configured: boolean\n /** Whether this account currently serves requests (first usable slot). */\n active: boolean\n /** Rotation mark: `''` (usable), `'rate-limit'`, or `'invalid-credential'`. */\n mark: string\n /** Known cooldown end in millis; 0 when unknown or not cooling down. */\n cooldownUntil: number\n /** The per-account report; `failures`-only when the fetch itself failed. */\n report: CommandCodeUsageReport\n}\n\n/** The settings page's account card data: one entry per configured account. */\nexport interface CommandCodeAccountsReport {\n accounts: CommandCodeAccountUsage[]\n}\n\n/** The npm package identity both contribution registrations claim. */\nexport const USAGE_REMOTE_PACKAGE = REMOTE_PACKAGE\n\n/** Canonical `/` endpoint of the usage report Remote. */\nexport const USAGE_REPORT_ENDPOINT = 'commandcode/report'\n\n/**\n * The shared read/validate helpers for the usage report endpoint, prefixed\n * so rejection messages name the offending boundary.\n */\nconst { reject, record, stringField, numberField, booleanField } =\n makeBoundaryValidator('commandcode/report result:')\n\n/** Validate one window-limit block (`fiveHour` / `weekly`). */\nfunction windowLimit(value: unknown, field: string): { used: number; cap: number; exceeded: boolean; resetAt: number } {\n const source = record(value, field)\n return {\n used: numberField(source, 'used', `${field}.used`),\n cap: numberField(source, 'cap', `${field}.cap`),\n exceeded: booleanField(source, 'exceeded', `${field}.exceeded`),\n resetAt: numberField(source, 'resetAt', `${field}.resetAt`),\n }\n}\n\n/**\n * Parse one untrusted boundary value into a {@link CommandCodeUsageReport}.\n * Optional sections stay optional; every present field is shape-checked so a\n * malformed frame fails the boundary instead of rendering garbage.\n */\nfunction parseUsageReport(value: unknown): CommandCodeUsageReport {\n const source = record(value, 'report')\n const failures = source.failures\n if (!Array.isArray(failures) || failures.some((entry) => typeof entry !== 'string')) reject('failures')\n const report: CommandCodeUsageReport = { failures: failures as string[] }\n\n if (source.blocked !== undefined) {\n const blocked = source.blocked\n // Positive check: TS's never-return control-flow analysis only recognizes\n // function declarations, not the factory's destructured-arrow `reject`, so\n // narrow `blocked` in the positive branch instead.\n if (blocked === 'invalid-key' || blocked === 'service-unavailable' || blocked === 'network') {\n report.blocked = blocked\n } else {\n reject('blocked')\n }\n }\n\n if (source.account !== undefined) {\n const account = record(source.account, 'account')\n report.account = {\n id: stringField(account, 'id', 'account.id'),\n name: stringField(account, 'name', 'account.name'),\n userName: stringField(account, 'userName', 'account.userName'),\n }\n }\n\n if (source.usage !== undefined) {\n const usage = record(source.usage, 'usage')\n report.usage = {\n totalCount: numberField(usage, 'totalCount', 'usage.totalCount'),\n totalCost: numberField(usage, 'totalCost', 'usage.totalCost'),\n successRate: numberField(usage, 'successRate', 'usage.successRate'),\n completedCount: numberField(usage, 'completedCount', 'usage.completedCount'),\n failedCount: numberField(usage, 'failedCount', 'usage.failedCount'),\n totalTokensIn: numberField(usage, 'totalTokensIn', 'usage.totalTokensIn'),\n totalTokensOut: numberField(usage, 'totalTokensOut', 'usage.totalTokensOut'),\n totalCredits: numberField(usage, 'totalCredits', 'usage.totalCredits'),\n periodBasis: stringField(usage, 'periodBasis', 'usage.periodBasis'),\n }\n }\n\n if (source.credits !== undefined) {\n const credits = record(source.credits, 'credits')\n report.credits = {\n monthlyCredits: numberField(credits, 'monthlyCredits', 'credits.monthlyCredits'),\n purchasedCredits: numberField(credits, 'purchasedCredits', 'credits.purchasedCredits'),\n freeCredits: numberField(credits, 'freeCredits', 'credits.freeCredits'),\n fiveHour: windowLimit(credits.fiveHour, 'credits.fiveHour'),\n weekly: windowLimit(credits.weekly, 'credits.weekly'),\n }\n }\n\n if (source.plan !== undefined) {\n const plan = record(source.plan, 'plan')\n const monthly = plan.monthlyCredits\n if (monthly !== null && (typeof monthly !== 'number' || !Number.isFinite(monthly))) reject('plan.monthlyCredits')\n report.plan = {\n planId: stringField(plan, 'planId', 'plan.planId'),\n name: stringField(plan, 'name', 'plan.name'),\n status: stringField(plan, 'status', 'plan.status'),\n monthlyCredits: monthly as number | null,\n currentPeriodEnd: numberField(plan, 'currentPeriodEnd', 'plan.currentPeriodEnd'),\n }\n }\n\n return report\n}\n\n/** Parse one untrusted boundary value into a {@link CommandCodeAccountUsage}. */\nfunction parseAccountUsage(value: unknown): CommandCodeAccountUsage {\n const source = record(value, 'account')\n return {\n id: stringField(source, 'id', 'account.id'),\n label: stringField(source, 'label', 'account.label'),\n configured: booleanField(source, 'configured', 'account.configured'),\n active: booleanField(source, 'active', 'account.active'),\n mark: stringField(source, 'mark', 'account.mark'),\n cooldownUntil: numberField(source, 'cooldownUntil', 'account.cooldownUntil'),\n report: parseUsageReport(source.report),\n }\n}\n\n/** Parse the wire result into a {@link CommandCodeAccountsReport}. */\nfunction parseAccountsReport(value: unknown): CommandCodeAccountsReport {\n const source = record(value, 'result')\n const accounts = source.accounts\n if (Array.isArray(accounts)) {\n return { accounts: accounts.map(parseAccountUsage) }\n }\n return reject('accounts')\n}\n\n/**\n * The strict result codec both halves attach to the descriptor. Hand-rolled:\n * the client bundle may not require a schema library, and `TypertSchema` is\n * deliberately minimal so one `parse` function satisfies it.\n */\nexport const usageReportSchema: TypertSchema = {\n parse: parseAccountsReport,\n}\n\n/**\n * The one invocation descriptor, shared verbatim by the Host registration and\n * the Client mount. `service` names the Cordis key the Gateway resolves the\n * receiver from; `namespace`/`method` name the wire endpoint.\n */\nexport const USAGE_REPORT_DESCRIPTOR: InvocationDescriptor =\n makeRemoteDescriptor(\n USAGE_REPORT_ENDPOINT,\n 'report',\n `${USAGE_REMOTE_PACKAGE}#CommandCodeAccountsReport`,\n usageReportSchema,\n )\n\n/** The Host-face contribution registered on `ctx.typert`. */\nexport const USAGE_HOST_CONTRIBUTION = {\n package: USAGE_REMOTE_PACKAGE,\n face: 'host' as const,\n schemas: [],\n // 0.1.2's Typert registry requires every Host contribution to carry its\n // reflection model. This hand-written Remote deliberately has no generated\n // reflection exports, so use the official empty-model form rather than a\n // cast that leaves registry inspection with `model: undefined`.\n model: { services: [], events: [], objects: [] },\n invocations: [USAGE_REPORT_DESCRIPTOR],\n}\n\n/** The Client-face contribution mounted on `ctx.remote`. */\nexport const USAGE_REMOTE_CONTRIBUTION: TypertRemoteContribution = {\n package: USAGE_REMOTE_PACKAGE,\n descriptors: [USAGE_REPORT_DESCRIPTOR],\n}\n\n// ---------------------------------------------------------------------------\n// Model catalog Remote (`commandcode/models`)\n// ---------------------------------------------------------------------------\n\n/** One catalog entry the settings page's model editors offer. */\nexport interface CommandCodeCatalogModel {\n /** Catalog model id (e.g. `deepseek/deepseek-v4-pro`). */\n id: string\n /** Display name from the catalog. */\n name: string\n /**\n * Minimum plan-tier key for this model (a `KNOWN_PLANS` value: `go`,\n * `goat`, `pro`, `provider`, `max`), or undefined for models outside the\n * snapshot. The settings page groups the model-editor dropdowns under\n * tier headings from this — the browser cannot import the Host's\n * capability snapshot, so the Host stamps it per entry.\n */\n tier?: string\n}\n\n/** The model-catalog Remote result: the full catalog, sorted for picking. */\nexport interface CommandCodeCatalog {\n models: CommandCodeCatalogModel[]\n}\n\n/** Canonical `/` endpoint of the model-catalog Remote. */\nexport const MODELS_ENDPOINT = 'commandcode/models'\n\n/**\n * The shared read/validate helpers for the model-catalog endpoint — a\n * separate instance so catalog boundary errors name `commandcode/models`,\n * not the report endpoint.\n */\nconst {\n record: catalogRecord,\n stringField: catalogString,\n} = makeBoundaryValidator('commandcode/models result:')\n\n/** Parse one untrusted boundary value into a {@link CommandCodeCatalogModel}. */\nfunction parseCatalogModel(value: unknown): CommandCodeCatalogModel {\n const source = catalogRecord(value, 'model')\n const model: CommandCodeCatalogModel = {\n id: catalogString(source, 'id', 'model.id'),\n name: catalogString(source, 'name', 'model.name'),\n }\n // Tier is optional on the wire (older Hosts predate it); a present\n // non-string is a contract violation, not a silent drop.\n if (source.tier !== undefined) {\n model.tier = catalogString(source, 'tier', 'model.tier')\n }\n return model\n}\n\n/** Parse the wire result into a {@link CommandCodeCatalog}. */\nfunction parseCatalog(value: unknown): CommandCodeCatalog {\n const source = catalogRecord(value, 'result')\n const models = source.models\n if (Array.isArray(models)) {\n return { models: models.map(parseCatalogModel) }\n }\n throw new TypeError('commandcode/models result: invalid models')\n}\n\n/** The strict result codec for the model-catalog Remote. */\nexport const modelsSchema: TypertSchema = {\n parse: parseCatalog,\n}\n\n/**\n * The model-catalog invocation descriptor, sharing the same `commandcodeUsage`\n * service and `commandcode` namespace as the usage report.\n */\nexport const MODELS_DESCRIPTOR: InvocationDescriptor =\n makeRemoteDescriptor(\n MODELS_ENDPOINT,\n 'models',\n `${USAGE_REMOTE_PACKAGE}#CommandCodeCatalog`,\n modelsSchema,\n )\n\n/** The Client-face contribution for the model-catalog endpoint. */\nexport const MODELS_REMOTE_CONTRIBUTION: TypertRemoteContribution = {\n package: USAGE_REMOTE_PACKAGE,\n descriptors: [MODELS_DESCRIPTOR],\n}","/**\n * Wire contract for the Command Code login Remote endpoints\n * (`commandcode/loginBegin`, `commandcode/loginStatus`,\n * `commandcode/loginCancel`).\n *\n * The settings page can start a browser login against the official Command\n * Code Studio (the same loopback flow `command-code login` performs) instead\n * of pasting an API key. The loopback server must live in the Host half — it\n * binds a local port and receives the key — so the page drives it through the\n * Typert Gateway exactly like the usage report.\n *\n * This module is the single source both halves share, deliberately\n * dependency-free (`import type` edges only): the strict status validator the\n * client trusts, the three descriptors both halves register, and the two\n * contribution objects. The state shape mirrors the Host-only flow machine in\n * `src/login.ts` as plain JSON.\n *\n * @module dsh-commandcode-provider/login-wire\n */\n\nimport type { InvocationDescriptor, TypertRemoteContribution, TypertSchema } from '@deepseek-ai/dsh-typert-protocol'\nimport {\n makeBoundaryValidator,\n makeRemoteDescriptor,\n REMOTE_PACKAGE,\n} from './wire-shared.ts'\n\n/** Why a login attempt ended in `failed` (stable across versions for copy). */\nexport type CommandCodeLoginFailureReason =\n /** The Studio page reported the authorization was denied by the user. */\n | 'denied'\n /** No callback arrived within the flow's timeout window. */\n | 'timeout'\n /** The delivered key failed `/alpha/whoami` validation (401). */\n | 'invalid-key'\n /** The validation request could not reach the API. */\n | 'network'\n /** The key could not be stored (credentials seam unavailable). */\n | 'unavailable'\n /** The attempt was cancelled by the user or torn down with the plugin. */\n | 'cancelled'\n /** Anything else. */\n | 'error'\n\n/** One login attempt's full state face, as carried over the wire. */\nexport interface CommandCodeLoginStatus {\n /**\n * `idle` — no attempt; `waiting` — the loopback server is up and the\n * Studio URL is live; `success` — the key validated and was stored;\n * `failed` — see `reason`/`message`.\n */\n state: 'idle' | 'waiting' | 'success' | 'failed'\n /** The Studio authorization URL while `waiting`. */\n authUrl?: string\n /** The account display name reported by the Studio, on `success`. */\n userName?: string\n /** The key's label from the Studio, on `success`. */\n keyName?: string\n /** Why the attempt failed, when `failed`. */\n reason?: CommandCodeLoginFailureReason\n /** Human-readable failure detail, when `failed` (secondary to `reason`). */\n message?: string\n}\n\n/** The canonical endpoint paths of the three login Remotes. */\nexport const LOGIN_BEGIN_ENDPOINT = 'commandcode/loginBegin'\nexport const LOGIN_STATUS_ENDPOINT = 'commandcode/loginStatus'\nexport const LOGIN_CANCEL_ENDPOINT = 'commandcode/loginCancel'\n\nconst REASONS: readonly CommandCodeLoginFailureReason[] = [\n 'denied', 'timeout', 'invalid-key', 'network', 'unavailable', 'cancelled', 'error',\n]\n\n/** The shared read/validate helpers, prefixed with the login endpoint so\n * rejection messages name the offending boundary. */\nconst { reject, record, stringField } =\n makeBoundaryValidator('commandcode/login result:')\n\n/**\n * Parse one untrusted boundary value into a {@link CommandCodeLoginStatus}.\n * Every field is shape-checked so a malformed frame fails the boundary\n * instead of leaking into the page.\n */\nexport function parseLoginStatus(value: unknown): CommandCodeLoginStatus {\n const source = record(value, 'status')\n const state = source.state\n // The factory's destructured-arrow `reject` is typed never-returning but\n // TS narrows it only in the positive branch — assign inside the guard.\n if (state === 'idle' || state === 'waiting' || state === 'success' || state === 'failed') {\n const status: CommandCodeLoginStatus = { state }\n if (source.authUrl !== undefined) {\n status.authUrl = stringField(source, 'authUrl', 'authUrl')\n }\n if (source.userName !== undefined) {\n status.userName = stringField(source, 'userName', 'userName')\n }\n if (source.keyName !== undefined) {\n status.keyName = stringField(source, 'keyName', 'keyName')\n }\n if (source.reason !== undefined) {\n const reason = source.reason\n if (typeof reason === 'string' && REASONS.includes(reason as CommandCodeLoginFailureReason)) {\n status.reason = reason as CommandCodeLoginFailureReason\n } else {\n reject('reason')\n }\n }\n if (source.message !== undefined) {\n status.message = stringField(source, 'message', 'message')\n }\n return status\n }\n return reject('state')\n}\n\n/** The strict result codec shared by all three login endpoints. */\nexport const loginStatusSchema: TypertSchema = {\n parse: parseLoginStatus,\n}\n\n/** Build one login invocation descriptor (uniform result, no parameters). */\nfunction loginDescriptor(endpoint: string, method: string): InvocationDescriptor {\n return makeRemoteDescriptor(\n endpoint,\n method,\n `${REMOTE_PACKAGE}#CommandCodeLoginStatus`,\n loginStatusSchema,\n )\n}\n\n/** The three login descriptors, shared verbatim by Host registration and Client mount. */\nexport const LOGIN_DESCRIPTORS: readonly InvocationDescriptor[] = [\n loginDescriptor(LOGIN_BEGIN_ENDPOINT, 'loginBegin'),\n loginDescriptor(LOGIN_STATUS_ENDPOINT, 'loginStatus'),\n loginDescriptor(LOGIN_CANCEL_ENDPOINT, 'loginCancel'),\n]\n\n/** The Host-face contribution fragment registered on `ctx.typert`. */\nexport const LOGIN_HOST_CONTRIBUTION = {\n package: REMOTE_PACKAGE,\n face: 'host' as const,\n schemas: [],\n invocations: LOGIN_DESCRIPTORS,\n}\n\n/** The Client-face contribution fragment mounted on `ctx.remote`. */\nexport const LOGIN_REMOTE_CONTRIBUTION: TypertRemoteContribution = {\n package: REMOTE_PACKAGE,\n descriptors: LOGIN_DESCRIPTORS,\n}\n","/**\n * The shared login row: one field row that starts the Host-side browser\n * login, links to the Studio authorization page while the attempt is live,\n * and reports the outcome — rendered by BOTH the settings page (`section.tsx`)\n * and the Models-page provider card (`card.tsx`) so the two surfaces share\n * one component and one hint state machine.\n *\n * The hint copy/class logic lives in the JSX-free `./login.ts`\n * (`loginHint`/`loginFailureCopy`) so node tests can drive every phase; this\n * file only renders it.\n *\n * @module dsh-commandcode-provider/client/login-row\n */\n\nimport type { Translate } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SettingsCommandCodeKey } from './locales.ts'\nimport { loginHint } from './login.ts'\nimport type { LoginPageState } from './login.ts'\n\n/** One login row's props (same face both surfaces supply). */\nexport interface LoginRowProps {\n state: LoginPageState\n disabled: boolean\n t: Translate\n onBegin(): void\n onCancel(): void\n}\n\n/** The sign-in alternative to pasting a key (settings page + Models card). */\nexport function LoginRow({ state, disabled, t, onBegin, onCancel }: LoginRowProps) {\n const busy = state.phase === 'starting' || state.phase === 'waiting'\n const hint = loginHint(state, t)\n return (\n
\n
\n {t('loginTitle')}\n \n {busy ? (\n \n ) : (\n \n )}\n \n
\n {state.authUrl !== undefined ? (\n

\n {t('loginOpenLink')}\n

\n ) : null}\n

{hint.text}

\n
\n )\n}","/**\n * Model-select helpers for the settings page's model editors (browser half).\n *\n * The routing-rule editor and the visible-models filter both pick catalog\n * models through the same checkbox multi-select dropdown (`ModelMultiSelect`\n * in section.tsx). The dropdown's data shaping — search filtering, stale-id\n * detection, tier grouping — lives here, React-free, so node tests can drive\n * it directly.\n *\n * Dependency-free by design: the client bundle may only import platform/seed\n * modules, so the plan snapshot below is a deliberately small vendored copy\n * (tier key → heading label) rather than an import of src/capabilities.ts.\n * When upstream adds a plan tier, extend BOTH tables.\n *\n * @module dsh-commandcode-provider/model-select\n */\n\n/**\n * Minimum plan tier → dropdown section heading. Mirrors the Host-side\n * `KNOWN_PLANS` values + `PLAN_LABELS` in src/capabilities.ts (kept as a\n * vendored copy because the client bundle cannot import host modules).\n * Covers every tier key `KNOWN_PLANS` uses today; an unknown tier key falls\n * back to the raw key rather than vanishing the row.\n */\nconst TIER_HEADINGS: Readonly> = {\n go: 'Go',\n goat: 'GOAT',\n pro: 'Pro',\n provider: 'Provider',\n max: 'Max',\n}\n\n/**\n * The dropdown section heading for a catalog model id, or undefined for\n * models outside the known plan tiers (unmapped models and stale ids render\n * unheaded). `knownPlans` is the Host-side `KNOWN_PLANS` table, threaded in\n * by the caller so this module stays dependency-free.\n */\nexport function tierHeadingFor(\n modelId: string,\n knownPlans: Readonly>,\n): string | undefined {\n const tier = knownPlans[modelId]\n if (tier === undefined) return undefined\n return TIER_HEADINGS[tier] ?? tier\n}\n\n/** One selectable catalog model (mirrors `CatalogModelOption` in settings.ts). */\nexport interface SelectableModel {\n /** Catalog model id (e.g. `deepseek/deepseek-v4-pro`). */\n id: string\n /** Display name from the catalog. */\n name: string\n}\n\n/** One dropdown row: a live catalog model or a stale selection. */\nexport interface ModelSelectOption {\n /** Catalog model id (stale ids keep their raw id as the value). */\n value: string\n /** Display name (stale ids fall back to the raw id). */\n label: string\n /** True when the id is selected but the catalog no longer carries it. */\n stale: boolean\n}\n\n/** One dropdown section: a plan-tier heading plus its rows. */\nexport interface ModelSelectGroup {\n /** Section heading, or undefined for models outside the known plan tiers. */\n heading: string | undefined\n /** Rows in this section, in picker order. */\n options: ModelSelectOption[]\n}\n\n/**\n * Whether `text` matches `query` as a case-insensitive substring over the\n * model id AND display name. An empty/blank query matches everything.\n */\nexport function matchesModelQuery(\n model: SelectableModel,\n query: string,\n): boolean {\n const needle = query.trim().toLowerCase()\n if (needle === '') return true\n return model.id.toLowerCase().includes(needle)\n || model.name.toLowerCase().includes(needle)\n}\n\n/**\n * Build the dropdown options: the catalog (already in picker order) plus\n * any selected ids the catalog no longer carries, flagged stale so the UI\n * can mark them — a saved selection never silently loses an entry, and the\n * user can see which ones went stale upstream.\n *\n * When `query` is non-blank, catalog rows are filtered by\n * {@link matchesModelQuery}; stale rows are kept only while they match too,\n * so a search for a live model does not surface unrelated stale ids.\n */\nexport function buildModelSelectOptions(\n catalog: readonly SelectableModel[],\n selected: readonly string[],\n query = '',\n): ModelSelectOption[] {\n const catalogIds = new Set(catalog.map((model) => model.id))\n const options = catalog\n .filter((model) => matchesModelQuery(model, query))\n .map((model) => ({ value: model.id, label: model.name, stale: false }))\n // Dedupe defensively (order-preserving): hand-edited settings can repeat\n // or blank an id, and duplicate Menu ids would confuse selection state.\n const seen = new Set(catalogIds)\n for (const id of selected) {\n if (id === '' || seen.has(id)) continue\n seen.add(id)\n if (catalogIds.has(id)) continue\n if (!matchesModelQuery({ id, name: id }, query)) continue\n options.push({ value: id, label: id, stale: true })\n }\n return options\n}\n\n/**\n * Group dropdown options under plan-tier headings (`tierOf` maps a model id\n * to its tier heading, or undefined for unmapped models — see\n * {@link tierHeadingFor}). Live rows keep their relative order; stale ids\n * and unmapped live rows share one trailing unheaded group. Groups merge\n * repeats, so a catalog interleaving two tiers still renders one section\n * per tier.\n */\nexport function groupModelSelectOptions(\n options: readonly ModelSelectOption[],\n tierOf: (modelId: string) => string | undefined,\n): ModelSelectGroup[] {\n const groups: ModelSelectGroup[] = []\n const byHeading = new Map()\n for (const option of options) {\n // Stale ids always render unheaded (their tier is unknowable); unmapped\n // live rows join the same trailing group so `heading: undefined`\n // appears at most once.\n const heading = option.stale ? undefined : tierOf(option.value)\n let group = byHeading.get(heading)\n if (group === undefined) {\n group = { heading, options: [] }\n byHeading.set(heading, group)\n groups.push(group)\n }\n group.options.push(option)\n }\n return groups\n}\n\n/**\n * Toggle one model id in a selection: remove it when present, append it\n * when absent (append keeps catalog order irrelevant — the picker re-sorts\n * by plan tier on render).\n */\nexport function toggleModelSelection(\n selected: readonly string[],\n modelId: string,\n): string[] {\n return selected.includes(modelId)\n ? selected.filter((value) => value !== modelId)\n : [...selected, modelId]\n}\n\n/** The catalog facts the visible-models card reads (mirrors `SettingsPageState`). */\nexport interface CatalogReadiness {\n /** Catalog model ids the Host reported (`[]` before the first fetch lands). */\n catalogIds: readonly string[]\n /** Whether the catalog fetch failed (or the Remote is unavailable). */\n catalogFailed: boolean\n}\n\n/**\n * Whether the catalog is trustworthy enough to call an unlisted selection\n * \"retired\". FALSE while the first fetch is still in flight and after a\n * failure, because the catalog is empty then and every selected id would look\n * stale — which turns the one-click stale cleanup into a button that silently\n * empties the allowlist. A successfully loaded but empty catalog is treated as\n * untrustworthy too: an empty list is far more likely a Host problem than every\n * model being retired at once, and the explicit \"show all\" entry covers the\n * user who really wants to clear the list.\n */\nexport function catalogIsReady(readiness: CatalogReadiness): boolean {\n return readiness.catalogIds.length > 0 && !readiness.catalogFailed\n}\n\n/**\n * Selected ids the loaded catalog no longer carries, in selection order.\n * Callers gate user-visible \"stale\" affordances on {@link catalogIsReady} —\n * the list itself is informational.\n */\nexport function staleModelIds(\n selected: readonly string[],\n readiness: CatalogReadiness,\n): string[] {\n const catalogIds = new Set(readiness.catalogIds)\n return selected.filter((id) => !catalogIds.has(id))\n}\n","","/**\n * The plugin's own version, read from package.json at build time.\n *\n * The client bundle inlines the JSON import (rolldown resolves it during the\n * tsdown build; node tests read it through tsx), so the rendered value always\n * matches the published package version with no second constant to keep in\n * sync. Rendered as a muted footer line on the settings page so a user can\n * report the exact build they run.\n *\n * @module dsh-commandcode-provider/client/version\n */\n\nimport pkg from '../../package.json'\n\n/** The published package version (e.g. `'0.6.0'`). */\nexport const PLUGIN_VERSION: string = pkg.version\n\n/**\n * This package's GitHub releases page, derived from the repository field so\n * the update hint's link target can never drift from the published home.\n * Tolerates both repository shapes (`{ url }` and the plain string form).\n */\nexport const PLUGIN_RELEASES_URL: string = (() => {\n const repo: unknown = (pkg as { repository?: unknown }).repository\n const url = typeof repo === 'string' ? repo : (repo as { url?: unknown } | undefined)?.url\n return `${typeof url === 'string' ? url.replace(/^git\\+/, '').replace(/\\.git$/, '') : 'https://github.com/Mars-Sea/dsh-commandcode-provider'}/releases`\n})()\n","/**\n * Plugin update hint (browser half).\n *\n * A deliberately small feature: when the \"Command Code\" settings page opens,\n * ask the npm registry for the package's published `latest` version and —\n * only when it is newer than the running build — let the page's footer show a\n * muted \"newer version available\" link to the GitHub releases. Everything\n * here is React-free and side-effect-seamed so node tests can drive it.\n *\n * Behaviour contract:\n *\n * - The registry is queried at most once per {@link UPDATE_CHECK_INTERVAL_MS}\n * per browser profile; the learned version is cached in `localStorage`\n * alongside the attempt time, so re-opening the settings page is free.\n * - A failed check records the attempt time too (an offline browser must not\n * hammer the registry on every page open) but keeps any previously learned\n * version, so the hint survives transient outages until it expires.\n * - Every failure mode (blocked network, non-OK status, malformed payload,\n * unavailable storage) degrades to \"no hint\"; nothing ever throws out of\n * {@link checkForUpdate}.\n *\n * The registry serves `access-control-allow-origin: *`, so the plain browser\n * fetch works from the GUI origin without any Host-side proxying.\n *\n * @module dsh-commandcode-provider/client/update\n */\n\n/** How often the page may hit the registry: once a day. */\nexport const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000\n\n/** Abort a hung registry request rather than keep the footer waiting. */\nexport const FETCH_TIMEOUT_MS = 5000\n\n/**\n * The npm registry document for this package's `latest` dist-tag. The scoped\n * name is path-escaped (`%2F`) so no client normalizes the slash away.\n */\nexport const NPM_LATEST_URL =\n 'https://registry.npmjs.org/@mars-sea%2Fdsh-commandcode-provider/latest'\n\n/**\n * Compare two version strings (`major.minor.patch[-pre]`). Returns a negative\n * number when `a` sorts before `b`, positive when after, zero when equal.\n *\n * Tolerant by design: a leading `v` is stripped, unparsable numeric parts\n * count as `0`, and semver prerelease rules apply (release > prerelease;\n * numeric identifiers compare numerically, everything else lexically, a\n * shorter identifier list sorts first). Enough for release tags; not a full\n * semver validator.\n */\nexport function compareVersions(a: string, b: string): number {\n const left = splitVersion(a)\n const right = splitVersion(b)\n const depth = Math.max(left.core.length, right.core.length)\n for (let index = 0; index < depth; index += 1) {\n const delta = (left.core[index] ?? 0) - (right.core[index] ?? 0)\n if (delta !== 0) return Math.sign(delta)\n }\n // A release outranks any prerelease of the same core version.\n if (left.pre.length === 0 && right.pre.length === 0) return 0\n if (left.pre.length === 0) return 1\n if (right.pre.length === 0) return -1\n const width = Math.max(left.pre.length, right.pre.length)\n for (let index = 0; index < width; index += 1) {\n const l = left.pre[index]\n const r = right.pre[index]\n if (l === undefined) return -1\n if (r === undefined) return 1\n const lNumeric = /^\\d+$/.test(l)\n const rNumeric = /^\\d+$/.test(r)\n let delta: number\n if (lNumeric && rNumeric) delta = Number(l) - Number(r)\n else if (lNumeric) delta = -1 // numeric identifiers sort below alphanumeric\n else if (rNumeric) delta = 1\n else delta = l < r ? -1 : l > r ? 1 : 0\n if (delta !== 0) return Math.sign(delta)\n }\n return 0\n}\n\n/** True when `candidate` is strictly newer than `current`. */\nexport function isNewerVersion(candidate: string, current: string): boolean {\n return compareVersions(candidate, current) > 0\n}\n\n/** Split a tolerant version string into numeric core + prerelease ids. */\nfunction splitVersion(value: string): { core: number[]; pre: string[] } {\n // Split on the FIRST dash only: `1.0.0-alpha-1` has prerelease `alpha-1`,\n // not `alpha` (a split on every dash would drop the `-1`).\n const cleaned = value.trim().replace(/^v/i, '')\n const dash = cleaned.indexOf('-')\n const coreText = dash < 0 ? cleaned : cleaned.slice(0, dash)\n const preText = dash < 0 ? undefined : cleaned.slice(dash + 1)\n const core = coreText === ''\n ? [0]\n : coreText.split('.').map((part) => {\n const parsed = Number.parseInt(part, 10)\n return Number.isFinite(parsed) ? parsed : 0\n })\n const pre = preText === undefined ? [] : preText.split('.')\n return { core, pre }\n}\n\n/**\n * Extract the published version from the registry's `/latest` manifest\n * (`{ name, version, … }`). Throws on anything unexpected so callers treat a\n * shape change as a failed attempt, never as bogus data.\n */\nexport function parseLatestVersion(payload: unknown): string {\n if (typeof payload !== 'object' || payload === null) {\n throw new Error('npm latest payload is not an object')\n }\n const version = (payload as { version?: unknown }).version\n if (typeof version !== 'string' || !/^\\d+\\.\\d+\\./.test(version)) {\n throw new Error('npm latest payload has no usable version')\n }\n return version\n}\n\n/** Fetch and parse the published `latest` version. Rejects on any failure. */\nexport async function fetchLatestVersion(\n fetchImpl: typeof fetch = fetch,\n): Promise {\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)\n try {\n const response = await fetchImpl(NPM_LATEST_URL, { signal: controller.signal })\n if (!response.ok) {\n throw new Error(`registry responded ${response.status}`)\n }\n return parseLatestVersion(await response.json())\n } finally {\n clearTimeout(timer)\n }\n}\n\n/** What the cache remembers about the last check. */\nexport interface UpdateCheckRecord {\n /** When the last attempt completed (success or failure), epoch ms. */\n at: number\n /** The last successfully learned upstream version, if any. */\n version?: string | undefined\n}\n\n/** Storage seam so tests can stand in for `localStorage`. */\nexport interface UpdateCheckStore {\n read(): UpdateCheckRecord | undefined\n write(record: UpdateCheckRecord): void\n}\n\n/** The `localStorage` key holding {@link UpdateCheckRecord}. */\nexport const UPDATE_CHECK_CACHE_KEY = '@mars-sea/dsh-commandcode-provider/update-check'\n\n/**\n * A {@link UpdateCheckStore} backed by `localStorage`. Tolerates a missing or\n * throwing storage (SSR-ish contexts, private modes): reads yield `undefined`,\n * writes are dropped.\n */\nexport function localStorageUpdateStore(\n storage: Storage | undefined = typeof localStorage === 'undefined' ? undefined : localStorage,\n): UpdateCheckStore {\n return {\n read(): UpdateCheckRecord | undefined {\n if (storage === undefined) return undefined\n try {\n const raw = storage.getItem(UPDATE_CHECK_CACHE_KEY)\n if (raw === null) return undefined\n const parsed: unknown = JSON.parse(raw)\n if (typeof parsed !== 'object' || parsed === null) return undefined\n const at = (parsed as { at?: unknown }).at\n if (typeof at !== 'number' || !Number.isFinite(at)) return undefined\n const version = (parsed as { version?: unknown }).version\n return {\n at,\n version: typeof version === 'string' && version !== '' ? version : undefined,\n }\n } catch {\n return undefined\n }\n },\n write(record: UpdateCheckRecord): void {\n if (storage === undefined) return\n try {\n storage.setItem(UPDATE_CHECK_CACHE_KEY, JSON.stringify(record))\n } catch {\n // Quota/private-mode failures must never break the page.\n }\n },\n }\n}\n\n/**\n * Run one throttled update check. Resolves with the newest published version\n * when it is newer than `currentVersion`, otherwise `undefined`.\n *\n * Within the throttle window (or on failure) the cached version answers, so\n * the hint keeps working offline; past the window the registry is consulted\n * again and the attempt time is refreshed either way.\n */\nexport async function checkForUpdate(options: {\n currentVersion: string\n now: number\n store: UpdateCheckStore\n fetchImpl?: typeof fetch\n}): Promise {\n const { currentVersion, now, store } = options\n const hintOf = (version: string | undefined): string | undefined =>\n version !== undefined && isNewerVersion(version, currentVersion) ? version : undefined\n\n const cache = store.read()\n if (cache !== undefined && now - cache.at < UPDATE_CHECK_INTERVAL_MS) {\n return hintOf(cache.version)\n }\n\n let learned: string | undefined\n try {\n learned = await fetchLatestVersion(options.fetchImpl)\n } catch {\n // Degrade silently: record the attempt below and fall back to the cache.\n }\n const version = learned ?? cache?.version\n store.write({ at: now, version })\n return hintOf(version)\n}\n","/**\n * React component for the \"Command Code\" settings page (browser half).\n *\n * Renders as a `settings.section` entry — a page at the same settings-nav\n * level as General / Models / Plugins. The shell supplies the nav row and\n * renders this body inside the content column. All copy comes from the\n * `settings.commandcode` locale namespace; all state comes from the\n * `CommandCodeSettingsController` injected by the slot registration.\n *\n * The layout mirrors the harness's settings pages: a max-width content\n * column, labelled fields with hints, a reset affordance, and a\n * save/discard footer. Rarely touched connection facts (API base, working\n * directory, timeouts, plan filter) fold into a collapsed Advanced card\n * (`AdvancedSection`) so the page leads with the key and the usage facts.\n * Styles are injected once by the client entry\n * (see src/client/index.ts) and class-prefixed `cc-` to stay local.\n */\n\nimport { useEffect, useMemo, useState } from 'react'\nimport { Button, Menu } from '@deepseek-ai/dsh-client-ui-primitives'\nimport type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'\nimport type { Translate } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { CommandCodeCredits } from '../adapter.ts'\nimport type { CommandCodeAccountUsage, CommandCodeUsageReport } from '../usage-wire.ts'\nimport type { SettingsCommandCodeKey } from './locales.ts'\nimport type { AccountItemState, CatalogModelOption, RuleItemState, SettingsPageState, StagedField } from './settings.ts'\nimport type { LoginPageState } from './login.ts'\nimport { LoginRow } from './login-row.tsx'\nimport { buildModelSelectOptions, catalogIsReady, groupModelSelectOptions, staleModelIds, tierHeadingFor, toggleModelSelection } from './model-select.ts'\nimport type { UsagePageState } from './usage.ts'\nimport { formatMoney, formatMoneyExact, formatResetAt, formatSuccessRate, formatTokensCompact, windowRatio } from './usage.ts'\nimport { PLUGIN_RELEASES_URL, PLUGIN_VERSION } from './version.ts'\nimport { checkForUpdate, localStorageUpdateStore } from './update.ts'\n\n/** Props composed by the slot registration: locale seat + injected face. */\nexport interface CommandCodeSettingsProps {\n t: Translate\n useCommandCodeSettings(selector: (state: SettingsPageState) => T): T\n useCommandCodeUsage(selector: (state: UsagePageState) => T): T\n useCommandCodeLogin(selector: (state: LoginPageState) => T): T\n edit(field: string, text: string): void\n resetField(field: string): void\n save(): void\n discard(): void\n refreshUsage(): void\n beginLogin(): void\n cancelLogin(): void\n addAccount(): void\n removeAccount(id: string): void\n editAccountLabel(id: string, text: string): void\n editAccountKey(id: string, text: string): void\n toggleKeyClear(id: string): void\n addRule(): void\n removeRule(id: string): void\n editRuleModels(id: string, ids: string[]): void\n editRuleAccount(id: string, text: string): void\n editVisibleModels(ids: string[]): void\n clearVisibleModels(): void\n}\n\n/** The section fields folded into the collapsible Advanced card. */\ntype AdvancedField = 'apiBase' | 'workingDir' | 'requestTimeoutMs' | 'streamIdleTimeoutMs' | 'filterModelsByPlan' | 'webSearch'\nconst ADVANCED_FIELDS: readonly AdvancedField[] = [\n 'apiBase',\n 'workingDir',\n 'requestTimeoutMs',\n 'streamIdleTimeoutMs',\n 'filterModelsByPlan',\n 'webSearch',\n]\n\n/** One labelled field row in the page body. */\nfunction Field({\n id,\n label,\n hint,\n state,\n disabled,\n numeric,\n placeholder,\n onEdit,\n onReset,\n t,\n}: {\n id: string\n label: string\n hint: string\n state: StagedField\n disabled: boolean\n numeric?: boolean\n placeholder?: string | undefined\n onEdit(text: string): void\n onReset(): void\n t: Translate\n}) {\n return (\n
\n
\n \n \n {state.overridden ? {t('overridden')} : null}\n \n \n
\n onEdit(event.target.value)}\n />\n

\n {state.invalid ? invalidCopy(state.invalidReason, t) : hint}\n

\n
\n )\n}\n\n/** The per-field error copy for a staged draft's failure reason. */\nfunction invalidCopy(reason: StagedField['invalidReason'], t: Translate): string {\n if (reason === 'tooSmall') return t('numberTooSmall')\n if (reason === 'tooLarge') return t('numberTooLarge')\n return t('invalidNumber')\n}\n\n/**\n * The collapsed \"Advanced\" card: API base, working dir, both timeouts, and\n * the plan filter live here so the page leads with the facts a user actually\n * touches. Starts collapsed on every visit; expands on demand. While\n * collapsed, a badge names the customized count so a nonzero override stays\n * visible (a number field's error blocks save and must be reachable).\n */\nfunction AdvancedSection({\n state,\n disabled,\n t,\n onEdit,\n onReset,\n}: {\n state: SettingsPageState\n disabled: boolean\n t: Translate\n onEdit(field: string, text: string): void\n onReset(field: string): void\n}) {\n const [expanded, setExpanded] = useState(false)\n const overridden = ADVANCED_FIELDS.filter((field) => state[field].overridden).length\n const invalid = ADVANCED_FIELDS.some((field) => state[field].invalid)\n return (\n
\n setExpanded((value) => !value)}\n >\n {t('advancedSettings')}\n {overridden > 0 ? (\n \n {overridden === 1 ? t('advancedOverriddenOne') : t('advancedOverriddenMany', { count: overridden })}\n \n ) : null}\n {/* An invalid number blocks save while collapsed with no visible cue\n — surface it on the header so the blocker is reachable. */}\n {!expanded && invalid ? (\n {t('advancedInvalid')}\n ) : null}\n \n \n \n {expanded ? (\n
\n

{t('advancedSettingsHint')}

\n onEdit('apiBase', text)}\n onReset={() => onReset('apiBase')}\n t={t}\n />\n onEdit('workingDir', text)}\n onReset={() => onReset('workingDir')}\n t={t}\n />\n onEdit('requestTimeoutMs', text)}\n onReset={() => onReset('requestTimeoutMs')}\n t={t}\n />\n onEdit('streamIdleTimeoutMs', text)}\n onReset={() => onReset('streamIdleTimeoutMs')}\n t={t}\n />\n onEdit('filterModelsByPlan', text)}\n onReset={() => onReset('filterModelsByPlan')}\n t={t}\n />\n onEdit('webSearch', text)}\n onReset={() => onReset('webSearch')}\n t={t}\n />\n
\n ) : null}\n {expanded && invalid ? (\n

{t('advancedInvalid')}

\n ) : null}\n
\n )\n}\n\n/**\n * One boolean field row rendered as a toggle. The staged text is `'true'` /\n * `'false'` / `''` (unset → `defaultChecked`); toggling stages the string the\n * boolean field spec parses back into a real boolean on save.\n */\nfunction ToggleField({\n id,\n label,\n hint,\n state,\n disabled,\n defaultChecked,\n onEdit,\n onReset,\n t,\n}: {\n id: string\n label: string\n hint: string\n state: StagedField\n disabled: boolean\n defaultChecked: boolean\n onEdit(text: string): void\n onReset(): void\n t: Translate\n}) {\n const checked = state.text === '' ? defaultChecked : state.text === 'true'\n return (\n
\n
\n {/* Plain text (not a second label): the toggle input below already\n has its accessible name from the wrapping label. */}\n {label}\n \n {state.overridden ? {t('overridden')} : null}\n \n \n
\n \n
\n )\n}\n\n/**\n * The API-key control: write-only, reports configured state, never echoes the\n * key. The input is masked by default with a Show/Hide toggle so a pasted key\n * can be spot-checked without leaving the field, and a stored key can be\n * staged for removal (the next save unsets it) when it is bad or unwanted.\n */\nfunction SecretKeyField({\n label,\n hint,\n state,\n disabled,\n configured,\n configuredLabel,\n unconfiguredLabel,\n clearStaged,\n showLabel,\n hideLabel,\n clearLabel,\n clearStagedLabel,\n undoClearLabel,\n onEdit,\n onToggleClear,\n}: {\n label: string\n hint: string\n state: StagedField\n disabled: boolean\n configured: boolean\n configuredLabel: string\n unconfiguredLabel: string\n clearStaged: boolean\n showLabel: string\n hideLabel: string\n clearLabel: string\n clearStagedLabel: string\n undoClearLabel: string\n onEdit(text: string): void\n onToggleClear(): void\n}) {\n const [visible, setVisible] = useState(false)\n return (\n
\n
\n \n \n \n {configured ? configuredLabel : unconfiguredLabel}\n \n {clearStaged ? {clearStagedLabel} : null}\n {configured ? (\n \n ) : null}\n setVisible((value) => !value)}\n >\n {visible ? hideLabel : showLabel}\n \n \n
\n onEdit(event.target.value)}\n />\n

{hint}

\n
\n )\n}\n\n/** One stat tile in the account card's summary grid. */\nfunction UsageStat({ label, value, sub }: { label: string; value: string; sub?: string | undefined }) {\n return (\n
\n {label}\n {value}\n {sub !== undefined && sub !== '' ? {sub} : null}\n
\n )\n}\n\n/** One window-limit row: label, used/cap, a fill bar, and the reset time. */\nfunction UsageWindow({\n label,\n limit: { used, cap, exceeded, resetAt },\n t,\n}: {\n label: string\n limit: CommandCodeCredits['fiveHour']\n t: Translate\n}) {\n const ratio = windowRatio(used, cap)\n const reset = formatResetAt(resetAt)\n return (\n
\n
\n {label}\n {exceeded ? {t('usageExceeded')} : null}\n {cap > 0 ? `${formatMoney(used)} / ${formatMoney(cap)}` : formatMoney(used)}\n
\n
\n
\n
\n {reset !== '' ?

{t('usageReset')} {reset}

: null}\n
\n )\n}\n\n/** One account's rotation state as a short badge next to its label. */\nfunction AccountMark({ entry, t }: { entry: CommandCodeAccountUsage; t: Translate }) {\n if (entry.active) return {t('usageActive')}\n if (entry.mark === 'invalid-credential') return {t('usageInvalidKey')}\n if (entry.cooldownUntil > 0) {\n return {t('usageCooldown')} {formatResetAt(entry.cooldownUntil)}\n }\n if (entry.mark === 'rate-limit') return {t('usageCooldown')}\n return null\n}\n\n/**\n * One pool account's facts (identity, totals, credits, window limits)\n * rendered inside the account-usage card.\n */\nfunction AccountReport({ entry, fetchedAt, t, onRemove }: {\n entry: CommandCodeAccountUsage\n /**\n * When the shared usage snapshot was fetched — shares the account's bottom\n * meta row with the billing period end so the two timestamps occupy one\n * line (period/partial facts left, fetch freshness right).\n */\n fetchedAt?: number | undefined\n t: Translate\n /** Present only for removable (non-default) accounts on a writable page. */\n onRemove?: (() => void) | undefined\n}) {\n const report = entry.report\n const account = report.account\n const accountName = account === undefined ? '' : account.userName || account.name\n const credits = report.credits\n const plan = report.plan\n const planName = plan?.name ?? ''\n const planStatus = plan !== undefined && plan.status !== '' && plan.status !== 'active' ? plan.status : ''\n const showPeriod = plan !== undefined && plan.currentPeriodEnd > 0\n const showPartial = report.failures.length > 0 && report.blocked === undefined\n\n return (\n
\n
\n

{entry.label}

\n \n {accountName !== '' ? {accountName} : null}\n {planName !== '' ? {planName} : null}\n {planStatus !== '' ? {planStatus} : null}\n \n {onRemove !== undefined ? (\n \n ) : null}\n
\n\n {!entry.configured ?

{t('usageUnconfigured')}

: null}\n\n {report.blocked !== undefined ? (\n
\n

{blockedTitle(report.blocked, t)}

\n

{blockedHint(report.blocked, t)}

\n
\n ) : null}\n\n {report.usage !== undefined ? (\n
\n \n \n \n \n
\n ) : null}\n\n {credits !== undefined ? (\n
\n \n \n \n
\n ) : null}\n\n {credits !== undefined ? (\n
\n \n \n
\n ) : null}\n\n {showPeriod || showPartial || fetchedAt !== undefined ? (\n
\n {showPeriod ? (\n

{t('usagePeriodEnd')} {new Date(plan.currentPeriodEnd).toLocaleDateString()}

\n ) : null}\n {showPartial ? (\n

{t('usagePartial')}

\n ) : null}\n \n {fetchedAt !== undefined ? (\n

{t('usageUpdated')} {new Date(fetchedAt).toLocaleTimeString()}

\n ) : null}\n
\n ) : null}\n
\n )\n}\n\n/** The headline copy for a report whose every endpoint failed the same way. */\nfunction blockedTitle(reason: CommandCodeUsageReport['blocked'], t: Translate): string {\n if (reason === 'invalid-key') return t('usageKeyInvalid')\n if (reason === 'service-unavailable') return t('usageServiceUnavailable')\n return t('usageNetworkError')\n}\n\n/** The actionable hint under a blocked report's headline. */\nfunction blockedHint(reason: CommandCodeUsageReport['blocked'], t: Translate): string {\n if (reason === 'invalid-key') return t('usageKeyInvalidHint')\n if (reason === 'service-unavailable') return t('usageServiceUnavailableHint')\n return t('usageNetworkHint')\n}\n\n/** The status dot on an account tab: cooling/invalid warn, everything else ok. */\nfunction AccountTabDot({ entry }: { entry: CommandCodeAccountUsage }) {\n const cls = entry.mark === 'invalid-credential'\n ? 'cc-tabDot cc-tabDotError'\n : entry.mark !== '' || entry.cooldownUntil > 0\n ? 'cc-tabDot cc-tabDotWarn'\n : 'cc-tabDot cc-tabDotOk'\n // Decorative: the tab's text label already carries the account identity.\n return \n}\n\n/**\n * The account-usage card: the `/commandcode` dashboard's facts rendered as\n * a native settings card. With several accounts the card is a carousel — a\n * tab strip (label + status dot) switches between accounts so the page stays\n * short; each account's report carries its own remove affordance (the\n * default account is not removable). Accounts staged for removal in the\n * management card are hidden here immediately. Data arrives through the\n * `commandcode/report` Remote; the API keys never leave the Host.\n */\nfunction UsageCard({ t, usage, apiKeyConfigured, removingIds, removableIds, canManage, onRefresh, onRemoveAccount }: {\n t: Translate\n usage: UsagePageState\n apiKeyConfigured: boolean\n /** Ids of accounts staged for removal (hidden from the carousel). */\n removingIds: string[]\n /**\n * Ids of accounts the settings document can actually remove (the stored\n * extra accounts' refs). Composition-only accounts (literal-key slots with\n * positional `account-N` ids) are NOT removable from the page — the\n * settings document cannot name them — so they get no remove button.\n */\n removableIds: string[]\n /** Whether the page accepts writes (the remove affordance follows it). */\n canManage: boolean\n onRefresh(): void\n onRemoveAccount(id: string): void\n}) {\n // First paint with a configured key fetches automatically; later fetches\n // are explicit (refresh button) or follow a landed save.\n useEffect(() => {\n if (apiKeyConfigured && usage.status === 'idle') onRefresh()\n }, [apiKeyConfigured, usage.status, onRefresh])\n\n const loading = usage.status === 'loading'\n const report = usage.report\n // Locally remembered removals: the usage controller keeps the old report\n // until the post-save refresh lands, and removedRefs clears at save-land —\n // without this the just-removed account would pop back in for one refresh\n // round-trip. Cleared when a fresh report arrives (fetchedAt changes).\n const [locallyRemoved, setLocallyRemoved] = useState([])\n useEffect(() => {\n setLocallyRemoved([])\n }, [usage.fetchedAt])\n const hidden = new Set([...removingIds, ...locallyRemoved])\n const seenIds = new Set()\n const entries = (report?.accounts ?? []).filter((entry) => {\n if (hidden.has(entry.id)) return false\n // Hand-edited settings can name the same credential ref twice; dedupe so\n // the tab strip never carries duplicate keys/selections.\n if (seenIds.has(entry.id)) return false\n seenIds.add(entry.id)\n return true\n })\n const [selectedId, setSelectedId] = useState(undefined)\n // The selected tab: the explicit choice while it still exists, else the\n // serving (active) account, else the first entry.\n const selected = entries.find((entry) => entry.id === selectedId)\n ?? entries.find((entry) => entry.active)\n ?? entries[0]\n const removeSelected = canManage && selected !== undefined && removableIds.includes(selected.id)\n ? () => {\n const id = selected.id\n setLocallyRemoved((prev) => [...prev, id])\n onRemoveAccount(id)\n }\n : undefined\n\n return (\n
\n
\n

{t('usageTitle')}

\n \n \n
\n\n {!apiKeyConfigured ?

{t('usageNoKey')}

: null}\n {apiKeyConfigured && report === undefined && loading ?

{t('usageLoading')}

: null}\n {usage.status === 'error' ? (\n

\n {t('usageError')}{usage.error !== undefined && usage.error !== '' ? ` — ${usage.error}` : ''}\n

\n ) : null}\n\n {/* Plain buttons, not tabs: each switches the visible account panel\n without a tabpanel/keyboard-tab contract to uphold. */}\n {entries.length > 1 ? (\n
\n {entries.map((entry) => (\n setSelectedId(entry.id)}\n >\n \n {entry.label}\n \n ))}\n
\n ) : null}\n\n {selected !== undefined ? (\n \n ) : null}\n
\n )\n}\n\n/**\n * One extra account row: label, key, configured badge. Saved accounts are\n * removed from the usage card above; a NOT-YET-SAVED addition never appears\n * there (the usage report is Host-side), so it keeps its own remove button —\n * otherwise the only way to undo a mistaken Add would be discarding every\n * other staged edit.\n */\nfunction AccountRow({ account, disabled, t, onLabel, onKey, onToggleClear, onRemove }: {\n account: AccountItemState\n disabled: boolean\n t: Translate\n onLabel(text: string): void\n onKey(text: string): void\n onToggleClear(): void\n onRemove(): void\n}) {\n const locked = !account.writable\n const [keyVisible, setKeyVisible] = useState(false)\n return (\n
\n
\n \n \n {account.added ? {t('unsaved')} : null}\n \n {account.configured ? t('apiKeySet') : t('apiKeyUnset')}\n \n {account.clearStaged ? {t('usageKeyClearStaged')} : null}\n {account.configured && !account.clearStaged ? (\n \n ) : null}\n {account.clearStaged ? (\n \n ) : null}\n setKeyVisible((value) => !value)}\n >\n {keyVisible ? t('hide') : t('show')}\n \n {account.added ? (\n \n ) : null}\n \n
\n onLabel(event.target.value)}\n />\n onKey(event.target.value)}\n />\n

{locked ? t('apiKeyLocked') : t('accountKeyHint')}

\n
\n )\n}\n\n/** The multi-account card: the active-account selector + extra accounts in rotation order + add button. */\nfunction AccountsCard({ t, state, disabled, onAdd, onRemove, onLabel, onKey, onToggleClear, onActive, onActiveReset }: {\n t: Translate\n state: SettingsPageState\n disabled: boolean\n onAdd(): void\n onRemove(id: string): void\n onLabel(id: string, text: string): void\n onKey(id: string, text: string): void\n onToggleClear(id: string): void\n onActive(text: string): void\n onActiveReset(): void\n}) {\n const active = state.activeAccount\n return (\n
\n
\n
\n \n \n \n \n
\n

{t('accountsHint')}

\n
\n
\n
\n \n \n {active.overridden ? {t('overridden')} : null}\n \n \n
\n onActive(event.target.value)}\n >\n \n \n {state.accounts.filter((account) => !account.added).map((account) => (\n \n ))}\n \n

{t('activeAccountHint')}

\n
\n {state.accounts.map((account) => (\n onLabel(account.id, text)}\n onKey={(text) => onKey(account.id, text)}\n onToggleClear={() => onToggleClear(account.id)}\n onRemove={() => onRemove(account.id)}\n />\n ))}\n
\n )\n}\n\n/** One model → account routing rule row. */\nfunction RuleRow({ rule, accounts, catalog, disabled, t, onModels, onAccount, onRemove }: {\n rule: RuleItemState\n accounts: AccountItemState[]\n catalog: CatalogModelOption[]\n disabled: boolean\n t: Translate\n onModels(ids: string[]): void\n onAccount(text: string): void\n onRemove(): void\n}) {\n const targets = [\n { value: 'default', label: t('accountDefault') },\n ...accounts.filter((account) => !account.added).map((account) => ({ value: account.ref, label: account.label })),\n ]\n return (\n
\n
\n \n \n {rule.added ? {t('unsaved')} : null}\n \n \n
\n \n onAccount(event.target.value)}\n aria-label={t('ruleAccount')}\n >\n {targets.map((target) => (\n \n ))}\n \n

{t('ruleHint')}

\n
\n )\n}\n\n/**\n * A checkbox multi-select dropdown for picking catalog models (the\n * routing-rule rows and the visible-models filter share it). The trigger\n * shows the selection count; the Menu lists every catalog model with a\n * checkbox, toggled by clicking the row. A search box under the trigger\n * (inside the Menu anchor, so focusing it never trips the outside-click\n * close) filters the list by id/display-name substring, and items group\n * under plan-tier headings in picker order. Selected ids the catalog no\n * longer carries still render — flagged stale — so a saved selection never\n * silently loses an entry, and the VisibleModelsCard offers a one-click\n * cleanup.\n */\nfunction ModelMultiSelect({ id, selected, catalog, disabled, t, onSelect }: {\n id: string\n selected: string[]\n catalog: CatalogModelOption[]\n disabled: boolean\n t: Translate\n onSelect(ids: string[]): void\n}) {\n const [open, setOpen] = useState(false)\n const [query, setQuery] = useState('')\n // The search box must not inherit a stale query from a previous open.\n useEffect(() => {\n if (open) setQuery('')\n }, [open])\n // Tier headings come from the catalog entries themselves (the Host stamps\n // each entry's plan-tier key on the Remote); rebuild only when the catalog\n // changes.\n const tiers = useMemo(\n () => Object.fromEntries(\n catalog.flatMap((model) => model.tier === undefined ? [] : [[model.id, model.tier] as const]),\n ),\n [catalog],\n )\n // The catalog is sorted for picking; append any selected ids the catalog no\n // longer carries (removed upstream) so the current selection stays visible.\n const options = buildModelSelectOptions(catalog, selected, query)\n const groups = groupModelSelectOptions(options, (modelId) => tierHeadingFor(modelId, tiers))\n const items: MenuEntry[] = groups.flatMap((group) => [\n ...(group.heading === undefined\n ? []\n : [{ type: 'label' as const, id: `cc-tier-${group.heading}`, text: group.heading }]),\n ...group.options.map((option) => ({\n id: option.value,\n label: (\n \n \n {option.label}\n {option.stale ? {t('modelStale')} : null}\n \n ),\n })),\n ])\n // The search box lives INSIDE the Menu anchor (which renders in place\n // inside the Menu's root span): a pointerdown there counts as \"inside\",\n // so focusing/typing never trips the Menu's outside-click close. A box\n // rendered as a sibling would close the Menu on the first click.\n return (\n setOpen(false)}\n onSelect={(modelId) => {\n onSelect(toggleModelSelection(selected, modelId))\n }}\n selectedIds={selected}\n items={items}\n footer={options.length === 0 ? [{\n type: 'label' as const,\n id: 'cc-model-search-empty',\n text: t('modelSearchEmpty'),\n }] : []}\n portal\n anchor={\n \n setOpen((value) => !value)}\n >\n \n {selected.length === 0 ? t('ruleModelPick') : t('ruleModelCount', { count: selected.length })}\n \n \n \n {open ? (\n setQuery(event.target.value)}\n />\n ) : null}\n \n }\n />\n )\n}\n\n/** The model → account routing card: rules in list order (first match wins). */\nfunction RulesCard({ t, state, disabled, onAdd, onRemove, onModels, onAccount }: {\n t: Translate\n state: SettingsPageState\n disabled: boolean\n onAdd(): void\n onRemove(id: string): void\n onModels(id: string, ids: string[]): void\n onAccount(id: string, text: string): void\n}) {\n return (\n
\n
\n
\n \n \n \n \n
\n

{t('rulesHint')}

\n {state.catalogFailed ?

{t('rulesCatalogFailed')}

: null}\n
\n {state.rules.map((rule) => (\n onModels(rule.id, ids)}\n onAccount={(text) => onAccount(rule.id, text)}\n onRemove={() => onRemove(rule.id)}\n />\n ))}\n {state.rules.length === 0 ?

{t('rulesEmpty')}

: null}\n
\n )\n}\n\n/** The visible-model filter card: an allowlist over the catalog. Empty = show all. */\nfunction VisibleModelsCard({ t, state, disabled, onSelect, onClear }: {\n t: Translate\n state: SettingsPageState\n disabled: boolean\n onSelect(ids: string[]): void\n onClear(): void\n}) {\n const count = state.visibleModels.length\n const pickT: Translate = (key, params) => {\n if (key === 'ruleModelPick') return t('visibleModelsPick')\n if (key === 'ruleModelCount') return t('visibleModelsCount', params)\n return t(key, params)\n }\n // Selected ids the live catalog no longer carries (retired upstream):\n // kept, flagged stale in the dropdown, removable in one click. Never\n // auto-dropped — an empty catalog (fetch failure) must not wipe the list.\n // \"Stale\" is only meaningful against a catalog we actually hold, so both the\n // cleanup button and its hint are gated on catalogIsReady: before the first\n // fetch lands (and after a failure) the empty catalog makes every selection\n // look retired, turning the one-click cleanup into a button that silently\n // empties the allowlist. The explicit \"show all\" entry stays available\n // either way — clearing the list is then the user's stated intent rather\n // than an inference from missing data.\n const readiness = { catalogIds: state.catalogModels.map((model) => model.id), catalogFailed: state.catalogFailed }\n const staleIds = staleModelIds(state.visibleModels, readiness)\n const catalogReady = catalogIsReady(readiness)\n return (\n
\n
\n
\n \n \n {catalogReady && staleIds.length > 0 ? (\n onSelect(state.visibleModels.filter((id) => !staleIds.includes(id)))}\n >\n {t('visibleModelsCleanStale', { count: staleIds.length })}\n \n ) : null}\n {count > 0 ? (\n \n ) : null}\n \n
\n

{t('visibleModelsHint')}

\n {state.catalogFailed ?

{t('rulesCatalogFailed')}

: null}\n {staleIds.length > 0 && catalogReady ? (\n

{t('visibleModelsStaleHint', { count: staleIds.length })}

\n ) : null}\n \n
\n
\n )\n}\n\n/**\n * Show the \"Saved ✓\" affordance for a short window after each accepted save.\n * The controller only counts saves (`savedCount`); the flash timing lives\n * here so the state machine stays timer-free.\n */\nfunction useSavedFlash(tick: number): boolean {\n const [visible, setVisible] = useState(false)\n useEffect(() => {\n if (tick === 0) return\n setVisible(true)\n const timer = setTimeout(() => setVisible(false), 2500)\n return () => clearTimeout(timer)\n }, [tick])\n return visible\n}\n\n/**\n * The update hint: one throttled npm-registry check per page open (the\n * throttle and all failure handling live in ./update.ts). Resolves to the\n * newest published version when it is newer than this build, else undefined —\n * every failure mode degrades to no hint at all.\n */\nfunction usePluginUpdate(): string | undefined {\n const [available, setAvailable] = useState(undefined)\n useEffect(() => {\n let cancelled = false\n void checkForUpdate({\n currentVersion: PLUGIN_VERSION,\n now: Date.now(),\n store: localStorageUpdateStore(),\n }).then((version) => {\n if (!cancelled) setAvailable(version)\n // A rejected checkForUpdate would be a bug (it catches internally);\n // swallow it regardless — the footer must never break the page.\n }, () => {})\n return () => {\n cancelled = true\n }\n }, [])\n return available\n}\n\n/** The settings page body: connection facts for the Command Code provider. */\nexport function CommandCodeSettingsPage(props: CommandCodeSettingsProps) {\n const { t } = props\n const state = props.useCommandCodeSettings((snapshot) => snapshot)\n const usage = props.useCommandCodeUsage((snapshot) => snapshot)\n const login = props.useCommandCodeLogin((snapshot) => snapshot)\n const disabled = !state.writable\n const keyLocked = !state.apiKeyWritable\n const savedVisible = useSavedFlash(state.savedCount)\n const updateVersion = usePluginUpdate()\n return (\n
\n

{t('title')}

\n

{t('intro')}

\n {!state.writable ?

{t('readOnly')}

: null}\n account.id)}\n canManage={state.writable}\n onRefresh={props.refreshUsage}\n onRemoveAccount={props.removeAccount}\n />\n props.toggleKeyClear(id)}\n onActive={(text) => props.edit('activeAccount', text)}\n onActiveReset={() => props.resetField('activeAccount')}\n />\n \n \n
\n props.edit('apiKey', text)}\n onToggleClear={() => props.toggleKeyClear('default')}\n />\n \n
\n \n
\n {state.failed ?

{t('saveFailed')}

: null}\n {savedVisible ?

{t('saved')}

: null}\n \n \n {t(state.saving ? 'saving' : 'save')}\n \n
\n

\n Command Code Provider v{PLUGIN_VERSION}\n {updateVersion !== undefined ? (\n <>\n {' · '}\n \n v{updateVersion} {t('updateAvailable')}\n \n \n ) : null}\n

\n
\n )\n}\n","/**\n * The Command Code configuration panel inside the harness Models settings page\n * (browser half). Rendered through the `settings.models.provider-card` keyed\n * slot available in dsh 0.1.2 (rc.1), registered with\n * `entryKey = 'llm-commandcode'` (the plugin's settings namespace, the key the\n * Models page dispatches for every Command Code provider row).\n *\n * The official Models page opens one editor card per provider row through its\n * own 编辑 button. For a namespace the page does not curate a layout for\n * (`llm-commandcode`), that editor is a bare shell — a pointer to\n * `settings.yaml` above a permanently disabled apply button. This panel takes\n * its place: the slot outlet renders right beside the official editor inside\n * the same row card, so the component watches the outlet's siblings and, while\n * the official editor is open, hides the useless shell and shows the real\n * controls in its slot — the credential/route badges, the API-key field,\n * official sign-in, and the discard/save footer. Closed, it renders nothing\n * and the row looks exactly like any other provider row.\n *\n * The slot's owner props (`configured`, `keyConfigured`) mirror what the\n * Models page already knows; the authoritative credential facts still come\n * from this plugin's `CommandCodeSettingsController` shared with the dedicated\n * settings page, so the two surfaces can never disagree about whether a key\n * is stored.\n *\n * A controller-less render (panel mounted before the section registered its\n * inject face — the composition runs one apply) degrades to the stateless\n * registration notice inside the opened panel.\n *\n * Styles ride the page stylesheet the client entry injects once (`cc-`\n * prefixed classes); the card adds no CSS of its own.\n */\n\nimport { useEffect, useRef, useState } from 'react'\nimport type { Translate } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SettingsCommandCodeKey } from './locales.ts'\nimport type { SettingsPageState, StagedField } from './settings.ts'\nimport type { LoginPageState } from './login.ts'\nimport { LoginRow } from './login-row.tsx'\n\n/**\n * The Models-page extension slots, merged into the SlotMap with the exact\n * declarations dsh 0.1.2 (rc.1)'s ui-settings-models ships. The merge must\n * stay structurally identical to upstream's (kind/scope/owner), or a future\n * dsh carrying its own declaration would fail the duplicate-merge check at\n * compile time.\n */\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface SlotMap {\n /** One provider card's adapter extension area, keyed by the row's settingsNs. */\n 'settings.models.provider-card': { kind: 'keyed'; scope: 'root'; owner: ProviderCardExtrasOwnerProps }\n /** Ordered extension area after the provider rows and the add controls. */\n 'settings.models.footer': { kind: 'list'; scope: 'root'; owner: ModelsFooterOwnerProps }\n }\n}\n\n/** The provider directory row as the Models page dispatches it. */\nexport interface ProviderDirectoryRow {\n /** The provider route id (`commandcode` for this plugin). */\n readonly provider: string\n /** The row's display name. */\n readonly displayName: string\n /** The settings namespace the row configures (the slot's dispatch key). */\n readonly settingsNs: string\n /** The settings path the row's profile lives at. */\n readonly settingsPath: readonly string[]\n /** Whether the provider route is live. */\n readonly active: boolean\n /** Whether the adapter declares the route as shipped. */\n readonly declared?: boolean\n}\n\n/** Owner share of one provider-card extension occurrence (upstream's shape). */\nexport interface ProviderCardExtrasOwnerProps {\n /** The card's directory row. */\n readonly provider: ProviderDirectoryRow\n /** Whether any layer configures this provider (its profile resolves). */\n readonly configured: boolean\n /** The row's referenced api-key credential, confirmed configured by the page's join. */\n readonly keyConfigured: boolean\n}\n\n/** Owner share of the footer area (the section supplies nothing). */\nexport interface ModelsFooterOwnerProps {\n /** Marker field: footer owner props are intentionally empty. */\n children?: never\n}\n\n/** Owner props the Models page supplies at its dispatch sites. */\nexport type ProviderCardOwnerProps = ProviderCardExtrasOwnerProps\n\n/**\n * The slot whose outlet anchors this panel inside the Models row — the\n * renderer's own `data-slot` attribute value, stable across builds (unlike\n * CSS-module class hashes).\n */\nexport const CARD_SLOT_KEY = 'settings.models.provider-card'\n\n/** The DOM facts the sibling lookup reads (satisfied by real Elements). */\nexport interface SlotWrapperSiblings {\n previousElementSibling: { className: string } | null\n nextElementSibling: { className: string } | null\n}\n\n/**\n * Find the official editor card among the slot outlet's siblings, or null\n * while it is closed. The Models page renders the editor as an immediate\n * sibling of the outlet wrapper — after it in a provider row (the target of\n * the row's 编辑 toggle), before it in the first-run setup card and the\n * add-provider card, where it is always open. The editor is the only such\n * sibling whose CSS module class carries the `editor` stem\n * (`_editor`); the row header and the add card's provider select\n * never do, so the lookup needs no hash knowledge.\n */\nexport function adjacentEditorCard(wrapper: SlotWrapperSiblings | null): { className: string } | null {\n if (wrapper === null) return null\n for (const sibling of [wrapper.previousElementSibling, wrapper.nextElementSibling]) {\n if (sibling !== null && typeof sibling.className === 'string' && sibling.className.includes('editor')) {\n return sibling\n }\n }\n return null\n}\n\n/** The closed-panel style: the outlet stays mounted as the detection anchor. */\nconst HIDDEN_STYLE = { display: 'none' } as const\n\n/** Injected face the card's slot registration supplies. */\nexport interface CommandCodeCardProps {\n t: Translate\n useCommandCodeSettings(selector: (state: SettingsPageState) => T): T\n useCommandCodeLogin(selector: (state: LoginPageState) => T): T\n edit(field: string, text: string): void\n save(): void\n discard(): void\n beginLogin(): void\n cancelLogin(): void\n}\n\n/** The card's two postures. */\ntype CardMode =\n | { kind: 'registration' }\n | { kind: 'live'; ready: boolean; controllerConfigured: boolean; writable: boolean; apiKeyWritable: boolean }\n\n/**\n * Decide the card's posture from one settings snapshot. Pure: the component\n * subscribes once and passes the snapshot in, so hook order never depends\n * on the registration→live transition. (The owner facts stay on the\n * component — only the snapshot decides the posture.)\n */\nexport function cardMode(\n snapshot: SettingsPageState | undefined,\n): CardMode {\n if (snapshot === undefined) return { kind: 'registration' }\n return {\n kind: 'live',\n ready: snapshot.available,\n controllerConfigured: snapshot.apiKeyConfigured,\n writable: snapshot.writable,\n apiKeyWritable: snapshot.apiKeyWritable,\n }\n}\n\n/** Status badge for the credential state (green when configured). */\nfunction StatusBadge({ ok, okLabel, pendingLabel }: {\n ok: boolean\n okLabel: string\n pendingLabel: string\n}) {\n return {ok ? okLabel : pendingLabel}\n}\n\n/** Compact key field for the not-configured card. */\nfunction CardKeyField({ state, disabled, t, onEdit }: {\n state: StagedField\n disabled: boolean\n t: Translate\n onEdit(text: string): void\n}) {\n const [visible, setVisible] = useState(false)\n return (\n
\n
\n \n \n \n \n
\n onEdit(event.target.value)}\n />\n

{t('apiKeyHint')}

\n
\n )\n}\n\n/**\n * The slot component body. Dispatched on every Command Code provider card of\n * the Models page (saved row, first-run setup posture, and add-provider\n * draft).\n *\n * Closed (the official 编辑 toggle off) the panel renders nothing: the row\n * head the Models page owns already names the provider and shows the\n * credential dot, so a page full of providers stays compact. Opening the\n * official editor mounts the editor shell as the outlet's sibling; the panel\n * watches for it, hides the shell (it carries only the settings.yaml hint and\n * a disabled apply for this namespace), and shows the real controls — badges,\n * API-key field, sign-in, discard/save.\n */\nexport function CommandCodeProviderCard(props: CommandCodeCardProps & ProviderCardOwnerProps) {\n const { t } = props\n // Single subscription: the whole snapshot drives posture + body together.\n const state = props.useCommandCodeSettings !== undefined\n ? props.useCommandCodeSettings((snapshot) => snapshot)\n : undefined\n const mode = cardMode(state)\n const login = props.useCommandCodeLogin !== undefined\n ? props.useCommandCodeLogin((snapshot) => snapshot)\n : undefined\n const dirty = state?.dirty ?? false\n const saving = state?.saving ?? false\n const invalid = state?.invalid ?? false\n const failed = state?.failed ?? false\n const savingBlocked = !dirty || invalid\n const configured = mode.kind === 'live' && mode.ready ? mode.controllerConfigured : props.keyConfigured\n const disabled = mode.kind === 'live' && (!mode.writable || (state !== undefined && !mode.apiKeyWritable))\n const showBody = mode.kind === 'live' && mode.ready && state !== undefined\n // The official editor's open state lives in the Models page's own component\n // state and never reaches this slot's props; the outlet wrapper is the\n // stable neighbor, so watch its siblings for the editor's mount/unmount.\n // The outlet stays mounted either way — it is the observation anchor — so\n // the closed panel hides its own root instead of unmounting.\n const rootRef = useRef(null)\n const [editorOpen, setEditorOpen] = useState(false)\n useEffect(() => {\n const root = rootRef.current\n if (root === null || typeof MutationObserver === 'undefined') return\n const wrapper = root.closest(`[data-slot=\"${CARD_SLOT_KEY}\"]`) ?? root.parentElement\n if (wrapper === null) return\n const row = wrapper.parentElement\n if (row === null) return\n let hiddenEditor: HTMLElement | null = null\n const sync = () => {\n const editor = adjacentEditorCard(wrapper) as HTMLElement | null\n setEditorOpen(editor !== null)\n if (editor !== null) {\n // React pins no inline style on the editor shell, so this survives\n // the shell's own re-renders; a shell that unmounts and remounts is\n // re-hidden by the next observation.\n editor.style.display = 'none'\n hiddenEditor = editor\n }\n }\n sync()\n const observer = new MutationObserver(sync)\n observer.observe(row, { childList: true })\n return () => {\n observer.disconnect()\n // If the shell outlives the panel (plugin reload), give it back: the\n // settings.yaml hint is the honest fallback face again.\n if (hiddenEditor !== null) hiddenEditor.style.display = ''\n }\n }, [])\n return (\n \n {editorOpen && mode.kind === 'registration' ?

{t('cardRegistrationHint')}

: null}\n {editorOpen && mode.kind === 'live' && !mode.ready ?

{t('cardLoadingHint')}

: null}\n {editorOpen && showBody ? (\n <>\n
\n
\n {t('cardTitle')}\n \n \n {props.provider.active ? {t('cardRouteActive')} : null}\n \n
\n
\n props.edit('apiKey', text)}\n />\n {login !== undefined ? (\n \n ) : null}\n
\n {failed ?

{t('saveFailed')}

: null}\n \n {t('discard')}\n \n \n {t(saving ? 'saving' : 'save')}\n \n
\n \n ) : null}\n
\n )\n}\n","/**\n * Locale copy for the \"Command Code\" settings page, and the declaration that\n * merges the page's namespace into the framework's `LocaleNamespaceMap` so\n * `ctx.locale.register` / `ctx.slots.register(..., { locale })` are typed.\n *\n * zh is the source of truth for the key set (repo convention); en must carry\n * the exact same keys — a mismatch is a compile error at the register site.\n */\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** Copy of the Command Code settings page. */\n 'settings.commandcode': SettingsCommandCodeKey\n }\n}\n\n/** Dictionary keys of the Command Code settings page. */\nexport type SettingsCommandCodeKey =\n | 'nav'\n | 'title'\n | 'intro'\n | 'apiKey'\n | 'apiKeyHint'\n | 'apiKeySet'\n | 'apiKeyUnset'\n | 'apiKeyLocked'\n | 'apiBase'\n | 'apiBaseHint'\n | 'workingDir'\n | 'workingDirHint'\n | 'requestTimeoutMs'\n | 'requestTimeoutMsHint'\n | 'streamIdleTimeoutMs'\n | 'streamIdleTimeoutMsHint'\n | 'advancedSettings'\n | 'advancedSettingsHint'\n | 'advancedOverriddenOne'\n | 'advancedOverriddenMany'\n | 'advancedInvalid'\n | 'filterModelsByPlan'\n | 'filterModelsByPlanHint'\n | 'webSearch'\n | 'webSearchHint'\n | 'accountsTitle'\n | 'accountsHint'\n | 'accountAdd'\n | 'accountRemove'\n | 'accountLabel'\n | 'accountKey'\n | 'accountKeyHint'\n | 'accountDefault'\n | 'activeAccount'\n | 'activeAccountAuto'\n | 'activeAccountHint'\n | 'rulesTitle'\n | 'rulesHint'\n | 'rulesEmpty'\n | 'rulesCatalogFailed'\n | 'ruleAdd'\n | 'ruleRemove'\n | 'ruleModel'\n | 'ruleModelPick'\n | 'ruleModelCount'\n | 'ruleAccount'\n | 'ruleHint'\n | 'modelSearchPlaceholder'\n | 'modelSearchEmpty'\n | 'modelStale'\n | 'visibleModelsTitle'\n | 'visibleModelsHint'\n | 'visibleModelsPick'\n | 'visibleModelsCount'\n | 'visibleModelsShowAll'\n | 'visibleModelsStaleHint'\n | 'visibleModelsCleanStale'\n | 'overridden'\n | 'reset'\n | 'invalidNumber'\n | 'numberTooSmall'\n | 'numberTooLarge'\n | 'readOnly'\n | 'unsaved'\n | 'save'\n | 'saving'\n | 'saved'\n | 'saveFailed'\n | 'discard'\n | 'cancel'\n | 'show'\n | 'hide'\n | 'usageTitle'\n | 'usageRefresh'\n | 'usageRefreshing'\n | 'usageLoading'\n | 'usageNoKey'\n | 'usageError'\n | 'usageRequests'\n | 'usageFailed'\n | 'usageSuccessRate'\n | 'usageCost'\n | 'usageTokens'\n | 'usageTokensIn'\n | 'usageTokensOut'\n | 'usageMonthly'\n | 'usagePurchased'\n | 'usageFree'\n | 'usageFiveHour'\n | 'usageWeekly'\n | 'usageExceeded'\n | 'usageReset'\n | 'usagePartial'\n | 'usageKeyClear'\n | 'usageKeyClearStaged'\n | 'usageUndoKeyClear'\n | 'usageKeyInvalid'\n | 'usageKeyInvalidHint'\n | 'usageServiceUnavailable'\n | 'usageServiceUnavailableHint'\n | 'usageNetworkError'\n | 'usageNetworkHint'\n | 'usageUpdated'\n | 'usagePeriodEnd'\n | 'usageActive'\n | 'usageCooldown'\n | 'usageInvalidKey'\n | 'usageUnconfigured'\n | 'updateAvailable'\n | 'updateHint'\n | 'loginTitle'\n | 'loginHintIdle'\n | 'loginButton'\n | 'loginStarting'\n | 'loginWaiting'\n | 'loginOpenLink'\n | 'loginCancel'\n | 'loginSuccess'\n | 'loginUnavailable'\n | 'loginDenied'\n | 'loginTimeout'\n | 'loginInvalidKey'\n | 'loginNetwork'\n | 'loginStoreFailed'\n | 'loginCancelled'\n | 'loginFailedGeneric'\n | 'cardTitle'\n | 'cardRouteActive'\n | 'cardLoadingHint'\n | 'cardRegistrationHint'\n\nexport const zh: Record = {\n nav: 'Command Code',\n title: 'Command Code',\n intro:\n '配置 Command Code Provider 连接。API 密钥仅保存在本机凭据服务中,不会回显;'\n + '其他字段写入用户设置,下次请求即生效。',\n apiKey: 'API 密钥',\n apiKeyHint: '在 commandcode.ai 控制台创建。留空保存不会覆盖已存储的密钥。',\n apiKeySet: '已配置',\n apiKeyUnset: '未配置',\n apiKeyLocked: '密钥由只读来源提供',\n apiBase: 'API 地址',\n apiBaseHint: '默认 https://api.commandcode.ai,一般无需修改。',\n workingDir: '工作目录',\n workingDirHint: '可选。留空时使用占位符显示的进程工作目录;仅在需要固定路径时填写。',\n requestTimeoutMs: '请求超时(毫秒)',\n requestTimeoutMsHint: '等待响应首个字节的超时;默认 60000。',\n streamIdleTimeoutMs: '流空闲超时(毫秒)',\n streamIdleTimeoutMsHint: '生成流停滞多久视为断连;默认 300000(长思考模型可静默数分钟,默认值刻意放宽)。',\n advancedSettings: '高级设置',\n advancedSettingsHint: 'API 地址、工作目录、超时与模型过滤等不常修改的选项。',\n advancedOverriddenOne: '已自定义 1 项',\n advancedOverriddenMany: '已自定义 {count} 项',\n advancedInvalid: '高级设置中有未填好的数字,请展开修正后再保存。',\n filterModelsByPlan: '隐藏套餐外模型',\n filterModelsByPlanHint: '开启后,模型选择器只列出当前套餐可用的模型;账户持有按需余额时会显示全部。',\n webSearch: '用 Command Code 承载联网搜索',\n webSearchHint: '开启后,dsh 的 web_search 工具由 Command Code 承担(复用同一个 API key 与地址),并优先于其他搜索后端;关闭则把选择权交还给之前的后端(如 modsearch),而不是强制回退到 DeepSeek 搜索。',\n accountsTitle: '多账户轮换',\n accountsHint: '当前账户达到用量限额(429)或密钥失效(401)时,请求自动切换到下一个账户;全部耗尽时会提示最早的重置时间。',\n accountAdd: '添加账户',\n accountRemove: '移除',\n accountLabel: '账户备注名',\n accountKey: 'API 密钥',\n accountKeyHint: '该账户的 API 密钥。留空保存不会覆盖已存储的密钥。',\n accountDefault: '默认账户',\n activeAccount: '当前使用账户',\n activeAccountAuto: '自动(第一个可用账户)',\n activeAccountHint: '手动指定优先使用的账户,保存后下次请求即生效;所选账户耗尽时仍会自动切换到其他可用账户。',\n rulesTitle: '按模型切换账户',\n rulesHint: '选择模型并路由到某个账户(可多选)。命中规则的模型且该账户可用时优先使用;账户耗尽或密钥失效时仍自动回落到其他账户。规则按列表顺序匹配,第一条命中生效。',\n rulesEmpty: '尚未配置规则。',\n rulesCatalogFailed: '模型目录获取失败,暂时无法选择模型;已保存的规则仍会生效。',\n ruleAdd: '添加规则',\n ruleRemove: '移除',\n ruleModel: '模型',\n ruleModelPick: '选择模型…',\n ruleModelCount: '已选 {count} 个模型',\n ruleAccount: '目标账户',\n ruleHint: '从下拉列表勾选要路由的模型(可多选),再选择目标账户。',\n modelSearchPlaceholder: '搜索模型…',\n modelSearchEmpty: '没有匹配的模型。',\n modelStale: '已下架',\n visibleModelsTitle: '模型白名单',\n visibleModelsHint:\n '勾选要保留的模型,模型选择器就只列出这些;一个都不勾选时则显示全部模型。'\n + '保存后,下次打开模型选择器生效。',\n visibleModelsPick: '选择要保留的模型…',\n visibleModelsCount: '已选 {count} 个模型',\n visibleModelsShowAll: '显示全部',\n visibleModelsStaleHint: '有 {count} 个已选模型在目录中找不到了(可能已下架),不影响其他模型;可清理或保留。',\n visibleModelsCleanStale: '清理失效({count})',\n overridden: '已覆盖',\n reset: '重置',\n invalidNumber: '无效数字',\n numberTooSmall: '不能小于 1(毫秒)',\n numberTooLarge: '超出允许上限(2147483647 毫秒)',\n readOnly: '当前配置为只读。',\n unsaved: '未保存',\n save: '保存',\n saving: '保存中',\n saved: '已保存 ✓',\n saveFailed: '保存失败,请重试。',\n discard: '放弃',\n cancel: '取消',\n show: '显示',\n hide: '隐藏',\n usageTitle: '账户用量',\n usageRefresh: '刷新',\n usageRefreshing: '刷新中…',\n usageLoading: '正在获取账户用量…',\n usageNoKey: '配置 API 密钥后,这里会显示账户的用量与额度状态。',\n usageError: '用量获取失败',\n usageRequests: '请求',\n usageFailed: '失败',\n usageSuccessRate: '成功率',\n usageCost: '花费',\n usageTokens: 'Token',\n usageTokensIn: '入',\n usageTokensOut: '出',\n usageMonthly: '月额度',\n usagePurchased: '已购',\n usageFree: '赠送',\n usageFiveHour: '5 小时窗口',\n usageWeekly: '每周窗口',\n usageExceeded: '已超限',\n usageReset: '重置于',\n usagePartial: '部分端点数据不可用',\n usageKeyClear: '清除已存密钥',\n usageKeyClearStaged: '将清除(保存后生效)',\n usageUndoKeyClear: '撤销清除',\n usageKeyInvalid: 'API 密钥无效或已过期',\n usageKeyInvalidHint: '服务端拒绝了全部请求(401)。请检查该账户配置的密钥,或到 commandcode.ai 控制台重新生成。',\n usageServiceUnavailable: 'Command Code 服务暂时不可用',\n usageServiceUnavailableHint: '服务端返回了错误(5xx),稍后点击刷新重试。',\n usageNetworkError: '无法连接 Command Code 服务',\n usageNetworkHint: '所有请求都没有到达服务端。请检查网络连接或 API 地址设置。',\n usageUpdated: '更新于',\n usagePeriodEnd: '账期截止',\n usageActive: '当前使用',\n usageCooldown: '限额冷却中',\n usageInvalidKey: '密钥无效',\n usageUnconfigured: '该账户尚未配置 API 密钥。',\n updateAvailable: '可更新',\n updateHint: '已发布新版本,点击查看发布说明;更新插件后刷新本页,提示会自动消失。',\n loginTitle: '通过官方登录获取密钥',\n loginHintIdle: '不想手动创建密钥?点击登录后浏览器会打开 commandcode.ai 授权页,完成后密钥自动写入本机凭据服务,下次请求即生效。',\n loginButton: '登录 Command Code',\n loginStarting: '正在启动本地回调服务…',\n loginWaiting: '等待在浏览器中完成授权…',\n loginOpenLink: '打开授权页面 ↗',\n loginCancel: '取消登录',\n loginSuccess: '已登录为',\n loginUnavailable: '此环境暂不支持登录流程,请手动粘贴密钥。',\n loginDenied: '授权被拒绝。可重试,或手动粘贴密钥。',\n loginTimeout: '等待超时:未在窗口期内收到授权回调,请重试。',\n loginInvalidKey: '获取到的密钥未通过校验(401),请重试或手动粘贴。',\n loginNetwork: '无法连接 Command Code 服务校验密钥,请检查网络后重试。',\n loginStoreFailed: '密钥无法写入本机凭据服务,请手动粘贴。',\n loginCancelled: '登录已取消。',\n loginFailedGeneric: '登录失败,请重试或手动粘贴密钥。',\n cardTitle: 'Command Code',\n cardRouteActive: '已启用',\n cardLoadingHint: '正在读取 Command Code 配置…',\n cardRegistrationHint: '此卡片随 Command Code 插件注册,需要较新版本的 DeepSeek Harness 才会显示完整内容。',\n}\n\nexport const en: Record = {\n nav: 'Command Code',\n title: 'Command Code',\n intro:\n 'Configure the Command Code Provider connection. The API key is stored only'\n + ' in the local credential service and never echoed; other fields are written'\n + ' to user settings and take effect on the next request.',\n apiKey: 'API key',\n apiKeyHint: 'Create one in the commandcode.ai console. Saving with this field'\n + ' blank keeps the stored key.',\n apiKeySet: 'Configured',\n apiKeyUnset: 'Not configured',\n apiKeyLocked: 'Key provided by a read-only source',\n apiBase: 'API base URL',\n apiBaseHint: 'Defaults to https://api.commandcode.ai; usually leave as-is.',\n workingDir: 'Working directory',\n workingDirHint: 'Optional. Leave blank to use the process cwd shown as the'\n + ' placeholder; fill in only to pin a specific path.',\n requestTimeoutMs: 'Request timeout (ms)',\n requestTimeoutMsHint: 'Time to wait for the first response byte; default 60000.',\n streamIdleTimeoutMs: 'Stream idle timeout (ms)',\n streamIdleTimeoutMsHint: 'How long a stalled stream is treated as dead; default 300000'\n + ' (deliberately generous — long-thinking models can stay silent for minutes).',\n advancedSettings: 'Advanced',\n advancedSettingsHint: 'Rarely touched options: API base URL, working directory, timeouts, and model filtering.',\n advancedOverriddenOne: '1 customized',\n advancedOverriddenMany: '{count} customized',\n advancedInvalid: 'A number in Advanced settings is not ready to save; expand to fix it.',\n filterModelsByPlan: 'Hide out-of-plan models',\n filterModelsByPlanHint: 'When on, the model picker lists only models your subscription'\n + ' includes; any on-demand credit balance shows the full catalog.',\n webSearch: 'Serve dsh web search with Command Code',\n webSearchHint: 'When on, the model-facing web_search tool is backed by Command Code'\n + ' (same API key and base URL as chat), winning over other search backends.'\n + ' Off hands the selection back to the previous backend (e.g. modsearch)'\n + ' instead of forcing the shipped DeepSeek search.',\n accountsTitle: 'Account rotation',\n accountsHint: 'When the active account hits its usage limit (429) or its key'\n + ' fails (401), requests switch to the next account; when every account is'\n + ' exhausted the error names the earliest window reset.',\n accountAdd: 'Add account',\n accountRemove: 'Remove',\n accountLabel: 'Account label',\n accountKey: 'API key',\n accountKeyHint: 'This account’s API key. Saving with the field blank keeps the stored key.',\n accountDefault: 'Default account',\n activeAccount: 'Active account',\n activeAccountAuto: 'Auto (first usable account)',\n activeAccountHint: 'Pin the preferred account; applies to the next request after saving.'\n + ' If the selected account is exhausted, requests still rotate to another usable account.',\n rulesTitle: 'Route models to accounts',\n rulesHint: 'Pick models (multi-select) and route them to an account. When the'\n + ' request’s model is in a rule and that account is usable, it serves;'\n + ' an exhausted or invalid routed account falls back to the normal rotation.'\n + ' Rules match in list order — the first hit wins.',\n rulesEmpty: 'No rules yet.',\n rulesCatalogFailed: 'Could not load the model catalog — selecting models is unavailable; saved rules still apply.',\n ruleAdd: 'Add rule',\n ruleRemove: 'Remove',\n ruleModel: 'Models',\n ruleModelPick: 'Select models…',\n ruleModelCount: '{count} model(s) selected',\n ruleAccount: 'Target account',\n ruleHint: 'Check the models to route from the dropdown (multi-select), then pick the target account.',\n modelSearchPlaceholder: 'Search models…',\n modelSearchEmpty: 'No matching models.',\n modelStale: 'Retired',\n visibleModelsTitle: 'Model allowlist',\n visibleModelsHint:\n 'Check the models you want to keep, and model pickers will list only those. '\n + 'If nothing is checked, every model is shown. After saving, the change '\n + 'applies the next time you open a model picker.',\n visibleModelsPick: 'Select models to keep…',\n visibleModelsCount: '{count} model(s) selected',\n visibleModelsShowAll: 'Show all',\n visibleModelsStaleHint: '{count} selected model(s) are no longer in the catalog (possibly retired);'\n + ' other models are unaffected. Clean them up or keep them.',\n visibleModelsCleanStale: 'Clean stale ({count})',\n overridden: 'Overridden',\n reset: 'Reset',\n invalidNumber: 'Invalid number',\n numberTooSmall: 'Must be at least 1 (ms)',\n numberTooLarge: 'Above the allowed maximum (2147483647 ms)',\n readOnly: 'Settings are read-only.',\n unsaved: 'Unsaved',\n save: 'Save',\n saving: 'Saving',\n saved: 'Saved ✓',\n saveFailed: 'Save failed, please retry.',\n discard: 'Discard',\n cancel: 'Cancel',\n show: 'Show',\n hide: 'Hide',\n usageTitle: 'Account usage',\n usageRefresh: 'Refresh',\n usageRefreshing: 'Refreshing…',\n usageLoading: 'Fetching account usage…',\n usageNoKey: 'Configure an API key to see this account’s usage and credit state here.',\n usageError: 'Could not fetch usage',\n usageRequests: 'Requests',\n usageFailed: 'failed',\n usageSuccessRate: 'Success rate',\n usageCost: 'Spend',\n usageTokens: 'Tokens',\n usageTokensIn: 'in',\n usageTokensOut: 'out',\n usageMonthly: 'Monthly',\n usagePurchased: 'Purchased',\n usageFree: 'Free',\n usageFiveHour: '5-hour window',\n usageWeekly: 'Weekly window',\n usageExceeded: 'Exceeded',\n usageReset: 'Resets',\n usagePartial: 'Some endpoint data unavailable',\n usageKeyClear: 'Clear stored key',\n usageKeyClearStaged: 'Will be cleared on save',\n usageUndoKeyClear: 'Undo clear',\n usageKeyInvalid: 'API key invalid or expired',\n usageKeyInvalidHint: 'The server rejects every request (401). Check the key configured for this account, or generate a new one in the commandcode.ai console.',\n usageServiceUnavailable: 'The Command Code service is temporarily unavailable',\n usageServiceUnavailableHint: 'The server returned errors (5xx); try Refresh again later.',\n usageNetworkError: 'Could not reach the Command Code service',\n usageNetworkHint: 'No request reached the server. Check your network connection or the API base setting.',\n usageUpdated: 'Updated',\n usagePeriodEnd: 'Period ends',\n usageActive: 'Active',\n usageCooldown: 'Cooling down',\n usageInvalidKey: 'Invalid key',\n usageUnconfigured: 'No API key configured for this account yet.',\n updateAvailable: 'update available',\n updateHint: 'A newer version has been published; click for release notes. The notice disappears once the plugin is updated.',\n loginTitle: 'Sign in to fetch a key',\n loginHintIdle: 'Rather not create a key by hand? Sign in and your browser opens the commandcode.ai authorization page; the approved key is stored in the local credential service and applies to the next request.',\n loginButton: 'Sign in to Command Code',\n loginStarting: 'Starting the local callback server…',\n loginWaiting: 'Waiting for authorization in your browser…',\n loginOpenLink: 'Open the authorization page ↗',\n loginCancel: 'Cancel sign-in',\n loginSuccess: 'Signed in as',\n loginUnavailable: 'Sign-in is unavailable in this environment; paste the API key instead.',\n loginDenied: 'Authorization was denied. Try again or paste the key manually.',\n loginTimeout: 'Timed out waiting for the authorization callback; try again.',\n loginInvalidKey: 'The delivered key failed validation (401). Try again or paste it manually.',\n loginNetwork: 'Could not reach the Command Code service to validate the key; check your network and retry.',\n loginStoreFailed: 'The key could not be stored in the local credential service; paste it manually.',\n loginCancelled: 'Sign-in cancelled.',\n loginFailedGeneric: 'Sign-in failed; try again or paste the key manually.',\n cardTitle: 'Command Code',\n cardRouteActive: 'Active',\n cardLoadingHint: 'Loading the Command Code configuration…',\n cardRegistrationHint: 'This card is contributed by the Command Code plugin; a newer DeepSeek Harness is needed to show the full controls.',\n}\n","/**\n * Browser half of the dsh-commandcode-provider bundle.\n *\n * Two responsibilities:\n *\n * 1. A \"Command Code\" settings page (a `settings.section` entry at the same\n * nav level as General / Models / Plugins). The Models page renders an\n * unknown-adapter-family card for the `commandcode` provider and disables\n * its submit, so the API key cannot be configured there; this page is the\n * dedicated surface. It writes the API key through the credentials domain\n * (the `COMMANDCODE_API_KEY` reference the plugin resolves via\n * `ctx.remote.credentials`) and the connection facts through the\n * `llm-commandcode` settings namespace, so a saved key or endpoint reaches\n * the very next request.\n *\n * 2. The Models-page provider card (settings.models.provider-card) and the\n * friendly image-gate error wrapper — see `./card.tsx` / `./sessions.ts`.\n * The wrapper is deliberately narrow: only the `model-unavailable` code is\n * rewritten, only when the message matches the image-session gate, and only\n * the message text changes.\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport { createSnapshotStore } from './snapshot-store.ts'\n// Type-only imports that pull in the client-service augmentations\n// (`slots`/`remote`/`locale` on Context) and the `settings.section` SlotMap\n// entry (`settingsScope` arrives through dsh-client-ui-settings).\nimport type {} from '@deepseek-ai/dsh-api-remotes/client'\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\nimport type {} from '@deepseek-ai/dsh-client-ui-renderer/client'\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { installFriendlyImageError } from './sessions.ts'\nimport type { ConnectionLike } from './sessions.ts'\nimport { CommandCodeSettingsController, COMMANDCODE_NS, type SettingsPageState } from './settings.ts'\nimport type { HostDescriptionSource, SettingsPageApi } from './settings.ts'\nimport { adaptLegacyCredentials, type LegacyCredentialsApi } from './legacy-credentials.ts'\nimport { CommandCodeUsageController, type UsagePageState, type UsageRemote } from './usage.ts'\nimport { CommandCodeLoginController, type LoginPageState, type LoginRemote } from './login.ts'\nimport { USAGE_REMOTE_CONTRIBUTION, MODELS_REMOTE_CONTRIBUTION } from '../usage-wire.ts'\nimport { LOGIN_REMOTE_CONTRIBUTION } from '../login-wire.ts'\nimport type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'\nimport { CommandCodeSettingsPage } from './section.tsx'\nimport { CommandCodeProviderCard } from './card.tsx'\nimport { zh, en } from './locales.ts'\n\nexport { isImageSessionRejection, withFriendlyImageError } from './sessions.ts'\n\n/** CSS for the settings page, injected once (harness bundle convention). */\nconst PAGE_CSS = `\n.cc-section{max-width:720px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}\n.cc-title{margin:0;font-size:18px;font-weight:600}\n.cc-intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px;line-height:1.5}\n.cc-readOnly{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}\n.cc-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:4px 16px}\n.cc-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}\n.cc-field+.cc-field{border-top:1px solid var(--dsw-alias-border-l2)}\n.cc-fieldHead{align-items:center;gap:8px;display:flex}\n.cc-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}\n.cc-badges{align-items:center;gap:8px;display:inline-flex}\n.cc-badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}\n.cc-badgeMuted{white-space:nowrap;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px}\n.cc-reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}\n.cc-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}\n.cc-reset:disabled{cursor:default;opacity:.5}\n.cc-input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}\n.cc-input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}\n.cc-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}\n/* The routing-rule model multi-select: a button trigger that opens an\n * anchored Menu of checkbox rows. The trigger mirrors .cc-input sizing so it\n * sits flush with the sibling account select. */\n.cc-ruleTrigger{align-items:center;gap:8px;display:flex;width:100%;text-align:left;cursor:pointer}\n.cc-ruleTrigger:disabled{cursor:default}\n.cc-ruleTriggerText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.cc-ruleCaret{flex-shrink:0;border-right:1.5px solid var(--dsw-alias-label-tertiary);border-bottom:1.5px solid var(--dsw-alias-label-tertiary);width:6px;height:6px;margin-right:4px;margin-bottom:2px;transform:rotate(45deg)}\n.cc-checkRow{align-items:center;gap:8px;display:inline-flex;min-width:0}\n.cc-checkRow:hover{cursor:pointer}\n.cc-check{appearance:none;flex-shrink:0;width:15px;height:15px;margin:0;border:1px solid var(--dsw-alias-border-l2);border-radius:4px;background:var(--dsw-alias-bg-layer-1);position:relative}\n.cc-check:checked{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}\n.cc-check:checked::after{content:'';position:absolute;top:2px;left:5px;width:3px;height:7px;border:solid #fff;border-width:0 1.5px 1.5px 0;transform:rotate(45deg)}\n.cc-checkName{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n/* The model multi-select search box: stacked under the trigger while the\n * dropdown is open, same input sizing so the pair reads as one control. The\n * box lives inside the Menu anchor (which renders inside the Menu's root\n * span), so focusing/typing it never trips the Menu's outside-click close. */\n.cc-modelSelectAnchor{flex-direction:column;gap:6px;display:flex;width:100%}\n.cc-modelSearch{width:100%}\n.cc-modelSearch::-webkit-search-cancel-button{cursor:pointer}\n/* Selects need their own treatment to sit flush with the text inputs:\n * the UA stylesheet renders \n

{t('activeAccountHint')}

\n \n {state.accounts.map((account) => (\n onLabel(account.id, text)}\n onKey={(text) => onKey(account.id, text)}\n onToggleClear={() => onToggleClear(account.id)}\n onRemove={() => onRemove(account.id)}\n />\n ))}\n \n )\n}\n\n/** One model → account routing rule row. */\nfunction RuleRow({ rule, accounts, catalog, disabled, t, onModels, onAccount, onRemove }: {\n rule: RuleItemState\n accounts: AccountItemState[]\n catalog: CatalogModelOption[]\n disabled: boolean\n t: Translate\n onModels(ids: string[]): void\n onAccount(text: string): void\n onRemove(): void\n}) {\n const targets = [\n { value: 'default', label: t('accountDefault') },\n ...accounts.filter((account) => !account.added).map((account) => ({ value: account.ref, label: account.label })),\n ]\n return (\n
\n
\n \n \n {rule.added ? {t('unsaved')} : null}\n \n \n
\n \n onAccount(event.target.value)}\n aria-label={t('ruleAccount')}\n >\n {targets.map((target) => (\n \n ))}\n \n

{t('ruleHint')}

\n
\n )\n}\n\n/**\n * A checkbox multi-select dropdown for picking catalog models (the\n * routing-rule rows and the visible-models filter share it). The trigger\n * shows the selection count; the Menu lists every catalog model with a\n * checkbox, toggled by clicking the row. A search box under the trigger\n * (inside the Menu anchor, so focusing it never trips the outside-click\n * close) filters the list by id/display-name substring, and items group\n * under plan-tier headings in picker order. Selected ids the catalog no\n * longer carries still render — flagged stale — so a saved selection never\n * silently loses an entry, and the VisibleModelsCard offers a one-click\n * cleanup.\n */\nfunction ModelMultiSelect({ id, selected, catalog, disabled, t, onSelect }: {\n id: string\n selected: string[]\n catalog: CatalogModelOption[]\n disabled: boolean\n t: Translate\n onSelect(ids: string[]): void\n}) {\n const [open, setOpen] = useState(false)\n const [query, setQuery] = useState('')\n // The search box must not inherit a stale query from a previous open.\n useEffect(() => {\n if (open) setQuery('')\n }, [open])\n // Tier headings come from the catalog entries themselves (the Host stamps\n // each entry's plan-tier key on the Remote); rebuild only when the catalog\n // changes.\n const tiers = useMemo(\n () => Object.fromEntries(\n catalog.flatMap((model) => model.tier === undefined ? [] : [[model.id, model.tier] as const]),\n ),\n [catalog],\n )\n // The catalog is sorted for picking; append any selected ids the catalog no\n // longer carries (removed upstream) so the current selection stays visible.\n const options = buildModelSelectOptions(catalog, selected, query)\n const groups = groupModelSelectOptions(options, (modelId) => tierHeadingFor(modelId, tiers))\n const items: MenuEntry[] = groups.flatMap((group) => [\n ...(group.heading === undefined\n ? []\n : [{ type: 'label' as const, id: `cc-tier-${group.heading}`, text: group.heading }]),\n ...group.options.map((option) => ({\n id: option.value,\n label: (\n \n \n {option.label}\n {option.stale ? {t('modelStale')} : null}\n \n ),\n })),\n ])\n // The search box lives INSIDE the Menu anchor (which renders in place\n // inside the Menu's root span): a pointerdown there counts as \"inside\",\n // so focusing/typing never trips the Menu's outside-click close. A box\n // rendered as a sibling would close the Menu on the first click.\n return (\n setOpen(false)}\n onSelect={(modelId) => {\n onSelect(toggleModelSelection(selected, modelId))\n }}\n selectedIds={selected}\n items={items}\n footer={options.length === 0 ? [{\n type: 'label' as const,\n id: 'cc-model-search-empty',\n text: t('modelSearchEmpty'),\n }] : []}\n portal\n anchor={\n \n setOpen((value) => !value)}\n >\n \n {selected.length === 0 ? t('ruleModelPick') : t('ruleModelCount', { count: selected.length })}\n \n \n \n {open ? (\n setQuery(event.target.value)}\n />\n ) : null}\n \n }\n />\n )\n}\n\n/** The model → account routing card: rules in list order (first match wins). */\nfunction RulesCard({ t, state, disabled, onAdd, onRemove, onModels, onAccount }: {\n t: Translate\n state: SettingsPageState\n disabled: boolean\n onAdd(): void\n onRemove(id: string): void\n onModels(id: string, ids: string[]): void\n onAccount(id: string, text: string): void\n}) {\n return (\n
\n
\n
\n \n \n \n \n
\n

{t('rulesHint')}

\n {state.catalogFailed ?

{t('rulesCatalogFailed')}

: null}\n
\n {state.rules.map((rule) => (\n onModels(rule.id, ids)}\n onAccount={(text) => onAccount(rule.id, text)}\n onRemove={() => onRemove(rule.id)}\n />\n ))}\n {state.rules.length === 0 ?

{t('rulesEmpty')}

: null}\n
\n )\n}\n\n/** The visible-model filter card: an allowlist over the catalog. Empty = show all. */\nfunction VisibleModelsCard({ t, state, disabled, onSelect, onClear }: {\n t: Translate\n state: SettingsPageState\n disabled: boolean\n onSelect(ids: string[]): void\n onClear(): void\n}) {\n const count = state.visibleModels.length\n const pickT: Translate = (key, params) => {\n if (key === 'ruleModelPick') return t('visibleModelsPick')\n if (key === 'ruleModelCount') return t('visibleModelsCount', params)\n return t(key, params)\n }\n // Selected ids the live catalog no longer carries (retired upstream):\n // kept, flagged stale in the dropdown, removable in one click. Never\n // auto-dropped — an empty catalog (fetch failure) must not wipe the list.\n // \"Stale\" is only meaningful against a catalog we actually hold, so both the\n // cleanup button and its hint are gated on catalogIsReady: before the first\n // fetch lands (and after a failure) the empty catalog makes every selection\n // look retired, turning the one-click cleanup into a button that silently\n // empties the allowlist. The explicit \"show all\" entry stays available\n // either way — clearing the list is then the user's stated intent rather\n // than an inference from missing data.\n const readiness = { catalogIds: state.catalogModels.map((model) => model.id), catalogFailed: state.catalogFailed }\n const staleIds = staleModelIds(state.visibleModels, readiness)\n const catalogReady = catalogIsReady(readiness)\n return (\n
\n
\n
\n \n \n {catalogReady && staleIds.length > 0 ? (\n onSelect(state.visibleModels.filter((id) => !staleIds.includes(id)))}\n >\n {t('visibleModelsCleanStale', { count: staleIds.length })}\n \n ) : null}\n {count > 0 ? (\n \n ) : null}\n \n
\n

{t('visibleModelsHint')}

\n {state.catalogFailed ?

{t('rulesCatalogFailed')}

: null}\n {staleIds.length > 0 && catalogReady ? (\n

{t('visibleModelsStaleHint', { count: staleIds.length })}

\n ) : null}\n \n
\n
\n )\n}\n\n/**\n * Show the \"Saved ✓\" affordance for a short window after each accepted save.\n * The controller only counts saves (`savedCount`); the flash timing lives\n * here so the state machine stays timer-free.\n */\nfunction useSavedFlash(tick: number): boolean {\n const [visible, setVisible] = useState(false)\n useEffect(() => {\n if (tick === 0) return\n setVisible(true)\n const timer = setTimeout(() => setVisible(false), 2500)\n return () => clearTimeout(timer)\n }, [tick])\n return visible\n}\n\n/**\n * The update hint: one throttled npm-registry check per page open (the\n * throttle and all failure handling live in ./update.ts). Resolves to the\n * newest published version when it is newer than this build, else undefined —\n * every failure mode degrades to no hint at all.\n */\nfunction usePluginUpdate(): string | undefined {\n const [available, setAvailable] = useState(undefined)\n useEffect(() => {\n let cancelled = false\n void checkForUpdate({\n currentVersion: PLUGIN_VERSION,\n now: Date.now(),\n store: localStorageUpdateStore(),\n }).then((version) => {\n if (!cancelled) setAvailable(version)\n // A rejected checkForUpdate would be a bug (it catches internally);\n // swallow it regardless — the footer must never break the page.\n }, () => {})\n return () => {\n cancelled = true\n }\n }, [])\n return available\n}\n\n/** The settings page body: connection facts for the Command Code provider. */\nexport function CommandCodeSettingsPage(props: CommandCodeSettingsProps) {\n const { t } = props\n const state = props.useCommandCodeSettings((snapshot) => snapshot)\n const usage = props.useCommandCodeUsage((snapshot) => snapshot)\n const login = props.useCommandCodeLogin((snapshot) => snapshot)\n const disabled = !state.writable\n const keyLocked = !state.apiKeyWritable\n const savedVisible = useSavedFlash(state.savedCount)\n const updateVersion = usePluginUpdate()\n return (\n
\n

{t('title')}

\n

{t('intro')}

\n {!state.writable ?

{t('readOnly')}

: null}\n account.id)}\n canManage={state.writable}\n onRefresh={props.refreshUsage}\n onRemoveAccount={props.removeAccount}\n />\n props.toggleKeyClear(id)}\n onActive={(text) => props.edit('activeAccount', text)}\n onActiveReset={() => props.resetField('activeAccount')}\n />\n \n \n
\n props.edit('apiKey', text)}\n onToggleClear={() => props.toggleKeyClear('default')}\n />\n \n
\n \n
\n {state.failed ?

{t('saveFailed')}

: null}\n {savedVisible ?

{t('saved')}

: null}\n \n \n {t(state.saving ? 'saving' : 'save')}\n \n
\n

\n Command Code Provider v{PLUGIN_VERSION}\n {updateVersion !== undefined ? (\n <>\n {' · '}\n \n v{updateVersion} {t('updateAvailable')}\n \n \n ) : null}\n

\n
\n )\n}\n","/**\n * The Command Code configuration panel inside the harness Models settings page\n * (browser half). Rendered through the `settings.models.provider-card` keyed\n * slot available in dsh 0.1.2 (rc.1), registered with\n * `entryKey = 'llm-commandcode'` (the plugin's settings namespace, the key the\n * Models page dispatches for every Command Code provider row).\n *\n * The official Models page opens one editor card per provider row through its\n * own 编辑 button. For a namespace the page does not curate a layout for\n * (`llm-commandcode`), that editor is a bare shell — a pointer to\n * `settings.yaml` above a permanently disabled apply button. This panel takes\n * its place: the slot outlet renders right beside the official editor inside\n * the same row card, so the component watches the outlet's siblings and, while\n * the official editor is open, hides the useless shell and shows the real\n * controls in its slot — the credential/route badges, the API-key field,\n * official sign-in, and the discard/save footer. Closed, it renders nothing\n * and the row looks exactly like any other provider row.\n *\n * The slot's owner props (`configured`, `keyConfigured`) mirror what the\n * Models page already knows; the authoritative credential facts still come\n * from this plugin's `CommandCodeSettingsController` shared with the dedicated\n * settings page, so the two surfaces can never disagree about whether a key\n * is stored.\n *\n * A controller-less render (panel mounted before the section registered its\n * inject face — the composition runs one apply) degrades to the stateless\n * registration notice inside the opened panel.\n *\n * Styles ride the page stylesheet the client entry injects once (`cc-`\n * prefixed classes); the card adds no CSS of its own.\n */\n\nimport { useEffect, useRef, useState } from 'react'\nimport type { Translate } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SettingsCommandCodeKey } from './locales.ts'\nimport type { SettingsPageState, StagedField } from './settings.ts'\nimport type { LoginPageState } from './login.ts'\nimport { LoginRow } from './login-row.tsx'\n\n/**\n * The Models-page extension slots, merged into the SlotMap with the exact\n * declarations dsh 0.1.2 (rc.1)'s ui-settings-models ships. The merge must\n * stay structurally identical to upstream's (kind/scope/owner), or a future\n * dsh carrying its own declaration would fail the duplicate-merge check at\n * compile time.\n */\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface SlotMap {\n /** One provider card's adapter extension area, keyed by the row's settingsNs. */\n 'settings.models.provider-card': { kind: 'keyed'; scope: 'root'; owner: ProviderCardExtrasOwnerProps }\n /** Ordered extension area after the provider rows and the add controls. */\n 'settings.models.footer': { kind: 'list'; scope: 'root'; owner: ModelsFooterOwnerProps }\n }\n}\n\n/** The provider directory row as the Models page dispatches it. */\nexport interface ProviderDirectoryRow {\n /** The provider route id (`commandcode` for this plugin). */\n readonly provider: string\n /** The row's display name. */\n readonly displayName: string\n /** The settings namespace the row configures (the slot's dispatch key). */\n readonly settingsNs: string\n /** The settings path the row's profile lives at. */\n readonly settingsPath: readonly string[]\n /** Whether the provider route is live. */\n readonly active: boolean\n /** Whether the adapter declares the route as shipped. */\n readonly declared?: boolean\n}\n\n/** Owner share of one provider-card extension occurrence (upstream's shape). */\nexport interface ProviderCardExtrasOwnerProps {\n /** The card's directory row. */\n readonly provider: ProviderDirectoryRow\n /** Whether any layer configures this provider (its profile resolves). */\n readonly configured: boolean\n /** The row's referenced api-key credential, confirmed configured by the page's join. */\n readonly keyConfigured: boolean\n}\n\n/** Owner share of the footer area (the section supplies nothing). */\nexport interface ModelsFooterOwnerProps {\n /** Marker field: footer owner props are intentionally empty. */\n children?: never\n}\n\n/** Owner props the Models page supplies at its dispatch sites. */\nexport type ProviderCardOwnerProps = ProviderCardExtrasOwnerProps\n\n/**\n * The slot whose outlet anchors this panel inside the Models row — the\n * renderer's own `data-slot` attribute value, stable across builds (unlike\n * CSS-module class hashes).\n */\nexport const CARD_SLOT_KEY = 'settings.models.provider-card'\n\n/** The DOM facts the sibling lookup reads (satisfied by real Elements). */\nexport interface SlotWrapperSiblings {\n previousElementSibling: { className: string } | null\n nextElementSibling: { className: string } | null\n}\n\n/**\n * Find the official editor card among the slot outlet's siblings, or null\n * while it is closed. The Models page renders the editor as an immediate\n * sibling of the outlet wrapper — after it in a provider row (the target of\n * the row's 编辑 toggle), before it in the first-run setup card and the\n * add-provider card, where it is always open. The editor is the only such\n * sibling whose CSS module class carries the `editor` stem\n * (`_editor`); the row header and the add card's provider select\n * never do, so the lookup needs no hash knowledge.\n */\nexport function adjacentEditorCard(wrapper: SlotWrapperSiblings | null): { className: string } | null {\n if (wrapper === null) return null\n for (const sibling of [wrapper.previousElementSibling, wrapper.nextElementSibling]) {\n if (sibling !== null && typeof sibling.className === 'string' && sibling.className.includes('editor')) {\n return sibling\n }\n }\n return null\n}\n\n/** The closed-panel style: the outlet stays mounted as the detection anchor. */\nconst HIDDEN_STYLE = { display: 'none' } as const\n\n/** Injected face the card's slot registration supplies. */\nexport interface CommandCodeCardProps {\n t: Translate\n useCommandCodeSettings(selector: (state: SettingsPageState) => T): T\n useCommandCodeLogin(selector: (state: LoginPageState) => T): T\n edit(field: string, text: string): void\n save(): void\n discard(): void\n beginLogin(): void\n cancelLogin(): void\n}\n\n/** The card's two postures. */\ntype CardMode =\n | { kind: 'registration' }\n | { kind: 'live'; ready: boolean; controllerConfigured: boolean; writable: boolean; apiKeyWritable: boolean }\n\n/**\n * Decide the card's posture from one settings snapshot. Pure: the component\n * subscribes once and passes the snapshot in, so hook order never depends\n * on the registration→live transition. (The owner facts stay on the\n * component — only the snapshot decides the posture.)\n */\nexport function cardMode(\n snapshot: SettingsPageState | undefined,\n): CardMode {\n if (snapshot === undefined) return { kind: 'registration' }\n return {\n kind: 'live',\n ready: snapshot.available,\n controllerConfigured: snapshot.apiKeyConfigured,\n writable: snapshot.writable,\n apiKeyWritable: snapshot.apiKeyWritable,\n }\n}\n\n/** Status badge for the credential state (green when configured). */\nfunction StatusBadge({ ok, okLabel, pendingLabel }: {\n ok: boolean\n okLabel: string\n pendingLabel: string\n}) {\n return {ok ? okLabel : pendingLabel}\n}\n\n/** Compact key field for the not-configured card. */\nfunction CardKeyField({ state, disabled, t, onEdit }: {\n state: StagedField\n disabled: boolean\n t: Translate\n onEdit(text: string): void\n}) {\n const [visible, setVisible] = useState(false)\n return (\n
\n
\n \n \n \n \n
\n onEdit(event.target.value)}\n />\n

{t('apiKeyHint')}

\n
\n )\n}\n\n/**\n * The slot component body. Dispatched on every Command Code provider card of\n * the Models page (saved row, first-run setup posture, and add-provider\n * draft).\n *\n * Closed (the official 编辑 toggle off) the panel renders nothing: the row\n * head the Models page owns already names the provider and shows the\n * credential dot, so a page full of providers stays compact. Opening the\n * official editor mounts the editor shell as the outlet's sibling; the panel\n * watches for it, hides the shell (it carries only the settings.yaml hint and\n * a disabled apply for this namespace), and shows the real controls — badges,\n * API-key field, sign-in, discard/save.\n */\nexport function CommandCodeProviderCard(props: CommandCodeCardProps & ProviderCardOwnerProps) {\n const { t } = props\n // Single subscription: the whole snapshot drives posture + body together.\n const state = props.useCommandCodeSettings !== undefined\n ? props.useCommandCodeSettings((snapshot) => snapshot)\n : undefined\n const mode = cardMode(state)\n const login = props.useCommandCodeLogin !== undefined\n ? props.useCommandCodeLogin((snapshot) => snapshot)\n : undefined\n const dirty = state?.dirty ?? false\n const saving = state?.saving ?? false\n const invalid = state?.invalid ?? false\n const failed = state?.failed ?? false\n const savingBlocked = !dirty || invalid\n const configured = mode.kind === 'live' && mode.ready ? mode.controllerConfigured : props.keyConfigured\n const disabled = mode.kind === 'live' && (!mode.writable || (state !== undefined && !mode.apiKeyWritable))\n const showBody = mode.kind === 'live' && mode.ready && state !== undefined\n // The official editor's open state lives in the Models page's own component\n // state and never reaches this slot's props; the outlet wrapper is the\n // stable neighbor, so watch its siblings for the editor's mount/unmount.\n // The outlet stays mounted either way — it is the observation anchor — so\n // the closed panel hides its own root instead of unmounting.\n const rootRef = useRef(null)\n const [editorOpen, setEditorOpen] = useState(false)\n useEffect(() => {\n const root = rootRef.current\n if (root === null || typeof MutationObserver === 'undefined') return\n const wrapper = root.closest(`[data-slot=\"${CARD_SLOT_KEY}\"]`) ?? root.parentElement\n if (wrapper === null) return\n const row = wrapper.parentElement\n if (row === null) return\n let hiddenEditor: HTMLElement | null = null\n const sync = () => {\n const editor = adjacentEditorCard(wrapper) as HTMLElement | null\n setEditorOpen(editor !== null)\n if (editor !== null) {\n // React pins no inline style on the editor shell, so this survives\n // the shell's own re-renders; a shell that unmounts and remounts is\n // re-hidden by the next observation.\n editor.style.display = 'none'\n hiddenEditor = editor\n }\n }\n sync()\n const observer = new MutationObserver(sync)\n observer.observe(row, { childList: true })\n return () => {\n observer.disconnect()\n // If the shell outlives the panel (plugin reload), give it back: the\n // settings.yaml hint is the honest fallback face again.\n if (hiddenEditor !== null) hiddenEditor.style.display = ''\n }\n }, [])\n return (\n \n {editorOpen && mode.kind === 'registration' ?

{t('cardRegistrationHint')}

: null}\n {editorOpen && mode.kind === 'live' && !mode.ready ?

{t('cardLoadingHint')}

: null}\n {editorOpen && showBody ? (\n <>\n
\n
\n {t('cardTitle')}\n \n \n {props.provider.active ? {t('cardRouteActive')} : null}\n \n
\n
\n props.edit('apiKey', text)}\n />\n {login !== undefined ? (\n \n ) : null}\n
\n {failed ?

{t('saveFailed')}

: null}\n \n {t('discard')}\n \n \n {t(saving ? 'saving' : 'save')}\n \n
\n \n ) : null}\n \n )\n}\n","/**\n * English copy for the Command Code **plans & quota panel** (the sidebar\n * footer card and the center-column dashboard it opens).\n *\n * Deliberately NOT part of the `settings.commandcode` locale namespace: that\n * namespace follows the harness's active language, and this surface is\n * specified to read in English regardless of it. Keeping the strings out of\n * the locale registry is what makes that a fact rather than a preference —\n * there is no dictionary lookup that could resolve to Chinese.\n *\n * The settings page keeps its own bilingual `settings.commandcode` namespace\n * (see `./locales.ts`); the two never mix.\n *\n * @module dsh-commandcode-provider/client/panel-copy\n */\n\n/**\n * The plans & quota panel key set. `zh` in the settings namespace is the\n * source of truth there; here English is the only locale, so this union is\n * the key set.\n */\nexport type PanelKey =\n /** Footer card title and panel heading. */\n | 'nav'\n /** Panel sub-heading under the title. */\n | 'subtitle'\n /** Refresh button / in-flight label. */\n | 'refresh'\n | 'refreshing'\n /** First-paint fetch. */\n | 'loading'\n /** No credential at all: how to get one. */\n | 'noKey'\n | 'noKeyHint'\n /** Report section headings. */\n | 'plan'\n | 'credits'\n | 'limits'\n | 'usage'\n /** Monthly credit state and its tiles. */\n | 'monthly'\n | 'monthlyLimit'\n | 'monthlyUsed'\n | 'remaining'\n | 'purchased'\n | 'free'\n /** Usage-window rows (long labels in the dashboard, short in the footer). */\n | 'fiveHour'\n | 'weekly'\n | 'fiveHourShort'\n | 'weeklyShort'\n | 'windowUnlimited'\n | 'exceeded'\n | 'exhausted'\n | 'resets'\n /** Usage tiles. */\n | 'requests'\n | 'failed'\n | 'successRate'\n | 'spend'\n | 'tokens'\n | 'tokensIn'\n | 'tokensOut'\n /** Meta line. */\n | 'periodEnds'\n | 'updated'\n | 'partial'\n /** Rotation state on an account. */\n | 'active'\n | 'coolingDown'\n | 'invalidKey'\n /** Footer-card plan fallbacks. */\n | 'unconfigured'\n | 'unavailable'\n /** Whole-report failures (mirrors the settings card's blocked taxonomy). */\n | 'errorInvalidKey'\n | 'errorInvalidKeyHint'\n | 'errorServiceUnavailable'\n | 'errorServiceUnavailableHint'\n | 'errorNetwork'\n | 'errorNetworkHint'\n /** A fetch that failed for any other reason. */\n | 'errorGeneric'\n\n/** The panel's literal string table. */\nexport const PANEL_COPY: Record = {\n nav: 'Command Code',\n subtitle: 'Plans, credits and quota windows',\n refresh: 'Refresh',\n refreshing: 'Refreshing…',\n loading: 'Loading account usage…',\n noKey: 'No API key configured',\n noKeyHint: 'Paste a key — or sign in — under Settings → Command Code, then refresh.',\n plan: 'Plan',\n credits: 'Credits',\n limits: 'Quota windows',\n usage: 'Usage',\n monthly: 'Monthly',\n monthlyLimit: 'Monthly limit',\n monthlyUsed: 'Monthly used',\n remaining: 'Remaining',\n purchased: 'Purchased',\n free: 'Free',\n fiveHour: '5-hour window',\n weekly: 'Weekly window',\n fiveHourShort: '5-hour',\n weeklyShort: 'Weekly',\n windowUnlimited: 'unlimited',\n exceeded: 'Exceeded',\n exhausted: 'Used up',\n resets: 'Resets',\n requests: 'Requests',\n failed: 'failed',\n successRate: 'Success rate',\n spend: 'Spend',\n tokens: 'Tokens',\n tokensIn: 'in',\n tokensOut: 'out',\n periodEnds: 'Period ends',\n updated: 'Updated',\n partial: 'Some endpoint data unavailable',\n active: 'Active',\n coolingDown: 'Cooling down',\n invalidKey: 'Invalid key',\n unconfigured: 'Not configured',\n unavailable: 'No data',\n errorInvalidKey: 'API key invalid or expired',\n errorInvalidKeyHint: 'The server rejected every request (401). Check the key for this account, or generate a new one in the commandcode.ai console.',\n errorServiceUnavailable: 'The Command Code service is temporarily unavailable',\n errorServiceUnavailableHint: 'The server returned errors (5xx). Try Refresh again in a moment.',\n errorNetwork: 'Could not reach the Command Code service',\n errorNetworkHint: 'No request reached the server. Check your network connection or the API base setting.',\n errorGeneric: 'Could not fetch account usage',\n}\n\n/** Look up one panel string. The fallback key keeps a bad call visible, never blank. */\nexport function panelText(key: PanelKey): string {\n return PANEL_COPY[key] ?? key\n}\n","/**\n * View layer for the Command Code plans & quota panel (the sidebar footer card\n * and the dashboard it opens in the center column).\n *\n * Deliberately JSX-free and React-free, mirroring `./settings.ts` and\n * `./usage.ts`: it turns the shared usage snapshot into one presentation\n * tree that both React components render, and it owns the one shared side\n * effect (the throttled background refresh that keeps the sidebar card\n * current). Node tests drive everything here without a DOM.\n *\n * Every displayed string is decided here, as a `PanelKey` plus a `text`\n * record resolved from `./panel-copy.ts` — so the components carry no\n * formatting, pluralization, or copy of their own, and the panel cannot\n * regress into the harness locale (see that module for why it is English-only).\n *\n * @module dsh-commandcode-provider/client/panel\n */\n\nimport type { CommandCodeUsageReport } from '../adapter.ts'\nimport type { CommandCodeAccountUsage } from '../usage-wire.ts'\nimport type { UsagePageState } from './usage.ts'\nimport {\n formatMoney,\n formatMoneyExact,\n formatSuccessRate,\n formatTokensCompact,\n} from './usage.ts'\nimport { panelText, PANEL_COPY } from './panel-copy.ts'\nimport type { PanelKey } from './panel-copy.ts'\n\n/** One quota window (5-hour or weekly) in display form. */\nexport interface PanelWindowView {\n /** Row-label key into the view's `text` record. */\n label: PanelKey\n /** `used / cap`, or just `used` when the window is uncapped. */\n value: string\n /** The window reports a cap, so `percent` is meaningful. */\n capped: boolean\n /** Percentage actually consumed (may exceed 100 when over the cap). */\n percent: number\n /** Fill width for the bar, clamped to [0, 100]. */\n barPercent: number\n /** Window is over its cap. */\n exceeded: boolean\n /** Local reset time, empty when the endpoint reported none. */\n resetsAt: string\n}\n\n/**\n * Monthly credit state, derived exactly the way the official CLI derives it\n * (`getCreditDepletionPct` in `command-code/dist/cli.mjs`): the plan's credit\n * total is the LIMIT, and the billing endpoint's `credits.monthlyCredits` is\n * the REMAINING balance, so consumption is `limit - remaining`. The CLI's own\n * wording for the same two numbers is `Plan: N% used, X credits left`.\n *\n * Purchased and free credits are separate balances that extend what an account\n * can spend, so they are reported as their own tiles rather than folded into\n * the limit.\n */\nexport interface PanelMonthlyView {\n /**\n * A ratio is meaningful — the plan published a credit total AND the billing\n * endpoint published the balance left of it. When false the view draws no\n * bar: a percentage needs both halves, and inventing either one is how a\n * failed billing call turns into a confident \"100% used\".\n */\n known: boolean\n /** Plan credit total for the period (the limit); a dash when unreported. */\n limit: string\n /** Consumed this period (`limit - remaining`); a dash until both are known. */\n used: string\n /** Credits still available; a dash when the balance was not reported. */\n remaining: string\n /** Purchased top-up balance. */\n purchased: string\n /** Promotional balance. */\n free: string\n /** Consumption as a percentage of the limit; 0 when there is no ratio. */\n percent: number\n /** Fill width for the bar, clamped to [0, 100]. */\n barPercent: number\n /** The plan's credits are used up. */\n exhausted: boolean\n /** Billing period end, local date; empty when unreported. */\n periodEnds: string\n}\n\n/** One compact bar in the sidebar footer card. */\nexport interface PanelFooterBar {\n /** Row label key (short forms: `Monthly`, `5-hour`, `Weekly`). */\n label: PanelKey\n /** Printed right-hand percentage, e.g. `32%`; empty when there is no ratio. */\n percent: string\n /** Fill width, clamped to [0, 100]. */\n barPercent: number\n /** Render in the warning colour. */\n warn: boolean\n /**\n * This window's own spend against its limit, e.g. `$1.32 / $6.00`. RENDERED,\n * not just a tooltip: the card exists to show these figures, and the\n * magnitudes are small dollar amounts (the endpoint reports a Pro account's\n * five-hour cap as `3` and its weekly cap as `6`).\n */\n detail: string\n}\n\n/** One usage tile. */\nexport interface PanelStatView {\n label: PanelKey\n value: string\n /** Secondary line, empty when there is none. */\n sub: string\n}\n\n/** One pool account's report, ready to render. */\nexport interface PanelAccountView {\n id: string\n label: string\n /** The account's display name (user name, else account name); empty when unreported. */\n owner: string\n /** Subscription display name; empty when unreported. */\n planName: string\n /** Subscription status when it is not a plain `active`; empty otherwise. */\n planStatus: string\n /** Rotation/credential state ('invalidKey' / 'coolingDown'); undefined when plain. */\n mark: PanelKey | undefined\n /** Known cooldown end, appended to the cooling-down badge; empty otherwise. */\n cooldownUntil: string\n /** No credential resolved for this slot. */\n unconfigured: boolean\n /** Billing period end, empty when unreported. */\n periodEnds: string\n /** Whether the account currently serves requests. */\n active: boolean\n /** Monthly credit state; undefined when neither endpoint reported it. */\n monthly: PanelMonthlyView | undefined\n windows: PanelWindowView[]\n stats: PanelStatView[]\n}\n\n/** What actually went wrong, once — for the alert box. */\nexport interface PanelFailureView {\n title: PanelKey\n /** The actionable line under the title. */\n hint: PanelKey\n /** The underlying transport message, shown small; empty when there is none. */\n detail: string\n}\n\n/** The whole panel, in render order. */\nexport interface PanelView {\n /** Every English string this view references, by key. */\n text: Partial>\n /** Footer-card plan text (`Go`, `Pro`, …), or a state word. */\n planName: string\n /** Footer-card rotation/credential state line; empty when nothing to say. */\n status: string\n /**\n * The compact bars the sidebar footer card stacks: the two quota windows an\n * account actually runs into, 5-hour first then weekly. The monthly\n * limit/usage bar belongs to the DASHBOARD alone — it moves once a billing\n * period, whereas these two are what stop a session — so it is deliberately\n * absent here. A window with no cap is left out: there is no ratio to draw.\n */\n footerBars: PanelFooterBar[]\n /**\n * This period's spend in dollars, formatted; empty when the usage endpoint\n * reported nothing. NOT drawn as its own row — the card's visible figures are\n * the two windows' own spend (see {@link PanelFooterBar.detail}), and a\n * separate period total would sit next to the weekly window's near-identical\n * figure — so it rides the tooltip and the accessible name instead.\n */\n cost: string\n /**\n * The footer card's accessible name and tooltip: `Command Code · Pro ·\n * 5-hour $0.04 / $3.00 (1%) · Weekly $1.32 / $6.00 (22%) · Spend $1.32`.\n * Always starts with the visible title, so the accessible name contains the\n * visible label.\n */\n footTitle: string\n /** Every account, in rotation order (empty when there is nothing to show). */\n accounts: PanelAccountView[]\n /** Id of the account the panel opens on (the serving one), if any. */\n selectedId: string | undefined\n /** That account's view, for the footer card; undefined with no accounts. */\n selected: PanelAccountView | undefined\n /** A fetch is in flight and no data has landed for this paint. */\n loading: boolean\n /** No credential is configured at all. */\n noKey: boolean\n /** Report-level failure box; undefined when the report is usable. */\n failure: PanelFailureView | undefined\n /** A fetch failed while data is still on screen. */\n staleError: string | undefined\n /** Endpoint-level partial failure note; undefined when every endpoint answered. */\n partial: PanelKey | undefined\n /** Freshness line, empty when nothing has been fetched. */\n updatedAt: string\n}\n\n/** Inputs {@link buildPanelView} needs beyond the usage snapshot. */\nexport interface PanelViewInput {\n usage: UsagePageState\n /** Whether any account holds a credential (the settings controller's fact). */\n apiKeyConfigured: boolean\n /** Account ids staged for removal; hidden here immediately, like the settings card. */\n removingIds?: readonly string[]\n}\n\n/** A quota window's state as the wire carries it. */\ninterface WindowInput {\n used: number\n cap: number\n exceeded: boolean\n resetAt: number\n}\n\n/** `$1.23`, and `$0.0123` only when the amount is too small for cents to show it. */\nfunction money(value: number): string {\n if (value === 0) return formatMoney(0)\n return Math.abs(value) < 0.01 ? formatMoneyExact(value) : formatMoney(value)\n}\n\n/**\n * What a figure shows when the endpoint that carries it did not report one.\n * A dash, never a zero: the panel exists to state what the account has spent,\n * so \"we were not told\" has to look different from \"nothing\".\n */\nconst UNREPORTED = '—'\n\n/** Local reset time; empty when the endpoint reported none. */\nfunction resetText(ms: number): string {\n if (ms <= 0) return ''\n return new Date(ms).toLocaleString()\n}\n\n/** Local short date; empty when unset. */\nfunction dateText(ms: number): string {\n if (ms <= 0) return ''\n return new Date(ms).toLocaleDateString()\n}\n\n/** Local time-of-day; empty when unset. */\nfunction timeText(ms: number): string {\n if (ms <= 0) return ''\n return new Date(ms).toLocaleTimeString()\n}\n\n/**\n * Consumption as a percentage, NOT clamped at 100 — an over-quota window\n * reports its real overshoot (150%), which is what the printed figure should\n * say. Bar widths clamp separately via {@link barPercent}.\n */\nfunction rawPercent(used: number, cap: number): number {\n if (cap <= 0) return 0\n return Math.round((used / cap) * 100)\n}\n\n/** A percentage usable as a CSS width, clamped into [0, 100]. */\nfunction barPercent(percent: number): number {\n return Math.min(100, Math.max(0, percent))\n}\n\n/** Build one quota-window view. */\nfunction windowView(label: PanelKey, limit: WindowInput): PanelWindowView {\n const percent = rawPercent(limit.used, limit.cap)\n const capped = limit.cap > 0\n return {\n label,\n // Spend only: an uncapped window has no denominator, so its row pairs this\n // figure with the \"unlimited\" label instead of a `used / cap` it lacks.\n value: capped ? `${money(limit.used)} / ${money(limit.cap)}` : money(limit.used),\n capped,\n percent,\n barPercent: barPercent(percent),\n exceeded: limit.exceeded,\n resetsAt: resetText(limit.resetAt),\n }\n}\n\n/**\n * Build the monthly credit view from the two endpoints that carry it.\n *\n * The limit is the PLAN's credit total, not the billing endpoint's\n * `monthlyCredits` — that field is a remaining balance (see\n * {@link PanelMonthlyView}). Unknown plans (no `monthlyCredits` on the plan\n * record) therefore still show their balances, just without a ratio.\n *\n * The trap this guards: a MISSING balance is not a consumed one. The billing\n * endpoint is one of four the report fetches in parallel, so it fails on its\n * own while the plan still arrives — and reading the absent balance as 0 would\n * turn that partial failure into `limit - 0 = limit`, i.e. a confident \"100%\n * used, quota exhausted\" for an account that may have spent nothing. The\n * official CLI draws no meter unless the credits payload is present at all\n * (`hasCreditsInfo`), so \"unreported\" is the upstream-faithful reading here\n * too. Every derived figure stays a placeholder until the balance reported.\n */\nfunction monthlyView(report: CommandCodeUsageReport): PanelMonthlyView | undefined {\n const credits = report.credits\n const plan = report.plan\n if (credits === undefined && plan === undefined) return undefined\n\n const limitValue = plan?.monthlyCredits ?? null\n // Only an EXPLICIT `false` means the balance was omitted. An unset flag comes\n // from a Host half older than the field, which always sent a real number, so\n // it must keep rendering instead of turning into a dash everywhere.\n const remainingReported = credits !== undefined && credits.monthlyReported !== false\n const remaining = remainingReported ? Math.max(0, credits.monthlyCredits) : 0\n // A ratio needs BOTH halves: the plan's total and the balance left of it.\n const known = limitValue !== null && limitValue > 0 && remainingReported\n const used = known ? Math.max(0, limitValue - remaining) : 0\n const percent = known ? rawPercent(used, limitValue) : 0\n\n return {\n known,\n limit: limitValue !== null ? money(limitValue) : UNREPORTED,\n used: known ? money(used) : UNREPORTED,\n remaining: remainingReported ? money(remaining) : UNREPORTED,\n purchased: credits !== undefined && credits.purchasedReported !== false ? money(Math.max(0, credits.purchasedCredits)) : UNREPORTED,\n free: credits !== undefined && credits.freeReported !== false ? money(Math.max(0, credits.freeCredits)) : UNREPORTED,\n percent,\n barPercent: barPercent(percent),\n exhausted: known && remaining <= 0,\n periodEnds: dateText(plan?.currentPeriodEnd ?? 0),\n }\n}\n\n/** Build one account's view. */\nfunction accountView(entry: CommandCodeAccountUsage): PanelAccountView {\n const { report } = entry\n const account = report.account\n const plan = report.plan\n const credits = report.credits\n const usage = report.usage\n\n const stats: PanelStatView[] = []\n if (usage !== undefined) {\n stats.push({\n label: 'requests',\n value: String(usage.completedCount),\n sub: `${usage.failedCount} ${panelText('failed')}`,\n })\n stats.push({ label: 'successRate', value: `${formatSuccessRate(usage.successRate)}%`, sub: '' })\n stats.push({\n label: 'spend',\n value: formatMoneyExact(usage.totalCost),\n sub: `${formatMoney(usage.totalCredits)} credits`,\n })\n stats.push({\n label: 'tokens',\n value: formatTokensCompact(usage.totalTokensIn + usage.totalTokensOut),\n sub: `${formatTokensCompact(usage.totalTokensIn)} ${panelText('tokensIn')} / ${formatTokensCompact(usage.totalTokensOut)} ${panelText('tokensOut')}`,\n })\n }\n\n const windows: PanelWindowView[] = []\n // One row per window the endpoint actually REPORTED. An account on an\n // unlimited plan reports none, and drawing two zero-cap rows for it would\n // invent a limit the account does not have; a window that reported\n // `cap === 0` is uncapped spend and does get a row, labelled as such.\n if (credits?.fiveHour !== undefined) windows.push(windowView('fiveHour', credits.fiveHour))\n if (credits?.weekly !== undefined) windows.push(windowView('weekly', credits.weekly))\n\n // Rotation/credential state: the serving account first, then the two marks\n // the pool can carry, then a cooldown whose end time is known.\n let mark: PanelKey | undefined\n if (entry.active) mark = 'active'\n else if (entry.mark === 'invalid-credential') mark = 'invalidKey'\n else if (entry.cooldownUntil > 0 || entry.mark === 'rate-limit') mark = 'coolingDown'\n\n return {\n id: entry.id,\n label: entry.label,\n owner: account === undefined ? '' : account.userName || account.name,\n planName: plan?.name ?? '',\n planStatus: plan !== undefined && plan.status !== '' && plan.status !== 'active' ? plan.status : '',\n mark,\n cooldownUntil: entry.cooldownUntil > 0 ? resetText(entry.cooldownUntil) : '',\n unconfigured: !entry.configured,\n periodEnds: dateText(plan?.currentPeriodEnd ?? 0),\n active: entry.active,\n monthly: monthlyView(report),\n windows,\n stats,\n }\n}\n\n/** The report-level failure box, or undefined when the report is usable. */\nfunction failureView(state: UsagePageState): PanelFailureView | undefined {\n const blocked = state.report?.accounts.find((entry) => entry.report.blocked !== undefined)?.report.blocked\n if (blocked === 'invalid-key') {\n return { title: 'errorInvalidKey', hint: 'errorInvalidKeyHint', detail: '' }\n }\n if (blocked === 'service-unavailable') {\n return { title: 'errorServiceUnavailable', hint: 'errorServiceUnavailableHint', detail: '' }\n }\n if (blocked === 'network') {\n return { title: 'errorNetwork', hint: 'errorNetworkHint', detail: '' }\n }\n // A transport-level failure (unmounted Remote, offline browser) arrives as\n // the controller's own error rather than as a blocked report.\n if (state.status === 'error' && state.report === undefined) {\n return { title: 'errorGeneric', hint: 'errorGeneric', detail: state.error ?? '' }\n }\n return undefined\n}\n\n/**\n * Every panel string, resolved once per projection. One object with all keys\n * (rather than per-field lookups in the components) keeps the copy table and\n * the render sites in lockstep: a key cannot be read from `text` unless\n * {@link PANEL_COPY} declares it.\n */\nfunction panelStrings(): Record {\n const keys = Object.keys(PANEL_COPY) as PanelKey[]\n const out = {} as Record\n for (const key of keys) out[key] = panelText(key)\n return out\n}\n\n/**\n * Project the shared usage snapshot into the panel's render tree.\n *\n * Deduplication matches the settings card: hand-edited settings can name one\n * credential twice, and removal staging hides an account before the post-save\n * refresh lands.\n */\nexport function buildPanelView(input: PanelViewInput): PanelView {\n const { usage } = input\n const hidden = new Set(input.removingIds ?? [])\n const seen = new Set()\n const entries = (usage.report?.accounts ?? []).filter((entry) => {\n if (hidden.has(entry.id)) return false\n if (seen.has(entry.id)) return false\n seen.add(entry.id)\n return true\n })\n const accounts = entries.map(accountView)\n const selectedEntry = entries.find((entry) => entry.active) ?? entries[0]\n const selectedView = accounts.find((view) => view.id === selectedEntry?.id)\n\n // The footer card stacks BOTH quota windows — the two limits an account\n // actually runs into — 5-hour first (the tighter and nearer one), then\n // weekly. `barPercent` clamps the fill; the printed percentage keeps the true\n // consumption, so an over-cap window still reads 150%.\n const footerBars: PanelFooterBar[] = []\n const windowBars: Array<[PanelKey, WindowInput | undefined]> = [\n ['fiveHourShort', selectedEntry?.report.credits?.fiveHour],\n ['weeklyShort', selectedEntry?.report.credits?.weekly],\n ]\n for (const [label, window] of windowBars) {\n if (window === undefined || window.cap <= 0) continue\n const percent = rawPercent(window.used, window.cap)\n footerBars.push({\n label,\n percent: `${percent}%`,\n barPercent: barPercent(percent),\n warn: window.exceeded,\n detail: `${money(window.used)} / ${money(window.cap)}`,\n })\n }\n\n // The card's one non-ratio figure: what this period has cost in dollars.\n const totalCost = selectedEntry?.report.usage?.totalCost\n const cost = totalCost === undefined ? '' : money(totalCost)\n\n let status = ''\n if (entries.length > 0 && !entries.some(entry => entry.configured)) status = panelText('unconfigured')\n else if (selectedView?.mark !== undefined && selectedView.mark !== 'active') status = panelText(selectedView.mark)\n else if (selectedEntry === undefined) status = panelText('unavailable')\n\n const planName = selectedView !== undefined && selectedView.planName !== '' ? selectedView.planName : panelText('nav')\n // `planName` falls back to the panel name, so it is only a second part when it\n // actually names a plan — otherwise the title would read `Command Code ·\n // Command Code`.\n const titleParts = planName === panelText('nav') ? [planName] : [panelText('nav'), planName]\n for (const bar of footerBars) {\n const figures = bar.detail === '' ? '' : ` ${bar.detail}`\n titleParts.push(`${panelText(bar.label)}${figures} (${bar.percent})`)\n }\n if (cost !== '') titleParts.push(`${panelText('spend')} ${cost}`)\n\n return {\n text: panelStrings(),\n planName,\n status,\n footerBars,\n cost,\n footTitle: titleParts.join(' · '),\n accounts,\n selectedId: selectedEntry?.id,\n selected: selectedView,\n loading: usage.status === 'loading' && usage.report === undefined,\n noKey: entries.length > 0 && !entries.some(entry => entry.configured),\n failure: failureView(usage),\n staleError: usage.status === 'error' && usage.report !== undefined ? usage.error ?? '' : undefined,\n partial: (usage.report?.accounts.some((entry) => entry.report.failures.length > 0 && entry.report.blocked === undefined) ?? false)\n ? 'partial'\n : undefined,\n updatedAt: timeText(usage.fetchedAt ?? 0),\n }\n}\n\n// ---------------------------------------------------------------------------\n// Auto refresh (one shared loop, started by whichever surface mounts first)\n// ---------------------------------------------------------------------------\n\n/**\n * How often the panel re-reads the report while a surface is mounted. The\n * quota windows move slowly and one report costs four upstream calls, so this\n * is a background freshness tick, not a live meter.\n */\nexport const PANEL_AUTO_REFRESH_MS = 120_000\n\n/** The narrow face of `CommandCodeUsageController` this module drives. */\nexport interface RefreshSource {\n state(): UsagePageState\n refresh(): Promise\n}\n\n/** Timer seam: injectable so tests never wait on real time. */\nexport interface AutoRefreshTimer {\n set(callback: () => void, ms: number): unknown\n clear(handle: unknown): void\n}\n\n/** The default timer (the client bundle runs in a browser; node tests inject one). */\nconst REAL_TIMER: AutoRefreshTimer = {\n set: (callback, ms) => setTimeout(callback, ms),\n clear: (handle) => clearTimeout(handle as ReturnType),\n}\n\n/** Live mount count of {@link startPanelAutoRefresh}. */\nlet references = 0\n/** Owner ticket of the running loop, if any. */\nlet activeTicket: number | undefined\n/** Ticket handed to the next starter. */\nlet ticketSeq = 0\n/** Pending timer handle of the running loop. */\nlet handle: unknown\n\n/**\n * Start the shared auto refresh. One fetch when the surface appears (the\n * sidebar row is the point of the panel — it must be current, not wait for a\n * click), then a tick every {@link PANEL_AUTO_REFRESH_MS} while a surface\n * stays mounted.\n *\n * Reference-counted: the sidebar entry and the dashboard can be mounted at\n * once, so only the first start fetches and only the last stop halts the loop.\n * Every tick goes through `usage.refresh()`, which already collapses a\n * concurrent fetch onto the in-flight one — a tick never double-fetches\n * against the settings page's own refresh.\n *\n * @param usage - the shared usage controller.\n * @param isConfigured - whether a credential exists right now (re-read per tick).\n * @param timer - timer seam for tests.\n * @returns the disposer that drops this surface's reference.\n */\nexport function startPanelAutoRefresh(\n usage: RefreshSource,\n isConfigured: () => boolean,\n timer: AutoRefreshTimer = REAL_TIMER,\n): () => void {\n references += 1\n const ticket = ++ticketSeq\n const tick = (): void => {\n if (activeTicket !== ticket) return\n if (isConfigured()) void usage.refresh()\n handle = timer.set(tick, PANEL_AUTO_REFRESH_MS)\n }\n\n if (activeTicket === undefined) {\n activeTicket = ticket\n handle = timer.set(tick, PANEL_AUTO_REFRESH_MS)\n // First paint: refresh immediately so the row is never blank on arrival.\n if (isConfigured()) void usage.refresh()\n }\n\n let stopped = false\n return () => {\n if (stopped) return\n stopped = true\n references -= 1\n // Still mounted elsewhere: drop this reference only. Note the check is the\n // REFCOUNT, not this surface's ticket — the surface that started the loop\n // is not necessarily the last one to unmount, and a first-unmounted owner\n // must not be the reason a tick keeps firing.\n if (references > 0) return\n activeTicket = undefined\n if (handle !== undefined) {\n timer.clear(handle)\n handle = undefined\n }\n }\n}\n\n/**\n * Drop every live reference and halt the loop without a timer.\n *\n * Exported for tests only: the loop's state is module-level on purpose (two\n * surfaces, one upstream poll), so a test that mounts a surface must be able\n * to start from a clean slate. Production code releases through the disposer\n * {@link startPanelAutoRefresh} returns — the plugin's fiber unwinds it.\n */\nexport function resetPanelAutoRefresh(): void {\n references = 0\n activeTicket = undefined\n handle = undefined\n}\n","/**\n * React components for the Command Code plans & quota panel (browser half):\n * the sidebar footer card and the dashboard it opens in the center column.\n *\n * Both render one {@link PanelView} projected by `./panel.ts` — no fact is\n * derived here. Strings arrive as keys into `view.text`, and every one of them\n * is English, because the view is built from `./panel-copy.ts` rather than the\n * harness `ctx.locale` namespace (which follows the user's language and would\n * render this panel in Chinese on a Chinese harness — the thing this surface\n * exists to avoid).\n *\n * The footer card is the panel's home: the sidebar shell renders it in the foot\n * area directly above the Settings seat, so each quota window's own spend and\n * limit — the 5-hour and the weekly one — are on screen without opening\n * anything. Clicking it selects the `main` panel this file also renders — and\n * unlike a `sidebar.panellist` row, whose button chrome and label the SHELL\n * owns, this entry owns its whole surface and therefore calls `open()` itself.\n *\n * Styles ride the stylesheet `./panel-styles.ts` returns, injected once by\n * the client entry; classes are `ccp-` prefixed to stay clear of the settings\n * page's `cc-` set.\n *\n * @module dsh-commandcode-provider/client/panel-view\n */\n\nimport { useEffect, useState } from 'react'\nimport { Button } from '@deepseek-ai/dsh-client-ui-primitives'\nimport type { SnapshotStore } from './snapshot-store.ts'\nimport type { UsagePageState } from './usage.ts'\nimport type { SettingsPageState } from './settings.ts'\nimport { buildPanelView } from './panel.ts'\nimport type { PanelAccountView, PanelStatView, PanelView, PanelWindowView } from './panel.ts'\nimport type { PanelKey } from './panel-copy.ts'\n// SlotMap merge for `main` / `sidebar.footer.action` (load-bearing: the slots\n// this file's components register into are typed only by that augmentation).\nimport './panel-slots.ts'\n\n/**\n * Owner share of the sidebar-foot action hole: the shell renders the foot area\n * and hands each action only the column fold state. There is no button chrome\n * and no `label` seat — the entry is the whole surface.\n */\nexport interface SidebarFooterActionOwnerProps {\n /** Whether the sidebar renders wide content (false = 56px rail). */\n wide: boolean\n}\n\n/**\n * The injected face both panel slots carry. Bound by the client entry so the\n * components stay unaware of the settings controller, the login controller,\n * the layout service and the module-level auto-refresh loop.\n *\n * NOTE the split from {@link PanelComponentProps}: this is the face the\n * registration's `inject` factory RETURNS, and the renderer does not hand it to\n * the component verbatim. `bindInjectSources` destructures the `hooks`\n * compartment OUT of the face and re-exposes each member as a `use` prop\n * (`commandCodeUsage` → `useCommandCodeUsage`). A component that reads\n * `props.hooks.*` therefore finds `undefined` at runtime and crashes on render\n * — which the slot renderer contains by ABDICATING the entry, so the surface\n * vanishes with no visible error. Read the `useX` seats instead.\n */\nexport interface PanelInjected {\n hooks: {\n commandCodeUsage: SnapshotStore\n commandCodeSettings: SnapshotStore\n }\n /** Fetch the report now (the dashboard's Refresh action). */\n refresh(): void\n /** Start the shared background poll for this mount; returns its disposer. */\n startAutoRefresh(): () => void\n /** Select this panel in the center column (`ctx.layout.selectPanel`). */\n open(): void\n}\n\n/**\n * The props a panel component actually receives: the bound `useX` seats (what\n * {@link PanelInjected}'s `hooks` compartment becomes), the pass-through\n * actions, and no raw `hooks` key.\n *\n * `buildPanelView` is cheap (one pass over the account list) and recomputing it\n * per notification is what keeps a store update from ever rendering a stale\n * quota, so the selector is deliberately the whole snapshot.\n */\nexport interface PanelComponentProps {\n useCommandCodeUsage(selector: (state: UsagePageState) => T): T\n useCommandCodeSettings(selector: (state: SettingsPageState) => T): T\n refresh(): void\n startAutoRefresh(): () => void\n open(): void\n}\n\n/** Props of the sidebar footer card: the panel face plus the shell's fold state. */\nexport interface CommandCodeFooterEntryProps extends PanelComponentProps, SidebarFooterActionOwnerProps {}\n\n/** The panel's view, recomputed from both seats on every notification. */\nfunction usePanelView(props: PanelComponentProps): PanelView {\n const usage = props.useCommandCodeUsage((state) => state)\n const settings = props.useCommandCodeSettings((state) => state)\n return buildPanelView({\n usage,\n apiKeyConfigured: settings.anyAccountConfigured,\n removingIds: settings.accountsRemoving,\n })\n}\n\n/**\n * The quota ring. One glyph serves the rail button, the footer card's top row\n * and the dashboard header: a faint track plus an arc whose sweep is the\n * consumption, drawn from 12 o'clock. Circumference 2πr = 45.55 at r = 7.25.\n */\nfunction Ring({ percent, warn, size }: { percent: number; warn: boolean; size: number }) {\n const clamped = Math.min(100, Math.max(0, percent))\n const circumference = 45.55\n // Rounded to three decimals: the raw product lands on floats like\n // 34.162499999999994, which is a needless DOM diff churn.\n const dashoffset = Math.round(circumference * (1 - clamped / 100) * 1000) / 1000\n return (\n \n \n \n \n \n \n )\n}\n\n/** One labelled bar. `compact` drops the reset line; the footer draws its own. */\nfunction QuotaBar({ label, value, percent, barPercent, warn, resetsAt, resetsLabel }: {\n label: string\n value: string\n percent: string\n barPercent: number\n warn: string\n resetsAt: string\n resetsLabel: string\n}) {\n const clamped = Math.min(100, Math.max(0, barPercent))\n return (\n
\n
\n {label}\n {warn !== '' ? {warn} : null}\n \n {value !== '' ? {value} : null}\n {percent}\n
\n
\n
\n
\n {resetsAt !== '' ?

{resetsLabel} {resetsAt}

: null}\n
\n )\n}\n\n/** A labelled figure. */\nfunction Tile({ label, value, sub }: { label: string; value: string; sub?: string }) {\n return (\n
\n {label}\n {value}\n {sub !== undefined && sub !== '' ? {sub} : null}\n
\n )\n}\n\n/** One usage tile. */\nfunction StatTile({ stat, label }: { stat: PanelStatView; label: string }) {\n return \n}\n\n/** The rotation/credential badge line for one account. */\nfunction markText(account: PanelAccountView, text: (key: PanelKey) => string): string {\n if (account.mark === undefined) return ''\n if (account.mark === 'coolingDown' && account.cooldownUntil !== '') {\n return `${text('coolingDown')} · ${account.cooldownUntil}`\n }\n return text(account.mark)\n}\n\n/** `Default account` → `D`; used for the card's monogram chip. */\nfunction initial(label: string): string {\n const trimmed = label.trim()\n return trimmed === '' ? '?' : trimmed[0]!.toUpperCase()\n}\n\n/**\n * One account's full report: the monthly limit/usage bar, the two quota\n * windows, then the credit and usage totals.\n */\nfunction AccountCard({ account, view }: { account: PanelAccountView; view: PanelView }) {\n const text = (key: PanelKey): string => view.text[key] ?? key\n const mark = markText(account, text)\n const monthly = account.monthly\n\n return (\n
\n
\n {initial(account.label)}\n \n {account.label}\n {account.owner !== '' ? {account.owner} : null}\n \n \n {account.planName !== '' ? {account.planName} : null}\n {account.planStatus !== '' ? {account.planStatus} : null}\n {mark !== '' ? (\n {mark}\n ) : null}\n {account.periodEnds !== '' ? (\n {text('periodEnds')} {account.periodEnds}\n ) : null}\n
\n\n {account.unconfigured ?

{text('unconfigured')}

: null}\n\n {monthly !== undefined && monthly.known ? (\n \n ) : (\n
\n {/* The plan is a fact the card already shows in its header badge; what\n is missing here is the RATIO, so the row names the plan whenever\n the report carries one and falls back to a placeholder only when\n the report itself is silent. */}\n {text('plan')}\n {account.planName !== '' ? account.planName : text('unavailable')}\n
\n )}\n\n {/* A balance the billing endpoint DID report, so a card without a ratio\n still states something true instead of nothing. */}\n {monthly !== undefined && monthly.remaining !== '—' ? (\n
\n {text('remaining')}\n {monthly.remaining}\n
\n ) : null}\n\n {account.windows.length > 0 ? (\n
\n {account.windows.map((window: PanelWindowView) => (\n \n ))}\n
\n ) : null}\n\n {monthly !== undefined ? (\n
\n

{text('credits')}

\n
\n \n \n \n \n \n
\n
\n ) : null}\n\n {account.stats.length > 0 ? (\n
\n

{text('usage')}

\n
\n {account.stats.map((stat) => (\n \n ))}\n
\n
\n ) : null}\n
\n )\n}\n\n/**\n * The center-column dashboard, registered into the layout's keyed `main` slot\n * under the same id the footer card selects, so the two are one navigation\n * entry: the card shows the plan, the monthly bar and the quota window; the\n * panel shows everything, including the per-account breakdown.\n */\nexport function CommandCodePanel(props: PanelComponentProps) {\n const view = usePanelView(props)\n const text = (key: PanelKey): string => view.text[key] ?? key\n const [selectedId, setSelectedId] = useState(undefined)\n\n const startAutoRefresh = props.startAutoRefresh\n useEffect(() => startAutoRefresh(), [startAutoRefresh])\n\n const accounts = view.accounts\n const selected = accounts.find((account) => account.id === selectedId)\n ?? view.selected\n ?? accounts[0]\n\n return (\n
\n
\n
\n
\n

{text('nav')}

\n

{text('subtitle')}

\n
\n \n {view.updatedAt !== '' ? {text('updated')} {view.updatedAt} : null}\n props.refresh()}\n >\n {view.loading ? text('refreshing') : text('refresh')}\n \n
\n\n {view.noKey ? (\n
\n

{text('noKey')}

\n

{text('noKeyHint')}

\n
\n ) : null}\n\n {view.failure !== undefined ? (\n
\n

{text(view.failure.title)}

\n

{text(view.failure.hint)}

\n {view.failure.detail !== '' ?

{view.failure.detail}

: null}\n
\n ) : null}\n\n {!view.noKey && view.failure === undefined && accounts.length === 0 && view.loading ? (\n

{text('loading')}

\n ) : null}\n\n {view.staleError !== undefined && view.staleError !== '' ? (\n

{text('errorGeneric')} — {view.staleError}

\n ) : null}\n\n {accounts.length > 1 ? (\n
\n {accounts.map((account) => (\n setSelectedId(account.id)}\n >\n {account.label}\n \n ))}\n
\n ) : null}\n\n {selected !== undefined ? : null}\n\n {view.partial !== undefined ?

{text(view.partial)}

: null}\n
\n
\n )\n}\n\n/**\n * The sidebar footer card, registered into `sidebar.footer.action` — the list\n * the shell renders in the sidebar's foot area directly ABOVE the Settings\n * seat, so the panel reads as a bottom-pinned sibling of Settings rather than\n * a global panel icon at the top of the column.\n *\n * The shell wraps nothing here, so this component owns the surface: the\n * button, its chrome and its accessible name. In the expanded column it draws\n * the title row, then one block per quota window (5-hour, then weekly) — the\n * window's own spend and limit (`$1.32 / $6.00`), its percentage and its bar —\n * and nothing else: the card's figures are the two windows the account runs\n * into, so the period total stays in the tooltip rather than taking a third\n * line. In the 56px rail it collapses to a 36px icon button carrying the ring,\n * matching the shell's own rail geometry. `wide` comes from the shell as an\n * owner prop — unlike the old `sidebar.panellist` row, this slot really does\n * supply it.\n */\nexport function CommandCodeFooterEntry(props: CommandCodeFooterEntryProps) {\n const view = usePanelView(props)\n\n const startAutoRefresh = props.startAutoRefresh\n useEffect(() => startAutoRefresh(), [startAutoRefresh])\n\n const text = (key: PanelKey): string => view.text[key] ?? key\n // The ring tracks the tightest window — the 5-hour one whenever it is capped,\n // which is the limit an account actually runs into first. `footerBars` is\n // ordered that way, so the leading bar is the headline.\n const headline = view.footerBars[0]\n // The tooltip doubles as the accessible name; it always begins with the\n // visible title, so the label the user reads is contained in the name.\n const title = view.footTitle\n\n if (!props.wide) {\n return (\n props.open()}\n >\n \n \n )\n }\n\n return (\n props.open()}\n >\n \n \n {text('nav')}\n \n {view.status !== '' ? (\n {view.status}\n ) : view.planName !== text('nav') ? (\n {view.planName}\n ) : null}\n \n\n {view.footerBars.map((bar) => (\n \n \n {text(bar.label)}\n \n {bar.detail !== '' ? {bar.detail} : null}\n {bar.percent}\n \n \n \n \n \n ))}\n \n )\n}\n","/** Shared, JSON-only billing facts. No Host or browser runtime imports. */\nimport type { CommandCodeModelPrice, CommandCodeModelRates, CommandCodePriceTable } from './usage-wire.ts'\n\nexport interface CostTokens {\n uncachedInputTokens: number\n outputTokens: number\n cacheReadTokens: number\n cacheWriteTokens: number\n}\n\n/** Requests with identical model/rates are folded into one group. */\nexport interface CostUsageGroup {\n provider: string\n model: string\n at: number | null\n contextTokens: number\n tokens: CostTokens\n}\n\nexport interface SessionCostFacts {\n pricingKey: number\n groups: CostUsageGroup[]\n}\n\nexport const zeroCostTokens = (): CostTokens => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })\nexport const COST_TOKEN_KEYS = ['uncachedInputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'] as const\n\nexport function peakHour(at: number, windows: ReadonlyArray): boolean {\n const time = new Date(at)\n return time.getUTCDay() !== 0 && time.getUTCDay() !== 6\n && windows.some(([start, end]) => time.getUTCHours() >= start && time.getUTCHours() < end)\n}\n\n/**\n * All prompt billing buckets determine a request's band, never cumulative session input.\n *\n * A tiered model returns the matching band and does NOT then apply `peak`: the\n * page publishes those two dimensions independently, no row carries both today,\n * and the generator would report a tiered row on every sync — so the combination\n * becoming real is visible before it reaches a user. This ordering is a latent\n * choice, not a verified upstream rule.\n */\nexport function requestRates(price: CommandCodeModelPrice, at: number | null, contextTokens: number, table: CommandCodePriceTable): CommandCodeModelRates {\n // The first band whose bound accepts the prompt. ORDER IS LOAD-BEARING, and\n // both producers enforce it: the generator refuses non-ascending bounds or a\n // bounded last band, and the wire parser rejects the same shapes — so a\n // conforming table cannot present bands out of order, and this `find` cannot\n // select a band a later one should have won.\n const tier = price.contextTiers?.find(tier => tier.maxContext === undefined || contextTokens <= tier.maxContext)\n if (tier !== undefined) return tier\n return price.peak !== undefined && at !== null && peakHour(at, table.peakHours) ? price.peak : price\n}\n\n/** Cache version and wire guard: changing bands/rates must refold historical groups. */\nexport function pricingKey(table: CommandCodePriceTable): number {\n const rates = (r: CommandCodeModelRates) => [r.inputCost, r.outputCost, r.cacheReadCost, r.cacheWriteCost ?? null]\n const canonical = JSON.stringify(['commandCodeCost-v1', table.peakHours, table.models.map(p => [p.id, p.slug, p.free === true, rates(p), p.peak ? rates(p.peak) : null, p.contextTiers?.map(t => [t.maxContext ?? null, rates(t)]) ?? null])])\n let hash = 2166136261\n for (const c of canonical) hash = Math.imul(hash ^ c.charCodeAt(0), 16777619) >>> 0\n return hash\n}\n\n/** Only safe to merge requests whose billing classification is identical. */\nexport function costGroupKey(group: CostUsageGroup, table: CommandCodePriceTable): string {\n const price = table.models.find(p => p.id === group.model || p.slug === group.model)\n return JSON.stringify([group.provider, group.model, price === undefined ? null : requestRates(price, group.at, group.contextTokens, table), group.at === null])\n}\n","/**\n * Session-cost view model for the composer readout.\n *\n * Deliberately JSX-free and React-free, mirroring `./panel.ts`: it turns the\n * session's token accounting plus the Host's price table into one presentation\n * value AND the exact text the two surfaces of the harness's token-usage UI\n * receive — the amount appended to the shipped pill, and the rows appended to\n * the shipped usage dialog. Node tests therefore drive the whole calculation,\n * and every user-visible string, without a DOM.\n *\n * The figures it prices are the session's DURABLE cumulative buckets, which is\n * what the composer's own \"N tokens\" pill reads too (`tokenUsage`), so the two\n * surfaces can never disagree about how much was used. Dollars are computed\n * here because Command Code publishes per-token rates and bills against\n * dollar-denominated windows; the account's own reported `totalCost` is a\n * billing PERIOD figure, not this session's.\n *\n * Three rules are load-bearing:\n *\n * 1. **Only Command Code usage is priced.** A session served by another\n * provider must render nothing, never a Command Code estimate.\n * 2. **A missing rate is never invented.** The pricing page publishes\n * input/output/cache-read rates for every model but a cache-WRITE rate for\n * only some, so unpriced cache-write tokens are surfaced as such rather than\n * charged at a guessed multiple of the input rate.\n * 3. **Unpriceable means invisible.** No usage, no model, no table, an unknown\n * model, or all-zero buckets renders nothing at all — a confident `$0.00`\n * would be a lie, and this module never returns one.\n *\n * @module dsh-commandcode-provider/client/session-cost\n */\n\nimport { COST_TOKEN_KEYS, pricingKey, requestRates, peakHour, zeroCostTokens, type SessionCostFacts } from '../cost-facts.ts'\nimport type { CommandCodeModelPrice, CommandCodeModelRates, CommandCodePriceTable } from '../usage-wire.ts'\nimport { formatMoney, formatMoneyExact, formatTokensCompact } from './usage.ts'\n\n/** Tokens per published rate unit — the pricing page quotes USD per million. */\nconst TOKENS_PER_RATE_UNIT = 1_000_000\n\n/** The provider route whose usage this surface prices. */\nconst COMMANDCODE_PROVIDER = 'commandcode'\n\n/**\n * The English copy for this surface.\n *\n * A plain constant, NOT the `settings.commandcode` locale namespace: like the\n * plans & quota panel, the readout stays English on a Chinese harness. Do not\n * route it through `ctx.locale`.\n */\nexport const SESSION_COST_COPY = {\n /** Shown instead of an amount when the model costs nothing on every plan. */\n free: 'Free',\n /**\n * The separator the readout prefixes itself with, so the cost reads as the\n * last item of the token-usage pill's text run rather than a control beside\n * it. Rendered with the same colour and margins the shipped pill uses\n * between its own items.\n */\n separator: '·',\n /** The heading the tooltip leads with. */\n panelTitle: 'Session cost',\n /** Marks an estimate from published rates rather than an invoice. */\n approximate: '≈',\n /** Tooltip line for the unpriced cache-write tokens. */\n unpricedCacheWrite: 'cache write tokens have no published rate',\n /** Tooltip line naming the rate half in force. */\n peakRates: 'peak rates',\n /** Tooltip line naming the rate half in force. */\n offPeakRates: 'off-peak rates',\n /** Tooltip line explaining the approximate marker. */\n approximateNote: 'estimate from published rates, not the provider invoice',\n /** Row/tooltip label for uncached prompt tokens. */\n uncachedInput: 'uncached input',\n /** Row/tooltip label for completion tokens. */\n output: 'output',\n /** Row/tooltip label for cache-served input tokens. */\n cacheRead: 'cache read',\n /** Row/tooltip label for cache-written input tokens. */\n cacheWrite: 'cache write',\n} as const\n\n/**\n * The session's cumulative token buckets, as the `tokenUsage` projection\n * carries them. Declared structurally and defensively: this bundle does not\n * depend on the session-controller package, and a bucket the provider never\n * reported is absent rather than zero.\n */\nexport interface SessionUsageBuckets {\n readonly uncachedInputTokens?: number\n readonly outputTokens?: number\n readonly cacheReadTokens?: number\n readonly cacheWriteTokens?: number\n}\n\n/** One entry of the `modelSelection` projection. */\nexport interface SessionModelSelection {\n readonly provider: string\n readonly model: string\n}\n\n/**\n * The `modelSelection` projection: the selection the latest request consumed,\n * and the one the next request will use (falling back to the former).\n */\nexport interface SessionModelSelectionProjection {\n readonly lastUsed: SessionModelSelection | null\n readonly next: SessionModelSelection | null\n}\n\n/** Everything the view needs, already read off the seats by the component. */\nexport interface SessionCostInput {\n /** Durable billing groups; absent on older Hosts means no trustworthy estimate. */\n facts?: SessionCostFacts | undefined\n /** The durable cumulative token buckets, or undefined before any request. */\n usage: SessionUsageBuckets | undefined\n /** The session's model selection fold, or undefined on an older Host. */\n selection: SessionModelSelectionProjection | undefined\n /** The Host's price table, or undefined until it lands. */\n table: CommandCodePriceTable | undefined\n /** Legacy caller field; historical request timestamps alone determine rates. */\n now: number\n}\n\n/** One bucket's line in the usage dialog. */\nexport interface SessionCostBucketRow {\n /** Stable key. */\n key: 'uncachedInput' | 'cacheRead' | 'cacheWrite' | 'output'\n /** Row label. */\n label: string\n /** Tokens charged in this bucket; a bucket that charged nothing withholds its\n * row rather than printing a meaningless zero. */\n tokens: number\n /** What this bucket cost, or undefined when the table has no rate for it. */\n costText: string | undefined\n}\n\n/** The composer readout's presentation value. */\nexport interface SessionCostView {\n /** Priced total in dollars. */\n total: number\n /** Visible amount text, e.g. `$0.0123` (or the free word, when free). */\n value: string\n /** Full tooltip/accessible text, including the bucket breakdown. */\n title: string\n /** Per-bucket lines for the usage dialog, in reading order. */\n rows: SessionCostBucketRow[]\n /** Caveat lines for the pill's tooltip (rate half, approximation, unpriced). */\n notes: string[]\n /** The model costs nothing on every plan right now. */\n free: boolean\n /** The model's `peak` rates are in force. */\n peak: boolean\n /** Cache-write tokens the table has no rate for (never guessed, so the\n * total is a floor rather than an estimate). */\n unpricedCacheWriteTokens: number\n /** Published-rate totals are estimates rather than provider invoices. */\n approximate: boolean\n}\n\n/** A finite, non-negative count — anything else reads as absent. */\nfunction count(value: number | undefined): number {\n return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/**\n * Whether `now` falls inside a peak-pricing window, per the windows that travel\n * with the price table. Monday–Friday (UTC) only, and each window is\n * end-exclusive — the same rule the Host snapshot applies when it labels the\n * model picker. The schedule is read from the wire rather than restated so\n * there is one definition of the windows, on the Host.\n */\nexport function isPeakHour(now: number, peakHours: ReadonlyArray): boolean {\n const at = new Date(now)\n const day = at.getUTCDay()\n if (day === 0 || day === 6) return false\n const hour = at.getUTCHours()\n return peakHours.some(([start, end]) => hour >= start && hour < end)\n}\n\n/**\n * Index a price table for lookup. Rows are keyed by catalog id and by pricing\n * slug, both exact and lowercased, because a session reports a catalog id while\n * a row no catalog model claims is served under the page's slug.\n */\nfunction indexTable(table: CommandCodePriceTable): Map {\n const index = new Map()\n for (const price of table.models) {\n for (const key of [price.id, price.slug]) {\n if (typeof key !== 'string' || key === '') continue\n if (!index.has(key)) index.set(key, price)\n const lower = key.toLowerCase()\n if (!index.has(lower)) index.set(lower, price)\n }\n }\n return index\n}\n\n/**\n * The dollar cost of each bucket, plus the total and the unpriced remainder.\n *\n * Every bucket is charged at its own published rate; the cache-write bucket\n * contributes only when the model publishes a rate for it, otherwise its tokens\n * are returned as {@link SessionCostView.unpricedCacheWriteTokens} and its own\n * cost stays undefined so the panel can say so instead of printing a zero.\n */\ninterface SessionCostBreakdown {\n uncachedInput: number\n cacheRead: number\n cacheWrite: number | undefined\n output: number\n total: number\n unpricedCacheWriteTokens: number\n}\n\nfunction costOf(\n usage: SessionUsageBuckets,\n rates: CommandCodeModelRates,\n free: boolean,\n): SessionCostBreakdown {\n if (free) {\n return { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0, total: 0, unpricedCacheWriteTokens: 0 }\n }\n const perUnit = (tokens: number, rate: number): number => (tokens * rate) / TOKENS_PER_RATE_UNIT\n const uncachedInput = perUnit(count(usage.uncachedInputTokens), rates.inputCost)\n const output = perUnit(count(usage.outputTokens), rates.outputCost)\n const cacheRead = perUnit(count(usage.cacheReadTokens), rates.cacheReadCost)\n const cacheWriteTokens = count(usage.cacheWriteTokens)\n const cacheWrite = rates.cacheWriteCost === undefined\n ? undefined\n : perUnit(cacheWriteTokens, rates.cacheWriteCost)\n return {\n uncachedInput,\n cacheRead,\n cacheWrite,\n output,\n total: uncachedInput + output + cacheRead + (cacheWrite ?? 0),\n unpricedCacheWriteTokens: rates.cacheWriteCost === undefined ? cacheWriteTokens : 0,\n }\n}\n\n/**\n * Amount text for a session cost.\n *\n * The panel's `money()` convention (2 decimals, 4 below a cent) is right for\n * billing windows, but a live session total starts far below a cent, where\n * `toFixed(4)` would print a flat `$0.0000` — which reads as broken rather than\n * as small. So a total under $0.0001 is stated as a bound instead.\n */\nexport function sessionCostAmount(total: number): string {\n if (!Number.isFinite(total) || total <= 0) return formatMoney(0)\n if (total < 0.0001) return `<$0.0001`\n return total < 0.01 ? formatMoneyExact(total) : formatMoney(total)\n}\n\n/** One `label value` clause of the tooltip. */\nfunction clause(label: string, tokens: number): string | undefined {\n return tokens > 0 ? `${label} ${formatTokensCompact(tokens)}` : undefined\n}\n\n/**\n * Build the composer readout, or undefined when there is nothing honest to show.\n *\n * Undefined is the correct answer for a session with no usage yet, a session\n * another provider served, a model the price table does not know, and a table\n * that has not landed — the pill simply is not there.\n */\nexport function buildSessionCostView(input: SessionCostInput): SessionCostView | undefined {\n const { usage, table, facts } = input\n if (!usage || !table || !facts || facts.pricingKey !== pricingKey(table)) return undefined\n const totals = zeroCostTokens()\n const index = indexTable(table)\n const breakdown: SessionCostBreakdown = { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0, total: 0, unpricedCacheWriteTokens: 0 }\n let free = true\n let peak = false\n let partial = false\n let priced = false\n const notes: string[] = []\n for (const group of facts.groups) {\n for (const key of COST_TOKEN_KEYS) totals[key] += group.tokens[key]\n if (group.provider !== COMMANDCODE_PROVIDER) { partial = true; continue }\n const price = index.get(group.model)\n if (!price) { partial = true; continue }\n const isFree = price.free === true\n free &&= isFree\n if (price.peak && group.at === null) {\n partial = true\n notes.push('request time unavailable; off-peak lower bound')\n }\n const rates = requestRates(price, group.at, group.contextTokens, table)\n const part = costOf(group.tokens, rates, isFree)\n priced ||= isFree || part.total > 0\n for (const key of ['uncachedInput', 'cacheRead', 'output', 'total', 'unpricedCacheWriteTokens'] as const) breakdown[key] += part[key]\n breakdown.cacheWrite = (breakdown.cacheWrite ?? 0) + (part.cacheWrite ?? 0)\n if (price.peak) {\n const isPeak = group.at !== null && peakHour(group.at, table.peakHours)\n peak ||= isPeak\n notes.push(isPeak ? SESSION_COST_COPY.peakRates : SESSION_COST_COPY.offPeakRates)\n }\n }\n // Both projections must describe the same durable cut. Never decorate token\n // rows from a newer/older fold, and never price an old Host's cumulative usage.\n if (COST_TOKEN_KEYS.some(key => totals[key] !== count(usage[key])) || !priced) return undefined\n const { total, unpricedCacheWriteTokens } = breakdown\n partial ||= unpricedCacheWriteTokens > 0\n free &&= !partial\n if (partial) notes.push('Command Code priced subtotal only; unpriced or other-provider usage excluded')\n notes.push(SESSION_COST_COPY.approximateNote)\n const uncachedInput = totals.uncachedInputTokens\n const output = totals.outputTokens\n const cacheRead = totals.cacheReadTokens\n const cacheWrite = totals.cacheWriteTokens\n // No billed token of any kind means there is nothing to report yet: a `$0.00`\n // pill on a session that has not made a request would be noise, not\n // information, and on a free model it would be redundant.\n if (uncachedInput === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) return undefined\n\n // Free priced groups do not establish zero spending for unpriced groups.\n // Keep the pure-Free label and real sub-cent amounts, but never a zero subtotal.\n if (!free && total <= 0) return undefined\n\n const value = free\n ? SESSION_COST_COPY.free\n : `${partial ? '≥' : SESSION_COST_COPY.approximate}${sessionCostAmount(total)}`\n const money = (amount: number | undefined): string | undefined =>\n amount === undefined ? undefined : sessionCostAmount(amount)\n // Reading order mirrors the prompt's own order: what was charged at the input\n // rate, then the two cache buckets, then what the model produced.\n const rows: SessionCostBucketRow[] = [\n { key: 'uncachedInput', label: SESSION_COST_COPY.uncachedInput, tokens: uncachedInput, costText: money(breakdown.uncachedInput) },\n { key: 'cacheRead', label: SESSION_COST_COPY.cacheRead, tokens: cacheRead, costText: money(breakdown.cacheRead) },\n { key: 'cacheWrite', label: SESSION_COST_COPY.cacheWrite, tokens: cacheWrite, costText: unpricedCacheWriteTokens === cacheWrite && cacheWrite > 0 ? undefined : money(breakdown.cacheWrite) },\n { key: 'output', label: SESSION_COST_COPY.output, tokens: output, costText: money(breakdown.output) },\n ]\n if (unpricedCacheWriteTokens > 0) notes.push(SESSION_COST_COPY.unpricedCacheWrite)\n // Empty clauses are dropped BEFORE joining: `clause()` returns undefined for a\n // bucket with no tokens, and joining the raw list would leave a `· ·` gap for\n // every absent bucket — a tooltip that reads as a rendering bug.\n const title = ([\n `${SESSION_COST_COPY.panelTitle} ${free ? SESSION_COST_COPY.free : sessionCostAmount(total)}`,\n clause(SESSION_COST_COPY.uncachedInput, uncachedInput),\n clause(SESSION_COST_COPY.output, output),\n clause(SESSION_COST_COPY.cacheRead, cacheRead),\n clause(SESSION_COST_COPY.cacheWrite, cacheWrite),\n ...notes,\n ] as Array).filter((part): part is string => part !== undefined).join(' · ')\n\n return {\n total,\n value,\n title,\n rows,\n notes: [...new Set(notes)],\n free,\n peak,\n unpricedCacheWriteTokens,\n approximate: !free,\n }\n}\n\n/**\n * The two nodes the text appended to the harness's token-usage pill is made of.\n *\n * The separator is a node of its own because it carries the shipped pill's\n * SEPARATOR colour rather than its label colour; the pill is a flex row whose\n * `gap` already spaces the appended item, so the separator is what makes the\n * result read as one continuous run (`1.2M tokens · Cache hit 87% · $0.0123`)\n * rather than as a value parked at the end of it.\n */\nexport interface SessionCostPillRun {\n /** The shipped pill's own separator glyph. */\n separator: string\n /** The amount, `≈` marker and free word included. */\n value: string\n}\n\n/** Split the appended run into the two nodes the display creates. */\nexport function sessionCostPillRun(view: SessionCostView): SessionCostPillRun {\n return { separator: SESSION_COST_COPY.separator, value: view.value }\n}\n\n/**\n * How the harness's own usage dialog is decorated: one entry per row the\n * shipped component renders, IN ITS OWN ORDER.\n *\n * The price goes on the right of the row it belongs to (`3,206,544 tok $0.07`)\n * rather than in a block of our own rows beneath them, so the dialog keeps the\n * harness's layout and gains nothing to read past.\n *\n * Two rows are special. `cacheHit` carries a percentage, not a count, so it is\n * never priced. `cacheWrite` is HIDDEN and never priced: the pricing page\n * publishes a cache-write rate for a minority of models, so its cell read\n * `unpriced` far more often than a number; its tokens still count toward the\n * total the pill shows.\n */\nexport type SessionCostShippedRow = 'cacheHit' | 'uncachedInput' | 'cacheRead' | 'cacheWrite' | 'output'\n\n/** One shipped dialog row and what to do with it. */\nexport interface SessionCostRowDecoration {\n /** Which shipped row this is. */\n row: SessionCostShippedRow\n /**\n * The exact token count that row must be showing, or undefined for a row that\n * carries no count. The display layer verifies it before decorating anything:\n * the shipped labels are the `chat` locale's own strings (never English), so\n * rows are matched POSITIONALLY and this is what proves the position is right.\n */\n tokens: number | undefined\n /** The price to append to the row's value, or undefined to leave it alone. */\n amount: string | undefined\n /** Whether the row is dropped from the dialog entirely. */\n hidden: boolean\n}\n\n/**\n * Decorate the harness's usage dialog, row by row.\n *\n * The row sequence mirrors the shipped component's conditions exactly — the\n * cache-hit row exists while there is billed prompt input, the cache-write row\n * while those tokens are non-zero — because the display layer matches what it\n * finds positionally. A shape it cannot confirm is a shape it does not touch.\n *\n * A free model decorates nothing: every row's cost is zero by definition, the\n * pill already says `Free`, and a column of `$0.00` would be noise. A bucket\n * whose rate the page does not publish is likewise left unfilled rather than\n * filled with an invented number.\n */\nexport function sessionCostRowDecorations(view: SessionCostView): SessionCostRowDecoration[] {\n const bucket = (key: SessionCostBucketRow['key']): number =>\n view.rows.find((row) => row.key === key)?.tokens ?? 0\n const price = (key: SessionCostBucketRow['key']): string | undefined => {\n if (view.free) return undefined\n return view.rows.find((row) => row.key === key)?.costText\n }\n const uncachedInput = bucket('uncachedInput')\n const cacheRead = bucket('cacheRead')\n const cacheWrite = bucket('cacheWrite')\n const output = bucket('output')\n const plan: SessionCostRowDecoration[] = []\n if (uncachedInput + cacheRead + cacheWrite > 0) {\n plan.push({ row: 'cacheHit', tokens: undefined, amount: undefined, hidden: false })\n }\n plan.push({ row: 'uncachedInput', tokens: uncachedInput, amount: price('uncachedInput'), hidden: false })\n plan.push({ row: 'cacheRead', tokens: cacheRead, amount: price('cacheRead'), hidden: false })\n if (cacheWrite !== 0) {\n // Hidden only when the page publishes NO cache-write rate: those tokens are\n // genuinely unpriced, the note says so, and the row would otherwise show a\n // blank cell. When the rate IS known its price is part of the total, so the\n // row must stay — hiding a row whose cost the pill already counts leaves the\n // visible rows unable to explain the figure above them.\n const amount = price('cacheWrite')\n plan.push({ row: 'cacheWrite', tokens: cacheWrite, amount, hidden: amount === undefined })\n }\n plan.push({ row: 'output', tokens: output, amount: price('output'), hidden: false })\n return plan\n}\n","/**\n * The DOM half of the composer's session-cost figure (browser only).\n *\n * The cost is not a surface of its own any more: it is injected INTO the two\n * surfaces the harness already draws under the composer — the token-usage pill\n * (`1.2M tokens · Cache hit 87%`) and the usage dialog that pill opens. This\n * module owns that injection; `./session-cost.ts` owns every number and every\n * string, and `./session-cost-view.tsx` owns the two seats the figures come\n * from.\n *\n * Why inject rather than take the pill's cell over (registering a `stats` entry\n * REPLACES the shipped readout, it does not extend it): the shipped row is one\n * component owning its tokens / cache-hit / duration figures through the `chat`\n * locale namespace, and its cache-hit percentage obeys an exact-integer rule\n * this plugin must not re-derive. Owning that cell would mean reproducing all of\n * it in English, and every upstream change to it would stop reaching users. So\n * the shipped markup stays the harness's, and this module adds exactly two\n * things to it.\n *\n * Four properties make that safe, all verified against the shipped build:\n *\n * 1. **The dialog's rows are styled by ELEMENT.** `dsh-client-ui-chat`'s\n * `stat-dialog` module styles its `dl` as a two-column grid and then\n * `.details dt` / `.details dd` — not by class. A price node appended INSIDE a\n * shipped value cell therefore inherits that cell's tabular numerals and right\n * alignment for free, and this plugin ships no CSS at all.\n * 2. **The dialog's rows are matched by POSITION and confirmed by value.** Their\n * labels are the `chat` locale's own strings (`Uncached input` in English,\n * `未缓存输入` in Chinese), so no label is ever read. Instead the shape is\n * predicted from the session's buckets — the cache-hit row exists while there\n * is billed prompt input, the cache-write row while those tokens are non-zero\n * — and each value cell must then carry exactly the token count predicted for\n * it. A dialog that does not confirm is left alone.\n * 3. **The pill keeps the cost visible when space runs out.** The button is an\n * inline flex row with `gap`, and its label carries `min-width:0` plus\n * ellipsis; our appended span keeps the default `min-width:auto`, so the\n * LABEL is what truncates. The cost also inherits the button's `font:inherit`\n * and `tabular-nums` typography and its hover/expanded tint.\n * 4. **React owns those value cells' text, so a price is re-attached, never\n * assumed.** The shipped component renders each count as a single string, and\n * React rewrites such a cell through `textContent` when the count changes —\n * which drops every child with it, our price included. Every sync therefore\n * re-checks that the node is still where it was put; the cost of that is a\n * read, and the alternative is a price that silently disappears mid-session.\n *\n * A `MutationObserver` on `document.body` with `childList` (deliberately NOT\n * `subtree`, so streaming text never wakes it) is the only way to learn that the\n * dialog — which is portaled straight onto `body`, with no slot of ours inside\n * it — has opened or closed. That observer is also what decorates a newly opened\n * dialog from the last view this module was handed.\n *\n * @module dsh-commandcode-provider/client/session-cost-display\n */\n\nimport type { SessionCostPillRun, SessionCostRowDecoration, SessionCostShippedRow, SessionCostView } from './session-cost.ts'\nimport {\n SESSION_COST_COPY,\n sessionCostPillRun,\n sessionCostRowDecorations,\n} from './session-cost.ts'\n\n/** The shipped composer stats row (dsh-client-ui-chat `StatsPills`). */\nconst STATS_ROOT = '[data-composer-stats]'\n\n/**\n * The shipped token-usage dialog's `
`. Unique in the whole of\n * `dsh-client-ui-chat`: the per-message turn-usage panel has its own dialog with\n * different markup, so the composer's is unambiguous.\n */\nconst USAGE_DIALOG = '[data-session-stats-usage]'\n\n/**\n * The usage dialog's trigger. Both pills announce a dialog, but the token one is\n * rendered LAST — and when no step carries timing the time pill is not a button\n * at all, so \"last\" still selects the token pill in both cases.\n */\nconst DIALOG_TRIGGER = 'button[aria-haspopup=\"dialog\"]'\n\n/**\n * Stable id for the hidden node the appended cost is described by. The pill's\n * own `aria-label` is computed by the harness on every render and cannot be\n * extended, so a description is how the cost reaches assistive technology.\n */\nconst A11Y_ID = 'dsh-commandcode-session-cost'\n\n/** How many consecutive misses before the missing anchor is worth a console line. */\nconst MISSES_BEFORE_WARNING = 3\n\n/**\n * How the display learns that the shipped usage dialog opened or closed.\n *\n * `target` is `document.body`, and the returned function detaches. The default\n * is a `MutationObserver`; the seam exists so tests can announce the dialog\n * without a global observer (the repository's usual injected-seam shape).\n */\nexport type SessionCostObserverFactory = (target: Node, listener: () => void) => (() => void) | undefined\n\n/**\n * The default observer: `childList` on `body`, and deliberately NOT `subtree`, so\n * streaming text inside the page never wakes it while the panel the harness\n * portals straight onto `body` does.\n */\nconst observeBodyChildren: SessionCostObserverFactory = (target, listener) => {\n if (typeof MutationObserver === 'undefined') return undefined\n const observer = new MutationObserver(listener)\n observer.observe(target, { childList: true })\n return () => observer.disconnect()\n}\n\n/** What the display layer reads: the injected document and where to look in it. */\nexport interface SessionCostDisplayOptions {\n /** The document to inject into (injected so node tests can drive a double). */\n doc: Document\n /**\n * The composer that owns this entry — our own outlet wrapper's parent, so a\n * session-scoped composer looks in its OWN card rather than at whichever\n * `[data-composer-stats]` happens to come first in the document.\n */\n scope: () => ParentNode | null\n /** Observer seam; defaults to a `childList` `MutationObserver` on `body`. */\n observe?: SessionCostObserverFactory\n}\n\n/**\n * Owns the nodes injected into the shipped token-usage UI.\n *\n * One instance per mounted entry. `sync()` is cheap and idempotent: it re-reads\n * both targets on every call, which is what lets it self-heal when React\n * remounts the row or replaces the dialog, and it rewrites a node only when its\n * text actually changed.\n */\nexport class SessionCostDisplay {\n private readonly doc: Document\n private readonly scope: () => ParentNode | null\n private readonly observe: SessionCostObserverFactory\n private view: SessionCostView | undefined = undefined\n /** Detaches the observer installed by `start()`. */\n private detach: (() => void) | undefined = undefined\n /** The button we appended into, while it is still connected. */\n private pillHost: Element | undefined = undefined\n /** Our appended root, and the two nodes whose text changes. */\n private pillRoot: HTMLElement | undefined = undefined\n private pillValue: HTMLElement | undefined = undefined\n private pillA11y: HTMLElement | undefined = undefined\n /** Whether WE set `aria-describedby` on the button (so only we take it back). */\n private described = false\n /** The dialog we decorated, the price nodes we own, and the cells we hid. */\n private dialogHost: Element | undefined = undefined\n private dialogPrices = new Map()\n private dialogHidden = new Set()\n private pillMisses = 0\n\n constructor(options: SessionCostDisplayOptions) {\n this.doc = options.doc\n this.scope = options.scope\n this.observe = options.observe ?? observeBodyChildren\n }\n\n /** Begin watching for the shipped usage dialog opening and closing. */\n start(): void {\n if (this.detach !== undefined) return\n const body = this.doc.body\n if (body === null || body === undefined) return\n this.detach = this.observe(body, () => this.apply())\n }\n\n /**\n * Hand the display the current figure.\n *\n * Undefined — nothing priceable about this session — removes everything this\n * module injected, leaving both shipped surfaces exactly as they ship.\n */\n sync(view: SessionCostView | undefined): void {\n this.view = view\n this.apply()\n }\n\n /** Remove every injected node and stop observing. Safe to call twice. */\n dispose(): void {\n this.detach?.()\n this.detach = undefined\n this.removePill()\n this.clearDialog()\n this.view = undefined\n }\n\n /** Re-apply the last known view to whatever both targets are right now. */\n private apply(): void {\n this.applyPill()\n this.applyDialog()\n }\n\n // -------------------------------------------------------------------------\n // The pill: the cost as the last item of the shipped token pill's text run\n // -------------------------------------------------------------------------\n\n private applyPill(): void {\n const view = this.view\n if (view === undefined) {\n this.removePill()\n return\n }\n if (this.pillHost?.isConnected !== true) {\n const button = this.resolvePillButton()\n if (button === null) {\n this.pillMisses += 1\n if (this.pillMisses === MISSES_BEFORE_WARNING) {\n console.warn(\n `[dsh-commandcode-provider] no ${STATS_ROOT} row to append the session cost to; the figure stays in the usage dialog only`,\n )\n }\n return\n }\n this.pillMisses = 0\n this.removePill()\n this.buildPill(button, sessionCostPillRun(view))\n }\n if (this.pillValue !== undefined) {\n const amount = sessionCostPillRun(view).value\n if (this.pillValue.textContent !== amount) this.pillValue.textContent = amount\n }\n if (this.pillRoot !== undefined) {\n if (this.pillRoot.title !== view.title) this.pillRoot.title = view.title\n const approximate = view.approximate ? 'true' : null\n if (this.pillRoot.getAttribute('data-approximate') !== approximate) {\n if (approximate === null) this.pillRoot.removeAttribute('data-approximate')\n else this.pillRoot.setAttribute('data-approximate', approximate)\n }\n }\n if (this.pillA11y !== undefined) {\n const described = `${SESSION_COST_COPY.panelTitle} ${view.value}`\n if (this.pillA11y.textContent !== described) this.pillA11y.textContent = described\n }\n }\n\n /** The shipped token pill, scoped to this entry's own composer. */\n private resolvePillButton(): Element | null {\n const scope = this.scope() ?? this.doc\n const root = scope.querySelector(STATS_ROOT)\n if (root === null) return null\n const triggers = root.querySelectorAll(DIALOG_TRIGGER)\n return triggers.length === 0 ? null : (triggers[triggers.length - 1] ?? null)\n }\n\n /**\n * Create and append the cost run. The button's child list is static\n * (`[svg, label]`), so appending once is enough for it to stay last.\n */\n private buildPill(button: Element, run: SessionCostPillRun): void {\n const doc = this.doc\n const root = doc.createElement('span')\n root.setAttribute('data-composer-session-cost', '')\n // The separator is its own node so it can carry the shipped pill's separator\n // colour. Its LEFT spacing comes from the button's flex gap; the right margin\n // reproduces the shipped separator's `margin:0 6px` rhythm, so the run reads\n // `tokens · Cache hit 87% · $0.0123` at one consistent rhythm.\n const separator = doc.createElement('span')\n separator.textContent = run.separator\n separator.setAttribute('aria-hidden', 'true')\n separator.style.color = 'var(--dsw-alias-separator-primary)'\n separator.style.margin = '0 6px 0 0'\n const value = doc.createElement('span')\n value.textContent = run.value\n value.style.fontWeight = '500'\n // Referenced by the button's `aria-describedby`: a hidden node named by id is\n // still read out, which is how the cost survives the aria-label override.\n const a11y = doc.createElement('span')\n a11y.id = A11Y_ID\n a11y.style.display = 'none'\n root.appendChild(separator)\n root.appendChild(value)\n root.appendChild(a11y)\n button.appendChild(root)\n // Only ever claim the attribute when the button has none of its own: a\n // shipped `aria-describedby` is the harness's, and overwriting it would\n // delete a description we cannot restore.\n if (!button.hasAttribute('aria-describedby')) {\n button.setAttribute('aria-describedby', A11Y_ID)\n this.described = true\n }\n this.pillHost = button\n this.pillRoot = root\n this.pillValue = value\n this.pillA11y = a11y\n }\n\n private removePill(): void {\n if (this.pillRoot !== undefined && this.pillRoot.parentNode !== null) {\n this.pillRoot.parentNode.removeChild(this.pillRoot)\n }\n if (this.described) {\n this.pillHost?.removeAttribute('aria-describedby')\n this.described = false\n }\n this.pillHost = undefined\n this.pillRoot = undefined\n this.pillValue = undefined\n this.pillA11y = undefined\n }\n\n // -------------------------------------------------------------------------\n // The dialog: a price on the right of each token row the harness already draws\n // -------------------------------------------------------------------------\n\n private applyDialog(): void {\n const view = this.view\n const host = view === undefined ? null : this.resolveDialog()\n if (host === null || view === undefined) {\n this.clearDialog()\n return\n }\n this.pruneDialog(host)\n const plan = sessionCostRowDecorations(view)\n const pairs = dialogPairs(host)\n if (!dialogShapeMatches(pairs, plan, this.dialogPrices)) {\n // The dialog is not the shape this session's buckets predict — a different\n // build, or a projection carrying values the counts cannot explain. Leave\n // it exactly as the harness drew it rather than pricing the wrong row.\n this.clearDialog()\n return\n }\n const hidden = new Set()\n for (const [index, row] of plan.entries()) {\n const pair = pairs[index]\n if (pair === undefined) continue\n const span = this.dialogPrices.get(pair.dd)\n if (row.hidden) {\n // The row is dropped: both cells, or the grid leaves an empty label\n // behind. An inline `display` survives the harness's own re-renders\n // (React pins no style on these nodes) and is given back on disposal.\n if (pair.dt.style.display !== 'none') pair.dt.style.display = 'none'\n if (pair.dd.style.display !== 'none') pair.dd.style.display = 'none'\n hidden.add(pair.dt)\n hidden.add(pair.dd)\n this.removeDialogPrice(pair.dd, span)\n continue\n }\n if (pair.dt.style.display === 'none') pair.dt.style.display = ''\n if (pair.dd.style.display === 'none') pair.dd.style.display = ''\n if (row.amount === undefined) {\n this.removeDialogPrice(pair.dd, span)\n continue\n }\n const price = span ?? this.createDialogPrice(pair.dd, row.row)\n // React owns this cell's text and rewrites it through `textContent` when\n // the count changes, which drops every child with it — so the price is\n // re-attached on a miss rather than assumed to still be there.\n if (price.parentNode !== pair.dd) pair.dd.appendChild(price)\n if (price.textContent !== row.amount) price.textContent = row.amount\n }\n // Cells hidden by an earlier shape (a cache-write row React then rewrote) go\n // back to the harness.\n for (const cell of [...this.dialogHidden]) {\n if (hidden.has(cell)) continue\n if (cell.style.display === 'none') cell.style.display = ''\n this.dialogHidden.delete(cell)\n }\n for (const cell of hidden) this.dialogHidden.add(cell)\n this.dialogHost = host\n }\n\n /** Forget the cells and prices a re-rendered dialog took with it. */\n private pruneDialog(host: Element): void {\n for (const [dd, price] of [...this.dialogPrices]) {\n if (dd.parentNode === host) continue\n if (price.parentNode !== null) price.parentNode.removeChild(price)\n this.dialogPrices.delete(dd)\n }\n for (const cell of [...this.dialogHidden]) {\n if (cell.parentNode === host) continue\n // The dialog was re-rendered and this cell went with the old tree. Give\n // the style back BEFORE dropping the reference: a detached node can be\n // recycled by the next render (React reuses DOM nodes across a remount of\n // the same shape), and a surviving `display: none` would hide a row of a\n // dialog we are no longer decorating — with nothing left tracking it to\n // restore it later.\n if (cell.style.display === 'none') cell.style.display = ''\n this.dialogHidden.delete(cell)\n }\n }\n\n /** The price node for one shipped value cell, appended as its last child. */\n private createDialogPrice(dd: HTMLElement, row: SessionCostShippedRow): HTMLElement {\n const price = this.doc.createElement('span')\n price.setAttribute('data-session-cost-price', row)\n // The cell is right-aligned over tabular numerals, so a fixed-width\n // inline-block lines the prices up as a column of their own to the right of\n // the counts instead of trailing each count raggedly.\n price.style.marginLeft = '6px'\n price.style.display = 'inline-block'\n price.style.minWidth = '56px'\n price.style.textAlign = 'right'\n price.style.fontWeight = '500'\n dd.appendChild(price)\n this.dialogPrices.set(dd, price)\n return price\n }\n\n private removeDialogPrice(dd: HTMLElement, price: HTMLElement | undefined): void {\n if (price === undefined) return\n if (price.parentNode !== null) price.parentNode.removeChild(price)\n this.dialogPrices.delete(dd)\n }\n\n /**\n * The shipped usage dialog, or null while it is closed.\n *\n * The dialog is portaled onto `body`, so unlike the pill it cannot be scoped\n * from this entry; DSH renders one composer, and the attribute is unique in\n * the chat client, so a document-level lookup is exact. Should a future build\n * mount two composers at once, both dialogs would describe whichever session\n * this entry belongs to — noted rather than defended against.\n */\n private resolveDialog(): Element | null {\n if (this.dialogHost?.isConnected === true) return this.dialogHost\n // The dialog we injected into is gone: our nodes went with it, so only the\n // bookkeeping is left to drop.\n this.clearDialog()\n return this.doc.querySelector(USAGE_DIALOG)\n }\n\n /** Give the dialog back: every price removed, every hidden row restored. */\n private clearDialog(): void {\n for (const price of this.dialogPrices.values()) {\n if (price.parentNode !== null) price.parentNode.removeChild(price)\n }\n this.dialogPrices.clear()\n for (const cell of this.dialogHidden) {\n if (cell.style.display === 'none') cell.style.display = ''\n }\n this.dialogHidden.clear()\n this.dialogHost = undefined\n }\n}\n\n/** The shipped dialog's `dt`/`dd` pairs, in document order. */\nfunction dialogPairs(host: Element): Array<{ dt: HTMLElement; dd: HTMLElement }> {\n const pairs: Array<{ dt: HTMLElement; dd: HTMLElement }> = []\n let label: HTMLElement | null = null\n for (const node of Array.from(host.childNodes)) {\n const tag = tagNameOf(node)\n if (tag === 'DT') label = node as HTMLElement\n else if (tag === 'DD' && label !== null) {\n pairs.push({ dt: label, dd: node as HTMLElement })\n label = null\n }\n }\n return pairs\n}\n\n/** `tagName` upper-cased, or an empty string for a non-element node. */\nfunction tagNameOf(node: Node): string {\n const tag = (node as Element).tagName\n return typeof tag === 'string' ? tag.toUpperCase() : ''\n}\n\n/**\n * Whether the dialog really holds the rows this session's buckets predict.\n *\n * The shipped labels belong to the `chat` locale — they are `Uncached input` in\n * English and something else entirely in Chinese — so rows are matched by\n * POSITION, and this is what makes that safe: the row count must agree, the\n * cache-hit row must be the percentage it is, and every other value must carry\n * exactly the token count of the bucket predicted for it. A mismatch means the\n * dialog is not what this view describes, and nothing is decorated.\n */\nfunction dialogShapeMatches(\n pairs: ReadonlyArray<{ dt: HTMLElement; dd: HTMLElement }>,\n plan: readonly SessionCostRowDecoration[],\n prices: ReadonlyMap,\n): boolean {\n if (pairs.length !== plan.length) return false\n return plan.every((row, index) => {\n const pair = pairs[index]\n if (pair === undefined) return false\n const text = hostValueText(pair.dd, prices.get(pair.dd))\n if (row.tokens === undefined) return text.includes('%')\n return digitsOf(text) === String(row.tokens)\n })\n}\n\n/**\n * A value cell's own text, without the price this module appended to it — the\n * digits of `3,206,544 tok` are the host's, the digits of `$0.07` are ours.\n */\nfunction hostValueText(dd: HTMLElement, price: HTMLElement | undefined): string {\n let text = ''\n for (const node of Array.from(dd.childNodes)) {\n if (node === price) continue\n text += node.textContent ?? ''\n }\n return text\n}\n\n/** Every digit of a display string, so locale grouping cannot break the match. */\nfunction digitsOf(text: string): string {\n return text.replace(/\\D/g, '')\n}\n","/**\n * The composer's live session-cost figure (browser half).\n *\n * This component renders NO surface of its own. The cost lives inside the\n * harness's own token-usage UI: the amount becomes the last item of the shipped\n * pill's text run (`1.2M tokens · Cache hit 87% · $0.0123`) and the per-bucket\n * breakdown becomes extra rows in the usage dialog that pill opens.\n * `./session-cost-display.ts` owns that injection and `./session-cost.ts` owns\n * every number and every string; this file owns nothing but the two seats the\n * figures come from and the lifetime of the injection.\n *\n * It is still registered as an entry in `conversation.composer.dock`, because\n * that registration is what delivers the seats: `useProjection` is a standard\n * prop the composer hands every dock occupant, so there is no other way to read\n * the session's token accounting. Registering under the shipped `stats` cell's\n * id instead would REPLACE the harness's readout rather than extend it;\n * `./session-cost-display.ts` records why that trade is refused.\n *\n * The rendered node is a hidden marker — `display:none`, so it can add neither a\n * box nor a flex gap to the composer card. It exists to locate THIS composer\n * from the entry (`closest()` on the dock's outlet anchor), so a session-scoped\n * composer reads its own card rather than whichever row comes first in the\n * document. It is rendered only while there is something to show, so a session\n * with nothing priceable contributes no markup at all.\n *\n * Two seats are read, from two different owners: `useProjection` comes from the\n * composer (a standard prop of the dock, not part of this registration), and\n * `useCommandCodePrices` comes from our own registration's `inject` face, which\n * `bindInjectSources` re-exposes from the `hooks` compartment. Reading\n * `props.hooks.*` instead finds `undefined` at runtime and crashes the render,\n * which the slot renderer contains by ABDICATING the entry — a surface that\n * vanishes with no visible error.\n *\n * Both effects are `useEffect`, never `useLayoutEffect`: there is nothing on\n * screen to align with (the injection is a text run and a hidden marker), and a\n * layout effect would log a warning from the server render the tests use.\n *\n * @module dsh-commandcode-provider/client/session-cost-view\n */\n\nimport type { SessionCostFacts } from '../cost-facts.ts'\nimport { useEffect, useRef } from 'react'\nimport type { SnapshotStore } from './snapshot-store.ts'\nimport type { SessionCostPricesState } from './prices.ts'\nimport { SessionCostDisplay } from './session-cost-display.ts'\nimport {\n buildSessionCostView,\n type SessionModelSelectionProjection,\n type SessionUsageBuckets,\n} from './session-cost.ts'\n\n/** The dock outlet this entry renders inside, i.e. what it can scope itself from. */\nconst DOCK_ANCHOR = '[data-slot=\"conversation.composer.dock\"]'\n\n/** Module-level constant so the marker's `style` prop never diffs. */\nconst HIDDEN_STYLE = { display: 'none' } as const\n\n/**\n * The injected face this registration carries. Bound by the client entry so the\n * component stays unaware of the Remote, the controller, and its caching.\n *\n * NOTE the split from {@link SessionCostComponentProps}: the renderer\n * destructures this face's `hooks` compartment and re-exposes each member as a\n * `use` prop (`commandCodePrices` → `useCommandCodePrices`); it does not\n * hand the component this object verbatim.\n */\nexport interface SessionCostInjected {\n hooks: {\n commandCodePrices: SnapshotStore\n }\n}\n\n/**\n * The projection keys this readout reads, declared structurally because the\n * plugin bundle does not depend on the session-controller package (whose\n * `UseProjection` this mirrors).\n */\ninterface ProjectionSeats {\n commandCodeCost: SessionCostFacts\n tokenUsage: SessionUsageBuckets\n modelSelection: SessionModelSelectionProjection\n}\n\n/** The projection reader the composer supplies to every dock occupant. */\nexport interface UseProjectionLike {\n (key: K): ProjectionSeats[K] | undefined\n (\n key: K,\n selector: (value: ProjectionSeats[K] | undefined) => S,\n eq?: (a: S, b: S) => boolean,\n ): S\n}\n\n/** The props this component actually receives. */\nexport interface SessionCostComponentProps {\n useCommandCodePrices(selector: (state: SessionCostPricesState) => T): T\n useProjection: UseProjectionLike\n}\n\n/** Log a missing seat once per page, so a silent no-op stays diagnosable. */\nlet warnedMissingSeat = false\n\n/**\n * The composer's session-cost entry. Guards the two seats before rendering the\n * mount: a dsh that does not supply the projection seat to dock occupants gets\n * an absent cost and one console line, never a render crash — which the renderer\n * would answer by abdicating the entry with no visible trace.\n */\nexport function CommandCodeSessionCost(props: SessionCostComponentProps) {\n if (typeof props.useProjection !== 'function' || typeof props.useCommandCodePrices !== 'function') {\n if (!warnedMissingSeat) {\n warnedMissingSeat = true\n console.error(\n '[dsh-commandcode-provider] the composer does not supply the projection/hook seats the session-cost readout needs; the readout stays hidden',\n )\n }\n return null\n }\n return \n}\n\n/** The mount: one injection lifetime, one hidden marker. Every hook lives here. */\nfunction SessionCostEntry(props: SessionCostComponentProps) {\n const markerRef = useRef(null)\n const displayRef = useRef(null)\n const prices = props.useCommandCodePrices((state) => state)\n const usage = props.useProjection('tokenUsage')\n const selection = props.useProjection('modelSelection')\n const facts = props.useProjection('commandCodeCost')\n const view = buildSessionCostView({ usage, selection, facts, table: prices.table, now: Date.now() })\n\n useEffect(() => {\n // No DOM outside a browser: the server render used by the tests, and a\n // node-hosted assembly, must both stay no-ops.\n if (typeof document === 'undefined') return\n const display = new SessionCostDisplay({\n doc: document,\n scope: () => markerRef.current?.closest(DOCK_ANCHOR)?.parentElement ?? null,\n })\n displayRef.current = display\n display.start()\n return () => {\n displayRef.current = null\n display.dispose()\n }\n }, [])\n\n // Every render: the projection is a fresh fold, so there is nothing cheaper to\n // compare than the plan the display derives from it — and the display rewrites\n // a node only when that node's own text actually moved.\n useEffect(() => {\n displayRef.current?.sync(view)\n })\n\n if (view === undefined) return null\n return \n}\n","/**\n * Stylesheet for the Command Code plans & quota panel (the sidebar footer card\n * and the dashboard it opens).\n *\n * Returned as a string rather than injected here so the modules stay free of\n * DOM side effects at import time — the client entry installs it once, keyed\n * by the same `data-plugin-css` attribute the settings-page stylesheet uses,\n * and removes it again when the plugin's fiber unwinds.\n *\n * Every colour comes from a harness theme alias with a neutral fallback, so\n * the panel follows the active theme (light/dark and any brand pack) without\n * hardcoded values. Classes are `ccp-` prefixed to stay clear of the settings\n * page's `cc-` set.\n *\n * @module dsh-commandcode-provider/client/panel-styles\n */\n\n/**\n * Stylesheet id (the `data-plugin-css` value that makes injection idempotent).\n *\n * The package prefix must match the one the settings-page stylesheet uses\n * (`injectPageCss` in `./index.ts`) and this package's real name: an id is the\n * injection's identity, so a stale fork prefix would let a second copy of the\n * plugin inject the same rules twice and would misreport the owner in the DOM.\n */\nexport const PANEL_CSS_ID = '@mars-sea/dsh-commandcode-provider/CommandCodePanel.module.css'\n\n/** The panel stylesheet. */\nexport const PANEL_CSS = `\n/* ------------------------------------------------- sidebar footer card */\n/* The shell's foot area renders this list ABOVE the Settings seat, so the card\n is the sidebar's bottom-most content. The shell supplies no chrome: the entry\n is the button. It is deliberately quiet — a surface that sits beside Settings\n should read as part of the column, not as a call to action — with one hover\n step and a hairline border.\n\n The shell's container is a flex ROW whose occupants (this card and ui-cordis's\n footer chip) each declare a full-width line and shrink-proof flex, so as a row\n it would overflow the column. Both were written for a full-width line, which\n is exactly what a column gives them. Matched by the CSS-module class STEM —\n never a hashed name — so a dsh that renames it degrades to the shell's own\n row rather than breaking. */\n[class*=\"_footerActions\"]{flex-direction:column}\n.ccp-foot{box-sizing:border-box;flex:0 0 auto;width:100%;min-width:0;font:inherit;color:var(--dsw-alias-label-secondary);text-align:left;cursor:pointer;background:0 0;border:1px solid transparent;border-radius:10px;flex-direction:column;gap:6px;margin:0 0 4px;padding:8px;display:flex}\n.ccp-foot:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l2)}\n.ccp-foot:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}\n.ccp-footTop{align-items:center;gap:8px;min-width:0;display:flex}\n.ccp-footName{white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;font-size:13px;font-weight:500;line-height:20px}\n/* One block per quota window: a head line carrying the window's own spend and\n limit, then the FULL-WIDTH bar under it. Stacking the two lets the card show\n the dollar figures — the reason this surface exists — without squeezing the\n bar into what is left beside them. Mirrors the dashboard's own window block. */\n.ccp-footRow{flex-direction:column;gap:4px;min-width:0;display:flex}\n.ccp-footHead{align-items:baseline;gap:8px;min-width:0;display:flex}\n.ccp-footLabel{flex:1;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}\n.ccp-footAmount{flex:none;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums;white-space:nowrap}\n/* The card's markup must stay PHRASING content — it renders inside the shell's\n own button — so these bars are spans, not divs. That makes display:block\n load-bearing on BOTH: an inline box ignores width and height outright, so\n without it the 5px track still painted (a flex item is blockified by its\n container) while the fill collapsed to 0x0 and the bar showed no usage. */\n.ccp-footBar{display:block;background:var(--dsw-alias-bg-layer-2);border-radius:999px;height:5px;overflow:hidden}\n.ccp-footFill{display:block;background:var(--dsw-alias-brand-primary);border-radius:999px;height:100%;transition:width .3s ease}\n.ccp-footFillWarn{background:var(--dsw-alias-state-error-primary)}\n.ccp-footPct{flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}\n\n/* The 56px rail: one icon button on the shell's own rail geometry (36px cell),\n so the collapsed column keeps a single 18px glyph like its siblings. */\n.ccp-railButton{box-sizing:border-box;width:36px;height:36px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:1px solid transparent;border-radius:8px;flex:none;justify-content:center;align-items:center;margin:0 0 4px;padding:0;display:inline-flex}\n.ccp-railButton:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}\n.ccp-railButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}\n\n/* The ring glyph. Sized entirely by its own width/height attribute, so the\n footer row, the rail button and the dashboard can each ask for their own. */\n.ccp-glyph{flex:none;justify-content:center;align-items:center;display:inline-flex;color:var(--dsw-alias-brand-primary)}\n\n/* ------------------------------------------------------------ dashboard */\n/* The center column in the layout frame: fill it, scroll the content column,\n and cap the reading width like the harness's own panels. */\n.ccp-main{background:var(--dsw-alias-bg-layer-1);width:100%;height:100%;overflow:auto;display:block}\n.ccp-mainInner{max-width:760px;margin:0 auto;padding:24px 20px 40px;flex-direction:column;gap:14px;display:flex;color:var(--dsw-alias-label-primary)}\n.ccp-header{align-items:center;gap:10px;display:flex;flex-wrap:wrap}\n.ccp-headerText{flex-direction:column;gap:2px;display:flex;min-width:0}\n.ccp-title{margin:0;font-size:18px;font-weight:600;line-height:1.4}\n.ccp-subtitle{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}\n.ccp-spacer{flex:1}\n.ccp-meta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;font-variant-numeric:tabular-nums}\n.ccp-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}\n\n/* Notices: the no-key guidance, a blocked report, and a stale-data error. */\n.ccp-notice{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:12px 14px;flex-direction:column;gap:4px;display:flex}\n.ccp-noticeError{border-color:var(--dsw-alias-state-error-primary)}\n.ccp-noticeTitle{margin:0;font-size:13px;font-weight:600;line-height:1.5}\n.ccp-noticeError .ccp-noticeTitle{color:var(--dsw-alias-state-error-primary)}\n.ccp-noticeHint{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}\n.ccp-noticeDetail{margin:0;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;word-break:break-word}\n\n.ccp-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:14px;padding:16px 18px;flex-direction:column;gap:16px;display:flex}\n.ccp-cardHead{align-items:center;gap:10px;display:flex;flex-wrap:wrap}\n.ccp-avatar{flex:none;width:28px;height:28px;color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-module-platform);border-radius:50%;justify-content:center;align-items:center;font-size:12px;font-weight:600;line-height:1;display:inline-flex}\n.ccp-cardIdentity{flex-direction:column;gap:1px;min-width:0;display:flex}\n.ccp-cardTitle{font-size:13px;font-weight:600;line-height:1.4}\n.ccp-cardOwner{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px}\n.ccp-block{flex-direction:column;gap:8px;display:flex}\n.ccp-blockTitle{margin:0;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:600;line-height:1.5;text-transform:uppercase;letter-spacing:.04em}\n.ccp-planRow{align-items:center;gap:8px;display:flex;flex-wrap:wrap}\n.ccp-fieldLabel{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}\n.ccp-planName{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:1.5}\n\n/* Stat tiles: the monthly credits and the usage totals share one grid. */\n.ccp-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px}\n.ccp-tile{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:8px;padding:8px 10px;flex-direction:column;gap:2px;display:flex;min-width:0}\n.ccp-tileLabel{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5}\n.ccp-tileValue{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4;font-variant-numeric:tabular-nums}\n.ccp-tileSub{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n\n/* Quota bars: the monthly bar and the two windows stack in one column, each a\n label row plus the track. */\n.ccp-windows{flex-direction:column;gap:14px;display:flex}\n.ccp-window{flex-direction:column;gap:6px;display:flex}\n.ccp-windowHead{align-items:baseline;gap:8px;display:flex}\n.ccp-windowLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:1.5}\n.ccp-windowValue{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5;font-variant-numeric:tabular-nums;white-space:nowrap}\n.ccp-windowPct{color:var(--dsw-alias-label-primary);min-width:38px;text-align:right;font-size:12px;font-weight:600;line-height:1.5;font-variant-numeric:tabular-nums}\n.ccp-warnTag{white-space:nowrap;background:var(--dsw-alias-state-warn-tertiary,var(--dsw-alias-bg-module-platform));color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-label-secondary));border-radius:999px;padding:0 8px;font-size:11px;font-weight:600;line-height:17px}\n.ccp-bar{overflow:hidden;background:var(--dsw-alias-bg-layer-1);border-radius:999px;height:8px}\n.ccp-barFill{background:var(--dsw-alias-brand-primary);border-radius:999px;height:100%;transition:width .3s ease}\n.ccp-barFillWarn{background:var(--dsw-alias-state-error-primary)}\n.ccp-windowReset{color:var(--dsw-alias-label-tertiary);margin:0;font-size:11px;line-height:1.5}\n\n/* Badges. */\n.ccp-badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-brand-primary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:600;line-height:17px}\n.ccp-badgeError{background:transparent;color:var(--dsw-alias-state-error-primary)}\n.ccp-badgeWarn{background:var(--dsw-alias-state-warn-tertiary,var(--dsw-alias-bg-module-platform));color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-label-secondary))}\n.ccp-badgeMuted{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px;max-width:220px;overflow:hidden;text-overflow:ellipsis}\n\n/* Account switch: plain buttons, like the settings page's usage carousel. */\n.ccp-tabs{flex-wrap:wrap;gap:6px;display:flex}\n.ccp-tab{align-items:center;font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:2px 10px;font-size:12px;line-height:18px;display:inline-flex;gap:6px}\n.ccp-tab:hover:not(.ccp-tabActive){color:var(--dsw-alias-label-primary)}\n.ccp-tabActive{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-brand-primary)}\n\n@media (prefers-reduced-motion:reduce){.ccp-footFill,.ccp-barFill{transition:none}}\n`\n","/**\n * Locale copy for the \"Command Code\" settings page, and the declaration that\n * merges the page's namespace into the framework's `LocaleNamespaceMap` so\n * `ctx.locale.register` / `ctx.slots.register(..., { locale })` are typed.\n *\n * zh is the source of truth for the key set (repo convention); en must carry\n * the exact same keys — a mismatch is a compile error at the register site.\n */\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** Copy of the Command Code settings page. */\n 'settings.commandcode': SettingsCommandCodeKey\n }\n}\n\n/** Dictionary keys of the Command Code settings page. */\nexport type SettingsCommandCodeKey =\n | 'nav'\n | 'title'\n | 'intro'\n | 'apiKey'\n | 'apiKeyHint'\n | 'apiKeySet'\n | 'apiKeyUnset'\n | 'apiKeyLocked'\n | 'apiBase'\n | 'apiBaseHint'\n | 'workingDir'\n | 'workingDirHint'\n | 'requestTimeoutMs'\n | 'requestTimeoutMsHint'\n | 'streamIdleTimeoutMs'\n | 'streamIdleTimeoutMsHint'\n | 'advancedSettings'\n | 'advancedSettingsHint'\n | 'advancedOverriddenOne'\n | 'advancedOverriddenMany'\n | 'advancedInvalid'\n | 'filterModelsByPlan'\n | 'filterModelsByPlanHint'\n | 'webSearch'\n | 'webSearchHint'\n | 'accountsTitle'\n | 'accountsHint'\n | 'accountAdd'\n | 'accountRemove'\n | 'accountLabel'\n | 'accountKey'\n | 'accountKeyHint'\n | 'accountDefault'\n | 'activeAccount'\n | 'activeAccountAuto'\n | 'activeAccountHint'\n | 'rulesTitle'\n | 'rulesHint'\n | 'rulesEmpty'\n | 'rulesCatalogFailed'\n | 'ruleAdd'\n | 'ruleRemove'\n | 'ruleModel'\n | 'ruleModelPick'\n | 'ruleModelCount'\n | 'ruleAccount'\n | 'ruleHint'\n | 'modelSearchPlaceholder'\n | 'modelSearchEmpty'\n | 'modelStale'\n | 'visibleModelsTitle'\n | 'visibleModelsHint'\n | 'visibleModelsPick'\n | 'visibleModelsCount'\n | 'visibleModelsShowAll'\n | 'visibleModelsStaleHint'\n | 'visibleModelsCleanStale'\n | 'overridden'\n | 'reset'\n | 'invalidNumber'\n | 'numberTooSmall'\n | 'numberTooLarge'\n | 'readOnly'\n | 'unsaved'\n | 'save'\n | 'saving'\n | 'saved'\n | 'saveFailed'\n | 'discard'\n | 'cancel'\n | 'show'\n | 'hide'\n | 'usageTitle'\n | 'usageRefresh'\n | 'usageRefreshing'\n | 'usageLoading'\n | 'usageNoKey'\n | 'usageError'\n | 'usageRequests'\n | 'usageFailed'\n | 'usageSuccessRate'\n | 'usageCost'\n | 'usageTokens'\n | 'usageTokensIn'\n | 'usageTokensOut'\n | 'usageMonthly'\n | 'usagePurchased'\n | 'usageFree'\n | 'usageFiveHour'\n | 'usageWeekly'\n | 'usageExceeded'\n | 'usageReset'\n | 'usagePartial'\n | 'usageKeyClear'\n | 'usageKeyClearStaged'\n | 'usageUndoKeyClear'\n | 'usageKeyInvalid'\n | 'usageKeyInvalidHint'\n | 'usageServiceUnavailable'\n | 'usageServiceUnavailableHint'\n | 'usageNetworkError'\n | 'usageNetworkHint'\n | 'usageUpdated'\n | 'usagePeriodEnd'\n | 'usageActive'\n | 'usageCooldown'\n | 'usageInvalidKey'\n | 'usageUnconfigured'\n | 'updateAvailable'\n | 'updateHint'\n | 'loginTitle'\n | 'loginHintIdle'\n | 'loginButton'\n | 'loginStarting'\n | 'loginWaiting'\n | 'loginOpenLink'\n | 'loginCancel'\n | 'loginSuccess'\n | 'loginUnavailable'\n | 'loginDenied'\n | 'loginTimeout'\n | 'loginInvalidKey'\n | 'loginNetwork'\n | 'loginStoreFailed'\n | 'loginCancelled'\n | 'loginFailedGeneric'\n | 'cardTitle'\n | 'cardRouteActive'\n | 'cardLoadingHint'\n | 'cardRegistrationHint'\n\nexport const zh: Record = {\n nav: 'Command Code',\n title: 'Command Code',\n intro:\n '配置 Command Code Provider 连接。API 密钥仅保存在本机凭据服务中,不会回显;'\n + '其他字段写入用户设置,下次请求即生效。',\n apiKey: 'API 密钥',\n apiKeyHint: '在 commandcode.ai 控制台创建。留空保存不会覆盖已存储的密钥。',\n apiKeySet: '已配置',\n apiKeyUnset: '未配置',\n apiKeyLocked: '密钥由只读来源提供',\n apiBase: 'API 地址',\n apiBaseHint: '默认 https://api.commandcode.ai,一般无需修改。',\n workingDir: '工作目录',\n workingDirHint: '可选。留空时使用占位符显示的进程工作目录;仅在需要固定路径时填写。',\n requestTimeoutMs: '请求超时(毫秒)',\n requestTimeoutMsHint: '等待响应首个字节的超时;默认 60000。',\n streamIdleTimeoutMs: '流空闲超时(毫秒)',\n streamIdleTimeoutMsHint: '生成流停滞多久视为断连;默认 300000(长思考模型可静默数分钟,默认值刻意放宽)。',\n advancedSettings: '高级设置',\n advancedSettingsHint: 'API 地址、工作目录、超时与模型过滤等不常修改的选项。',\n advancedOverriddenOne: '已自定义 1 项',\n advancedOverriddenMany: '已自定义 {count} 项',\n advancedInvalid: '高级设置中有未填好的数字,请展开修正后再保存。',\n filterModelsByPlan: '隐藏套餐外模型',\n filterModelsByPlanHint: '开启后,模型选择器只列出当前套餐可用的模型;账户持有按需余额时会显示全部。',\n webSearch: '用 Command Code 承载联网搜索',\n webSearchHint: '开启后,dsh 的 web_search 工具由 Command Code 承担(复用同一个 API key 与地址),并优先于其他搜索后端;关闭则把选择权交还给之前的后端(如 modsearch),而不是强制回退到 DeepSeek 搜索。',\n accountsTitle: '多账户轮换',\n accountsHint: '当前账户达到用量限额(429)或密钥失效(401)时,请求自动切换到下一个账户;全部耗尽时会提示最早的重置时间。',\n accountAdd: '添加账户',\n accountRemove: '移除',\n accountLabel: '账户备注名',\n accountKey: 'API 密钥',\n accountKeyHint: '该账户的 API 密钥。留空保存不会覆盖已存储的密钥。',\n accountDefault: '默认账户',\n activeAccount: '当前使用账户',\n activeAccountAuto: '自动(第一个可用账户)',\n activeAccountHint: '手动指定优先使用的账户,保存后下次请求即生效;所选账户耗尽时仍会自动切换到其他可用账户。',\n rulesTitle: '按模型切换账户',\n rulesHint: '选择模型并路由到某个账户(可多选)。命中规则的模型且该账户可用时优先使用;账户耗尽或密钥失效时仍自动回落到其他账户。规则按列表顺序匹配,第一条命中生效。',\n rulesEmpty: '尚未配置规则。',\n rulesCatalogFailed: '模型目录获取失败,暂时无法选择模型;已保存的规则仍会生效。',\n ruleAdd: '添加规则',\n ruleRemove: '移除',\n ruleModel: '模型',\n ruleModelPick: '选择模型…',\n ruleModelCount: '已选 {count} 个模型',\n ruleAccount: '目标账户',\n ruleHint: '从下拉列表勾选要路由的模型(可多选),再选择目标账户。',\n modelSearchPlaceholder: '搜索模型…',\n modelSearchEmpty: '没有匹配的模型。',\n modelStale: '已下架',\n visibleModelsTitle: '模型白名单',\n visibleModelsHint:\n '勾选要保留的模型,模型选择器就只列出这些;一个都不勾选时则显示全部模型。'\n + '保存后,下次打开模型选择器生效。',\n visibleModelsPick: '选择要保留的模型…',\n visibleModelsCount: '已选 {count} 个模型',\n visibleModelsShowAll: '显示全部',\n visibleModelsStaleHint: '有 {count} 个已选模型在目录中找不到了(可能已下架),不影响其他模型;可清理或保留。',\n visibleModelsCleanStale: '清理失效({count})',\n overridden: '已覆盖',\n reset: '重置',\n invalidNumber: '无效数字',\n numberTooSmall: '不能小于 1(毫秒)',\n numberTooLarge: '超出允许上限(2147483647 毫秒)',\n readOnly: '当前配置为只读。',\n unsaved: '未保存',\n save: '保存',\n saving: '保存中',\n saved: '已保存 ✓',\n saveFailed: '保存失败,请重试。',\n discard: '放弃',\n cancel: '取消',\n show: '显示',\n hide: '隐藏',\n usageTitle: '账户用量',\n usageRefresh: '刷新',\n usageRefreshing: '刷新中…',\n usageLoading: '正在获取账户用量…',\n usageNoKey: '配置 API 密钥后,这里会显示账户的用量与额度状态。',\n usageError: '用量获取失败',\n usageRequests: '请求',\n usageFailed: '失败',\n usageSuccessRate: '成功率',\n usageCost: '花费',\n usageTokens: 'Token',\n usageTokensIn: '入',\n usageTokensOut: '出',\n usageMonthly: '月额度',\n usagePurchased: '已购',\n usageFree: '赠送',\n usageFiveHour: '5 小时窗口',\n usageWeekly: '每周窗口',\n usageExceeded: '已超限',\n usageReset: '重置于',\n usagePartial: '部分端点数据不可用',\n usageKeyClear: '清除已存密钥',\n usageKeyClearStaged: '将清除(保存后生效)',\n usageUndoKeyClear: '撤销清除',\n usageKeyInvalid: 'API 密钥无效或已过期',\n usageKeyInvalidHint: '服务端拒绝了全部请求(401)。请检查该账户配置的密钥,或到 commandcode.ai 控制台重新生成。',\n usageServiceUnavailable: 'Command Code 服务暂时不可用',\n usageServiceUnavailableHint: '服务端返回了错误(5xx),稍后点击刷新重试。',\n usageNetworkError: '无法连接 Command Code 服务',\n usageNetworkHint: '所有请求都没有到达服务端。请检查网络连接或 API 地址设置。',\n usageUpdated: '更新于',\n usagePeriodEnd: '账期截止',\n usageActive: '当前使用',\n usageCooldown: '限额冷却中',\n usageInvalidKey: '密钥无效',\n usageUnconfigured: '该账户尚未配置 API 密钥。',\n updateAvailable: '可更新',\n updateHint: '已发布新版本,点击查看发布说明;更新插件后刷新本页,提示会自动消失。',\n loginTitle: '通过官方登录获取密钥',\n loginHintIdle: '不想手动创建密钥?点击登录后浏览器会打开 commandcode.ai 授权页,完成后密钥自动写入本机凭据服务,下次请求即生效。',\n loginButton: '登录 Command Code',\n loginStarting: '正在启动本地回调服务…',\n loginWaiting: '等待在浏览器中完成授权…',\n loginOpenLink: '打开授权页面 ↗',\n loginCancel: '取消登录',\n loginSuccess: '已登录为',\n loginUnavailable: '此环境暂不支持登录流程,请手动粘贴密钥。',\n loginDenied: '授权被拒绝。可重试,或手动粘贴密钥。',\n loginTimeout: '等待超时:未在窗口期内收到授权回调,请重试。',\n loginInvalidKey: '获取到的密钥未通过校验(401),请重试或手动粘贴。',\n loginNetwork: '无法连接 Command Code 服务校验密钥,请检查网络后重试。',\n loginStoreFailed: '密钥无法写入本机凭据服务,请手动粘贴。',\n loginCancelled: '登录已取消。',\n loginFailedGeneric: '登录失败,请重试或手动粘贴密钥。',\n cardTitle: 'Command Code',\n cardRouteActive: '已启用',\n cardLoadingHint: '正在读取 Command Code 配置…',\n cardRegistrationHint: '此卡片随 Command Code 插件注册,需要较新版本的 DeepSeek Harness 才会显示完整内容。',\n}\n\nexport const en: Record = {\n nav: 'Command Code',\n title: 'Command Code',\n intro:\n 'Configure the Command Code Provider connection. The API key is stored only'\n + ' in the local credential service and never echoed; other fields are written'\n + ' to user settings and take effect on the next request.',\n apiKey: 'API key',\n apiKeyHint: 'Create one in the commandcode.ai console. Saving with this field'\n + ' blank keeps the stored key.',\n apiKeySet: 'Configured',\n apiKeyUnset: 'Not configured',\n apiKeyLocked: 'Key provided by a read-only source',\n apiBase: 'API base URL',\n apiBaseHint: 'Defaults to https://api.commandcode.ai; usually leave as-is.',\n workingDir: 'Working directory',\n workingDirHint: 'Optional. Leave blank to use the process cwd shown as the'\n + ' placeholder; fill in only to pin a specific path.',\n requestTimeoutMs: 'Request timeout (ms)',\n requestTimeoutMsHint: 'Time to wait for the first response byte; default 60000.',\n streamIdleTimeoutMs: 'Stream idle timeout (ms)',\n streamIdleTimeoutMsHint: 'How long a stalled stream is treated as dead; default 300000'\n + ' (deliberately generous — long-thinking models can stay silent for minutes).',\n advancedSettings: 'Advanced',\n advancedSettingsHint: 'Rarely touched options: API base URL, working directory, timeouts, and model filtering.',\n advancedOverriddenOne: '1 customized',\n advancedOverriddenMany: '{count} customized',\n advancedInvalid: 'A number in Advanced settings is not ready to save; expand to fix it.',\n filterModelsByPlan: 'Hide out-of-plan models',\n filterModelsByPlanHint: 'When on, the model picker lists only models your subscription'\n + ' includes; any on-demand credit balance shows the full catalog.',\n webSearch: 'Serve dsh web search with Command Code',\n webSearchHint: 'When on, the model-facing web_search tool is backed by Command Code'\n + ' (same API key and base URL as chat), winning over other search backends.'\n + ' Off hands the selection back to the previous backend (e.g. modsearch)'\n + ' instead of forcing the shipped DeepSeek search.',\n accountsTitle: 'Account rotation',\n accountsHint: 'When the active account hits its usage limit (429) or its key'\n + ' fails (401), requests switch to the next account; when every account is'\n + ' exhausted the error names the earliest window reset.',\n accountAdd: 'Add account',\n accountRemove: 'Remove',\n accountLabel: 'Account label',\n accountKey: 'API key',\n accountKeyHint: 'This account’s API key. Saving with the field blank keeps the stored key.',\n accountDefault: 'Default account',\n activeAccount: 'Active account',\n activeAccountAuto: 'Auto (first usable account)',\n activeAccountHint: 'Pin the preferred account; applies to the next request after saving.'\n + ' If the selected account is exhausted, requests still rotate to another usable account.',\n rulesTitle: 'Route models to accounts',\n rulesHint: 'Pick models (multi-select) and route them to an account. When the'\n + ' request’s model is in a rule and that account is usable, it serves;'\n + ' an exhausted or invalid routed account falls back to the normal rotation.'\n + ' Rules match in list order — the first hit wins.',\n rulesEmpty: 'No rules yet.',\n rulesCatalogFailed: 'Could not load the model catalog — selecting models is unavailable; saved rules still apply.',\n ruleAdd: 'Add rule',\n ruleRemove: 'Remove',\n ruleModel: 'Models',\n ruleModelPick: 'Select models…',\n ruleModelCount: '{count} model(s) selected',\n ruleAccount: 'Target account',\n ruleHint: 'Check the models to route from the dropdown (multi-select), then pick the target account.',\n modelSearchPlaceholder: 'Search models…',\n modelSearchEmpty: 'No matching models.',\n modelStale: 'Retired',\n visibleModelsTitle: 'Model allowlist',\n visibleModelsHint:\n 'Check the models you want to keep, and model pickers will list only those. '\n + 'If nothing is checked, every model is shown. After saving, the change '\n + 'applies the next time you open a model picker.',\n visibleModelsPick: 'Select models to keep…',\n visibleModelsCount: '{count} model(s) selected',\n visibleModelsShowAll: 'Show all',\n visibleModelsStaleHint: '{count} selected model(s) are no longer in the catalog (possibly retired);'\n + ' other models are unaffected. Clean them up or keep them.',\n visibleModelsCleanStale: 'Clean stale ({count})',\n overridden: 'Overridden',\n reset: 'Reset',\n invalidNumber: 'Invalid number',\n numberTooSmall: 'Must be at least 1 (ms)',\n numberTooLarge: 'Above the allowed maximum (2147483647 ms)',\n readOnly: 'Settings are read-only.',\n unsaved: 'Unsaved',\n save: 'Save',\n saving: 'Saving',\n saved: 'Saved ✓',\n saveFailed: 'Save failed, please retry.',\n discard: 'Discard',\n cancel: 'Cancel',\n show: 'Show',\n hide: 'Hide',\n usageTitle: 'Account usage',\n usageRefresh: 'Refresh',\n usageRefreshing: 'Refreshing…',\n usageLoading: 'Fetching account usage…',\n usageNoKey: 'Configure an API key to see this account’s usage and credit state here.',\n usageError: 'Could not fetch usage',\n usageRequests: 'Requests',\n usageFailed: 'failed',\n usageSuccessRate: 'Success rate',\n usageCost: 'Spend',\n usageTokens: 'Tokens',\n usageTokensIn: 'in',\n usageTokensOut: 'out',\n usageMonthly: 'Monthly',\n usagePurchased: 'Purchased',\n usageFree: 'Free',\n usageFiveHour: '5-hour window',\n usageWeekly: 'Weekly window',\n usageExceeded: 'Exceeded',\n usageReset: 'Resets',\n usagePartial: 'Some endpoint data unavailable',\n usageKeyClear: 'Clear stored key',\n usageKeyClearStaged: 'Will be cleared on save',\n usageUndoKeyClear: 'Undo clear',\n usageKeyInvalid: 'API key invalid or expired',\n usageKeyInvalidHint: 'The server rejects every request (401). Check the key configured for this account, or generate a new one in the commandcode.ai console.',\n usageServiceUnavailable: 'The Command Code service is temporarily unavailable',\n usageServiceUnavailableHint: 'The server returned errors (5xx); try Refresh again later.',\n usageNetworkError: 'Could not reach the Command Code service',\n usageNetworkHint: 'No request reached the server. Check your network connection or the API base setting.',\n usageUpdated: 'Updated',\n usagePeriodEnd: 'Period ends',\n usageActive: 'Active',\n usageCooldown: 'Cooling down',\n usageInvalidKey: 'Invalid key',\n usageUnconfigured: 'No API key configured for this account yet.',\n updateAvailable: 'update available',\n updateHint: 'A newer version has been published; click for release notes. The notice disappears once the plugin is updated.',\n loginTitle: 'Sign in to fetch a key',\n loginHintIdle: 'Rather not create a key by hand? Sign in and your browser opens the commandcode.ai authorization page; the approved key is stored in the local credential service and applies to the next request.',\n loginButton: 'Sign in to Command Code',\n loginStarting: 'Starting the local callback server…',\n loginWaiting: 'Waiting for authorization in your browser…',\n loginOpenLink: 'Open the authorization page ↗',\n loginCancel: 'Cancel sign-in',\n loginSuccess: 'Signed in as',\n loginUnavailable: 'Sign-in is unavailable in this environment; paste the API key instead.',\n loginDenied: 'Authorization was denied. Try again or paste the key manually.',\n loginTimeout: 'Timed out waiting for the authorization callback; try again.',\n loginInvalidKey: 'The delivered key failed validation (401). Try again or paste it manually.',\n loginNetwork: 'Could not reach the Command Code service to validate the key; check your network and retry.',\n loginStoreFailed: 'The key could not be stored in the local credential service; paste it manually.',\n loginCancelled: 'Sign-in cancelled.',\n loginFailedGeneric: 'Sign-in failed; try again or paste the key manually.',\n cardTitle: 'Command Code',\n cardRouteActive: 'Active',\n cardLoadingHint: 'Loading the Command Code configuration…',\n cardRegistrationHint: 'This card is contributed by the Command Code plugin; a newer DeepSeek Harness is needed to show the full controls.',\n}\n","/**\n * Browser half of the dsh-commandcode-provider bundle.\n *\n * Two responsibilities:\n *\n * 1. A \"Command Code\" settings page (a `settings.section` entry at the same\n * nav level as General / Models / Plugins). The Models page renders an\n * unknown-adapter-family card for the `commandcode` provider and disables\n * its submit, so the API key cannot be configured there; this page is the\n * dedicated surface. It writes the API key through the credentials domain\n * (the `COMMANDCODE_API_KEY` reference the plugin resolves via\n * `ctx.remote.credentials`) and the connection facts through the\n * `llm-commandcode` settings namespace, so a saved key or endpoint reaches\n * the very next request.\n *\n * 2. The Models-page provider card (settings.models.provider-card) and the\n * friendly image-gate error wrapper — see `./card.tsx` / `./sessions.ts`.\n * The wrapper is deliberately narrow: only the `model-unavailable` code is\n * rewritten, only when the message matches the image-session gate, and only\n * the message text changes.\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport { createSnapshotStore } from './snapshot-store.ts'\n// Type-only imports that pull in the client-service augmentations\n// (`slots`/`remote`/`locale` on Context) and the `settings.section` SlotMap\n// entry (`settingsScope` arrives through dsh-client-ui-settings).\nimport type {} from '@deepseek-ai/dsh-api-remotes/client'\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\nimport type {} from '@deepseek-ai/dsh-client-ui-renderer/client'\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { installFriendlyImageError } from './sessions.ts'\nimport type { ConnectionLike } from './sessions.ts'\nimport { CommandCodeSettingsController, COMMANDCODE_NS, type SettingsPageState } from './settings.ts'\nimport type { HostDescriptionSource, SettingsPageApi } from './settings.ts'\nimport { adaptLegacyCredentials, type LegacyCredentialsApi } from './legacy-credentials.ts'\nimport { CommandCodeUsageController, type UsagePageState, type UsageRemote } from './usage.ts'\nimport { CommandCodePricesController, type SessionCostPricesState } from './prices.ts'\nimport { CommandCodeLoginController, type LoginPageState, type LoginRemote } from './login.ts'\nimport { USAGE_REMOTE_CONTRIBUTION, MODELS_REMOTE_CONTRIBUTION, PRICES_REMOTE_CONTRIBUTION } from '../usage-wire.ts'\nimport { LOGIN_REMOTE_CONTRIBUTION } from '../login-wire.ts'\nimport type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'\nimport { CommandCodeSettingsPage } from './section.tsx'\nimport { CommandCodeProviderCard } from './card.tsx'\nimport { CommandCodePanel, CommandCodeFooterEntry } from './panel-view.tsx'\nimport type { PanelInjected } from './panel-view.tsx'\nimport { CommandCodeSessionCost } from './session-cost-view.tsx'\nimport type { SessionCostInjected } from './session-cost-view.tsx'\nimport { startPanelAutoRefresh } from './panel.ts'\nimport { PANEL_CSS, PANEL_CSS_ID } from './panel-styles.ts'\n// Type-only: pulls in the SlotMap merge for the composer dock\n// (`./session-cost-slots.ts`). The sidebar-foot / center-column merge rides the\n// `./panel-view.tsx` value import above, which imports `./panel-slots.ts`.\nimport type {} from './session-cost-slots.ts'\nimport { zh, en } from './locales.ts'\n\nexport { isImageSessionRejection, withFriendlyImageError } from './sessions.ts'\n\n/** CSS for the settings page, injected once (harness bundle convention). */\nconst PAGE_CSS = `\n.cc-section{max-width:720px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}\n.cc-title{margin:0;font-size:18px;font-weight:600}\n.cc-intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px;line-height:1.5}\n.cc-readOnly{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}\n.cc-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:4px 16px}\n.cc-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}\n.cc-field+.cc-field{border-top:1px solid var(--dsw-alias-border-l2)}\n.cc-fieldHead{align-items:center;gap:8px;display:flex}\n.cc-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}\n.cc-badges{align-items:center;gap:8px;display:inline-flex}\n.cc-badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}\n.cc-badgeMuted{white-space:nowrap;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px}\n.cc-reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}\n.cc-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}\n.cc-reset:disabled{cursor:default;opacity:.5}\n.cc-input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}\n.cc-input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}\n.cc-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}\n/* The routing-rule model multi-select: a button trigger that opens an\n * anchored Menu of checkbox rows. The trigger mirrors .cc-input sizing so it\n * sits flush with the sibling account select. */\n.cc-ruleTrigger{align-items:center;gap:8px;display:flex;width:100%;text-align:left;cursor:pointer}\n.cc-ruleTrigger:disabled{cursor:default}\n.cc-ruleTriggerText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.cc-ruleCaret{flex-shrink:0;border-right:1.5px solid var(--dsw-alias-label-tertiary);border-bottom:1.5px solid var(--dsw-alias-label-tertiary);width:6px;height:6px;margin-right:4px;margin-bottom:2px;transform:rotate(45deg)}\n.cc-checkRow{align-items:center;gap:8px;display:inline-flex;min-width:0}\n.cc-checkRow:hover{cursor:pointer}\n.cc-check{appearance:none;flex-shrink:0;width:15px;height:15px;margin:0;border:1px solid var(--dsw-alias-border-l2);border-radius:4px;background:var(--dsw-alias-bg-layer-1);position:relative}\n.cc-check:checked{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}\n.cc-check:checked::after{content:'';position:absolute;top:2px;left:5px;width:3px;height:7px;border:solid #fff;border-width:0 1.5px 1.5px 0;transform:rotate(45deg)}\n.cc-checkName{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n/* The model multi-select search box: stacked under the trigger while the\n * dropdown is open, same input sizing so the pair reads as one control. The\n * box lives inside the Menu anchor (which renders inside the Menu's root\n * span), so focusing/typing it never trips the Menu's outside-click close. */\n.cc-modelSelectAnchor{flex-direction:column;gap:6px;display:flex;width:100%}\n.cc-modelSearch{width:100%}\n.cc-modelSearch::-webkit-search-cancel-button{cursor:pointer}\n/* Selects need their own treatment to sit flush with the text inputs:\n * the UA stylesheet renders