From 59e0619cf3416cadef66c9617ba26874749b7ec1 Mon Sep 17 00:00:00 2001 From: A8Chann Date: Fri, 11 Sep 2026 14:12:07 +0800 Subject: [PATCH] fix: keep tool results consecutive before carried image messages An assistant turn can issue several parallel tool calls and each tool result can carry images (e.g. read_image). Both message converters emitted the image carrier as a user message immediately after each tool result, so a two-call turn went assistant -> tool -> user(image) -> tool -> user(image). The Command Code gateway validates that an assistant's tool blocks are answered consecutively, so the interleaved user message made the whole request fail with 'An assistant message with tool_calls must be followed by tool messages responding to each tool_call_id' (HTTP 400) on every replay of the history. Buffer the image carriers in a pending list and flush them only once the whole tool group has been emitted (or the next non-tool message is reached), for both the CLI (messagesToCC) and OpenAI (messagesToOpenAI) transports. Adds regression tests pinning the assistant -> tool -> tool -> user -> user ordering. --- lib/client.js.map | 2 +- lib/index.js | 20 +++++- lib/index.js.map | 2 +- src/adapter.ts | 34 +++++++++- tests/adapter.test.ts | 145 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 6 deletions(-) diff --git a/lib/client.js.map b/lib/client.js.map index 2b10ed7..01059c4 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 \r\n

{t('activeAccountHint')}

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

{t('ruleHint')}

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

{t('rulesHint')}

\r\n {state.catalogFailed ?

{t('rulesCatalogFailed')}

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

{t('rulesEmpty')}

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

{t('visibleModelsHint')}

\r\n {state.catalogFailed ?

{t('rulesCatalogFailed')}

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

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

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

{t('title')}

\r\n

{t('intro')}

\r\n {!state.writable ?

{t('readOnly')}

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

{t('saveFailed')}

: null}\r\n {savedVisible ?

{t('saved')}

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

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

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

{t('apiKeyHint')}

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

{t('cardRegistrationHint')}

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

{t('cardLoadingHint')}

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

{t('saveFailed')}

: null}\r\n \r\n {t('discard')}\r\n \r\n \r\n {t(saving ? 'saving' : 'save')}\r\n \r\n
\r\n \r\n ) : null}\r\n \r\n )\r\n}\r\n","/**\r\n * Locale copy for the \"Command Code\" settings page, and the declaration that\r\n * merges the page's namespace into the framework's `LocaleNamespaceMap` so\r\n * `ctx.locale.register` / `ctx.slots.register(..., { locale })` are typed.\r\n *\r\n * zh is the source of truth for the key set (repo convention); en must carry\r\n * the exact same keys โ€” a mismatch is a compile error at the register site.\r\n */\r\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\r\n interface LocaleNamespaceMap {\r\n /** Copy of the Command Code settings page. */\r\n 'settings.commandcode': SettingsCommandCodeKey\r\n }\r\n}\r\n\r\n/** Dictionary keys of the Command Code settings page. */\r\nexport type SettingsCommandCodeKey =\r\n | 'nav'\r\n | 'title'\r\n | 'intro'\r\n | 'apiKey'\r\n | 'apiKeyHint'\r\n | 'apiKeySet'\r\n | 'apiKeyUnset'\r\n | 'apiKeyLocked'\r\n | 'apiBase'\r\n | 'apiBaseHint'\r\n | 'workingDir'\r\n | 'workingDirHint'\r\n | 'requestTimeoutMs'\r\n | 'requestTimeoutMsHint'\r\n | 'streamIdleTimeoutMs'\r\n | 'streamIdleTimeoutMsHint'\r\n | 'advancedSettings'\r\n | 'advancedSettingsHint'\r\n | 'advancedOverriddenOne'\r\n | 'advancedOverriddenMany'\r\n | 'advancedInvalid'\r\n | 'filterModelsByPlan'\r\n | 'filterModelsByPlanHint'\r\n | 'webSearch'\r\n | 'webSearchHint'\r\n | 'accountsTitle'\r\n | 'accountsHint'\r\n | 'accountAdd'\r\n | 'accountRemove'\r\n | 'accountLabel'\r\n | 'accountKey'\r\n | 'accountKeyHint'\r\n | 'accountDefault'\r\n | 'activeAccount'\r\n | 'activeAccountAuto'\r\n | 'activeAccountHint'\r\n | 'rulesTitle'\r\n | 'rulesHint'\r\n | 'rulesEmpty'\r\n | 'rulesCatalogFailed'\r\n | 'ruleAdd'\r\n | 'ruleRemove'\r\n | 'ruleModel'\r\n | 'ruleModelPick'\r\n | 'ruleModelCount'\r\n | 'ruleAccount'\r\n | 'ruleHint'\r\n | 'modelSearchPlaceholder'\r\n | 'modelSearchEmpty'\r\n | 'modelStale'\r\n | 'visibleModelsTitle'\r\n | 'visibleModelsHint'\r\n | 'visibleModelsPick'\r\n | 'visibleModelsCount'\r\n | 'visibleModelsShowAll'\r\n | 'visibleModelsStaleHint'\r\n | 'visibleModelsCleanStale'\r\n | 'overridden'\r\n | 'reset'\r\n | 'invalidNumber'\r\n | 'numberTooSmall'\r\n | 'numberTooLarge'\r\n | 'readOnly'\r\n | 'unsaved'\r\n | 'save'\r\n | 'saving'\r\n | 'saved'\r\n | 'saveFailed'\r\n | 'discard'\r\n | 'cancel'\r\n | 'show'\r\n | 'hide'\r\n | 'usageTitle'\r\n | 'usageRefresh'\r\n | 'usageRefreshing'\r\n | 'usageLoading'\r\n | 'usageNoKey'\r\n | 'usageError'\r\n | 'usageRequests'\r\n | 'usageFailed'\r\n | 'usageSuccessRate'\r\n | 'usageCost'\r\n | 'usageTokens'\r\n | 'usageTokensIn'\r\n | 'usageTokensOut'\r\n | 'usageMonthly'\r\n | 'usagePurchased'\r\n | 'usageFree'\r\n | 'usageFiveHour'\r\n | 'usageWeekly'\r\n | 'usageExceeded'\r\n | 'usageReset'\r\n | 'usagePartial'\r\n | 'usageKeyClear'\r\n | 'usageKeyClearStaged'\r\n | 'usageUndoKeyClear'\r\n | 'usageKeyInvalid'\r\n | 'usageKeyInvalidHint'\r\n | 'usageServiceUnavailable'\r\n | 'usageServiceUnavailableHint'\r\n | 'usageNetworkError'\r\n | 'usageNetworkHint'\r\n | 'usageUpdated'\r\n | 'usagePeriodEnd'\r\n | 'usageActive'\r\n | 'usageCooldown'\r\n | 'usageInvalidKey'\r\n | 'usageUnconfigured'\r\n | 'updateAvailable'\r\n | 'updateHint'\r\n | 'loginTitle'\r\n | 'loginHintIdle'\r\n | 'loginButton'\r\n | 'loginStarting'\r\n | 'loginWaiting'\r\n | 'loginOpenLink'\r\n | 'loginCancel'\r\n | 'loginSuccess'\r\n | 'loginUnavailable'\r\n | 'loginDenied'\r\n | 'loginTimeout'\r\n | 'loginInvalidKey'\r\n | 'loginNetwork'\r\n | 'loginStoreFailed'\r\n | 'loginCancelled'\r\n | 'loginFailedGeneric'\r\n | 'cardTitle'\r\n | 'cardRouteActive'\r\n | 'cardLoadingHint'\r\n | 'cardRegistrationHint'\r\n\r\nexport const zh: Record = {\r\n nav: 'Command Code',\r\n title: 'Command Code',\r\n intro:\r\n '้…็ฝฎ Command Code Provider ่ฟžๆŽฅใ€‚API ๅฏ†้’ฅไป…ไฟๅญ˜ๅœจๆœฌๆœบๅ‡ญๆฎๆœๅŠกไธญ๏ผŒไธไผšๅ›žๆ˜พ๏ผ›'\r\n + 'ๅ…ถไป–ๅญ—ๆฎตๅ†™ๅ…ฅ็”จๆˆท่ฎพ็ฝฎ๏ผŒไธ‹ๆฌก่ฏทๆฑ‚ๅณ็”Ÿๆ•ˆใ€‚',\r\n apiKey: 'API ๅฏ†้’ฅ',\r\n apiKeyHint: 'ๅœจ commandcode.ai ๆŽงๅˆถๅฐๅˆ›ๅปบใ€‚็•™็ฉบไฟๅญ˜ไธไผš่ฆ†็›–ๅทฒๅญ˜ๅ‚จ็š„ๅฏ†้’ฅใ€‚',\r\n apiKeySet: 'ๅทฒ้…็ฝฎ',\r\n apiKeyUnset: 'ๆœช้…็ฝฎ',\r\n apiKeyLocked: 'ๅฏ†้’ฅ็”ฑๅช่ฏปๆฅๆบๆไพ›',\r\n apiBase: 'API ๅœฐๅ€',\r\n apiBaseHint: '้ป˜่ฎค https://api.commandcode.ai๏ผŒไธ€่ˆฌๆ— ้œ€ไฟฎๆ”นใ€‚',\r\n workingDir: 'ๅทฅไฝœ็›ฎๅฝ•',\r\n workingDirHint: 'ๅฏ้€‰ใ€‚็•™็ฉบๆ—ถไฝฟ็”จๅ ไฝ็ฌฆๆ˜พ็คบ็š„่ฟ›็จ‹ๅทฅไฝœ็›ฎๅฝ•๏ผ›ไป…ๅœจ้œ€่ฆๅ›บๅฎš่ทฏๅพ„ๆ—ถๅกซๅ†™ใ€‚',\r\n requestTimeoutMs: '่ฏทๆฑ‚่ถ…ๆ—ถ๏ผˆๆฏซ็ง’๏ผ‰',\r\n requestTimeoutMsHint: '็ญ‰ๅพ…ๅ“ๅบ”้ฆ–ไธชๅญ—่Š‚็š„่ถ…ๆ—ถ๏ผ›้ป˜่ฎค 60000ใ€‚',\r\n streamIdleTimeoutMs: 'ๆต็ฉบ้—ฒ่ถ…ๆ—ถ๏ผˆๆฏซ็ง’๏ผ‰',\r\n streamIdleTimeoutMsHint: '็”Ÿๆˆๆตๅœๆปžๅคšไน…่ง†ไธบๆ–ญ่ฟž๏ผ›้ป˜่ฎค 300000๏ผˆ้•ฟๆ€่€ƒๆจกๅž‹ๅฏ้™้ป˜ๆ•ฐๅˆ†้’Ÿ๏ผŒ้ป˜่ฎคๅ€ผๅˆปๆ„ๆ”พๅฎฝ๏ผ‰ใ€‚',\r\n advancedSettings: '้ซ˜็บง่ฎพ็ฝฎ',\r\n advancedSettingsHint: 'API ๅœฐๅ€ใ€ๅทฅไฝœ็›ฎๅฝ•ใ€่ถ…ๆ—ถไธŽๆจกๅž‹่ฟ‡ๆปค็ญ‰ไธๅธธไฟฎๆ”น็š„้€‰้กนใ€‚',\r\n advancedOverriddenOne: 'ๅทฒ่‡ชๅฎšไน‰ 1 ้กน',\r\n advancedOverriddenMany: 'ๅทฒ่‡ชๅฎšไน‰ {count} ้กน',\r\n advancedInvalid: '้ซ˜็บง่ฎพ็ฝฎไธญๆœ‰ๆœชๅกซๅฅฝ็š„ๆ•ฐๅญ—๏ผŒ่ฏทๅฑ•ๅผ€ไฟฎๆญฃๅŽๅ†ไฟๅญ˜ใ€‚',\r\n filterModelsByPlan: '้š่—ๅฅ—้คๅค–ๆจกๅž‹',\r\n filterModelsByPlanHint: 'ๅผ€ๅฏๅŽ๏ผŒๆจกๅž‹้€‰ๆ‹ฉๅ™จๅชๅˆ—ๅ‡บๅฝ“ๅ‰ๅฅ—้คๅฏ็”จ็š„ๆจกๅž‹๏ผ›่ดฆๆˆทๆŒๆœ‰ๆŒ‰้œ€ไฝ™้ขๆ—ถไผšๆ˜พ็คบๅ…จ้ƒจใ€‚',\r\n webSearch: '็”จ Command Code ๆ‰ฟ่ฝฝ่”็ฝ‘ๆœ็ดข',\r\n webSearchHint: 'ๅผ€ๅฏๅŽ๏ผŒdsh ็š„ web_search ๅทฅๅ…ท็”ฑ Command Code ๆ‰ฟๆ‹…๏ผˆๅค็”จๅŒไธ€ไธช API key ไธŽๅœฐๅ€๏ผ‰๏ผŒๅนถไผ˜ๅ…ˆไบŽๅ…ถไป–ๆœ็ดขๅŽ็ซฏ๏ผ›ๅ…ณ้—ญๅˆ™ๆŠŠ้€‰ๆ‹ฉๆƒไบค่ฟ˜็ป™ไน‹ๅ‰็š„ๅŽ็ซฏ๏ผˆๅฆ‚ modsearch๏ผ‰๏ผŒ่€Œไธๆ˜ฏๅผบๅˆถๅ›ž้€€ๅˆฐ DeepSeek ๆœ็ดขใ€‚',\r\n accountsTitle: 'ๅคš่ดฆๆˆท่ฝฎๆข',\r\n accountsHint: 'ๅฝ“ๅ‰่ดฆๆˆท่พพๅˆฐ็”จ้‡้™้ข๏ผˆ429๏ผ‰ๆˆ–ๅฏ†้’ฅๅคฑๆ•ˆ๏ผˆ401๏ผ‰ๆ—ถ๏ผŒ่ฏทๆฑ‚่‡ชๅŠจๅˆ‡ๆขๅˆฐไธ‹ไธ€ไธช่ดฆๆˆท๏ผ›ๅ…จ้ƒจ่€—ๅฐฝๆ—ถไผšๆ็คบๆœ€ๆ—ฉ็š„้‡็ฝฎๆ—ถ้—ดใ€‚',\r\n accountAdd: 'ๆทปๅŠ ่ดฆๆˆท',\r\n accountRemove: '็งป้™ค',\r\n accountLabel: '่ดฆๆˆทๅค‡ๆณจๅ',\r\n accountKey: 'API ๅฏ†้’ฅ',\r\n accountKeyHint: '่ฏฅ่ดฆๆˆท็š„ API ๅฏ†้’ฅใ€‚็•™็ฉบไฟๅญ˜ไธไผš่ฆ†็›–ๅทฒๅญ˜ๅ‚จ็š„ๅฏ†้’ฅใ€‚',\r\n accountDefault: '้ป˜่ฎค่ดฆๆˆท',\r\n activeAccount: 'ๅฝ“ๅ‰ไฝฟ็”จ่ดฆๆˆท',\r\n activeAccountAuto: '่‡ชๅŠจ๏ผˆ็ฌฌไธ€ไธชๅฏ็”จ่ดฆๆˆท๏ผ‰',\r\n activeAccountHint: 'ๆ‰‹ๅŠจๆŒ‡ๅฎšไผ˜ๅ…ˆไฝฟ็”จ็š„่ดฆๆˆท๏ผŒไฟๅญ˜ๅŽไธ‹ๆฌก่ฏทๆฑ‚ๅณ็”Ÿๆ•ˆ๏ผ›ๆ‰€้€‰่ดฆๆˆท่€—ๅฐฝๆ—ถไปไผš่‡ชๅŠจๅˆ‡ๆขๅˆฐๅ…ถไป–ๅฏ็”จ่ดฆๆˆทใ€‚',\r\n rulesTitle: 'ๆŒ‰ๆจกๅž‹ๅˆ‡ๆข่ดฆๆˆท',\r\n rulesHint: '้€‰ๆ‹ฉๆจกๅž‹ๅนถ่ทฏ็”ฑๅˆฐๆŸไธช่ดฆๆˆท๏ผˆๅฏๅคš้€‰๏ผ‰ใ€‚ๅ‘ฝไธญ่ง„ๅˆ™็š„ๆจกๅž‹ไธ”่ฏฅ่ดฆๆˆทๅฏ็”จๆ—ถไผ˜ๅ…ˆไฝฟ็”จ๏ผ›่ดฆๆˆท่€—ๅฐฝๆˆ–ๅฏ†้’ฅๅคฑๆ•ˆๆ—ถไป่‡ชๅŠจๅ›ž่ฝๅˆฐๅ…ถไป–่ดฆๆˆทใ€‚่ง„ๅˆ™ๆŒ‰ๅˆ—่กจ้กบๅบๅŒน้…๏ผŒ็ฌฌไธ€ๆกๅ‘ฝไธญ็”Ÿๆ•ˆใ€‚',\r\n rulesEmpty: 'ๅฐšๆœช้…็ฝฎ่ง„ๅˆ™ใ€‚',\r\n rulesCatalogFailed: 'ๆจกๅž‹็›ฎๅฝ•่Žทๅ–ๅคฑ่ดฅ๏ผŒๆš‚ๆ—ถๆ— ๆณ•้€‰ๆ‹ฉๆจกๅž‹๏ผ›ๅทฒไฟๅญ˜็š„่ง„ๅˆ™ไปไผš็”Ÿๆ•ˆใ€‚',\r\n ruleAdd: 'ๆทปๅŠ ่ง„ๅˆ™',\r\n ruleRemove: '็งป้™ค',\r\n ruleModel: 'ๆจกๅž‹',\r\n ruleModelPick: '้€‰ๆ‹ฉๆจกๅž‹โ€ฆ',\r\n ruleModelCount: 'ๅทฒ้€‰ {count} ไธชๆจกๅž‹',\r\n ruleAccount: '็›ฎๆ ‡่ดฆๆˆท',\r\n ruleHint: 'ไปŽไธ‹ๆ‹‰ๅˆ—่กจๅ‹พ้€‰่ฆ่ทฏ็”ฑ็š„ๆจกๅž‹๏ผˆๅฏๅคš้€‰๏ผ‰๏ผŒๅ†้€‰ๆ‹ฉ็›ฎๆ ‡่ดฆๆˆทใ€‚',\r\n modelSearchPlaceholder: 'ๆœ็ดขๆจกๅž‹โ€ฆ',\r\n modelSearchEmpty: 'ๆฒกๆœ‰ๅŒน้…็š„ๆจกๅž‹ใ€‚',\r\n modelStale: 'ๅทฒไธ‹ๆžถ',\r\n visibleModelsTitle: 'ๆจกๅž‹็™ฝๅๅ•',\r\n visibleModelsHint:\r\n 'ๅ‹พ้€‰่ฆไฟ็•™็š„ๆจกๅž‹๏ผŒๆจกๅž‹้€‰ๆ‹ฉๅ™จๅฐฑๅชๅˆ—ๅ‡บ่ฟ™ไบ›๏ผ›ไธ€ไธช้ƒฝไธๅ‹พ้€‰ๆ—ถๅˆ™ๆ˜พ็คบๅ…จ้ƒจๆจกๅž‹ใ€‚'\r\n + 'ไฟๅญ˜ๅŽ๏ผŒไธ‹ๆฌกๆ‰“ๅผ€ๆจกๅž‹้€‰ๆ‹ฉๅ™จ็”Ÿๆ•ˆใ€‚',\r\n visibleModelsPick: '้€‰ๆ‹ฉ่ฆไฟ็•™็š„ๆจกๅž‹โ€ฆ',\r\n visibleModelsCount: 'ๅทฒ้€‰ {count} ไธชๆจกๅž‹',\r\n visibleModelsShowAll: 'ๆ˜พ็คบๅ…จ้ƒจ',\r\n visibleModelsStaleHint: 'ๆœ‰ {count} ไธชๅทฒ้€‰ๆจกๅž‹ๅœจ็›ฎๅฝ•ไธญๆ‰พไธๅˆฐไบ†๏ผˆๅฏ่ƒฝๅทฒไธ‹ๆžถ๏ผ‰๏ผŒไธๅฝฑๅ“ๅ…ถไป–ๆจกๅž‹๏ผ›ๅฏๆธ…็†ๆˆ–ไฟ็•™ใ€‚',\r\n visibleModelsCleanStale: 'ๆธ…็†ๅคฑๆ•ˆ๏ผˆ{count}๏ผ‰',\r\n overridden: 'ๅทฒ่ฆ†็›–',\r\n reset: '้‡็ฝฎ',\r\n invalidNumber: 'ๆ— ๆ•ˆๆ•ฐๅญ—',\r\n numberTooSmall: 'ไธ่ƒฝๅฐไบŽ 1๏ผˆๆฏซ็ง’๏ผ‰',\r\n numberTooLarge: '่ถ…ๅ‡บๅ…่ฎธไธŠ้™๏ผˆ2147483647 ๆฏซ็ง’๏ผ‰',\r\n readOnly: 'ๅฝ“ๅ‰้…็ฝฎไธบๅช่ฏปใ€‚',\r\n unsaved: 'ๆœชไฟๅญ˜',\r\n save: 'ไฟๅญ˜',\r\n saving: 'ไฟๅญ˜ไธญ',\r\n saved: 'ๅทฒไฟๅญ˜ โœ“',\r\n saveFailed: 'ไฟๅญ˜ๅคฑ่ดฅ๏ผŒ่ฏท้‡่ฏ•ใ€‚',\r\n discard: 'ๆ”พๅผƒ',\r\n cancel: 'ๅ–ๆถˆ',\r\n show: 'ๆ˜พ็คบ',\r\n hide: '้š่—',\r\n usageTitle: '่ดฆๆˆท็”จ้‡',\r\n usageRefresh: 'ๅˆทๆ–ฐ',\r\n usageRefreshing: 'ๅˆทๆ–ฐไธญโ€ฆ',\r\n usageLoading: 'ๆญฃๅœจ่Žทๅ–่ดฆๆˆท็”จ้‡โ€ฆ',\r\n usageNoKey: '้…็ฝฎ API ๅฏ†้’ฅๅŽ๏ผŒ่ฟ™้‡Œไผšๆ˜พ็คบ่ดฆๆˆท็š„็”จ้‡ไธŽ้ขๅบฆ็Šถๆ€ใ€‚',\r\n usageError: '็”จ้‡่Žทๅ–ๅคฑ่ดฅ',\r\n usageRequests: '่ฏทๆฑ‚',\r\n usageFailed: 'ๅคฑ่ดฅ',\r\n usageSuccessRate: 'ๆˆๅŠŸ็އ',\r\n usageCost: '่Šฑ่ดน',\r\n usageTokens: 'Token',\r\n usageTokensIn: 'ๅ…ฅ',\r\n usageTokensOut: 'ๅ‡บ',\r\n usageMonthly: 'ๆœˆ้ขๅบฆ',\r\n usagePurchased: 'ๅทฒ่ดญ',\r\n usageFree: '่ต ้€',\r\n usageFiveHour: '5 ๅฐๆ—ถ็ช—ๅฃ',\r\n usageWeekly: 'ๆฏๅ‘จ็ช—ๅฃ',\r\n usageExceeded: 'ๅทฒ่ถ…้™',\r\n usageReset: '้‡็ฝฎไบŽ',\r\n usagePartial: '้ƒจๅˆ†็ซฏ็‚นๆ•ฐๆฎไธๅฏ็”จ',\r\n usageKeyClear: 'ๆธ…้™คๅทฒๅญ˜ๅฏ†้’ฅ',\r\n usageKeyClearStaged: 'ๅฐ†ๆธ…้™ค๏ผˆไฟๅญ˜ๅŽ็”Ÿๆ•ˆ๏ผ‰',\r\n usageUndoKeyClear: 'ๆ’ค้”€ๆธ…้™ค',\r\n usageKeyInvalid: 'API ๅฏ†้’ฅๆ— ๆ•ˆๆˆ–ๅทฒ่ฟ‡ๆœŸ',\r\n usageKeyInvalidHint: 'ๆœๅŠก็ซฏๆ‹’็ปไบ†ๅ…จ้ƒจ่ฏทๆฑ‚๏ผˆ401๏ผ‰ใ€‚่ฏทๆฃ€ๆŸฅ่ฏฅ่ดฆๆˆท้…็ฝฎ็š„ๅฏ†้’ฅ๏ผŒๆˆ–ๅˆฐ commandcode.ai ๆŽงๅˆถๅฐ้‡ๆ–ฐ็”Ÿๆˆใ€‚',\r\n usageServiceUnavailable: 'Command Code ๆœๅŠกๆš‚ๆ—ถไธๅฏ็”จ',\r\n usageServiceUnavailableHint: 'ๆœๅŠก็ซฏ่ฟ”ๅ›žไบ†้”™่ฏฏ๏ผˆ5xx๏ผ‰๏ผŒ็จๅŽ็‚นๅ‡ปๅˆทๆ–ฐ้‡่ฏ•ใ€‚',\r\n usageNetworkError: 'ๆ— ๆณ•่ฟžๆŽฅ Command Code ๆœๅŠก',\r\n usageNetworkHint: 'ๆ‰€ๆœ‰่ฏทๆฑ‚้ƒฝๆฒกๆœ‰ๅˆฐ่พพๆœๅŠก็ซฏใ€‚่ฏทๆฃ€ๆŸฅ็ฝ‘็ปœ่ฟžๆŽฅๆˆ– API ๅœฐๅ€่ฎพ็ฝฎใ€‚',\r\n usageUpdated: 'ๆ›ดๆ–ฐไบŽ',\r\n usagePeriodEnd: '่ดฆๆœŸๆˆชๆญข',\r\n usageActive: 'ๅฝ“ๅ‰ไฝฟ็”จ',\r\n usageCooldown: '้™้ขๅ†ทๅดไธญ',\r\n usageInvalidKey: 'ๅฏ†้’ฅๆ— ๆ•ˆ',\r\n usageUnconfigured: '่ฏฅ่ดฆๆˆทๅฐšๆœช้…็ฝฎ API ๅฏ†้’ฅใ€‚',\r\n updateAvailable: 'ๅฏๆ›ดๆ–ฐ',\r\n updateHint: 'ๅทฒๅ‘ๅธƒๆ–ฐ็‰ˆๆœฌ๏ผŒ็‚นๅ‡ปๆŸฅ็œ‹ๅ‘ๅธƒ่ฏดๆ˜Ž๏ผ›ๆ›ดๆ–ฐๆ’ไปถๅŽๅˆทๆ–ฐๆœฌ้กต๏ผŒๆ็คบไผš่‡ชๅŠจๆถˆๅคฑใ€‚',\r\n loginTitle: '้€š่ฟ‡ๅฎ˜ๆ–น็™ปๅฝ•่Žทๅ–ๅฏ†้’ฅ',\r\n loginHintIdle: 'ไธๆƒณๆ‰‹ๅŠจๅˆ›ๅปบๅฏ†้’ฅ๏ผŸ็‚นๅ‡ป็™ปๅฝ•ๅŽๆต่งˆๅ™จไผšๆ‰“ๅผ€ commandcode.ai ๆŽˆๆƒ้กต๏ผŒๅฎŒๆˆๅŽๅฏ†้’ฅ่‡ชๅŠจๅ†™ๅ…ฅๆœฌๆœบๅ‡ญๆฎๆœๅŠก๏ผŒไธ‹ๆฌก่ฏทๆฑ‚ๅณ็”Ÿๆ•ˆใ€‚',\r\n loginButton: '็™ปๅฝ• Command Code',\r\n loginStarting: 'ๆญฃๅœจๅฏๅŠจๆœฌๅœฐๅ›ž่ฐƒๆœๅŠกโ€ฆ',\r\n loginWaiting: '็ญ‰ๅพ…ๅœจๆต่งˆๅ™จไธญๅฎŒๆˆๆŽˆๆƒโ€ฆ',\r\n loginOpenLink: 'ๆ‰“ๅผ€ๆŽˆๆƒ้กต้ข โ†—',\r\n loginCancel: 'ๅ–ๆถˆ็™ปๅฝ•',\r\n loginSuccess: 'ๅทฒ็™ปๅฝ•ไธบ',\r\n loginUnavailable: 'ๆญค็Žฏๅขƒๆš‚ไธๆ”ฏๆŒ็™ปๅฝ•ๆต็จ‹๏ผŒ่ฏทๆ‰‹ๅŠจ็ฒ˜่ดดๅฏ†้’ฅใ€‚',\r\n loginDenied: 'ๆŽˆๆƒ่ขซๆ‹’็ปใ€‚ๅฏ้‡่ฏ•๏ผŒๆˆ–ๆ‰‹ๅŠจ็ฒ˜่ดดๅฏ†้’ฅใ€‚',\r\n loginTimeout: '็ญ‰ๅพ…่ถ…ๆ—ถ๏ผšๆœชๅœจ็ช—ๅฃๆœŸๅ†…ๆ”ถๅˆฐๆŽˆๆƒๅ›ž่ฐƒ๏ผŒ่ฏท้‡่ฏ•ใ€‚',\r\n loginInvalidKey: '่Žทๅ–ๅˆฐ็š„ๅฏ†้’ฅๆœช้€š่ฟ‡ๆ ก้ชŒ๏ผˆ401๏ผ‰๏ผŒ่ฏท้‡่ฏ•ๆˆ–ๆ‰‹ๅŠจ็ฒ˜่ดดใ€‚',\r\n loginNetwork: 'ๆ— ๆณ•่ฟžๆŽฅ Command Code ๆœๅŠกๆ ก้ชŒๅฏ†้’ฅ๏ผŒ่ฏทๆฃ€ๆŸฅ็ฝ‘็ปœๅŽ้‡่ฏ•ใ€‚',\r\n loginStoreFailed: 'ๅฏ†้’ฅๆ— ๆณ•ๅ†™ๅ…ฅๆœฌๆœบๅ‡ญๆฎๆœๅŠก๏ผŒ่ฏทๆ‰‹ๅŠจ็ฒ˜่ดดใ€‚',\r\n loginCancelled: '็™ปๅฝ•ๅทฒๅ–ๆถˆใ€‚',\r\n loginFailedGeneric: '็™ปๅฝ•ๅคฑ่ดฅ๏ผŒ่ฏท้‡่ฏ•ๆˆ–ๆ‰‹ๅŠจ็ฒ˜่ดดๅฏ†้’ฅใ€‚',\r\n cardTitle: 'Command Code',\r\n cardRouteActive: 'ๅทฒๅฏ็”จ',\r\n cardLoadingHint: 'ๆญฃๅœจ่ฏปๅ– Command Code ้…็ฝฎโ€ฆ',\r\n cardRegistrationHint: 'ๆญคๅก็‰‡้š Command Code ๆ’ไปถๆณจๅ†Œ๏ผŒ้œ€่ฆ่พƒๆ–ฐ็‰ˆๆœฌ็š„ DeepSeek Harness ๆ‰ไผšๆ˜พ็คบๅฎŒๆ•ดๅ†…ๅฎนใ€‚',\r\n}\r\n\r\nexport const en: Record = {\r\n nav: 'Command Code',\r\n title: 'Command Code',\r\n intro:\r\n 'Configure the Command Code Provider connection. The API key is stored only'\r\n + ' in the local credential service and never echoed; other fields are written'\r\n + ' to user settings and take effect on the next request.',\r\n apiKey: 'API key',\r\n apiKeyHint: 'Create one in the commandcode.ai console. Saving with this field'\r\n + ' blank keeps the stored key.',\r\n apiKeySet: 'Configured',\r\n apiKeyUnset: 'Not configured',\r\n apiKeyLocked: 'Key provided by a read-only source',\r\n apiBase: 'API base URL',\r\n apiBaseHint: 'Defaults to https://api.commandcode.ai; usually leave as-is.',\r\n workingDir: 'Working directory',\r\n workingDirHint: 'Optional. Leave blank to use the process cwd shown as the'\r\n + ' placeholder; fill in only to pin a specific path.',\r\n requestTimeoutMs: 'Request timeout (ms)',\r\n requestTimeoutMsHint: 'Time to wait for the first response byte; default 60000.',\r\n streamIdleTimeoutMs: 'Stream idle timeout (ms)',\r\n streamIdleTimeoutMsHint: 'How long a stalled stream is treated as dead; default 300000'\r\n + ' (deliberately generous โ€” long-thinking models can stay silent for minutes).',\r\n advancedSettings: 'Advanced',\r\n advancedSettingsHint: 'Rarely touched options: API base URL, working directory, timeouts, and model filtering.',\r\n advancedOverriddenOne: '1 customized',\r\n advancedOverriddenMany: '{count} customized',\r\n advancedInvalid: 'A number in Advanced settings is not ready to save; expand to fix it.',\r\n filterModelsByPlan: 'Hide out-of-plan models',\r\n filterModelsByPlanHint: 'When on, the model picker lists only models your subscription'\r\n + ' includes; any on-demand credit balance shows the full catalog.',\r\n webSearch: 'Serve dsh web search with Command Code',\r\n webSearchHint: 'When on, the model-facing web_search tool is backed by Command Code'\r\n + ' (same API key and base URL as chat), winning over other search backends.'\r\n + ' Off hands the selection back to the previous backend (e.g. modsearch)'\r\n + ' instead of forcing the shipped DeepSeek search.',\r\n accountsTitle: 'Account rotation',\r\n accountsHint: 'When the active account hits its usage limit (429) or its key'\r\n + ' fails (401), requests switch to the next account; when every account is'\r\n + ' exhausted the error names the earliest window reset.',\r\n accountAdd: 'Add account',\r\n accountRemove: 'Remove',\r\n accountLabel: 'Account label',\r\n accountKey: 'API key',\r\n accountKeyHint: 'This accountโ€™s API key. Saving with the field blank keeps the stored key.',\r\n accountDefault: 'Default account',\r\n activeAccount: 'Active account',\r\n activeAccountAuto: 'Auto (first usable account)',\r\n activeAccountHint: 'Pin the preferred account; applies to the next request after saving.'\r\n + ' If the selected account is exhausted, requests still rotate to another usable account.',\r\n rulesTitle: 'Route models to accounts',\r\n rulesHint: 'Pick models (multi-select) and route them to an account. When the'\r\n + ' requestโ€™s model is in a rule and that account is usable, it serves;'\r\n + ' an exhausted or invalid routed account falls back to the normal rotation.'\r\n + ' Rules match in list order โ€” the first hit wins.',\r\n rulesEmpty: 'No rules yet.',\r\n rulesCatalogFailed: 'Could not load the model catalog โ€” selecting models is unavailable; saved rules still apply.',\r\n ruleAdd: 'Add rule',\r\n ruleRemove: 'Remove',\r\n ruleModel: 'Models',\r\n ruleModelPick: 'Select modelsโ€ฆ',\r\n ruleModelCount: '{count} model(s) selected',\r\n ruleAccount: 'Target account',\r\n ruleHint: 'Check the models to route from the dropdown (multi-select), then pick the target account.',\r\n modelSearchPlaceholder: 'Search modelsโ€ฆ',\r\n modelSearchEmpty: 'No matching models.',\r\n modelStale: 'Retired',\r\n visibleModelsTitle: 'Model allowlist',\r\n visibleModelsHint:\r\n 'Check the models you want to keep, and model pickers will list only those. '\r\n + 'If nothing is checked, every model is shown. After saving, the change '\r\n + 'applies the next time you open a model picker.',\r\n visibleModelsPick: 'Select models to keepโ€ฆ',\r\n visibleModelsCount: '{count} model(s) selected',\r\n visibleModelsShowAll: 'Show all',\r\n visibleModelsStaleHint: '{count} selected model(s) are no longer in the catalog (possibly retired);'\r\n + ' other models are unaffected. Clean them up or keep them.',\r\n visibleModelsCleanStale: 'Clean stale ({count})',\r\n overridden: 'Overridden',\r\n reset: 'Reset',\r\n invalidNumber: 'Invalid number',\r\n numberTooSmall: 'Must be at least 1 (ms)',\r\n numberTooLarge: 'Above the allowed maximum (2147483647 ms)',\r\n readOnly: 'Settings are read-only.',\r\n unsaved: 'Unsaved',\r\n save: 'Save',\r\n saving: 'Saving',\r\n saved: 'Saved โœ“',\r\n saveFailed: 'Save failed, please retry.',\r\n discard: 'Discard',\r\n cancel: 'Cancel',\r\n show: 'Show',\r\n hide: 'Hide',\r\n usageTitle: 'Account usage',\r\n usageRefresh: 'Refresh',\r\n usageRefreshing: 'Refreshingโ€ฆ',\r\n usageLoading: 'Fetching account usageโ€ฆ',\r\n usageNoKey: 'Configure an API key to see this accountโ€™s usage and credit state here.',\r\n usageError: 'Could not fetch usage',\r\n usageRequests: 'Requests',\r\n usageFailed: 'failed',\r\n usageSuccessRate: 'Success rate',\r\n usageCost: 'Spend',\r\n usageTokens: 'Tokens',\r\n usageTokensIn: 'in',\r\n usageTokensOut: 'out',\r\n usageMonthly: 'Monthly',\r\n usagePurchased: 'Purchased',\r\n usageFree: 'Free',\r\n usageFiveHour: '5-hour window',\r\n usageWeekly: 'Weekly window',\r\n usageExceeded: 'Exceeded',\r\n usageReset: 'Resets',\r\n usagePartial: 'Some endpoint data unavailable',\r\n usageKeyClear: 'Clear stored key',\r\n usageKeyClearStaged: 'Will be cleared on save',\r\n usageUndoKeyClear: 'Undo clear',\r\n usageKeyInvalid: 'API key invalid or expired',\r\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.',\r\n usageServiceUnavailable: 'The Command Code service is temporarily unavailable',\r\n usageServiceUnavailableHint: 'The server returned errors (5xx); try Refresh again later.',\r\n usageNetworkError: 'Could not reach the Command Code service',\r\n usageNetworkHint: 'No request reached the server. Check your network connection or the API base setting.',\r\n usageUpdated: 'Updated',\r\n usagePeriodEnd: 'Period ends',\r\n usageActive: 'Active',\r\n usageCooldown: 'Cooling down',\r\n usageInvalidKey: 'Invalid key',\r\n usageUnconfigured: 'No API key configured for this account yet.',\r\n updateAvailable: 'update available',\r\n updateHint: 'A newer version has been published; click for release notes. The notice disappears once the plugin is updated.',\r\n loginTitle: 'Sign in to fetch a key',\r\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.',\r\n loginButton: 'Sign in to Command Code',\r\n loginStarting: 'Starting the local callback serverโ€ฆ',\r\n loginWaiting: 'Waiting for authorization in your browserโ€ฆ',\r\n loginOpenLink: 'Open the authorization page โ†—',\r\n loginCancel: 'Cancel sign-in',\r\n loginSuccess: 'Signed in as',\r\n loginUnavailable: 'Sign-in is unavailable in this environment; paste the API key instead.',\r\n loginDenied: 'Authorization was denied. Try again or paste the key manually.',\r\n loginTimeout: 'Timed out waiting for the authorization callback; try again.',\r\n loginInvalidKey: 'The delivered key failed validation (401). Try again or paste it manually.',\r\n loginNetwork: 'Could not reach the Command Code service to validate the key; check your network and retry.',\r\n loginStoreFailed: 'The key could not be stored in the local credential service; paste it manually.',\r\n loginCancelled: 'Sign-in cancelled.',\r\n loginFailedGeneric: 'Sign-in failed; try again or paste the key manually.',\r\n cardTitle: 'Command Code',\r\n cardRouteActive: 'Active',\r\n cardLoadingHint: 'Loading the Command Code configurationโ€ฆ',\r\n cardRegistrationHint: 'This card is contributed by the Command Code plugin; a newer DeepSeek Harness is needed to show the full controls.',\r\n}\r\n","/**\r\n * Browser half of the dsh-commandcode-provider bundle.\r\n *\r\n * Two responsibilities:\r\n *\r\n * 1. A \"Command Code\" settings page (a `settings.section` entry at the same\r\n * nav level as General / Models / Plugins). The Models page renders an\r\n * unknown-adapter-family card for the `commandcode` provider and disables\r\n * its submit, so the API key cannot be configured there; this page is the\r\n * dedicated surface. It writes the API key through the credentials domain\r\n * (the `COMMANDCODE_API_KEY` reference the plugin resolves via\r\n * `ctx.remote.credentials`) and the connection facts through the\r\n * `llm-commandcode` settings namespace, so a saved key or endpoint reaches\r\n * the very next request.\r\n *\r\n * 2. The Models-page provider card (settings.models.provider-card) and the\r\n * friendly image-gate error wrapper โ€” see `./card.tsx` / `./sessions.ts`.\r\n * The wrapper is deliberately narrow: only the `model-unavailable` code is\r\n * rewritten, only when the message matches the image-session gate, and only\r\n * the message text changes.\r\n */\r\n\r\nimport type { Context } from '@deepseek-ai/cordis'\r\nimport { createSnapshotStore } from './snapshot-store.ts'\r\n// Type-only imports that pull in the client-service augmentations\r\n// (`slots`/`remote`/`locale` on Context) and the `settings.section` SlotMap\r\n// entry (`settingsScope` arrives through dsh-client-ui-settings).\r\nimport type {} from '@deepseek-ai/dsh-api-remotes/client'\r\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\r\nimport type {} from '@deepseek-ai/dsh-client-ui-renderer/client'\r\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\r\nimport { installFriendlyImageError } from './sessions.ts'\r\nimport type { ConnectionLike } from './sessions.ts'\r\nimport { CommandCodeSettingsController, COMMANDCODE_NS, type SettingsPageState } from './settings.ts'\r\nimport type { HostDescriptionSource, SettingsPageApi } from './settings.ts'\r\nimport { adaptLegacyCredentials, type LegacyCredentialsApi } from './legacy-credentials.ts'\r\nimport { CommandCodeUsageController, type UsagePageState, type UsageRemote } from './usage.ts'\r\nimport { CommandCodeLoginController, type LoginPageState, type LoginRemote } from './login.ts'\r\nimport { USAGE_REMOTE_CONTRIBUTION, MODELS_REMOTE_CONTRIBUTION } from '../usage-wire.ts'\r\nimport { LOGIN_REMOTE_CONTRIBUTION } from '../login-wire.ts'\r\nimport type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'\r\nimport { CommandCodeSettingsPage } from './section.tsx'\r\nimport { CommandCodeProviderCard } from './card.tsx'\r\nimport { zh, en } from './locales.ts'\r\n\r\nexport { isImageSessionRejection, withFriendlyImageError } from './sessions.ts'\r\n\r\n/** CSS for the settings page, injected once (harness bundle convention). */\r\nconst PAGE_CSS = `\r\n.cc-section{max-width:720px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:12px;display:flex}\r\n.cc-title{margin:0;font-size:18px;font-weight:600}\r\n.cc-intro{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px;line-height:1.5}\r\n.cc-readOnly{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}\r\n.cc-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:4px 16px}\r\n.cc-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}\r\n.cc-field+.cc-field{border-top:1px solid var(--dsw-alias-border-l2)}\r\n.cc-fieldHead{align-items:center;gap:8px;display:flex}\r\n.cc-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}\r\n.cc-badges{align-items:center;gap:8px;display:inline-flex}\r\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}\r\n.cc-badgeMuted{white-space:nowrap;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px}\r\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}\r\n.cc-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}\r\n.cc-reset:disabled{cursor:default;opacity:.5}\r\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}\r\n.cc-input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}\r\n.cc-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}\r\n/* The routing-rule model multi-select: a button trigger that opens an\r\n * anchored Menu of checkbox rows. The trigger mirrors .cc-input sizing so it\r\n * sits flush with the sibling account select. */\r\n.cc-ruleTrigger{align-items:center;gap:8px;display:flex;width:100%;text-align:left;cursor:pointer}\r\n.cc-ruleTrigger:disabled{cursor:default}\r\n.cc-ruleTriggerText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\r\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)}\r\n.cc-checkRow{align-items:center;gap:8px;display:inline-flex;min-width:0}\r\n.cc-checkRow:hover{cursor:pointer}\r\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}\r\n.cc-check:checked{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary)}\r\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)}\r\n.cc-checkName{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\r\n/* The model multi-select search box: stacked under the trigger while the\r\n * dropdown is open, same input sizing so the pair reads as one control. The\r\n * box lives inside the Menu anchor (which renders inside the Menu's root\r\n * span), so focusing/typing it never trips the Menu's outside-click close. */\r\n.cc-modelSelectAnchor{flex-direction:column;gap:6px;display:flex;width:100%}\r\n.cc-modelSearch{width:100%}\r\n.cc-modelSearch::-webkit-search-cancel-button{cursor:pointer}\r\n/* Selects need their own treatment to sit flush with the text inputs:\r\n * the UA stylesheet renders