From 5861632c5c3d2ab6dac45ef6d410580cd2504c5e Mon Sep 17 00:00:00 2001 From: xer-on Date: Sat, 12 Sep 2026 21:35:20 +0600 Subject: [PATCH] feat: add Command Code LLM adapter and client UI modules --- src/accounts.ts | 12 +- src/adapter.ts | 258 ++----------- src/capabilities.ts | 36 +- src/client/card.tsx | 6 +- src/client/index.ts | 216 ++++++++++- src/client/locales.ts | 147 +------ src/client/panel-copy.ts | 139 +++++++ src/client/panel-slots.ts | 47 +++ src/client/panel-styles.ts | 137 +++++++ src/client/panel-view.tsx | 458 ++++++++++++++++++++++ src/client/panel.ts | 590 +++++++++++++++++++++++++++++ src/client/prices.ts | 119 ++++++ src/client/section.tsx | 2 +- src/client/session-cost-display.ts | 491 ++++++++++++++++++++++++ src/client/session-cost-slots.ts | 52 +++ src/client/session-cost-view.tsx | 154 ++++++++ src/client/session-cost.ts | 457 ++++++++++++++++++++++ src/client/sessions.ts | 31 +- src/client/update.ts | 4 +- src/client/usage.ts | 20 +- src/client/version.ts | 2 +- src/command-locales.ts | 157 -------- src/commands.ts | 249 ------------ src/index.ts | 44 +-- src/model-prices.ts | 232 ++++++++++++ src/tui-settings.ts | 47 +-- src/usage-remote.ts | 42 +- src/usage-wire.ts | 156 +++++++- src/wire-shared.ts | 2 +- 29 files changed, 3383 insertions(+), 924 deletions(-) create mode 100644 src/client/panel-copy.ts create mode 100644 src/client/panel-slots.ts create mode 100644 src/client/panel-styles.ts create mode 100644 src/client/panel-view.tsx create mode 100644 src/client/panel.ts create mode 100644 src/client/prices.ts create mode 100644 src/client/session-cost-display.ts create mode 100644 src/client/session-cost-slots.ts create mode 100644 src/client/session-cost-view.tsx create mode 100644 src/client/session-cost.ts delete mode 100644 src/command-locales.ts delete mode 100644 src/commands.ts create mode 100644 src/model-prices.ts diff --git a/src/accounts.ts b/src/accounts.ts index 7f3d338..8b17b5a 100644 --- a/src/accounts.ts +++ b/src/accounts.ts @@ -311,14 +311,9 @@ export class CommandCodeAccountPool { const disabled = latest.filter((account) => account.state?.kind === 'disabled') if (disabled.length === latest.length) { - // Bilingual: the harness UI renders this message verbatim inside its - // (already localized) retry/turn-error chrome, so both languages ride - // in one string — English first, then the Chinese reading. throw new LlmError( `llm-commandcode: every configured Command Code account (${latest.length}) was rejected with 401` - + ' — check the stored API keys (Models page / settings) or the auth file' - + `;已配置的 ${latest.length} 个 Command Code 账户密钥均被拒绝(401)` - + '——请在设置页检查存储的 API 密钥,或重新运行 command-code login', + + ' — check the stored API keys (Models page / settings) or re-run command-code login', 'INVALID_CREDENTIAL', ) } @@ -338,10 +333,7 @@ export class CommandCodeAccountPool { throw new LlmError( `llm-commandcode: all ${latest.length} Command Code account(s) have exhausted their usage window` + (earliest > 0 ? `; the earliest window resets at ${clockLabel(earliest)}` : '') - + ' — requests will succeed again after the reset (or add another account)' - + `;已用尽全部 ${latest.length} 个 Command Code 账户的用量窗口` - + (earliest > 0 ? `,最早的重置时间为 ${clockLabel(earliest)}` : '') - + '——窗口重置后请求会自动恢复(也可以添加更多账户)', + + ' — requests succeed again after the reset (or add another account)', 'RATE_LIMIT', wait > 0 && wait <= RETRY_MAX_DELAY_MS ? { providerRetryAfterMs: wait } : undefined, ) diff --git a/src/adapter.ts b/src/adapter.ts index 8437664..d21e73e 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -1,11 +1,11 @@ /** * DeepSeek Harness LLM adapter for the Command Code Provider API. * - * Ported from pi-commandcode-provider@0.5.1 (MIT). This is an unofficial, - * community-maintained integration; you need your own Command Code account - * and API key or subscription, and Command Code's terms apply. + * An unofficial, community-maintained integration: you need your own Command + * Code account and API key or subscription, and Command Code's terms apply. * - * Wire protocol (reverse-engineered by the pi plugin, command-code@1.28.4; + * Wire protocol (reverse-engineered from the official command-code CLI, + * command-code@1.28.4; * re-verified against command-code@1.53.1 — endpoints, request shape, and * stream events unchanged): * POST {apiBase}/alpha/generate @@ -63,7 +63,7 @@ import { // Request / connection defaults (protocol constants). The model/plan/deal // capability snapshot lives in ./capabilities.ts — the sync-only surface. // --------------------------------------------------------------------------- -export const COMMAND_CODE_CLI_VERSION = '1.53.1' +export const COMMAND_CODE_CLI_VERSION = '1.53.0' export const DEFAULT_API_BASE = 'https://api.commandcode.ai' export const DEFAULT_GENERATE_MAX_TOKENS = 64_000 export const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 @@ -97,7 +97,7 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 const MODEL_CACHE_VERSION = 1 // --------------------------------------------------------------------------- -// Small helpers (ported from converters.ts / models.ts) +// Small helpers for the wire-format conversions below. // --------------------------------------------------------------------------- function isRecord(value: unknown): value is Record { @@ -155,150 +155,6 @@ function recordOrEmpty(value: unknown): Record { return {} } -// --------------------------------------------------------------------------- -// Tool-schema normalization (issue #35) -// The gateway validates every function schema's ROOT as an object schema and -// rejects the whole request otherwise (`schema must be a JSON Schema of -// type: "object", got type: null`). Tool schemas do not always come from -// the harness's own typed builder, which always declares `type: 'object'`: a -// third-party plugin or an MCP bridge can register a hand-written schema, and -// a generator can emit a root `$ref`. Neither is this plugin's to correct, but -// the request is the plugin's to send, so every schema leaving here is -// normalized to the object root the provider requires. Only the root is -// touched, and every path returns a copy: the harness may deep-freeze the -// caller's schema. -// --------------------------------------------------------------------------- - -/** How deep a root `$ref` / combinator chain is followed while normalizing. */ -const SCHEMA_NORMALIZE_MAX_DEPTH = 4 - -/** - * Whether a type-less node is object-shaped enough to be one with the type - * declared: `properties`/`required`/`additionalProperties` only make sense on - * an object, and a schema carrying them was meant to be one. - */ -function isObjectShaped(node: Record): boolean { - return ( - isRecord(node.properties) || - isRecord(node.patternProperties) || - Array.isArray(node.required) || - node.additionalProperties !== undefined - ) -} - -/** The `required` names of one schema node, ignoring malformed entries. */ -function requiredNames(node: Record): string[] { - return Array.isArray(node.required) ? node.required.filter((name): name is string => typeof name === 'string') : [] -} - -/** Resolve a local `#/...` pointer inside the schema that carried it. */ -function resolveLocalRef(root: Record, ref: string): Record | undefined { - if (!ref.startsWith('#/')) return undefined - let node: unknown = root - for (const rawSegment of ref.slice(2).split('/')) { - const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~') - if (!isRecord(node)) return undefined - node = node[segment] - } - return isRecord(node) ? node : undefined -} - -/** - * Flatten a type-less combinator node into one object schema, so the model - * still sees the branch fields instead of an argument-free tool. `allOf` - * branches must all hold, so their `required` entries are all kept; `anyOf` / - * `oneOf` branches are alternatives, so only a name required by every branch - * survives. Returns undefined when no branch describes an object. - */ -function mergeCombinatorBranches( - node: Record, - depth: number, -): Record | undefined { - // A plugin can hand over a self-referential schema object; the bound keeps - // the walk finite (JSON-parsed schemas are acyclic, JS ones need not be). - if (depth >= SCHEMA_NORMALIZE_MAX_DEPTH) return undefined - const properties: Record = {} - const required = new Set() - const alternatives: string[][] = [] - let sawBranch = false - - for (const key of ['allOf', 'anyOf', 'oneOf'] as const) { - const branches = node[key] - if (!Array.isArray(branches)) continue - const objectBranches: Record[] = [] - for (const branch of branches) { - if (!isRecord(branch)) continue - objectBranches.push(toolParametersSchema(branch, depth + 1)) - } - if (objectBranches.length === 0) continue - sawBranch = true - // An `anyOf`/`oneOf` group contributes the intersection of its branches; - // an `allOf` group contributes the union. - const names = objectBranches.map(requiredNames) - if (key === 'allOf') for (const name of names.flat()) required.add(name) - else if (names.length > 0) { - alternatives.push(names[0]!.filter((name) => names.every((list) => list.includes(name)))) - } - for (const branch of objectBranches) { - const branchProperties = isRecord(branch.properties) ? branch.properties : {} - for (const [name, schema] of Object.entries(branchProperties)) { - if (!(name in properties)) properties[name] = schema - } - } - } - if (!sawBranch) return undefined - // A name must satisfy every group, so each group's intersection joins the - // union: an allOf-required field and an anyOf-common field are both required. - for (const group of alternatives) for (const name of group) required.add(name) - - const merged: Record = { type: 'object', properties } - if (required.size > 0) merged.required = [...required] - // The merged schema is a superset of the alternatives it replaced, so it - // stays open unless the node itself closed it. - if (node.additionalProperties !== undefined) merged.additionalProperties = node.additionalProperties - for (const key of ['description', 'title'] as const) { - if (node[key] !== undefined) merged[key] = node[key] - } - return merged -} - -/** - * Normalize one tool's parameters schema to an object-rooted JSON Schema. - * A schema that already declares an object root is passed through untouched; - * a type-less object-shaped one gains the type; a root `$ref` or combinator is - * resolved/merged; anything else (an array/scalar root, or no schema at all) - * degrades to a permissive free-form object, because a request the provider - * refuses helps no one and the tool's own description is still in the prompt. - */ -function toolParametersSchema(parameters: unknown, depth = 0): Record { - if (!isRecord(parameters)) return { type: 'object', properties: {}, additionalProperties: true } - if (parameters.type === 'object') return parameters - // `["object", "null"]` is accepted by JSON Schema but not by the provider's - // validator, which compares the root `type` against the string "object". - if (Array.isArray(parameters.type) && parameters.type.includes('object')) { - return { ...parameters, type: 'object' } - } - - if (parameters.type === undefined || parameters.type === null) { - if (typeof parameters.$ref === 'string' && depth < SCHEMA_NORMALIZE_MAX_DEPTH) { - const target = resolveLocalRef(parameters, parameters.$ref) - if (target !== undefined) { - const { $ref: _ref, ...rest } = parameters - return toolParametersSchema({ ...target, ...rest }, depth + 1) - } - } - if (isObjectShaped(parameters)) return { ...parameters, type: 'object' } - const merged = mergeCombinatorBranches(parameters, depth) - if (merged !== undefined) return merged - // A root `$ref` this plugin cannot resolve still gets the declared type: - // that is what the provider validates for, and an object is what every - // schema generator emits one for. - if (typeof parameters.$ref === 'string') return { ...parameters, type: 'object' } - } - - return { type: 'object', properties: {}, additionalProperties: true } -} - export function projectSlugFromPath(pathName: string): string { const slug = pathName .toLowerCase() @@ -330,7 +186,7 @@ function parseStreamEventLine(line: string): unknown | undefined { // Credential fallback from the official Command Code CLI auth file. Used as // the last fallback by the plugin entry, so a user who already logged in with // `command-code login` can reuse that credential. Only the official CLI's own -// file is read — pi/OMP auth files are intentionally not scanned, so their +// file is read — no other tool's credential store is scanned, so foreign // credentials and formats cannot surprise this adapter. // --------------------------------------------------------------------------- @@ -363,7 +219,7 @@ export function resolveAuthFileApiKey(): string | undefined { } // --------------------------------------------------------------------------- -// Model catalog discovery with on-disk cache fallback (ported from models.ts) +// Model catalog discovery with on-disk cache fallback. // --------------------------------------------------------------------------- interface CommandCodeModel { @@ -421,12 +277,12 @@ async function writeModelsCache(cachePath: string, models: CommandCodeModel[]): // --------------------------------------------------------------------------- // Message conversion: harness Message[] -> Command Code wire messages. -// Both transports replay historical reasoning. The CLI transport carries it as -// a `reasoning` block inside the assistant message (the shape the official -// CLI's `toWireMessages` emits), the Provider API transport as the -// `reasoning_content` field, because DeepSeek's thinking-mode contract -// requires the previous chain of thought to be passed back whenever tool calls -// are in play (issue #34). Only tool calls with a paired tool result are +// The legacy /alpha/generate transport intentionally does NOT replay reasoning +// blocks (matches the official CLI of that era; prior +// private reasoning must not leak into later turns). The documented +// /provider/v1/chat/completions transport DOES replay them as +// `reasoning_content`, because DeepSeek's thinking-mode contract requires it +// when tools are in play. Only tool calls with a paired tool result are // replayed on both transports. // --------------------------------------------------------------------------- @@ -554,37 +410,26 @@ function toolResultTextForWire(media: ToolResultMedia): string { } /** Leading line of the user message that carries a tool result's images. */ -const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result' +const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:' /** * The model-visible note introducing the images carried out of one tool * result. Neither transport merges them back into the tool result, so this * line is what tells the model the image it is about to see belongs to the * tool it just ran rather than to the user; it also leads the part list, - * because an image-first content array is what some gateways reject. - * - * The note names the tool call the image came out of, because a turn's - * carriers are emitted as a group after the whole tool group (issue #33): - * position alone would have to carry the association, and every `read_image` - * result renders the same envelope text, so two parallel calls would produce - * two identical notes. The id is the WIRE id — the one the model saw on the - * `tool-call` it issued and on the tool message just above — so an overlong - * cross-provider id is named by the alias that replaced it, not by the - * harness-side id the model never saw. - * - * Count and pixel dimensions are appended when the tool's own text does not - * already state them (a result that returns the image and nothing else). + * because an image-first content array is what some gateways reject. Count and + * pixel dimensions are appended when the tool's own text does not already + * state them (a result that returns the image and nothing else). */ -function toolResultImageNote(media: ToolResultMedia, toolCallId: string): string { - const lead = `${TOOL_RESULT_IMAGE_TEXT} (${toolCallId}):` +function toolResultImageNote(media: ToolResultMedia): string { const first = media.images[0] - if (first === undefined) return lead + if (first === undefined) return TOOL_RESULT_IMAGE_TEXT const dimensions = `${first.width}x${first.height} px` if (first.width <= 0 || first.height <= 0 || media.text.includes(dimensions)) { - return lead + return TOOL_RESULT_IMAGE_TEXT } const count = media.images.length > 1 ? `${media.images.length} images, ` : '' - return `${lead} ${count}${dimensions}` + return `${TOOL_RESULT_IMAGE_TEXT} ${count}${dimensions}` } function hasImageContent(message: Message): boolean { @@ -659,13 +504,6 @@ async function messagesToCC( parts.push(await imageToCommandCode(block.attachment, readImage)) } } - // A user message that converted to nothing carries no information, and - // an empty content array is a needless gateway-compat risk, so it is - // dropped — the same rule the Provider API converter applies below. - // (`dsh-llm-deepseek` instead pushes `content: ''`; skipping is the - // safer half of that divergence to keep.) The flush above has already - // run, so a pending image carrier is never dropped with it. - if (parts.length === 0) continue out.push({ role: 'user', content: parts }) continue } @@ -676,16 +514,6 @@ async function messagesToCC( for (const block of message.content) { if (block.type === 'text') { parts.push({ type: 'text', text: block.text }) - } else if (block.type === 'reasoning') { - // Replay the thinking block, exactly as the official CLI's - // `toWireMessages` does (command-code@1.53.1: a `thinking` block - // becomes `{ type: 'reasoning', text }`). This is not optional - // politeness: the gateway rebuilds the provider request from these - // blocks, and a DeepSeek thinking-mode assistant turn whose tool - // calls arrive without its reasoning is rejected with "The - // `reasoning_content` in the thinking mode must be passed back to - // the API" — which failed every tool-loop turn (issue #34). - parts.push({ type: 'reasoning', text: block.text }) } else if (block.type === 'tool-call' && paired.has(block.id)) { parts.push({ type: 'tool-call', @@ -694,6 +522,7 @@ async function messagesToCC( input: recordOrEmpty(block.arguments), }) } + // reasoning blocks: skipped by design (see header comment) } if (parts.length > 0) out.push({ role: 'assistant', content: parts }) continue @@ -704,15 +533,12 @@ async function messagesToCC( const block = message.content[0] if (!block || block.type !== 'tool-result' || !paired.has(block.toolCallId)) continue const media = toolResultMedia(block) - // Resolved once: the tool message and the carrier note below must name - // the same call, or the model cannot tie an image back to its result. - const wireToolCallId = wireIds.get(block.toolCallId) ?? block.toolCallId out.push({ role: 'tool', content: [ { type: 'tool-result', - toolCallId: wireToolCallId, + toolCallId: wireIds.get(block.toolCallId) ?? block.toolCallId, // `paired` guarantees a call with this id exists, so the map // always hits; `|| 'unknown'` also guards an empty call name // (matches the official CLI's `?? "unknown"` fallback). @@ -733,7 +559,7 @@ async function messagesToCC( 'UNSUPPORTED_CONTENT', ) } - const carried: unknown[] = [{ type: 'text', text: toolResultImageNote(media, wireToolCallId) }] + const carried: unknown[] = [{ type: 'text', text: toolResultImageNote(media) }] for (const attachment of media.images) { carried.push(await imageToCommandCode(attachment, readImage)) } @@ -815,9 +641,6 @@ async function messagesToOpenAI( parts.push(await imageToOpenAI(block.attachment, readImage)) } } - // Same rule as the CLI converter: a converted-to-nothing user message is - // dropped rather than sent as an empty content array. The flush above - // already ran, so a pending image carrier survives this skip. if (parts.length === 0) continue const hasImage = parts.some((part) => (part as { type?: string }).type === 'image_url') if (!hasImage && parts.length === 1) { @@ -869,12 +692,9 @@ async function messagesToOpenAI( const block = message.content[0] if (!block || block.type !== 'tool-result' || !paired.has(block.toolCallId)) continue const media = toolResultMedia(block) - // Resolved once: the tool message and the carrier note below must name - // the same call, or the model cannot tie an image back to its result. - const wireToolCallId = wireIds.get(block.toolCallId) ?? block.toolCallId out.push({ role: 'tool', - tool_call_id: wireToolCallId, + tool_call_id: wireIds.get(block.toolCallId) ?? block.toolCallId, content: toolResultTextForWire(media), }) // Chat Completions allows no image part under `role: 'tool'`, so a @@ -886,7 +706,7 @@ async function messagesToOpenAI( 'UNSUPPORTED_CONTENT', ) } - const carried: unknown[] = [{ type: 'text', text: toolResultImageNote(media, wireToolCallId) }] + const carried: unknown[] = [{ type: 'text', text: toolResultImageNote(media) }] for (const attachment of media.images) { carried.push(await imageToOpenAI(attachment, readImage)) } @@ -1201,7 +1021,7 @@ async function buildCliBody( type: 'function', name: tool.name, description: tool.description, - input_schema: toolParametersSchema(tool.parameters), + input_schema: tool.parameters, })), system: facts.systemText, max_tokens: facts.maxTokens, @@ -1227,7 +1047,7 @@ async function buildOpenAIBody( function: { name: tool.name, description: tool.description, - parameters: toolParametersSchema(tool.parameters), + parameters: tool.parameters, }, })) return { @@ -1338,7 +1158,7 @@ async function connectGenerate( throw new LlmError( `Command Code API request to ${endpoint} did not respond within ${connection.requestTimeoutMs}ms` + `: ${errorChain(error)}` - + `;Command Code API 请求在 ${connection.requestTimeoutMs} 毫秒内未收到响应——通常是网络或代理问题,请检查后重试`, + + ' — this is usually a network or proxy problem; check it and retry', 'TIMEOUT', { cause: error }, ) @@ -1350,7 +1170,7 @@ async function connectGenerate( // names the real root cause instead of a generic wrapper. throw new LlmError( `Command Code API request to ${endpoint} failed: ${errorChain(error)}` - + ';Command Code API 请求连接失败——通常是网络或代理问题,请检查网络或代理设置后重试', + + ' — this is usually a network or proxy problem; check your network or proxy settings and retry', 'TRANSPORT', { cause: error }, ) @@ -1370,8 +1190,8 @@ async function connectGenerate( /** * The StreamChunk block-assembly state shared by both transport handlers: - * at most one text block and one reasoning block are open at a time (same - * assumption as the pi plugin). + * at most one text block and one reasoning block are open at a time (the same + * assumption the official CLI makes). */ interface BlockAssembler { nextIndex: number @@ -2043,7 +1863,7 @@ export class CommandCodeAdapter = new Set([ /** * Peak hours (UTC, hour-of-day range end-exclusive): 01–03 and 06–09. * Weekday-only — see `peakPricingState()`; weekends are fully off-peak. + * + * Exported because the vendored price table (`./model-prices.ts`) ships these + * windows to the browser with the table itself: the composer prices a session + * against the very schedule this snapshot knows rather than restating it, so + * there is one place to update when the windows move. */ -const PEAK_HOUR_RANGES: ReadonlyArray = [ +export const PEAK_HOUR_RANGES: ReadonlyArray = [ [1, 4], [6, 10], ] +/** + * Whether `now` (defaults to `Date.now()`) falls inside a peak-pricing window, + * ignoring which model is asking. Peak rates apply Monday–Friday (UTC) only, + * so a weekend timestamp is off-peak even inside {@link PEAK_HOUR_RANGES}. + * + * Model-independent on purpose: `peakPricingState()` adds the membership test + * on top for the picker's label, while the price table selects peak rates from + * a row's own `peak` block — so a model whose catalog id spelling differs from + * the one in {@link KNOWN_PEAK_PRICING} still gets the right half of the day. + */ +export function isPeakPricingHour(now: number = Date.now()): boolean { + const at = new Date(now) + const day = at.getUTCDay() + if (day === 0 || day === 6) return false + const hour = at.getUTCHours() + return PEAK_HOUR_RANGES.some(([start, end]) => hour >= start && hour < end) +} + /** * Whether `now` (defaults to `Date.now()`) falls in a peak-pricing window for * time-of-day-priced models. Peak rates apply Monday–Friday (UTC) only: the @@ -628,12 +651,7 @@ export function peakPricingState( now: number = Date.now(), ): 'peak' | 'off-peak' | undefined { if (!KNOWN_PEAK_PRICING.has(modelId)) return undefined - const at = new Date(now) - const day = at.getUTCDay() - if (day === 0 || day === 6) return 'off-peak' - const hour = at.getUTCHours() - const inPeak = PEAK_HOUR_RANGES.some(([start, end]) => hour >= start && hour < end) - return inPeak ? 'peak' : 'off-peak' + return isPeakPricingHour(now) ? 'peak' : 'off-peak' } /** diff --git a/src/client/card.tsx b/src/client/card.tsx index fba75c9..9d4fb01 100644 --- a/src/client/card.tsx +++ b/src/client/card.tsx @@ -6,7 +6,7 @@ * Models page dispatches for every Command Code provider row). * * The official Models page opens one editor card per provider row through its - * own 编辑 button. For a namespace the page does not curate a layout for + * own Edit button. For a namespace the page does not curate a layout for * (`llm-commandcode`), that editor is a bare shell — a pointer to * `settings.yaml` above a permanently disabled apply button. This panel takes * its place: the slot outlet renders right beside the official editor inside @@ -105,7 +105,7 @@ export interface SlotWrapperSiblings { * Find the official editor card among the slot outlet's siblings, or null * while it is closed. The Models page renders the editor as an immediate * sibling of the outlet wrapper — after it in a provider row (the target of - * the row's 编辑 toggle), before it in the first-run setup card and the + * the row's Edit toggle), before it in the first-run setup card and the * add-provider card, where it is always open. The editor is the only such * sibling whose CSS module class carries the `editor` stem * (`_editor`); the row header and the add card's provider select @@ -207,7 +207,7 @@ function CardKeyField({ state, disabled, t, onEdit }: { * the Models page (saved row, first-run setup posture, and add-provider * draft). * - * Closed (the official 编辑 toggle off) the panel renders nothing: the row + * Closed (the official Edit toggle off) the panel renders nothing: the row * head the Models page owns already names the provider and shows the * credential dot, so a page full of providers stays compact. Opening the * official editor mounts the editor shell as the outlet's sibling; the panel diff --git a/src/client/index.ts b/src/client/index.ts index c2de67b..427a6b0 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -35,13 +35,23 @@ import { CommandCodeSettingsController, COMMANDCODE_NS, type SettingsPageState } import type { HostDescriptionSource, SettingsPageApi } from './settings.ts' import { adaptLegacyCredentials, type LegacyCredentialsApi } from './legacy-credentials.ts' import { CommandCodeUsageController, type UsagePageState, type UsageRemote } from './usage.ts' +import { CommandCodePricesController, type SessionCostPricesState } from './prices.ts' import { CommandCodeLoginController, type LoginPageState, type LoginRemote } from './login.ts' -import { USAGE_REMOTE_CONTRIBUTION, MODELS_REMOTE_CONTRIBUTION } from '../usage-wire.ts' +import { USAGE_REMOTE_CONTRIBUTION, MODELS_REMOTE_CONTRIBUTION, PRICES_REMOTE_CONTRIBUTION } from '../usage-wire.ts' import { LOGIN_REMOTE_CONTRIBUTION } from '../login-wire.ts' import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' import { CommandCodeSettingsPage } from './section.tsx' import { CommandCodeProviderCard } from './card.tsx' -import { zh, en } from './locales.ts' +import { CommandCodePanel, CommandCodeFooterEntry } from './panel-view.tsx' +import type { PanelInjected } from './panel-view.tsx' +import { CommandCodeSessionCost } from './session-cost-view.tsx' +import type { SessionCostInjected } from './session-cost-view.tsx' +import { startPanelAutoRefresh } from './panel.ts' +import { PANEL_CSS, PANEL_CSS_ID } from './panel-styles.ts' +// Type-only: pulls in the SlotMap merge for the composer dock (the readout's +// home) so the registration below typechecks against the real contract. +import type {} from './session-cost-slots.ts' +import { commandCodeCopy } from './locales.ts' export { isImageSessionRejection, withFriendlyImageError } from './sessions.ts' @@ -176,15 +186,63 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box /** Inject the page stylesheet once (idempotent per tag). */ function injectPageCss(): void { if (typeof document === 'undefined') return - const id = '@mars-sea/dsh-commandcode-provider/CommandCodeSettingsPage.module.css' + const id = '@xer-on/dsh-commandcode-provider/CommandCodeSettingsPage.module.css' if (document.querySelector(`style[data-plugin-css="${id}"]`) !== null) return const tag = document.createElement('style') - tag.dataset.plugin = '@mars-sea/dsh-commandcode-provider' + tag.dataset.plugin = '@xer-on/dsh-commandcode-provider' tag.dataset.pluginCss = id tag.textContent = PAGE_CSS document.head.appendChild(tag) } +/** + * Install the plans & quota panel's stylesheet and return its disposer, for + * `ctx.effect` to own. Keyed by its own `data-plugin-css` id, so the injection + * is idempotent even if a second surface asks for it later. + */ +function injectPanelCss(): () => void { + if (typeof document === 'undefined') return () => {} + if (document.querySelector(`style[data-plugin-css="${PANEL_CSS_ID}"]`) !== null) return () => {} + const tag = document.createElement('style') + tag.dataset.plugin = '@xer-on/dsh-commandcode-provider' + tag.dataset.pluginCss = PANEL_CSS_ID + tag.textContent = PANEL_CSS + document.head.appendChild(tag) + return () => { + tag.remove() + } +} + +/** + * Plans & quota panel id. It is the layout's `MainPanelId`: one string shared + * by the `sidebar.footer.action` card and the `main` slot cell, so the card + * selects this panel and nothing else. `dsh-client-ui-layout` is not a + * dependency of this bundle (its type is only a brand over `string`), so the + * brand is applied at the two call sites instead of importing the package. + */ +const PANEL_ID = 'commandcode-panel' + +/** + * The composer figure's entry id in `conversation.composer.dock`. Its own id, + * not the shipped `stats` cell's: reusing `stats` would REPLACE the tokens / + * cache-hit / throughput readout rather than inject into it, and that readout is + * the harness's to format (see `./session-cost-display.ts`). + */ +const SESSION_COST_ID = 'commandcode-session-cost' + +/** + * The one shot of the `layout` service this plugin needs, declared structurally + * for the same reason as {@link PANEL_ID}: importing the package would make the + * browser resolve a client module this bundle never calls. Reached through the + * reflective `ctx.get('layout')`, never a bare `ctx.layout` property — cordis + * throws `cannot get property … without inject` for an undeclared service, and + * declaring `layout` statically would park the whole client fiber on a service + * some profiles never mount. + */ +interface LayoutSelectionSeam { + selectPanel(id: string): void +} + /** Connection fields retained by pre-0.1.2 clients and absent from the current transport handle. */ interface LegacyConnectionLike extends ConnectionLike { api?: ConnectionLike['api'] & { credentials?: LegacyCredentialsApi } @@ -202,20 +260,19 @@ export function apply(ctx: Context): void { const connection = ctx.get('connection') as LegacyConnectionLike | undefined if (connection !== undefined) { - // The wrapper is reached from a non-React path that has no `t` in scope; - // it reads the active client locale at call time, so a language switch - // immediately applies to the next selectModel failure. On legacy builds - // it wraps `connection.api.sessions`; 0.1.2 replaced that façade with + // The wrapper is reached from a non-React path that has no `t` in scope, + // so it reads the plugin's own English copy. On legacy builds it wraps + // `connection.api.sessions`; 0.1.2 replaced that façade with // `remote.session`, so the helper safely skips this UX-only rewrite there // instead of preventing the whole plugin from activating. - installFriendlyImageError(connection, () => ctx.locale.getLocale().active === 'zh' ? 'zh' : 'en') + installFriendlyImageError(connection) } // The "Command Code" settings page: register the section once the // `settings.section` declaration is on the ledger (ui-settings-general // owns the shell; registration order relative to it is not constrained — // `slots.inject` waits for the declaration). - ctx.effect(() => ctx.locale.register('settings.commandcode', { zh, en }), 'dsh-commandcode-provider: page copy') + ctx.effect(() => ctx.locale.register('settings.commandcode', 'en', commandCodeCopy), 'dsh-commandcode-provider: page copy') const legacyApi = adaptLegacyCredentials(connection?.api?.credentials) if (legacyApi !== undefined) { @@ -275,11 +332,16 @@ function applyClientSurfaces( // their error branches. let usageNamespace: (typeof ctx.remote)['commandcode'] | undefined let usageMountError: string | undefined + // Declared here, constructed below once the Remote seam exists: the mount + // effect underneath is the price table's only trigger, and it runs after this + // function's body either way. + let pricesController: CommandCodePricesController | undefined const contribution: TypertRemoteContribution = { package: USAGE_REMOTE_CONTRIBUTION.package, descriptors: [ ...USAGE_REMOTE_CONTRIBUTION.descriptors, ...MODELS_REMOTE_CONTRIBUTION.descriptors, + ...PRICES_REMOTE_CONTRIBUTION.descriptors, ...LOGIN_REMOTE_CONTRIBUTION.descriptors, ], } @@ -296,6 +358,11 @@ function applyClientSurfaces( usageNamespace = namespaceCtx.remote.commandcode // The catalog Remote is live now; (re)fetch it for the model editors. controller.refreshCatalog() + // ...and the price table, which the composer's cost readout needs. This + // is the only trigger: it is idempotent, so a readout already on screen + // simply starts pricing when the table lands, and the request is issued + // after the namespace exists (asking earlier could only fail). + pricesController?.ensure() namespaceCtx.effect(() => () => { usageNamespace = undefined }, 'dsh-commandcode-provider: usage namespace') @@ -326,6 +393,13 @@ function applyClientSurfaces( } return namespace.models() }, + prices: async () => { + const namespace = usageNamespace + if (namespace === undefined) { + return { ok: false, error: { message: usageMountError ?? 'commandcode/prices remote is not mounted' } } + } + return namespace.prices() + }, } // Wire the catalog Remote into the settings controller's models seam so the // model editors can fetch the catalog once the mount lands. @@ -335,6 +409,14 @@ function applyClientSurfaces( const usageStore = createSnapshotStore(usageController.state()) usageController.subscribe(() => usageStore.set(usageController.state())) + // The composer's session-cost readout prices a session from a static table, + // so its controller is a one-shot cache rather than a poll: it fetches once + // the namespace is live and never again. + pricesController = new CommandCodePricesController(usageRemote) + ctx.effect(() => () => pricesController?.dispose(), 'dsh-commandcode-provider: price table') + const pricesStore = createSnapshotStore(pricesController.state()) + pricesController.subscribe(() => pricesStore.set(pricesController.state())) + // The login panel: same namespace, three endpoints; the key never crosses // to the browser — the Host validates and stores it through the credentials // seam, and a landed login re-reads the credential badges + usage card. @@ -415,7 +497,7 @@ function applyClientSurfaces( // card of an adapter family. Registering under `llm-commandcode` mounts the // panel beside the official editor on every Command Code row — including // the first-run setup posture, exactly where a user without a key lands. - // While the official 编辑 toggle is open, the panel hides the official + // While the official Edit toggle is open, the panel hides the official // editor shell (for this namespace it holds only the settings.yaml hint // over a disabled apply) and shows the real controls; see card.tsx. // The registration carries its own inject face (store hooks + actions) @@ -441,6 +523,118 @@ function applyClientSurfaces( cancelLogin: () => void loginController.cancel(), }), }, CommandCodeProviderCard)) + + // The plans & quota panel: a footer card pinned at the bottom of the + // sidebar, on top of the Settings seat, that opens a dashboard in the + // center column. + // + // Two registrations, one navigation entry. `sidebar.footer.action` is the + // list the sidebar shell renders in its foot area directly above the + // Settings seat (`footArea` = `footerActions` then `settingsArea`), which is + // what puts this card at the bottom of the column rather than at the top + // with the global panel icons of `sidebar.panellist`. The layout's keyed + // `main` slot holds the panel the card opens — `ctx.layout.selectPanel(id)` + // resolves the id against that registry and throws when no cell occupies it, + // so BOTH are required, and BOTH need the `inject` face below (an entry + // without one receives none of the panel's data; see the `hooks` → `useX` + // rule in panel-view.tsx). + // + // Unlike a `sidebar.panellist` row, the shell renders NO chrome around a + // footer action: our component is the button, it owns the label (so a title + // carrying live quota needs no re-registration — that dance existed only + // because the shell caches a panellist entry's label), and it selects the + // panel itself through the `open` action below. + // + // Neither registration declares a `locale` namespace: the panel is English by + // construction, from `./panel-copy.ts`, not by locale lookup. + // + // Shape notes: + // * The stylesheet gets its own `ctx.effect` rather than riding an `inject` + // callback's return value, so the tag's lifetime is the plugin fiber's and + // is unaffected by a slot declaration collapsing and re-declaring. + // * Each registration is guarded so a failure in one surface cannot abort + // `applyClientSurfaces` and take the OTHER surfaces down with it — + // `slots.inject` rethrows a callback failure synchronously once the + // declaration exists. The renderer contains a *render* crash by abdicating + // the entry with no visible error (that is how an earlier revision of this + // panel shipped an invisible row), so a failure that leaves nothing on + // screen must at least be loud in the console. + const panelFace = (): PanelInjected => ({ + hooks: { commandCodeUsage: usageStore, commandCodeSettings: store }, + refresh: () => void usageController.refresh(), + // The credential gate rides the live settings snapshot, so a key saved on + // the settings page reaches the next tick without re-registration. + startAutoRefresh: () => startPanelAutoRefresh(usageController, () => controller.state().anyAccountConfigured), + // `layout` is read reflectively AT CLICK TIME, never captured at setup: + // ui-layout is not a dependency of this bundle (its types are not imported + // and its client module is never resolved), so a static `inject` would park + // the whole client fiber — settings page included — on a service another + // profile may never mount. By the time a click is possible the footer slot + // itself is on screen, and ui-sidebar only activates with `layout` present, + // so the read always lands; the guard covers the impossible case anyway. + open: () => { + const layout = ctx.get('layout') as LayoutSelectionSeam | undefined + if (typeof layout?.selectPanel === 'function') layout.selectPanel(PANEL_ID) + }, + }) + + ctx.effect(() => injectPanelCss(), 'dsh-commandcode-provider: panel styles') + + try { + ctx.slots.inject('main', () => ctx.slots.register( + { name: 'main', key: PANEL_ID, inject: panelFace }, + CommandCodePanel, + )) + } catch (error: unknown) { + console.error('[dsh-commandcode-provider] could not register the plans & quota panel:', error) + } + + try { + ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register( + // `order` is the only control over position inside a list slot, and the + // renderer sorts ascending. ui-cordis's footer chip registers at the + // default 0, so 1 makes this card the LAST action — the one directly on + // top of the Settings seat — regardless of which plugin's fiber mounts + // first. + { name: 'sidebar.footer.action', id: PANEL_ID, order: 1, inject: panelFace }, + CommandCodeFooterEntry, + )) + } catch (error: unknown) { + console.error('[dsh-commandcode-provider] could not register the sidebar footer card:', error) + } + + // The composer's session-cost figure: an entry in the dock below the input + // that renders NO surface of its own. The cost is injected into the harness's + // own token-usage UI — the amount as the last item of the shipped pill's text + // run, the breakdown as rows inside the usage dialog that pill opens (see + // `./session-cost-display.ts`). + // + // The entry exists for its SEATS, not for a surface: `useProjection` is a + // standard prop the composer hands every dock occupant, so this registration + // is the only way to read the session's token accounting. Its id must stay + // distinct from the shipped `stats` cell's, because registering under an + // existing id REPLACES that cell rather than extending it. + // + // It carries only the price-table hook: the token buckets and the model + // selection arrive from the composer itself as standard dock props + // (`useProjection`), which the owner supplies to every occupant. + // + // No `locale` namespace and no `t` seat: the figure is English by + // construction, from `./session-cost.ts`. + const sessionCostFace = (): SessionCostInjected => ({ + hooks: { commandCodePrices: pricesStore }, + }) + + try { + ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register( + // No `order`: the entry renders nothing, so its position among the dock's + // rows cannot matter — the sort only ever decides what a reader sees. + { name: 'conversation.composer.dock', id: SESSION_COST_ID, inject: sessionCostFace }, + CommandCodeSessionCost, + )) + } catch (error: unknown) { + console.error('[dsh-commandcode-provider] could not register the composer session-cost readout:', error) + } } export const inject: readonly string[] = [ diff --git a/src/client/locales.ts b/src/client/locales.ts index bfd5446..cc13158 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -1,10 +1,12 @@ /** - * Locale copy for the "Command Code" settings page, and the declaration that + * English copy for the "Command Code" settings page, and the declaration that * merges the page's namespace into the framework's `LocaleNamespaceMap` so * `ctx.locale.register` / `ctx.slots.register(..., { locale })` are typed. * - * zh is the source of truth for the key set (repo convention); en must carry - * the exact same keys — a mismatch is a compile error at the register site. + * Only English is registered (the framework's single-locale form). The lookup + * chain always ends at English, so a harness set to another language falls + * back to this dictionary rather than showing raw keys — which is what makes + * the page read English on every locale. */ declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -146,144 +148,7 @@ export type SettingsCommandCodeKey = | 'cardLoadingHint' | 'cardRegistrationHint' -export const zh: Record = { - nav: 'Command Code', - title: 'Command Code', - intro: - '配置 Command Code Provider 连接。API 密钥仅保存在本机凭据服务中,不会回显;' - + '其他字段写入用户设置,下次请求即生效。', - apiKey: 'API 密钥', - apiKeyHint: '在 commandcode.ai 控制台创建。留空保存不会覆盖已存储的密钥。', - apiKeySet: '已配置', - apiKeyUnset: '未配置', - apiKeyLocked: '密钥由只读来源提供', - apiBase: 'API 地址', - apiBaseHint: '默认 https://api.commandcode.ai,一般无需修改。', - workingDir: '工作目录', - workingDirHint: '可选。留空时使用占位符显示的进程工作目录;仅在需要固定路径时填写。', - requestTimeoutMs: '请求超时(毫秒)', - requestTimeoutMsHint: '等待响应首个字节的超时;默认 60000。', - streamIdleTimeoutMs: '流空闲超时(毫秒)', - streamIdleTimeoutMsHint: '生成流停滞多久视为断连;默认 300000(长思考模型可静默数分钟,默认值刻意放宽)。', - advancedSettings: '高级设置', - advancedSettingsHint: 'API 地址、工作目录、超时与模型过滤等不常修改的选项。', - advancedOverriddenOne: '已自定义 1 项', - advancedOverriddenMany: '已自定义 {count} 项', - advancedInvalid: '高级设置中有未填好的数字,请展开修正后再保存。', - filterModelsByPlan: '隐藏套餐外模型', - filterModelsByPlanHint: '开启后,模型选择器只列出当前套餐可用的模型;账户持有按需余额时会显示全部。', - webSearch: '用 Command Code 承载联网搜索', - webSearchHint: '开启后,dsh 的 web_search 工具由 Command Code 承担(复用同一个 API key 与地址),并优先于其他搜索后端;关闭则把选择权交还给之前的后端(如 modsearch),而不是强制回退到 DeepSeek 搜索。', - accountsTitle: '多账户轮换', - accountsHint: '当前账户达到用量限额(429)或密钥失效(401)时,请求自动切换到下一个账户;全部耗尽时会提示最早的重置时间。', - accountAdd: '添加账户', - accountRemove: '移除', - accountLabel: '账户备注名', - accountKey: 'API 密钥', - accountKeyHint: '该账户的 API 密钥。留空保存不会覆盖已存储的密钥。', - accountDefault: '默认账户', - activeAccount: '当前使用账户', - activeAccountAuto: '自动(第一个可用账户)', - activeAccountHint: '手动指定优先使用的账户,保存后下次请求即生效;所选账户耗尽时仍会自动切换到其他可用账户。', - rulesTitle: '按模型切换账户', - rulesHint: '选择模型并路由到某个账户(可多选)。命中规则的模型且该账户可用时优先使用;账户耗尽或密钥失效时仍自动回落到其他账户。规则按列表顺序匹配,第一条命中生效。', - rulesEmpty: '尚未配置规则。', - rulesCatalogFailed: '模型目录获取失败,暂时无法选择模型;已保存的规则仍会生效。', - ruleAdd: '添加规则', - ruleRemove: '移除', - ruleModel: '模型', - ruleModelPick: '选择模型…', - ruleModelCount: '已选 {count} 个模型', - ruleAccount: '目标账户', - ruleHint: '从下拉列表勾选要路由的模型(可多选),再选择目标账户。', - modelSearchPlaceholder: '搜索模型…', - modelSearchEmpty: '没有匹配的模型。', - modelStale: '已下架', - visibleModelsTitle: '模型白名单', - visibleModelsHint: - '勾选要保留的模型,模型选择器就只列出这些;一个都不勾选时则显示全部模型。' - + '保存后,下次打开模型选择器生效。', - visibleModelsPick: '选择要保留的模型…', - visibleModelsCount: '已选 {count} 个模型', - visibleModelsShowAll: '显示全部', - visibleModelsStaleHint: '有 {count} 个已选模型在目录中找不到了(可能已下架),不影响其他模型;可清理或保留。', - visibleModelsCleanStale: '清理失效({count})', - overridden: '已覆盖', - reset: '重置', - invalidNumber: '无效数字', - numberTooSmall: '不能小于 1(毫秒)', - numberTooLarge: '超出允许上限(2147483647 毫秒)', - readOnly: '当前配置为只读。', - unsaved: '未保存', - save: '保存', - saving: '保存中', - saved: '已保存 ✓', - saveFailed: '保存失败,请重试。', - discard: '放弃', - cancel: '取消', - show: '显示', - hide: '隐藏', - usageTitle: '账户用量', - usageRefresh: '刷新', - usageRefreshing: '刷新中…', - usageLoading: '正在获取账户用量…', - usageNoKey: '配置 API 密钥后,这里会显示账户的用量与额度状态。', - usageError: '用量获取失败', - usageRequests: '请求', - usageFailed: '失败', - usageSuccessRate: '成功率', - usageCost: '花费', - usageTokens: 'Token', - usageTokensIn: '入', - usageTokensOut: '出', - usageMonthly: '月额度', - usagePurchased: '已购', - usageFree: '赠送', - usageFiveHour: '5 小时窗口', - usageWeekly: '每周窗口', - usageExceeded: '已超限', - usageReset: '重置于', - usagePartial: '部分端点数据不可用', - usageKeyClear: '清除已存密钥', - usageKeyClearStaged: '将清除(保存后生效)', - usageUndoKeyClear: '撤销清除', - usageKeyInvalid: 'API 密钥无效或已过期', - usageKeyInvalidHint: '服务端拒绝了全部请求(401)。请检查该账户配置的密钥,或到 commandcode.ai 控制台重新生成。', - usageServiceUnavailable: 'Command Code 服务暂时不可用', - usageServiceUnavailableHint: '服务端返回了错误(5xx),稍后点击刷新重试。', - usageNetworkError: '无法连接 Command Code 服务', - usageNetworkHint: '所有请求都没有到达服务端。请检查网络连接或 API 地址设置。', - usageUpdated: '更新于', - usagePeriodEnd: '账期截止', - usageActive: '当前使用', - usageCooldown: '限额冷却中', - usageInvalidKey: '密钥无效', - usageUnconfigured: '该账户尚未配置 API 密钥。', - updateAvailable: '可更新', - updateHint: '已发布新版本,点击查看发布说明;更新插件后刷新本页,提示会自动消失。', - loginTitle: '通过官方登录获取密钥', - loginHintIdle: '不想手动创建密钥?点击登录后浏览器会打开 commandcode.ai 授权页,完成后密钥自动写入本机凭据服务,下次请求即生效。', - loginButton: '登录 Command Code', - loginStarting: '正在启动本地回调服务…', - loginWaiting: '等待在浏览器中完成授权…', - loginOpenLink: '打开授权页面 ↗', - loginCancel: '取消登录', - loginSuccess: '已登录为', - loginUnavailable: '此环境暂不支持登录流程,请手动粘贴密钥。', - loginDenied: '授权被拒绝。可重试,或手动粘贴密钥。', - loginTimeout: '等待超时:未在窗口期内收到授权回调,请重试。', - loginInvalidKey: '获取到的密钥未通过校验(401),请重试或手动粘贴。', - loginNetwork: '无法连接 Command Code 服务校验密钥,请检查网络后重试。', - loginStoreFailed: '密钥无法写入本机凭据服务,请手动粘贴。', - loginCancelled: '登录已取消。', - loginFailedGeneric: '登录失败,请重试或手动粘贴密钥。', - cardTitle: 'Command Code', - cardRouteActive: '已启用', - cardLoadingHint: '正在读取 Command Code 配置…', - cardRegistrationHint: '此卡片随 Command Code 插件注册,需要较新版本的 DeepSeek Harness 才会显示完整内容。', -} - -export const en: Record = { +export const commandCodeCopy: Record = { nav: 'Command Code', title: 'Command Code', intro: diff --git a/src/client/panel-copy.ts b/src/client/panel-copy.ts new file mode 100644 index 0000000..90137e3 --- /dev/null +++ b/src/client/panel-copy.ts @@ -0,0 +1,139 @@ +/** + * English copy for the Command Code **plans & quota panel** (the sidebar + * footer card and the center-column dashboard it opens). + * + * Deliberately NOT part of the `settings.commandcode` locale namespace: that + * namespace follows the harness's active language, and this surface is + * specified to read in English regardless of it. Keeping the strings out of + * the locale registry is what makes that a fact rather than a preference — + * there is no dictionary lookup that could resolve to Chinese. + * + * The settings page keeps its own bilingual `settings.commandcode` namespace + * (see `./locales.ts`); the two never mix. + * + * @module dsh-commandcode-provider/client/panel-copy + */ + +/** + * The plans & quota panel key set. `zh` in the settings namespace is the + * source of truth there; here English is the only locale, so this union is + * the key set. + */ +export type PanelKey = + /** Footer card title and panel heading. */ + | 'nav' + /** Panel sub-heading under the title. */ + | 'subtitle' + /** Refresh button / in-flight label. */ + | 'refresh' + | 'refreshing' + /** First-paint fetch. */ + | 'loading' + /** No credential at all: how to get one. */ + | 'noKey' + | 'noKeyHint' + /** Report section headings. */ + | 'plan' + | 'credits' + | 'limits' + | 'usage' + /** Monthly credit state and its tiles. */ + | 'monthly' + | 'monthlyLimit' + | 'monthlyUsed' + | 'remaining' + | 'purchased' + | 'free' + /** Usage-window rows (long labels in the dashboard, short in the footer). */ + | 'fiveHour' + | 'weekly' + | 'fiveHourShort' + | 'weeklyShort' + | 'windowUnlimited' + | 'exceeded' + | 'exhausted' + | 'resets' + /** Usage tiles. */ + | 'requests' + | 'failed' + | 'successRate' + | 'spend' + | 'tokens' + | 'tokensIn' + | 'tokensOut' + /** Meta line. */ + | 'periodEnds' + | 'updated' + | 'partial' + /** Rotation state on an account. */ + | 'active' + | 'coolingDown' + | 'invalidKey' + /** Footer-card plan fallbacks. */ + | 'unconfigured' + | 'unavailable' + /** Whole-report failures (mirrors the settings card's blocked taxonomy). */ + | 'errorInvalidKey' + | 'errorInvalidKeyHint' + | 'errorServiceUnavailable' + | 'errorServiceUnavailableHint' + | 'errorNetwork' + | 'errorNetworkHint' + /** A fetch that failed for any other reason. */ + | 'errorGeneric' + +/** The panel's literal string table. */ +export const PANEL_COPY: Record = { + nav: 'Command Code', + subtitle: 'Plans, credits and quota windows', + refresh: 'Refresh', + refreshing: 'Refreshing…', + loading: 'Loading account usage…', + noKey: 'No API key configured', + noKeyHint: 'Paste a key — or sign in — under Settings → Command Code, then refresh.', + plan: 'Plan', + credits: 'Credits', + limits: 'Quota windows', + usage: 'Usage', + monthly: 'Monthly', + monthlyLimit: 'Monthly limit', + monthlyUsed: 'Monthly used', + remaining: 'Remaining', + purchased: 'Purchased', + free: 'Free', + fiveHour: '5-hour window', + weekly: 'Weekly window', + fiveHourShort: '5-hour', + weeklyShort: 'Weekly', + windowUnlimited: 'unlimited', + exceeded: 'Exceeded', + exhausted: 'Used up', + resets: 'Resets', + requests: 'Requests', + failed: 'failed', + successRate: 'Success rate', + spend: 'Spend', + tokens: 'Tokens', + tokensIn: 'in', + tokensOut: 'out', + periodEnds: 'Period ends', + updated: 'Updated', + partial: 'Some endpoint data unavailable', + active: 'Active', + coolingDown: 'Cooling down', + invalidKey: 'Invalid key', + unconfigured: 'Not configured', + unavailable: 'No data', + errorInvalidKey: 'API key invalid or expired', + errorInvalidKeyHint: 'The server rejected every request (401). Check the key for this account, or generate a new one in the commandcode.ai console.', + errorServiceUnavailable: 'The Command Code service is temporarily unavailable', + errorServiceUnavailableHint: 'The server returned errors (5xx). Try Refresh again in a moment.', + errorNetwork: 'Could not reach the Command Code service', + errorNetworkHint: 'No request reached the server. Check your network connection or the API base setting.', + errorGeneric: 'Could not fetch account usage', +} + +/** Look up one panel string. The fallback key keeps a bad call visible, never blank. */ +export function panelText(key: PanelKey): string { + return PANEL_COPY[key] ?? key +} diff --git a/src/client/panel-slots.ts b/src/client/panel-slots.ts new file mode 100644 index 0000000..46a0bd6 --- /dev/null +++ b/src/client/panel-slots.ts @@ -0,0 +1,47 @@ +/** + * Slot contracts the plans & quota panel registers into. + * + * Neither slot belongs to this plugin: `sidebar.footer.action` is declared by + * `@deepseek-ai/dsh-client-ui-sidebar` and `main` by + * `@deepseek-ai/dsh-client-ui-layout`. Neither package is a dependency of this + * bundle (the panel only needs their *shapes* at compile time, and neither + * ships a client module the browser would have to resolve), so — exactly as + * `card.tsx` does for `settings.models.provider-card` — the declarations are + * re-stated here and merged into the framework's `SlotMap`. + * + * The re-statement must stay structurally identical to upstream's. That is the + * point of the merge: the registration site typechecks against the real + * contract (kind, scope, owner props, inject face), so an upstream change that + * invalidates this panel is a compile error here rather than a silent + * mis-registration at runtime. A future dsh that ships either declaration to + * the client through some other path would collide at compile time — which is + * the intended alarm, and why this file exists instead of a local cast. + * + * @module dsh-commandcode-provider/client/panel-slots + */ + +import type { SidebarFooterActionOwnerProps } from './panel-view.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** + * The layout's central panel, selected by the footer entry's id. The + * reserved `conversation` key hosts the Conversation; the panel this + * plugin contributes occupies `commandcode-panel` and receives no Session + * binding. Its owner props are empty — a global panel is root-scoped + * chrome, so clipboard, zoom, and user selection stay the browser's. + */ + 'main': { kind: 'keyed'; scope: 'root' } + /** + * The sidebar-foot action list, rendered inside the foot area directly + * ABOVE the Settings seat (`footArea` renders `footerActions` then + * `settingsArea`). Registering here is what pins a row to the bottom of + * the sidebar on top of Settings — unlike `sidebar.panellist`, whose rows + * render as global panel icons at the very top of the column. + * + * The shell wraps nothing: the entry owns its whole surface and receives + * only the column fold state. + */ + 'sidebar.footer.action': { kind: 'list'; scope: 'root'; owner: SidebarFooterActionOwnerProps } + } +} diff --git a/src/client/panel-styles.ts b/src/client/panel-styles.ts new file mode 100644 index 0000000..b665fbd --- /dev/null +++ b/src/client/panel-styles.ts @@ -0,0 +1,137 @@ +/** + * Stylesheet for the Command Code plans & quota panel (the sidebar footer card + * and the dashboard it opens). + * + * Returned as a string rather than injected here so the modules stay free of + * DOM side effects at import time — the client entry installs it once, keyed + * by the same `data-plugin-css` attribute the settings-page stylesheet uses, + * and removes it again when the plugin's fiber unwinds. + * + * Every colour comes from a harness theme alias with a neutral fallback, so + * the panel follows the active theme (light/dark and any brand pack) without + * hardcoded values. Classes are `ccp-` prefixed to stay clear of the settings + * page's `cc-` set. + * + * @module dsh-commandcode-provider/client/panel-styles + */ + +/** Stylesheet id (the `data-plugin-css` value that makes injection idempotent). */ +export const PANEL_CSS_ID = '@xer-on/dsh-commandcode-provider/CommandCodePanel.module.css' + +/** The panel stylesheet. */ +export const PANEL_CSS = ` +/* ------------------------------------------------- sidebar footer card */ +/* The shell's foot area renders this list ABOVE the Settings seat, so the card + is the sidebar's bottom-most content. The shell supplies no chrome: the entry + is the button. It is deliberately quiet — a surface that sits beside Settings + should read as part of the column, not as a call to action — with one hover + step and a hairline border. + + The shell's container is a flex ROW whose occupants (this card and ui-cordis's + footer chip) each declare a full-width line and shrink-proof flex, so as a row + it would overflow the column. Both were written for a full-width line, which + is exactly what a column gives them. Matched by the CSS-module class STEM — + never a hashed name — so a dsh that renames it degrades to the shell's own + row rather than breaking. */ +[class*="_footerActions"]{flex-direction:column} +.ccp-foot{box-sizing:border-box;flex:0 0 auto;width:100%;min-width:0;font:inherit;color:var(--dsw-alias-label-secondary);text-align:left;cursor:pointer;background:0 0;border:1px solid transparent;border-radius:10px;flex-direction:column;gap:6px;margin:0 0 4px;padding:8px;display:flex} +.ccp-foot:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l2)} +.ccp-foot:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px} +.ccp-footTop{align-items:center;gap:8px;min-width:0;display:flex} +.ccp-footName{white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;font-size:13px;font-weight:500;line-height:20px} +/* One block per quota window: a head line carrying the window's own spend and + limit, then the FULL-WIDTH bar under it. Stacking the two lets the card show + the dollar figures — the reason this surface exists — without squeezing the + bar into what is left beside them. Mirrors the dashboard's own window block. */ +.ccp-footRow{flex-direction:column;gap:4px;min-width:0;display:flex} +.ccp-footHead{align-items:baseline;gap:8px;min-width:0;display:flex} +.ccp-footLabel{flex:1;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px} +.ccp-footAmount{flex:none;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums;white-space:nowrap} +/* The card's markup must stay PHRASING content — it renders inside the shell's + own button — so these bars are spans, not divs. That makes display:block + load-bearing on BOTH: an inline box ignores width and height outright, so + without it the 5px track still painted (a flex item is blockified by its + container) while the fill collapsed to 0x0 and the bar showed no usage. */ +.ccp-footBar{display:block;background:var(--dsw-alias-bg-layer-2);border-radius:999px;height:5px;overflow:hidden} +.ccp-footFill{display:block;background:var(--dsw-alias-brand-primary);border-radius:999px;height:100%;transition:width .3s ease} +.ccp-footFillWarn{background:var(--dsw-alias-state-error-primary)} +.ccp-footPct{flex:none;width:34px;color:var(--dsw-alias-label-secondary);text-align:right;font-size:11px;line-height:16px;font-variant-numeric:tabular-nums} + +/* The 56px rail: one icon button on the shell's own rail geometry (36px cell), + so the collapsed column keeps a single 18px glyph like its siblings. */ +.ccp-railButton{box-sizing:border-box;width:36px;height:36px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:1px solid transparent;border-radius:8px;flex:none;justify-content:center;align-items:center;margin:0 0 4px;padding:0;display:inline-flex} +.ccp-railButton:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)} +.ccp-railButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px} + +/* The ring glyph. Sized entirely by its own width/height attribute, so the + footer row, the rail button and the dashboard can each ask for their own. */ +.ccp-glyph{flex:none;justify-content:center;align-items:center;display:inline-flex;color:var(--dsw-alias-brand-primary)} + +/* ------------------------------------------------------------ dashboard */ +/* The center column in the layout frame: fill it, scroll the content column, + and cap the reading width like the harness's own panels. */ +.ccp-main{background:var(--dsw-alias-bg-layer-1);width:100%;height:100%;overflow:auto;display:block} +.ccp-mainInner{max-width:760px;margin:0 auto;padding:24px 20px 40px;flex-direction:column;gap:14px;display:flex;color:var(--dsw-alias-label-primary)} +.ccp-header{align-items:center;gap:10px;display:flex;flex-wrap:wrap} +.ccp-headerText{flex-direction:column;gap:2px;display:flex;min-width:0} +.ccp-title{margin:0;font-size:18px;font-weight:600;line-height:1.4} +.ccp-subtitle{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5} +.ccp-spacer{flex:1} +.ccp-meta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;font-variant-numeric:tabular-nums} +.ccp-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5} + +/* Notices: the no-key guidance, a blocked report, and a stale-data error. */ +.ccp-notice{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:12px 14px;flex-direction:column;gap:4px;display:flex} +.ccp-noticeError{border-color:var(--dsw-alias-state-error-primary)} +.ccp-noticeTitle{margin:0;font-size:13px;font-weight:600;line-height:1.5} +.ccp-noticeError .ccp-noticeTitle{color:var(--dsw-alias-state-error-primary)} +.ccp-noticeHint{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55} +.ccp-noticeDetail{margin:0;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;word-break:break-word} + +.ccp-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:14px;padding:16px 18px;flex-direction:column;gap:16px;display:flex} +.ccp-cardHead{align-items:center;gap:10px;display:flex;flex-wrap:wrap} +.ccp-avatar{flex:none;width:28px;height:28px;color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-module-platform);border-radius:50%;justify-content:center;align-items:center;font-size:12px;font-weight:600;line-height:1;display:inline-flex} +.ccp-cardIdentity{flex-direction:column;gap:1px;min-width:0;display:flex} +.ccp-cardTitle{font-size:13px;font-weight:600;line-height:1.4} +.ccp-cardOwner{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px} +.ccp-block{flex-direction:column;gap:8px;display:flex} +.ccp-blockTitle{margin:0;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:600;line-height:1.5;text-transform:uppercase;letter-spacing:.04em} +.ccp-planRow{align-items:center;gap:8px;display:flex;flex-wrap:wrap} +.ccp-fieldLabel{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5} +.ccp-planName{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:1.5} + +/* Stat tiles: the monthly credits and the usage totals share one grid. */ +.ccp-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px} +.ccp-tile{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:8px;padding:8px 10px;flex-direction:column;gap:2px;display:flex;min-width:0} +.ccp-tileLabel{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5} +.ccp-tileValue{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4;font-variant-numeric:tabular-nums} +.ccp-tileSub{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + +/* Quota bars: the monthly bar and the two windows stack in one column, each a + label row plus the track. */ +.ccp-windows{flex-direction:column;gap:14px;display:flex} +.ccp-window{flex-direction:column;gap:6px;display:flex} +.ccp-windowHead{align-items:baseline;gap:8px;display:flex} +.ccp-windowLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:1.5} +.ccp-windowValue{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5;font-variant-numeric:tabular-nums;white-space:nowrap} +.ccp-windowPct{color:var(--dsw-alias-label-primary);min-width:38px;text-align:right;font-size:12px;font-weight:600;line-height:1.5;font-variant-numeric:tabular-nums} +.ccp-warnTag{white-space:nowrap;background:var(--dsw-alias-state-warn-tertiary,var(--dsw-alias-bg-module-platform));color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-label-secondary));border-radius:999px;padding:0 8px;font-size:11px;font-weight:600;line-height:17px} +.ccp-bar{overflow:hidden;background:var(--dsw-alias-bg-layer-1);border-radius:999px;height:8px} +.ccp-barFill{background:var(--dsw-alias-brand-primary);border-radius:999px;height:100%;transition:width .3s ease} +.ccp-barFillWarn{background:var(--dsw-alias-state-error-primary)} +.ccp-windowReset{color:var(--dsw-alias-label-tertiary);margin:0;font-size:11px;line-height:1.5} + +/* Badges. */ +.ccp-badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-brand-primary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:600;line-height:17px} +.ccp-badgeError{background:transparent;color:var(--dsw-alias-state-error-primary)} +.ccp-badgeWarn{background:var(--dsw-alias-state-warn-tertiary,var(--dsw-alias-bg-module-platform));color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-label-secondary))} +.ccp-badgeMuted{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px;max-width:220px;overflow:hidden;text-overflow:ellipsis} + +/* Account switch: plain buttons, like the settings page's usage carousel. */ +.ccp-tabs{flex-wrap:wrap;gap:6px;display:flex} +.ccp-tab{align-items:center;font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:2px 10px;font-size:12px;line-height:18px;display:inline-flex;gap:6px} +.ccp-tab:hover:not(.ccp-tabActive){color:var(--dsw-alias-label-primary)} +.ccp-tabActive{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-brand-primary)} + +@media (prefers-reduced-motion:reduce){.ccp-footFill,.ccp-barFill{transition:none}} +` diff --git a/src/client/panel-view.tsx b/src/client/panel-view.tsx new file mode 100644 index 0000000..83886f2 --- /dev/null +++ b/src/client/panel-view.tsx @@ -0,0 +1,458 @@ +/** + * React components for the Command Code plans & quota panel (browser half): + * the sidebar footer card and the dashboard it opens in the center column. + * + * Both render one {@link PanelView} projected by `./panel.ts` — no fact is + * derived here. Strings arrive as keys into `view.text`, and every one of them + * is English, because the view is built from `./panel-copy.ts` rather than the + * harness `ctx.locale` namespace (which follows the user's language and would + * render this panel in Chinese on a Chinese harness — the thing this surface + * exists to avoid). + * + * The footer card is the panel's home: the sidebar shell renders it in the foot + * area directly above the Settings seat, so each quota window's own spend and + * limit — the 5-hour and the weekly one — are on screen without opening + * anything. Clicking it selects the `main` panel this file also renders — and + * unlike a `sidebar.panellist` row, whose button chrome and label the SHELL + * owns, this entry owns its whole surface and therefore calls `open()` itself. + * + * Styles ride the stylesheet `./panel-styles.ts` returns, injected once by + * the client entry; classes are `ccp-` prefixed to stay clear of the settings + * page's `cc-` set. + * + * @module dsh-commandcode-provider/client/panel-view + */ + +import { useEffect, useState } from 'react' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import type { SnapshotStore } from './snapshot-store.ts' +import type { UsagePageState } from './usage.ts' +import type { SettingsPageState } from './settings.ts' +import { buildPanelView } from './panel.ts' +import type { PanelAccountView, PanelStatView, PanelView, PanelWindowView } from './panel.ts' +import type { PanelKey } from './panel-copy.ts' +// SlotMap merge for `main` / `sidebar.footer.action` (load-bearing: the slots +// this file's components register into are typed only by that augmentation). +import './panel-slots.ts' + +/** + * Owner share of the sidebar-foot action hole: the shell renders the foot area + * and hands each action only the column fold state. There is no button chrome + * and no `label` seat — the entry is the whole surface. + */ +export interface SidebarFooterActionOwnerProps { + /** Whether the sidebar renders wide content (false = 56px rail). */ + wide: boolean +} + +/** + * The injected face both panel slots carry. Bound by the client entry so the + * components stay unaware of the settings controller, the login controller, + * the layout service and the module-level auto-refresh loop. + * + * NOTE the split from {@link PanelComponentProps}: this is the face the + * registration's `inject` factory RETURNS, and the renderer does not hand it to + * the component verbatim. `bindInjectSources` destructures the `hooks` + * compartment OUT of the face and re-exposes each member as a `use` prop + * (`commandCodeUsage` → `useCommandCodeUsage`). A component that reads + * `props.hooks.*` therefore finds `undefined` at runtime and crashes on render + * — which the slot renderer contains by ABDICATING the entry, so the surface + * vanishes with no visible error. Read the `useX` seats instead. + */ +export interface PanelInjected { + hooks: { + commandCodeUsage: SnapshotStore + commandCodeSettings: SnapshotStore + } + /** Fetch the report now (the dashboard's Refresh action). */ + refresh(): void + /** Start the shared background poll for this mount; returns its disposer. */ + startAutoRefresh(): () => void + /** Select this panel in the center column (`ctx.layout.selectPanel`). */ + open(): void +} + +/** + * The props a panel component actually receives: the bound `useX` seats (what + * {@link PanelInjected}'s `hooks` compartment becomes), the pass-through + * actions, and no raw `hooks` key. + * + * `buildPanelView` is cheap (one pass over the account list) and recomputing it + * per notification is what keeps a store update from ever rendering a stale + * quota, so the selector is deliberately the whole snapshot. + */ +export interface PanelComponentProps { + useCommandCodeUsage(selector: (state: UsagePageState) => T): T + useCommandCodeSettings(selector: (state: SettingsPageState) => T): T + refresh(): void + startAutoRefresh(): () => void + open(): void +} + +/** Props of the sidebar footer card: the panel face plus the shell's fold state. */ +export interface CommandCodeFooterEntryProps extends PanelComponentProps, SidebarFooterActionOwnerProps {} + +/** The panel's view, recomputed from both seats on every notification. */ +function usePanelView(props: PanelComponentProps): PanelView { + const usage = props.useCommandCodeUsage((state) => state) + const settings = props.useCommandCodeSettings((state) => state) + return buildPanelView({ + usage, + apiKeyConfigured: settings.anyAccountConfigured, + removingIds: settings.accountsRemoving, + }) +} + +/** + * The quota ring. One glyph serves the rail button, the footer card's top row + * and the dashboard header: a faint track plus an arc whose sweep is the + * consumption, drawn from 12 o'clock. Circumference 2πr = 45.55 at r = 7.25. + */ +function Ring({ percent, warn, size }: { percent: number; warn: boolean; size: number }) { + const clamped = Math.min(100, Math.max(0, percent)) + const circumference = 45.55 + // Rounded to three decimals: the raw product lands on floats like + // 34.162499999999994, which is a needless DOM diff churn. + const dashoffset = Math.round(circumference * (1 - clamped / 100) * 1000) / 1000 + return ( + + ) +} + +/** One labelled bar. `compact` drops the reset line; the footer draws its own. */ +function QuotaBar({ label, value, percent, barPercent, warn, resetsAt, resetsLabel }: { + label: string + value: string + percent: string + barPercent: number + warn: string + resetsAt: string + resetsLabel: string +}) { + const clamped = Math.min(100, Math.max(0, barPercent)) + return ( +
+
+ {label} + {warn !== '' ? {warn} : null} + + {value !== '' ? {value} : null} + {percent} +
+
+
+
+ {resetsAt !== '' ?

{resetsLabel} {resetsAt}

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

{text('unconfigured')}

: null} + + {monthly !== undefined && monthly.known ? ( + + ) : ( +
+ {text('plan')} + {account.planName !== '' ? account.planName : text('unavailable')} +
+ )} + + {account.windows.length > 0 ? ( +
+ {account.windows.map((window: PanelWindowView) => ( + + ))} +
+ ) : null} + + {monthly !== undefined ? ( +
+

{text('credits')}

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

{text('usage')}

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

{text('nav')}

+

{text('subtitle')}

+
+ + {view.updatedAt !== '' ? {text('updated')} {view.updatedAt} : null} + +
+ + {view.noKey ? ( +
+

{text('noKey')}

+

{text('noKeyHint')}

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

{text(view.failure.title)}

+

{text(view.failure.hint)}

+ {view.failure.detail !== '' ?

{view.failure.detail}

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

{text('loading')}

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

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

+ ) : null} + + {accounts.length > 1 ? ( +
+ {accounts.map((account) => ( + + ))} +
+ ) : null} + + {selected !== undefined ? : null} + + {view.partial !== undefined ?

{text(view.partial)}

: null} +
+
+ ) +} + +/** + * The sidebar footer card, registered into `sidebar.footer.action` — the list + * the shell renders in the sidebar's foot area directly ABOVE the Settings + * seat, so the panel reads as a bottom-pinned sibling of Settings rather than + * a global panel icon at the top of the column. + * + * The shell wraps nothing here, so this component owns the surface: the + * button, its chrome and its accessible name. In the expanded column it draws + * the title row, then one block per quota window (5-hour, then weekly) — the + * window's own spend and limit (`$1.32 / $6.00`), its percentage and its bar — + * and nothing else: the card's figures are the two windows the account runs + * into, so the period total stays in the tooltip rather than taking a third + * line. In the 56px rail it collapses to a 36px icon button carrying the ring, + * matching the shell's own rail geometry. `wide` comes from the shell as an + * owner prop — unlike the old `sidebar.panellist` row, this slot really does + * supply it. + */ +export function CommandCodeFooterEntry(props: CommandCodeFooterEntryProps) { + const view = usePanelView(props) + + const startAutoRefresh = props.startAutoRefresh + useEffect(() => startAutoRefresh(), [startAutoRefresh]) + + const text = (key: PanelKey): string => view.text[key] ?? key + // The ring tracks the tightest window — the 5-hour one whenever it is capped, + // which is the limit an account actually runs into first. `footerBars` is + // ordered that way, so the leading bar is the headline. + const headline = view.footerBars[0] + // The tooltip doubles as the accessible name; it always begins with the + // visible title, so the label the user reads is contained in the name. + const title = view.footTitle + + if (!props.wide) { + return ( + + ) + } + + return ( + + ) +} diff --git a/src/client/panel.ts b/src/client/panel.ts new file mode 100644 index 0000000..7abcb37 --- /dev/null +++ b/src/client/panel.ts @@ -0,0 +1,590 @@ +/** + * View layer for the Command Code plans & quota panel (the sidebar footer card + * and the dashboard it opens in the center column). + * + * Deliberately JSX-free and React-free, mirroring `./settings.ts` and + * `./usage.ts`: it turns the shared usage snapshot into one presentation + * tree that both React components render, and it owns the one shared side + * effect (the throttled background refresh that keeps the sidebar card + * current). Node tests drive everything here without a DOM. + * + * Every displayed string is decided here, as a `PanelKey` plus a `text` + * record resolved from `./panel-copy.ts` — so the components carry no + * formatting, pluralization, or copy of their own, and the panel cannot + * regress into the harness locale (see that module for why it is English-only). + * + * @module dsh-commandcode-provider/client/panel + */ + +import type { CommandCodeUsageReport } from '../adapter.ts' +import type { CommandCodeAccountUsage } from '../usage-wire.ts' +import type { UsagePageState } from './usage.ts' +import { + formatMoney, + formatMoneyExact, + formatSuccessRate, + formatTokensCompact, +} from './usage.ts' +import { panelText, PANEL_COPY } from './panel-copy.ts' +import type { PanelKey } from './panel-copy.ts' + +/** One quota window (5-hour or weekly) in display form. */ +export interface PanelWindowView { + /** Row-label key into the view's `text` record. */ + label: PanelKey + /** `used / cap`, or just `used` when the window is uncapped. */ + value: string + /** The window reports a cap, so `percent` is meaningful. */ + capped: boolean + /** Percentage actually consumed (may exceed 100 when over the cap). */ + percent: number + /** Fill width for the bar, clamped to [0, 100]. */ + barPercent: number + /** Window is over its cap. */ + exceeded: boolean + /** Local reset time, empty when the endpoint reported none. */ + resetsAt: string +} + +/** + * Monthly credit state, derived exactly the way the official CLI derives it + * (`getCreditDepletionPct` in `command-code/dist/cli.mjs`): the plan's credit + * total is the LIMIT, and the billing endpoint's `credits.monthlyCredits` is + * the REMAINING balance, so consumption is `limit - remaining`. The CLI's own + * wording for the same two numbers is `Plan: N% used, X credits left`. + * + * Purchased and free credits are separate balances that extend what an account + * can spend, so they are reported as their own tiles rather than folded into + * the limit. + */ +export interface PanelMonthlyView { + /** + * A limit is known (`plan.monthlyCredits` > 0), so `used` / `limit` and the + * percentage are meaningful. When false the view still carries the balances + * the billing endpoint reported, but draws no bar: a percentage needs a + * denominator, and inventing one from a remaining balance would be wrong. + */ + known: boolean + /** Plan credit total for the period (the limit). */ + limit: string + /** Consumed this period (`limit - remaining`). */ + used: string + /** Credits still available. */ + remaining: string + /** Purchased top-up balance. */ + purchased: string + /** Promotional balance. */ + free: string + /** Consumption as a percentage of the limit; 0 when there is no limit. */ + percent: number + /** Fill width for the bar, clamped to [0, 100]. */ + barPercent: number + /** The plan's credits are used up. */ + exhausted: boolean + /** Billing period end, local date; empty when unreported. */ + periodEnds: string +} + +/** One compact bar in the sidebar footer card. */ +export interface PanelFooterBar { + /** Row label key (short forms: `Monthly`, `5-hour`, `Weekly`). */ + label: PanelKey + /** Printed right-hand percentage, e.g. `32%`; empty when there is no ratio. */ + percent: string + /** Fill width, clamped to [0, 100]. */ + barPercent: number + /** Render in the warning colour. */ + warn: boolean + /** + * This window's own spend against its limit, e.g. `$1.32 / $6.00`. RENDERED, + * not just a tooltip: the card exists to show these figures, and the + * magnitudes are small dollar amounts (the endpoint reports a Pro account's + * five-hour cap as `3` and its weekly cap as `6`). + */ + detail: string +} + +/** One usage tile. */ +export interface PanelStatView { + label: PanelKey + value: string + /** Secondary line, empty when there is none. */ + sub: string +} + +/** One pool account's report, ready to render. */ +export interface PanelAccountView { + id: string + label: string + /** The account's display name (user name, else account name); empty when unreported. */ + owner: string + /** Subscription display name; empty when unreported. */ + planName: string + /** Subscription status when it is not a plain `active`; empty otherwise. */ + planStatus: string + /** Rotation/credential state ('invalidKey' / 'coolingDown'); undefined when plain. */ + mark: PanelKey | undefined + /** Known cooldown end, appended to the cooling-down badge; empty otherwise. */ + cooldownUntil: string + /** No credential resolved for this slot. */ + unconfigured: boolean + /** Billing period end, empty when unreported. */ + periodEnds: string + /** Whether the account currently serves requests. */ + active: boolean + /** Monthly credit state; undefined when neither endpoint reported it. */ + monthly: PanelMonthlyView | undefined + windows: PanelWindowView[] + stats: PanelStatView[] +} + +/** What actually went wrong, once — for the alert box. */ +export interface PanelFailureView { + title: PanelKey + /** The actionable line under the title. */ + hint: PanelKey + /** The underlying transport message, shown small; empty when there is none. */ + detail: string +} + +/** The whole panel, in render order. */ +export interface PanelView { + /** Every English string this view references, by key. */ + text: Partial> + /** Footer-card plan text (`Go`, `Pro`, …), or a state word. */ + planName: string + /** Footer-card rotation/credential state line; empty when nothing to say. */ + status: string + /** + * The compact bars the sidebar footer card stacks: the two quota windows an + * account actually runs into, 5-hour first then weekly. The monthly + * limit/usage bar belongs to the DASHBOARD alone — it moves once a billing + * period, whereas these two are what stop a session — so it is deliberately + * absent here. A window with no cap is left out: there is no ratio to draw. + */ + footerBars: PanelFooterBar[] + /** + * This period's spend in dollars, formatted; empty when the usage endpoint + * reported nothing. NOT drawn as its own row — the card's visible figures are + * the two windows' own spend (see {@link PanelFooterBar.detail}), and a + * separate period total would sit next to the weekly window's near-identical + * figure — so it rides the tooltip and the accessible name instead. + */ + cost: string + /** + * The footer card's accessible name and tooltip: `Command Code · Pro · + * 5-hour $0.04 / $3.00 (1%) · Weekly $1.32 / $6.00 (22%) · Spend $1.32`. + * Always starts with the visible title, so the accessible name contains the + * visible label. + */ + footTitle: string + /** Every account, in rotation order (empty when there is nothing to show). */ + accounts: PanelAccountView[] + /** Id of the account the panel opens on (the serving one), if any. */ + selectedId: string | undefined + /** That account's view, for the footer card; undefined with no accounts. */ + selected: PanelAccountView | undefined + /** A fetch is in flight and no data has landed for this paint. */ + loading: boolean + /** No credential is configured at all. */ + noKey: boolean + /** Report-level failure box; undefined when the report is usable. */ + failure: PanelFailureView | undefined + /** A fetch failed while data is still on screen. */ + staleError: string | undefined + /** Endpoint-level partial failure note; undefined when every endpoint answered. */ + partial: PanelKey | undefined + /** Freshness line, empty when nothing has been fetched. */ + updatedAt: string +} + +/** Inputs {@link buildPanelView} needs beyond the usage snapshot. */ +export interface PanelViewInput { + usage: UsagePageState + /** Whether any account holds a credential (the settings controller's fact). */ + apiKeyConfigured: boolean + /** Account ids staged for removal; hidden here immediately, like the settings card. */ + removingIds?: readonly string[] +} + +/** A quota window's state as the wire carries it. */ +interface WindowInput { + used: number + cap: number + exceeded: boolean + resetAt: number +} + +/** `$1.23`, and `$0.0123` only when the amount is too small for cents to show it. */ +function money(value: number): string { + if (value === 0) return formatMoney(0) + return Math.abs(value) < 0.01 ? formatMoneyExact(value) : formatMoney(value) +} + +/** Local reset time; empty when the endpoint reported none. */ +function resetText(ms: number): string { + if (ms <= 0) return '' + return new Date(ms).toLocaleString() +} + +/** Local short date; empty when unset. */ +function dateText(ms: number): string { + if (ms <= 0) return '' + return new Date(ms).toLocaleDateString() +} + +/** Local time-of-day; empty when unset. */ +function timeText(ms: number): string { + if (ms <= 0) return '' + return new Date(ms).toLocaleTimeString() +} + +/** + * Consumption as a percentage, NOT clamped at 100 — an over-quota window + * reports its real overshoot (150%), which is what the printed figure should + * say. Bar widths clamp separately via {@link barPercent}. + */ +function rawPercent(used: number, cap: number): number { + if (cap <= 0) return 0 + return Math.round((used / cap) * 100) +} + +/** A percentage usable as a CSS width, clamped into [0, 100]. */ +function barPercent(percent: number): number { + return Math.min(100, Math.max(0, percent)) +} + +/** Build one quota-window view. */ +function windowView(label: PanelKey, limit: WindowInput): PanelWindowView { + const percent = rawPercent(limit.used, limit.cap) + const capped = limit.cap > 0 + return { + label, + value: capped ? `${money(limit.used)} / ${money(limit.cap)}` : money(limit.used), + capped, + percent, + barPercent: barPercent(percent), + exceeded: limit.exceeded, + resetsAt: resetText(limit.resetAt), + } +} + +/** + * Build the monthly credit view from the two endpoints that carry it. + * + * The limit is the PLAN's credit total, not the billing endpoint's + * `monthlyCredits` — that field is a remaining balance (see + * {@link PanelMonthlyView}). Unknown plans (no `monthlyCredits` on the plan + * record) therefore still show their balances, just without a ratio. + * + * Caveat inherited from the wire: `parseCreditLimits` defaults an absent + * `credits.monthlyCredits` to 0, so an account whose billing endpoint answered + * only window limits reads as fully consumed. The official CLI reads it the + * same way (`Math.max(0, s?.monthlyCredits ?? 0)`), and the alternative — + * treating 0 as "unknown" — would hide a genuinely exhausted account. + */ +function monthlyView(report: CommandCodeUsageReport): PanelMonthlyView | undefined { + const credits = report.credits + const plan = report.plan + if (credits === undefined && plan === undefined) return undefined + + const remaining = Math.max(0, credits?.monthlyCredits ?? 0) + const limitValue = plan?.monthlyCredits ?? null + const known = limitValue !== null && limitValue > 0 + const used = known ? Math.max(0, limitValue - remaining) : 0 + const percent = known ? rawPercent(used, limitValue) : 0 + + return { + known, + limit: money(limitValue ?? 0), + used: money(used), + remaining: money(remaining), + purchased: money(Math.max(0, credits?.purchasedCredits ?? 0)), + free: money(Math.max(0, credits?.freeCredits ?? 0)), + percent, + barPercent: barPercent(percent), + exhausted: known && remaining <= 0, + periodEnds: dateText(plan?.currentPeriodEnd ?? 0), + } +} + +/** Build one account's view. */ +function accountView(entry: CommandCodeAccountUsage): PanelAccountView { + const { report } = entry + const account = report.account + const plan = report.plan + const credits = report.credits + const usage = report.usage + + const stats: PanelStatView[] = [] + if (usage !== undefined) { + stats.push({ + label: 'requests', + value: String(usage.completedCount), + sub: `${usage.failedCount} ${panelText('failed')}`, + }) + stats.push({ label: 'successRate', value: `${formatSuccessRate(usage.successRate)}%`, sub: '' }) + stats.push({ + label: 'spend', + value: formatMoneyExact(usage.totalCost), + sub: `${formatMoney(usage.totalCredits)} credits`, + }) + stats.push({ + label: 'tokens', + value: formatTokensCompact(usage.totalTokensIn + usage.totalTokensOut), + sub: `${formatTokensCompact(usage.totalTokensIn)} ${panelText('tokensIn')} / ${formatTokensCompact(usage.totalTokensOut)} ${panelText('tokensOut')}`, + }) + } + + const windows: PanelWindowView[] = [] + if (credits !== undefined) { + windows.push(windowView('fiveHour', credits.fiveHour)) + windows.push(windowView('weekly', credits.weekly)) + } + + // Rotation/credential state: the serving account first, then the two marks + // the pool can carry, then a cooldown whose end time is known. + let mark: PanelKey | undefined + if (entry.active) mark = 'active' + else if (entry.mark === 'invalid-credential') mark = 'invalidKey' + else if (entry.cooldownUntil > 0 || entry.mark === 'rate-limit') mark = 'coolingDown' + + return { + id: entry.id, + label: entry.label, + owner: account === undefined ? '' : account.userName || account.name, + planName: plan?.name ?? '', + planStatus: plan !== undefined && plan.status !== '' && plan.status !== 'active' ? plan.status : '', + mark, + cooldownUntil: entry.cooldownUntil > 0 ? resetText(entry.cooldownUntil) : '', + unconfigured: !entry.configured, + periodEnds: dateText(plan?.currentPeriodEnd ?? 0), + active: entry.active, + monthly: monthlyView(report), + windows, + stats, + } +} + +/** The report-level failure box, or undefined when the report is usable. */ +function failureView(state: UsagePageState): PanelFailureView | undefined { + const blocked = state.report?.accounts.find((entry) => entry.report.blocked !== undefined)?.report.blocked + if (blocked === 'invalid-key') { + return { title: 'errorInvalidKey', hint: 'errorInvalidKeyHint', detail: '' } + } + if (blocked === 'service-unavailable') { + return { title: 'errorServiceUnavailable', hint: 'errorServiceUnavailableHint', detail: '' } + } + if (blocked === 'network') { + return { title: 'errorNetwork', hint: 'errorNetworkHint', detail: '' } + } + // A transport-level failure (unmounted Remote, offline browser) arrives as + // the controller's own error rather than as a blocked report. + if (state.status === 'error' && state.report === undefined) { + return { title: 'errorGeneric', hint: 'errorGeneric', detail: state.error ?? '' } + } + return undefined +} + +/** + * Every panel string, resolved once per projection. One object with all keys + * (rather than per-field lookups in the components) keeps the copy table and + * the render sites in lockstep: a key cannot be read from `text` unless + * {@link PANEL_COPY} declares it. + */ +function panelStrings(): Record { + const keys = Object.keys(PANEL_COPY) as PanelKey[] + const out = {} as Record + for (const key of keys) out[key] = panelText(key) + return out +} + +/** + * Project the shared usage snapshot into the panel's render tree. + * + * Deduplication matches the settings card: hand-edited settings can name one + * credential twice, and removal staging hides an account before the post-save + * refresh lands. + */ +export function buildPanelView(input: PanelViewInput): PanelView { + const { usage } = input + const hidden = new Set(input.removingIds ?? []) + const seen = new Set() + const entries = (usage.report?.accounts ?? []).filter((entry) => { + if (hidden.has(entry.id)) return false + if (seen.has(entry.id)) return false + seen.add(entry.id) + return true + }) + const accounts = entries.map(accountView) + const selectedEntry = entries.find((entry) => entry.active) ?? entries[0] + const selectedView = accounts.find((view) => view.id === selectedEntry?.id) + + // The footer card stacks BOTH quota windows — the two limits an account + // actually runs into — 5-hour first (the tighter and nearer one), then + // weekly. `barPercent` clamps the fill; the printed percentage keeps the true + // consumption, so an over-cap window still reads 150%. + const footerBars: PanelFooterBar[] = [] + const windowBars: Array<[PanelKey, WindowInput | undefined]> = [ + ['fiveHourShort', selectedEntry?.report.credits?.fiveHour], + ['weeklyShort', selectedEntry?.report.credits?.weekly], + ] + for (const [label, window] of windowBars) { + if (window === undefined || window.cap <= 0) continue + const percent = rawPercent(window.used, window.cap) + footerBars.push({ + label, + percent: `${percent}%`, + barPercent: barPercent(percent), + warn: window.exceeded, + detail: `${money(window.used)} / ${money(window.cap)}`, + }) + } + + // The card's one non-ratio figure: what this period has cost in dollars. + const totalCost = selectedEntry?.report.usage?.totalCost + const cost = totalCost === undefined ? '' : money(totalCost) + + let status = '' + if (!input.apiKeyConfigured) status = panelText('unconfigured') + else if (selectedView?.mark !== undefined && selectedView.mark !== 'active') status = panelText(selectedView.mark) + else if (selectedEntry === undefined) status = panelText('unavailable') + + const planName = selectedView !== undefined && selectedView.planName !== '' ? selectedView.planName : panelText('nav') + // `planName` falls back to the panel name, so it is only a second part when it + // actually names a plan — otherwise the title would read `Command Code · + // Command Code`. + const titleParts = planName === panelText('nav') ? [planName] : [panelText('nav'), planName] + for (const bar of footerBars) { + const figures = bar.detail === '' ? '' : ` ${bar.detail}` + titleParts.push(`${panelText(bar.label)}${figures} (${bar.percent})`) + } + if (cost !== '') titleParts.push(`${panelText('spend')} ${cost}`) + + return { + text: panelStrings(), + planName, + status, + footerBars, + cost, + footTitle: titleParts.join(' · '), + accounts, + selectedId: selectedEntry?.id, + selected: selectedView, + loading: usage.status === 'loading' && usage.report === undefined, + noKey: !input.apiKeyConfigured, + failure: failureView(usage), + staleError: usage.status === 'error' && usage.report !== undefined ? usage.error ?? '' : undefined, + partial: (usage.report?.accounts.some((entry) => entry.report.failures.length > 0 && entry.report.blocked === undefined) ?? false) + ? 'partial' + : undefined, + updatedAt: timeText(usage.fetchedAt ?? 0), + } +} + +// --------------------------------------------------------------------------- +// Auto refresh (one shared loop, started by whichever surface mounts first) +// --------------------------------------------------------------------------- + +/** + * How often the panel re-reads the report while a surface is mounted. The + * quota windows move slowly and one report costs four upstream calls, so this + * is a background freshness tick, not a live meter. + */ +export const PANEL_AUTO_REFRESH_MS = 120_000 + +/** The narrow face of `CommandCodeUsageController` this module drives. */ +export interface RefreshSource { + state(): UsagePageState + refresh(): Promise +} + +/** Timer seam: injectable so tests never wait on real time. */ +export interface AutoRefreshTimer { + set(callback: () => void, ms: number): unknown + clear(handle: unknown): void +} + +/** The default timer (the client bundle runs in a browser; node tests inject one). */ +const REAL_TIMER: AutoRefreshTimer = { + set: (callback, ms) => setTimeout(callback, ms), + clear: (handle) => clearTimeout(handle as ReturnType), +} + +/** Live mount count of {@link startPanelAutoRefresh}. */ +let references = 0 +/** Owner ticket of the running loop, if any. */ +let activeTicket: number | undefined +/** Ticket handed to the next starter. */ +let ticketSeq = 0 +/** Pending timer handle of the running loop. */ +let handle: unknown + +/** + * Start the shared auto refresh. One fetch when the surface appears (the + * sidebar row is the point of the panel — it must be current, not wait for a + * click), then a tick every {@link PANEL_AUTO_REFRESH_MS} while a surface + * stays mounted. + * + * Reference-counted: the sidebar entry and the dashboard can be mounted at + * once, so only the first start fetches and only the last stop halts the loop. + * Every tick goes through `usage.refresh()`, which already collapses a + * concurrent fetch onto the in-flight one — a tick never double-fetches + * against the settings page's own refresh. + * + * @param usage - the shared usage controller. + * @param isConfigured - whether a credential exists right now (re-read per tick). + * @param timer - timer seam for tests. + * @returns the disposer that drops this surface's reference. + */ +export function startPanelAutoRefresh( + usage: RefreshSource, + isConfigured: () => boolean, + timer: AutoRefreshTimer = REAL_TIMER, +): () => void { + references += 1 + const ticket = ++ticketSeq + const tick = (): void => { + if (activeTicket !== ticket) return + if (isConfigured()) void usage.refresh() + handle = timer.set(tick, PANEL_AUTO_REFRESH_MS) + } + + if (activeTicket === undefined) { + activeTicket = ticket + handle = timer.set(tick, PANEL_AUTO_REFRESH_MS) + // First paint: refresh immediately so the row is never blank on arrival. + if (isConfigured()) void usage.refresh() + } + + let stopped = false + return () => { + if (stopped) return + stopped = true + references -= 1 + // Still mounted elsewhere: drop this reference only. Note the check is the + // REFCOUNT, not this surface's ticket — the surface that started the loop + // is not necessarily the last one to unmount, and a first-unmounted owner + // must not be the reason a tick keeps firing. + if (references > 0) return + activeTicket = undefined + if (handle !== undefined) { + timer.clear(handle) + handle = undefined + } + } +} + +/** + * Drop every live reference and halt the loop without a timer. + * + * Exported for tests only: the loop's state is module-level on purpose (two + * surfaces, one upstream poll), so a test that mounts a surface must be able + * to start from a clean slate. Production code releases through the disposer + * {@link startPanelAutoRefresh} returns — the plugin's fiber unwinds it. + */ +export function resetPanelAutoRefresh(): void { + references = 0 + activeTicket = undefined + handle = undefined +} diff --git a/src/client/prices.ts b/src/client/prices.ts new file mode 100644 index 0000000..2e0ac0a --- /dev/null +++ b/src/client/prices.ts @@ -0,0 +1,119 @@ +/** + * Browser controller for the model price table the composer's session-cost + * figure depends on. + * + * The rates are vendored Host-side (`src/model-prices.ts`) and cross the + * `commandcode/prices` Remote, so the browser never carries its own copy that + * could drift from the snapshot, and a price update reaches an open page + * without rebuilding the client bundle. + * + * The table is static for the life of a page, so this controller is a one-shot + * cache rather than a poll: `ensure()` fetches at most once and is a no-op once + * it has succeeded, and a failure stays retryable because the surface that needs + * it can mount before the Remote namespace is live (the composer is often + * already on screen when the plugin's client half applies). Nothing here + * refreshes on a timer — unlike the account-usage card, whose endpoint really + * does move. + * + * Deliberately JSX-free, mirroring `./usage.ts`. + * + * @module dsh-commandcode-provider/client/prices + */ + +import type { CommandCodePriceTable } from '../usage-wire.ts' + +/** The narrow slice of the mounted Remote this controller calls. */ +export interface PricesRemote { + prices(): Promise< + | { ok: true; value: CommandCodePriceTable } + | { ok: false; error: { message: string } } + > +} + +/** The price table's fetch lifecycle. */ +export type PricesStatus = + /** Never requested. */ + | 'idle' + /** A fetch is in flight. */ + | 'loading' + /** The table is loaded and cached for the page's lifetime. */ + | 'ready' + /** The last fetch failed; `ensure()` may try again. */ + | 'error' + +/** The readout's price-table state face. */ +export interface SessionCostPricesState { + status: PricesStatus + /** The cached table (only ever set once). */ + table: CommandCodePriceTable | undefined + /** The last failure's message. */ + error: string | undefined +} + +const IDLE: SessionCostPricesState = { status: 'idle', table: undefined, error: undefined } + +/** + * One-shot cache over the `commandcode/prices` Remote. Public API mirrors + * {@link CommandCodeUsageController}: `state()`, `subscribe`, and `ensure()`. + */ +export class CommandCodePricesController { + private readonly remote: PricesRemote + private readonly listeners = new Set<() => void>() + private current: SessionCostPricesState = IDLE + private inFlight = false + private disposed = false + + constructor(remote: PricesRemote) { + this.remote = remote + } + + /** Release every subscription. Idempotent; in-flight results are dropped. */ + dispose(): void { + this.disposed = true + this.listeners.clear() + } + + /** Subscribe to state projections. @returns the disposer. */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + /** The current state face. */ + state(): SessionCostPricesState { + return this.current + } + + /** + * Fetch the table unless it is already loaded or in flight. Safe to call from + * every mount point: the Remote namespace landing and the composer mounting + * are both triggers, in either order, and only one request is ever issued. + */ + ensure(): void { + if (this.disposed || this.inFlight || this.current.status === 'ready') return + this.inFlight = true + this.publish({ status: 'loading', table: this.current.table, error: undefined }) + void this.remote.prices().then((result) => { + if (this.disposed) return + this.inFlight = false + if (result.ok) { + this.publish({ status: 'ready', table: result.value, error: undefined }) + return + } + this.publish({ status: 'error', table: undefined, error: result.error.message }) + }, (error: unknown) => { + if (this.disposed) return + this.inFlight = false + this.publish({ + status: 'error', + table: undefined, + error: error instanceof Error ? error.message : String(error), + }) + }) + } + + private publish(next: SessionCostPricesState): void { + this.current = next + for (const listener of this.listeners) listener() + } +} diff --git a/src/client/section.tsx b/src/client/section.tsx index ebbf26e..305811d 100644 --- a/src/client/section.tsx +++ b/src/client/section.tsx @@ -560,7 +560,7 @@ function AccountTabDot({ entry }: { entry: CommandCodeAccountUsage }) { } /** - * The account-usage card: the `/commandcode` dashboard's facts rendered as + * The account-usage card: the account's usage and credit facts rendered as * a native settings card. With several accounts the card is a carousel — a * tab strip (label + status dot) switches between accounts so the page stays * short; each account's report carries its own remove affordance (the diff --git a/src/client/session-cost-display.ts b/src/client/session-cost-display.ts new file mode 100644 index 0000000..7d07e53 --- /dev/null +++ b/src/client/session-cost-display.ts @@ -0,0 +1,491 @@ +/** + * The DOM half of the composer's session-cost figure (browser only). + * + * The cost is not a surface of its own any more: it is injected INTO the two + * surfaces the harness already draws under the composer — the token-usage pill + * (`1.2M tokens · Cache hit 87%`) and the usage dialog that pill opens. This + * module owns that injection; `./session-cost.ts` owns every number and every + * string, and `./session-cost-view.tsx` owns the two seats the figures come + * from. + * + * Why inject rather than take the pill's cell over (registering a `stats` entry + * REPLACES the shipped readout, it does not extend it): the shipped row is one + * component owning its tokens / cache-hit / duration figures through the `chat` + * locale namespace, and its cache-hit percentage obeys an exact-integer rule + * this plugin must not re-derive. Owning that cell would mean reproducing all of + * it in English, and every upstream change to it would stop reaching users. So + * the shipped markup stays the harness's, and this module adds exactly two + * things to it. + * + * Four properties make that safe, all verified against the shipped build: + * + * 1. **The dialog's rows are styled by ELEMENT.** `dsh-client-ui-chat`'s + * `stat-dialog` module styles its `dl` as a two-column grid and then + * `.details dt` / `.details dd` — not by class. A price node appended INSIDE a + * shipped value cell therefore inherits that cell's tabular numerals and right + * alignment for free, and this plugin ships no CSS at all. + * 2. **The dialog's rows are matched by POSITION and confirmed by value.** Their + * labels are the `chat` locale's own strings (`Uncached input` in English, + * `未缓存输入` in Chinese), so no label is ever read. Instead the shape is + * predicted from the session's buckets — the cache-hit row exists while there + * is billed prompt input, the cache-write row while those tokens are non-zero + * — and each value cell must then carry exactly the token count predicted for + * it. A dialog that does not confirm is left alone. + * 3. **The pill keeps the cost visible when space runs out.** The button is an + * inline flex row with `gap`, and its label carries `min-width:0` plus + * ellipsis; our appended span keeps the default `min-width:auto`, so the + * LABEL is what truncates. The cost also inherits the button's `font:inherit` + * and `tabular-nums` typography and its hover/expanded tint. + * 4. **React owns those value cells' text, so a price is re-attached, never + * assumed.** The shipped component renders each count as a single string, and + * React rewrites such a cell through `textContent` when the count changes — + * which drops every child with it, our price included. Every sync therefore + * re-checks that the node is still where it was put; the cost of that is a + * read, and the alternative is a price that silently disappears mid-session. + * + * A `MutationObserver` on `document.body` with `childList` (deliberately NOT + * `subtree`, so streaming text never wakes it) is the only way to learn that the + * dialog — which is portaled straight onto `body`, with no slot of ours inside + * it — has opened or closed. That observer is also what decorates a newly opened + * dialog from the last view this module was handed. + * + * @module dsh-commandcode-provider/client/session-cost-display + */ + +import type { SessionCostPillRun, SessionCostRowDecoration, SessionCostShippedRow, SessionCostView } from './session-cost.ts' +import { + SESSION_COST_COPY, + sessionCostPillRun, + sessionCostRowDecorations, +} from './session-cost.ts' + +/** The shipped composer stats row (dsh-client-ui-chat `StatsPills`). */ +const STATS_ROOT = '[data-composer-stats]' + +/** + * The shipped token-usage dialog's `
`. Unique in the whole of + * `dsh-client-ui-chat`: the per-message turn-usage panel has its own dialog with + * different markup, so the composer's is unambiguous. + */ +const USAGE_DIALOG = '[data-session-stats-usage]' + +/** + * The usage dialog's trigger. Both pills announce a dialog, but the token one is + * rendered LAST — and when no step carries timing the time pill is not a button + * at all, so "last" still selects the token pill in both cases. + */ +const DIALOG_TRIGGER = 'button[aria-haspopup="dialog"]' + +/** + * Stable id for the hidden node the appended cost is described by. The pill's + * own `aria-label` is computed by the harness on every render and cannot be + * extended, so a description is how the cost reaches assistive technology. + */ +const A11Y_ID = 'dsh-commandcode-session-cost' + +/** How many consecutive misses before the missing anchor is worth a console line. */ +const MISSES_BEFORE_WARNING = 3 + +/** + * How the display learns that the shipped usage dialog opened or closed. + * + * `target` is `document.body`, and the returned function detaches. The default + * is a `MutationObserver`; the seam exists so tests can announce the dialog + * without a global observer (the repository's usual injected-seam shape). + */ +export type SessionCostObserverFactory = (target: Node, listener: () => void) => (() => void) | undefined + +/** + * The default observer: `childList` on `body`, and deliberately NOT `subtree`, so + * streaming text inside the page never wakes it while the panel the harness + * portals straight onto `body` does. + */ +const observeBodyChildren: SessionCostObserverFactory = (target, listener) => { + if (typeof MutationObserver === 'undefined') return undefined + const observer = new MutationObserver(listener) + observer.observe(target, { childList: true }) + return () => observer.disconnect() +} + +/** What the display layer reads: the injected document and where to look in it. */ +export interface SessionCostDisplayOptions { + /** The document to inject into (injected so node tests can drive a double). */ + doc: Document + /** + * The composer that owns this entry — our own outlet wrapper's parent, so a + * session-scoped composer looks in its OWN card rather than at whichever + * `[data-composer-stats]` happens to come first in the document. + */ + scope: () => ParentNode | null + /** Observer seam; defaults to a `childList` `MutationObserver` on `body`. */ + observe?: SessionCostObserverFactory +} + +/** + * Owns the nodes injected into the shipped token-usage UI. + * + * One instance per mounted entry. `sync()` is cheap and idempotent: it re-reads + * both targets on every call, which is what lets it self-heal when React + * remounts the row or replaces the dialog, and it rewrites a node only when its + * text actually changed. + */ +export class SessionCostDisplay { + private readonly doc: Document + private readonly scope: () => ParentNode | null + private readonly observe: SessionCostObserverFactory + private view: SessionCostView | undefined = undefined + /** Detaches the observer installed by `start()`. */ + private detach: (() => void) | undefined = undefined + /** The button we appended into, while it is still connected. */ + private pillHost: Element | undefined = undefined + /** Our appended root, and the two nodes whose text changes. */ + private pillRoot: HTMLElement | undefined = undefined + private pillValue: HTMLElement | undefined = undefined + private pillA11y: HTMLElement | undefined = undefined + /** Whether WE set `aria-describedby` on the button (so only we take it back). */ + private described = false + /** The dialog we decorated, the price nodes we own, and the cells we hid. */ + private dialogHost: Element | undefined = undefined + private dialogPrices = new Map() + private dialogHidden = new Set() + private pillMisses = 0 + + constructor(options: SessionCostDisplayOptions) { + this.doc = options.doc + this.scope = options.scope + this.observe = options.observe ?? observeBodyChildren + } + + /** Begin watching for the shipped usage dialog opening and closing. */ + start(): void { + if (this.detach !== undefined) return + const body = this.doc.body + if (body === null || body === undefined) return + this.detach = this.observe(body, () => this.apply()) + } + + /** + * Hand the display the current figure. + * + * Undefined — nothing priceable about this session — removes everything this + * module injected, leaving both shipped surfaces exactly as they ship. + */ + sync(view: SessionCostView | undefined): void { + this.view = view + this.apply() + } + + /** Remove every injected node and stop observing. Safe to call twice. */ + dispose(): void { + this.detach?.() + this.detach = undefined + this.removePill() + this.clearDialog() + this.view = undefined + } + + /** Re-apply the last known view to whatever both targets are right now. */ + private apply(): void { + this.applyPill() + this.applyDialog() + } + + // ------------------------------------------------------------------------- + // The pill: the cost as the last item of the shipped token pill's text run + // ------------------------------------------------------------------------- + + private applyPill(): void { + const view = this.view + if (view === undefined) { + this.removePill() + return + } + if (this.pillHost?.isConnected !== true) { + const button = this.resolvePillButton() + if (button === null) { + this.pillMisses += 1 + if (this.pillMisses === MISSES_BEFORE_WARNING) { + console.warn( + `[dsh-commandcode-provider] no ${STATS_ROOT} row to append the session cost to; the figure stays in the usage dialog only`, + ) + } + return + } + this.pillMisses = 0 + this.removePill() + this.buildPill(button, sessionCostPillRun(view)) + } + if (this.pillValue !== undefined) { + const amount = sessionCostPillRun(view).value + if (this.pillValue.textContent !== amount) this.pillValue.textContent = amount + } + if (this.pillRoot !== undefined) { + if (this.pillRoot.title !== view.title) this.pillRoot.title = view.title + const approximate = view.approximate ? 'true' : null + if (this.pillRoot.getAttribute('data-approximate') !== approximate) { + if (approximate === null) this.pillRoot.removeAttribute('data-approximate') + else this.pillRoot.setAttribute('data-approximate', approximate) + } + } + if (this.pillA11y !== undefined) { + const described = `${SESSION_COST_COPY.panelTitle} ${view.value}` + if (this.pillA11y.textContent !== described) this.pillA11y.textContent = described + } + } + + /** The shipped token pill, scoped to this entry's own composer. */ + private resolvePillButton(): Element | null { + const scope = this.scope() ?? this.doc + const root = scope.querySelector(STATS_ROOT) + if (root === null) return null + const triggers = root.querySelectorAll(DIALOG_TRIGGER) + return triggers.length === 0 ? null : (triggers[triggers.length - 1] ?? null) + } + + /** + * Create and append the cost run. The button's child list is static + * (`[svg, label]`), so appending once is enough for it to stay last. + */ + private buildPill(button: Element, run: SessionCostPillRun): void { + const doc = this.doc + const root = doc.createElement('span') + root.setAttribute('data-composer-session-cost', '') + // The separator is its own node so it can carry the shipped pill's separator + // colour. Its LEFT spacing comes from the button's flex gap; the right margin + // reproduces the shipped separator's `margin:0 6px` rhythm, so the run reads + // `tokens · Cache hit 87% · $0.0123` at one consistent rhythm. + const separator = doc.createElement('span') + separator.textContent = run.separator + separator.setAttribute('aria-hidden', 'true') + separator.style.color = 'var(--dsw-alias-separator-primary)' + separator.style.margin = '0 6px 0 0' + const value = doc.createElement('span') + value.textContent = run.value + value.style.fontWeight = '500' + // Referenced by the button's `aria-describedby`: a hidden node named by id is + // still read out, which is how the cost survives the aria-label override. + const a11y = doc.createElement('span') + a11y.id = A11Y_ID + a11y.style.display = 'none' + root.appendChild(separator) + root.appendChild(value) + root.appendChild(a11y) + button.appendChild(root) + // Only ever claim the attribute when the button has none of its own: a + // shipped `aria-describedby` is the harness's, and overwriting it would + // delete a description we cannot restore. + if (!button.hasAttribute('aria-describedby')) { + button.setAttribute('aria-describedby', A11Y_ID) + this.described = true + } + this.pillHost = button + this.pillRoot = root + this.pillValue = value + this.pillA11y = a11y + } + + private removePill(): void { + if (this.pillRoot !== undefined && this.pillRoot.parentNode !== null) { + this.pillRoot.parentNode.removeChild(this.pillRoot) + } + if (this.described) { + this.pillHost?.removeAttribute('aria-describedby') + this.described = false + } + this.pillHost = undefined + this.pillRoot = undefined + this.pillValue = undefined + this.pillA11y = undefined + } + + // ------------------------------------------------------------------------- + // The dialog: a price on the right of each token row the harness already draws + // ------------------------------------------------------------------------- + + private applyDialog(): void { + const view = this.view + const host = view === undefined ? null : this.resolveDialog() + if (host === null || view === undefined) { + this.clearDialog() + return + } + this.pruneDialog(host) + const plan = sessionCostRowDecorations(view) + const pairs = dialogPairs(host) + if (!dialogShapeMatches(pairs, plan, this.dialogPrices)) { + // The dialog is not the shape this session's buckets predict — a different + // build, or a projection carrying values the counts cannot explain. Leave + // it exactly as the harness drew it rather than pricing the wrong row. + this.clearDialog() + return + } + const hidden = new Set() + for (const [index, row] of plan.entries()) { + const pair = pairs[index] + if (pair === undefined) continue + const span = this.dialogPrices.get(pair.dd) + if (row.hidden) { + // The row is dropped: both cells, or the grid leaves an empty label + // behind. An inline `display` survives the harness's own re-renders + // (React pins no style on these nodes) and is given back on disposal. + if (pair.dt.style.display !== 'none') pair.dt.style.display = 'none' + if (pair.dd.style.display !== 'none') pair.dd.style.display = 'none' + hidden.add(pair.dt) + hidden.add(pair.dd) + this.removeDialogPrice(pair.dd, span) + continue + } + if (pair.dt.style.display === 'none') pair.dt.style.display = '' + if (pair.dd.style.display === 'none') pair.dd.style.display = '' + if (row.amount === undefined) { + this.removeDialogPrice(pair.dd, span) + continue + } + const price = span ?? this.createDialogPrice(pair.dd, row.row) + // React owns this cell's text and rewrites it through `textContent` when + // the count changes, which drops every child with it — so the price is + // re-attached on a miss rather than assumed to still be there. + if (price.parentNode !== pair.dd) pair.dd.appendChild(price) + if (price.textContent !== row.amount) price.textContent = row.amount + } + // Cells hidden by an earlier shape (a cache-write row React then rewrote) go + // back to the harness. + for (const cell of [...this.dialogHidden]) { + if (hidden.has(cell)) continue + if (cell.style.display === 'none') cell.style.display = '' + this.dialogHidden.delete(cell) + } + for (const cell of hidden) this.dialogHidden.add(cell) + this.dialogHost = host + } + + /** Forget the cells and prices a re-rendered dialog took with it. */ + private pruneDialog(host: Element): void { + for (const [dd, price] of [...this.dialogPrices]) { + if (dd.parentNode === host) continue + if (price.parentNode !== null) price.parentNode.removeChild(price) + this.dialogPrices.delete(dd) + } + for (const cell of [...this.dialogHidden]) { + if (cell.parentNode === host) continue + this.dialogHidden.delete(cell) + } + } + + /** The price node for one shipped value cell, appended as its last child. */ + private createDialogPrice(dd: HTMLElement, row: SessionCostShippedRow): HTMLElement { + const price = this.doc.createElement('span') + price.setAttribute('data-session-cost-price', row) + // The cell is right-aligned over tabular numerals, so a fixed-width + // inline-block lines the prices up as a column of their own to the right of + // the counts instead of trailing each count raggedly. + price.style.marginLeft = '6px' + price.style.display = 'inline-block' + price.style.minWidth = '56px' + price.style.textAlign = 'right' + price.style.fontWeight = '500' + dd.appendChild(price) + this.dialogPrices.set(dd, price) + return price + } + + private removeDialogPrice(dd: HTMLElement, price: HTMLElement | undefined): void { + if (price === undefined) return + if (price.parentNode !== null) price.parentNode.removeChild(price) + this.dialogPrices.delete(dd) + } + + /** + * The shipped usage dialog, or null while it is closed. + * + * The dialog is portaled onto `body`, so unlike the pill it cannot be scoped + * from this entry; DSH renders one composer, and the attribute is unique in + * the chat client, so a document-level lookup is exact. Should a future build + * mount two composers at once, both dialogs would describe whichever session + * this entry belongs to — noted rather than defended against. + */ + private resolveDialog(): Element | null { + if (this.dialogHost?.isConnected === true) return this.dialogHost + // The dialog we injected into is gone: our nodes went with it, so only the + // bookkeeping is left to drop. + this.clearDialog() + return this.doc.querySelector(USAGE_DIALOG) + } + + /** Give the dialog back: every price removed, every hidden row restored. */ + private clearDialog(): void { + for (const price of this.dialogPrices.values()) { + if (price.parentNode !== null) price.parentNode.removeChild(price) + } + this.dialogPrices.clear() + for (const cell of this.dialogHidden) { + if (cell.style.display === 'none') cell.style.display = '' + } + this.dialogHidden.clear() + this.dialogHost = undefined + } +} + +/** The shipped dialog's `dt`/`dd` pairs, in document order. */ +function dialogPairs(host: Element): Array<{ dt: HTMLElement; dd: HTMLElement }> { + const pairs: Array<{ dt: HTMLElement; dd: HTMLElement }> = [] + let label: HTMLElement | null = null + for (const node of Array.from(host.childNodes)) { + const tag = tagNameOf(node) + if (tag === 'DT') label = node as HTMLElement + else if (tag === 'DD' && label !== null) { + pairs.push({ dt: label, dd: node as HTMLElement }) + label = null + } + } + return pairs +} + +/** `tagName` upper-cased, or an empty string for a non-element node. */ +function tagNameOf(node: Node): string { + const tag = (node as Element).tagName + return typeof tag === 'string' ? tag.toUpperCase() : '' +} + +/** + * Whether the dialog really holds the rows this session's buckets predict. + * + * The shipped labels belong to the `chat` locale — they are `Uncached input` in + * English and something else entirely in Chinese — so rows are matched by + * POSITION, and this is what makes that safe: the row count must agree, the + * cache-hit row must be the percentage it is, and every other value must carry + * exactly the token count of the bucket predicted for it. A mismatch means the + * dialog is not what this view describes, and nothing is decorated. + */ +function dialogShapeMatches( + pairs: ReadonlyArray<{ dt: HTMLElement; dd: HTMLElement }>, + plan: readonly SessionCostRowDecoration[], + prices: ReadonlyMap, +): boolean { + if (pairs.length !== plan.length) return false + return plan.every((row, index) => { + const pair = pairs[index] + if (pair === undefined) return false + const text = hostValueText(pair.dd, prices.get(pair.dd)) + if (row.tokens === undefined) return text.includes('%') + return digitsOf(text) === String(row.tokens) + }) +} + +/** + * A value cell's own text, without the price this module appended to it — the + * digits of `3,206,544 tok` are the host's, the digits of `$0.07` are ours. + */ +function hostValueText(dd: HTMLElement, price: HTMLElement | undefined): string { + let text = '' + for (const node of Array.from(dd.childNodes)) { + if (node === price) continue + text += node.textContent ?? '' + } + return text +} + +/** Every digit of a display string, so locale grouping cannot break the match. */ +function digitsOf(text: string): string { + return text.replace(/\D/g, '') +} diff --git a/src/client/session-cost-slots.ts b/src/client/session-cost-slots.ts new file mode 100644 index 0000000..4d1791f --- /dev/null +++ b/src/client/session-cost-slots.ts @@ -0,0 +1,52 @@ +/** + * Slot contract the session-cost readout registers into. + * + * `conversation.composer.dock` belongs to `@deepseek-ai/dsh-client-ui-conversation` + * (which declares it and renders it in the composer card) and is occupied by + * `@deepseek-ai/dsh-client-ui-chat` (whose `stats` cell is the readout showing + * the session's tokens, cache-hit share and throughput). Neither package is a + * dependency of this bundle — the readout only needs the slot's *shape* at + * compile time, and neither ships a client module the browser would have to + * resolve — so, exactly as `panel-slots.ts` does for the sidebar seats, the + * declaration is re-stated here and merged into the framework's `SlotMap`. + * + * The re-statement must stay structurally identical to upstream's + * (`{ kind: 'list', scope: 'session' }`). That is the point of the merge: the + * registration site typechecks against the real contract, so an upstream change + * that invalidates this readout is a compile error here rather than a silent + * mis-registration at runtime. A future dsh that ships this declaration to the + * client through some other path would collide at compile time — the intended + * alarm, and why this file exists instead of a local cast. + * + * Note what is deliberately NOT restated: the dock's standard props + * (`useProjection`, `sessionId`, `useChat`, …). The owner supplies those to + * every occupant at runtime; they are not part of the slot's registration + * contract, so the component declares the two it reads itself (see + * `session-cost-view.tsx`). + * + * @module dsh-commandcode-provider/client/session-cost-slots + */ + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** + * Ambient entries below the composer card. Registering a separate entry is + * what keeps the shipped `stats` cell intact — that occupant is one + * component owning the whole row, so reusing its id would REPLACE the + * tokens / cache-hit / throughput readout — and the readout's stylesheet + * then lifts this entry out of the dock's flow, so it lands at the right + * end of that row's line instead of on a line of its own. + */ + 'conversation.composer.dock': { kind: 'list'; scope: 'session' } + } +} + +/** + * Makes this file a MODULE, which is load-bearing rather than cosmetic. In a + * script (non-module) file, `declare module '…'` is not an augmentation: it is + * a fresh ambient module declaration that SHADOWS the real package, so + * `@deepseek-ai/dsh-client-ui-slots` would suddenly export `SlotMap` and + * nothing else — every consumer of its other exports (`Translate`, the slot + * hooks) fails to compile. An empty export keeps the merge a merge. + */ +export {} diff --git a/src/client/session-cost-view.tsx b/src/client/session-cost-view.tsx new file mode 100644 index 0000000..8e09ebf --- /dev/null +++ b/src/client/session-cost-view.tsx @@ -0,0 +1,154 @@ +/** + * The composer's live session-cost figure (browser half). + * + * This component renders NO surface of its own. The cost lives inside the + * harness's own token-usage UI: the amount becomes the last item of the shipped + * pill's text run (`1.2M tokens · Cache hit 87% · $0.0123`) and the per-bucket + * breakdown becomes extra rows in the usage dialog that pill opens. + * `./session-cost-display.ts` owns that injection and `./session-cost.ts` owns + * every number and every string; this file owns nothing but the two seats the + * figures come from and the lifetime of the injection. + * + * It is still registered as an entry in `conversation.composer.dock`, because + * that registration is what delivers the seats: `useProjection` is a standard + * prop the composer hands every dock occupant, so there is no other way to read + * the session's token accounting. Registering under the shipped `stats` cell's + * id instead would REPLACE the harness's readout rather than extend it; + * `./session-cost-display.ts` records why that trade is refused. + * + * The rendered node is a hidden marker — `display:none`, so it can add neither a + * box nor a flex gap to the composer card. It exists to locate THIS composer + * from the entry (`closest()` on the dock's outlet anchor), so a session-scoped + * composer reads its own card rather than whichever row comes first in the + * document. It is rendered only while there is something to show, so a session + * with nothing priceable contributes no markup at all. + * + * Two seats are read, from two different owners: `useProjection` comes from the + * composer (a standard prop of the dock, not part of this registration), and + * `useCommandCodePrices` comes from our own registration's `inject` face, which + * `bindInjectSources` re-exposes from the `hooks` compartment. Reading + * `props.hooks.*` instead finds `undefined` at runtime and crashes the render, + * which the slot renderer contains by ABDICATING the entry — a surface that + * vanishes with no visible error. + * + * Both effects are `useEffect`, never `useLayoutEffect`: there is nothing on + * screen to align with (the injection is a text run and a hidden marker), and a + * layout effect would log a warning from the server render the tests use. + * + * @module dsh-commandcode-provider/client/session-cost-view + */ + +import { useEffect, useRef } from 'react' +import type { SnapshotStore } from './snapshot-store.ts' +import type { SessionCostPricesState } from './prices.ts' +import { SessionCostDisplay } from './session-cost-display.ts' +import { + buildSessionCostView, + type SessionModelSelectionProjection, + type SessionUsageBuckets, +} from './session-cost.ts' + +/** The dock outlet this entry renders inside, i.e. what it can scope itself from. */ +const DOCK_ANCHOR = '[data-slot="conversation.composer.dock"]' + +/** Module-level constant so the marker's `style` prop never diffs. */ +const HIDDEN_STYLE = { display: 'none' } as const + +/** + * The injected face this registration carries. Bound by the client entry so the + * component stays unaware of the Remote, the controller, and its caching. + * + * NOTE the split from {@link SessionCostComponentProps}: the renderer + * destructures this face's `hooks` compartment and re-exposes each member as a + * `use` prop (`commandCodePrices` → `useCommandCodePrices`); it does not + * hand the component this object verbatim. + */ +export interface SessionCostInjected { + hooks: { + commandCodePrices: SnapshotStore + } +} + +/** + * The projection keys this readout reads, declared structurally because the + * plugin bundle does not depend on the session-controller package (whose + * `UseProjection` this mirrors). + */ +interface ProjectionSeats { + tokenUsage: SessionUsageBuckets + modelSelection: SessionModelSelectionProjection +} + +/** The projection reader the composer supplies to every dock occupant. */ +export interface UseProjectionLike { + (key: K): ProjectionSeats[K] | undefined + ( + key: K, + selector: (value: ProjectionSeats[K] | undefined) => S, + eq?: (a: S, b: S) => boolean, + ): S +} + +/** The props this component actually receives. */ +export interface SessionCostComponentProps { + useCommandCodePrices(selector: (state: SessionCostPricesState) => T): T + useProjection: UseProjectionLike +} + +/** Log a missing seat once per page, so a silent no-op stays diagnosable. */ +let warnedMissingSeat = false + +/** + * The composer's session-cost entry. Guards the two seats before rendering the + * mount: a dsh that does not supply the projection seat to dock occupants gets + * an absent cost and one console line, never a render crash — which the renderer + * would answer by abdicating the entry with no visible trace. + */ +export function CommandCodeSessionCost(props: SessionCostComponentProps) { + if (typeof props.useProjection !== 'function' || typeof props.useCommandCodePrices !== 'function') { + if (!warnedMissingSeat) { + warnedMissingSeat = true + console.error( + '[dsh-commandcode-provider] the composer does not supply the projection/hook seats the session-cost readout needs; the readout stays hidden', + ) + } + return null + } + return +} + +/** The mount: one injection lifetime, one hidden marker. Every hook lives here. */ +function SessionCostEntry(props: SessionCostComponentProps) { + const markerRef = useRef(null) + const displayRef = useRef(null) + const prices = props.useCommandCodePrices((state) => state) + const usage = props.useProjection('tokenUsage') + const selection = props.useProjection('modelSelection') + const view = buildSessionCostView({ usage, selection, table: prices.table, now: Date.now() }) + + useEffect(() => { + // No DOM outside a browser: the server render used by the tests, and a + // node-hosted assembly, must both stay no-ops. + if (typeof document === 'undefined') return + const display = new SessionCostDisplay({ + doc: document, + scope: () => markerRef.current?.closest(DOCK_ANCHOR)?.parentElement ?? null, + }) + displayRef.current = display + display.start() + return () => { + displayRef.current = null + display.dispose() + } + }, []) + + // Every render: the projection is a fresh fold, so there is nothing cheaper to + // compare than the plan the display derives from it — and the display rewrites + // a node only when that node's own text actually moved. + useEffect(() => { + displayRef.current?.sync(view) + }) + + if (view === undefined) return null + return +} diff --git a/src/client/session-cost.ts b/src/client/session-cost.ts new file mode 100644 index 0000000..ae53ae2 --- /dev/null +++ b/src/client/session-cost.ts @@ -0,0 +1,457 @@ +/** + * Session-cost view model for the composer readout. + * + * Deliberately JSX-free and React-free, mirroring `./panel.ts`: it turns the + * session's token accounting plus the Host's price table into one presentation + * value AND the exact text the two surfaces of the harness's token-usage UI + * receive — the amount appended to the shipped pill, and the rows appended to + * the shipped usage dialog. Node tests therefore drive the whole calculation, + * and every user-visible string, without a DOM. + * + * The figures it prices are the session's DURABLE cumulative buckets, which is + * what the composer's own "N tokens" pill reads too (`tokenUsage`), so the two + * surfaces can never disagree about how much was used. Dollars are computed + * here because Command Code publishes per-token rates and bills against + * dollar-denominated windows; the account's own reported `totalCost` is a + * billing PERIOD figure, not this session's. + * + * Three rules are load-bearing: + * + * 1. **Only Command Code usage is priced.** A session served by another + * provider must render nothing, never a Command Code estimate. + * 2. **A missing rate is never invented.** The pricing page publishes + * input/output/cache-read rates for every model but a cache-WRITE rate for + * only some, so unpriced cache-write tokens are surfaced as such rather than + * charged at a guessed multiple of the input rate. + * 3. **Unpriceable means invisible.** No usage, no model, no table, an unknown + * model, or all-zero buckets renders nothing at all — a confident `$0.00` + * would be a lie, and this module never returns one. + * + * @module dsh-commandcode-provider/client/session-cost + */ + +import type { CommandCodeModelPrice, CommandCodeModelRates, CommandCodePriceTable } from '../usage-wire.ts' +import { formatMoney, formatMoneyExact, formatTokensCompact } from './usage.ts' + +/** Tokens per published rate unit — the pricing page quotes USD per million. */ +const TOKENS_PER_RATE_UNIT = 1_000_000 + +/** The provider route whose usage this surface prices. */ +const COMMANDCODE_PROVIDER = 'commandcode' + +/** + * The English copy for this surface. + * + * A plain constant, NOT the `settings.commandcode` locale namespace: like the + * plans & quota panel, the readout stays English on a Chinese harness. Do not + * route it through `ctx.locale`. + */ +export const SESSION_COST_COPY = { + /** Shown instead of an amount when the model costs nothing on every plan. */ + free: 'Free', + /** + * The separator the readout prefixes itself with, so the cost reads as the + * last item of the token-usage pill's text run rather than a control beside + * it. Rendered with the same colour and margins the shipped pill uses + * between its own items. + */ + separator: '·', + /** The heading the tooltip leads with. */ + panelTitle: 'Session cost', + /** Marks a total computed from a single model on a session that used more. */ + approximate: '≈', + /** Tooltip line for the unpriced cache-write tokens. */ + unpricedCacheWrite: 'cache write tokens have no published rate', + /** Tooltip line naming the rate half in force. */ + peakRates: 'peak rates', + /** Tooltip line naming the rate half in force. */ + offPeakRates: 'off-peak rates', + /** Tooltip line explaining the approximate marker. */ + approximateNote: 'this session has used more than one model', + /** Row/tooltip label for uncached prompt tokens. */ + uncachedInput: 'uncached input', + /** Row/tooltip label for completion tokens. */ + output: 'output', + /** Row/tooltip label for cache-served input tokens. */ + cacheRead: 'cache read', + /** Row/tooltip label for cache-written input tokens. */ + cacheWrite: 'cache write', +} as const + +/** + * The session's cumulative token buckets, as the `tokenUsage` projection + * carries them. Declared structurally and defensively: this bundle does not + * depend on the session-controller package, and a bucket the provider never + * reported is absent rather than zero. + */ +export interface SessionUsageBuckets { + readonly uncachedInputTokens?: number + readonly outputTokens?: number + readonly cacheReadTokens?: number + readonly cacheWriteTokens?: number +} + +/** One entry of the `modelSelection` projection. */ +export interface SessionModelSelection { + readonly provider: string + readonly model: string +} + +/** + * The `modelSelection` projection: the selection the latest request consumed, + * and the one the next request will use (falling back to the former). + */ +export interface SessionModelSelectionProjection { + readonly lastUsed: SessionModelSelection | null + readonly next: SessionModelSelection | null +} + +/** Everything the view needs, already read off the seats by the component. */ +export interface SessionCostInput { + /** The durable cumulative token buckets, or undefined before any request. */ + usage: SessionUsageBuckets | undefined + /** The session's model selection fold, or undefined on an older Host. */ + selection: SessionModelSelectionProjection | undefined + /** The Host's price table, or undefined until it lands. */ + table: CommandCodePriceTable | undefined + /** Current wall-clock millis, injected so the peak-hour rule is testable. */ + now: number +} + +/** One bucket's line in the usage dialog. */ +export interface SessionCostBucketRow { + /** Stable key. */ + key: 'uncachedInput' | 'cacheRead' | 'cacheWrite' | 'output' + /** Row label. */ + label: string + /** Tokens charged in this bucket; a bucket that charged nothing withholds its + * row rather than printing a meaningless zero. */ + tokens: number + /** What this bucket cost, or undefined when the table has no rate for it. */ + costText: string | undefined +} + +/** The composer readout's presentation value. */ +export interface SessionCostView { + /** Priced total in dollars. */ + total: number + /** Visible amount text, e.g. `$0.0123` (or the free word, when free). */ + value: string + /** Full tooltip/accessible text, including the bucket breakdown. */ + title: string + /** Per-bucket lines for the usage dialog, in reading order. */ + rows: SessionCostBucketRow[] + /** Caveat lines for the pill's tooltip (rate half, approximation, unpriced). */ + notes: string[] + /** The model costs nothing on every plan right now. */ + free: boolean + /** The model's `peak` rates are in force. */ + peak: boolean + /** Cache-write tokens the table has no rate for (never guessed, so the + * total is a floor rather than an estimate). */ + unpricedCacheWriteTokens: number + /** The session has used more than one model, so a one-model total is + * approximate. */ + approximate: boolean +} + +/** A finite, non-negative count — anything else reads as absent. */ +function count(value: number | undefined): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 +} + +/** + * Whether `now` falls inside a peak-pricing window, per the windows that travel + * with the price table. Monday–Friday (UTC) only, and each window is + * end-exclusive — the same rule the Host snapshot applies when it labels the + * model picker. The schedule is read from the wire rather than restated so + * there is one definition of the windows, on the Host. + */ +export function isPeakHour(now: number, peakHours: ReadonlyArray): boolean { + const at = new Date(now) + const day = at.getUTCDay() + if (day === 0 || day === 6) return false + const hour = at.getUTCHours() + return peakHours.some(([start, end]) => hour >= start && hour < end) +} + +/** + * The rates in force for one model at `now`, and whether they are the peak + * override. A row with no `peak` block is flat-priced — its top-level rates are + * the off-peak rates, which is why the snapshot stores only the override. + */ +function ratesAt( + price: CommandCodeModelPrice, + now: number, + peakHours: ReadonlyArray, +): { rates: CommandCodeModelRates; peak: boolean } { + if (price.peak !== undefined && isPeakHour(now, peakHours)) { + return { rates: price.peak, peak: true } + } + return { rates: price, peak: false } +} + +/** + * Index a price table for lookup. Rows are keyed by catalog id and by pricing + * slug, both exact and lowercased, because a session reports a catalog id while + * a row no catalog model claims is served under the page's slug. + */ +function indexTable(table: CommandCodePriceTable): Map { + const index = new Map() + for (const price of table.models) { + for (const key of [price.id, price.slug]) { + if (typeof key !== 'string' || key === '') continue + if (!index.has(key)) index.set(key, price) + const lower = key.toLowerCase() + if (!index.has(lower)) index.set(lower, price) + } + } + return index +} + +/** + * The model whose rates price this session, plus whether the session has used + * more than one. + * + * `lastUsed` is the selection the latest recorded request consumed, so it is + * what the accumulated tokens were actually billed at; `next` differs only when + * a newer selection is pending, which is precisely the signal that more than one + * model has served this session. + */ +function resolveModel( + selection: SessionModelSelectionProjection | undefined, +): { model: string; approximate: boolean } | undefined { + const lastUsed = selection?.lastUsed ?? null + const next = selection?.next ?? null + const chosen = lastUsed ?? next + if (chosen === null || typeof chosen.model !== 'string' || chosen.model === '') return undefined + if (chosen.provider !== COMMANDCODE_PROVIDER) return undefined + const approximate = lastUsed !== null && next !== null && lastUsed.model !== next.model + return { model: chosen.model, approximate } +} + +/** + * The dollar cost of each bucket, plus the total and the unpriced remainder. + * + * Every bucket is charged at its own published rate; the cache-write bucket + * contributes only when the model publishes a rate for it, otherwise its tokens + * are returned as {@link SessionCostView.unpricedCacheWriteTokens} and its own + * cost stays undefined so the panel can say so instead of printing a zero. + */ +interface SessionCostBreakdown { + uncachedInput: number + cacheRead: number + cacheWrite: number | undefined + output: number + total: number + unpricedCacheWriteTokens: number +} + +function costOf( + usage: SessionUsageBuckets, + rates: CommandCodeModelRates, + free: boolean, +): SessionCostBreakdown { + if (free) { + return { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0, total: 0, unpricedCacheWriteTokens: 0 } + } + const perUnit = (tokens: number, rate: number): number => (tokens * rate) / TOKENS_PER_RATE_UNIT + const uncachedInput = perUnit(count(usage.uncachedInputTokens), rates.inputCost) + const output = perUnit(count(usage.outputTokens), rates.outputCost) + const cacheRead = perUnit(count(usage.cacheReadTokens), rates.cacheReadCost) + const cacheWriteTokens = count(usage.cacheWriteTokens) + const cacheWrite = rates.cacheWriteCost === undefined + ? undefined + : perUnit(cacheWriteTokens, rates.cacheWriteCost) + return { + uncachedInput, + cacheRead, + cacheWrite, + output, + total: uncachedInput + output + cacheRead + (cacheWrite ?? 0), + unpricedCacheWriteTokens: rates.cacheWriteCost === undefined ? cacheWriteTokens : 0, + } +} + +/** + * Amount text for a session cost. + * + * The panel's `money()` convention (2 decimals, 4 below a cent) is right for + * billing windows, but a live session total starts far below a cent, where + * `toFixed(4)` would print a flat `$0.0000` — which reads as broken rather than + * as small. So a total under $0.0001 is stated as a bound instead. + */ +export function sessionCostAmount(total: number): string { + if (!Number.isFinite(total) || total <= 0) return formatMoney(0) + if (total < 0.0001) return `<$0.0001` + return total < 0.01 ? formatMoneyExact(total) : formatMoney(total) +} + +/** One `label value` clause of the tooltip. */ +function clause(label: string, tokens: number): string | undefined { + return tokens > 0 ? `${label} ${formatTokensCompact(tokens)}` : undefined +} + +/** + * Build the composer readout, or undefined when there is nothing honest to show. + * + * Undefined is the correct answer for a session with no usage yet, a session + * another provider served, a model the price table does not know, and a table + * that has not landed — the pill simply is not there. + */ +export function buildSessionCostView(input: SessionCostInput): SessionCostView | undefined { + const { usage, table, now } = input + if (usage === undefined || table === undefined || table.models.length === 0) return undefined + const resolved = resolveModel(input.selection) + if (resolved === undefined) return undefined + + const price = indexTable(table).get(resolved.model) + if (price === undefined) return undefined + + const free = price.free === true + const { rates, peak } = ratesAt(price, now, table.peakHours) + const breakdown = costOf(usage, rates, free) + const { total, unpricedCacheWriteTokens } = breakdown + + const uncachedInput = count(usage.uncachedInputTokens) + const output = count(usage.outputTokens) + const cacheRead = count(usage.cacheReadTokens) + const cacheWrite = count(usage.cacheWriteTokens) + // No billed token of any kind means there is nothing to report yet: a `$0.00` + // pill on a session that has not made a request would be noise, not + // information, and on a free model it would be redundant. + if (uncachedInput === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) return undefined + + const value = free + ? SESSION_COST_COPY.free + : `${resolved.approximate ? SESSION_COST_COPY.approximate : ''}${sessionCostAmount(total)}` + const money = (amount: number | undefined): string | undefined => + amount === undefined ? undefined : sessionCostAmount(amount) + // Reading order mirrors the prompt's own order: what was charged at the input + // rate, then the two cache buckets, then what the model produced. + const rows: SessionCostBucketRow[] = [ + { key: 'uncachedInput', label: SESSION_COST_COPY.uncachedInput, tokens: uncachedInput, costText: money(breakdown.uncachedInput) }, + { key: 'cacheRead', label: SESSION_COST_COPY.cacheRead, tokens: cacheRead, costText: money(breakdown.cacheRead) }, + { key: 'cacheWrite', label: SESSION_COST_COPY.cacheWrite, tokens: cacheWrite, costText: money(breakdown.cacheWrite) }, + { key: 'output', label: SESSION_COST_COPY.output, tokens: output, costText: money(breakdown.output) }, + ] + // Widened to `string | undefined` before filtering: the copy table is `as + // const`, so the array would otherwise infer a union of literal types that a + // `part is string` predicate is not assignable to. + const notes = ([ + free ? undefined : (peak ? SESSION_COST_COPY.peakRates : SESSION_COST_COPY.offPeakRates), + unpricedCacheWriteTokens > 0 ? SESSION_COST_COPY.unpricedCacheWrite : undefined, + resolved.approximate ? SESSION_COST_COPY.approximateNote : undefined, + ] as Array).filter((part): part is string => part !== undefined) + const title = [ + `${SESSION_COST_COPY.panelTitle} ${free ? SESSION_COST_COPY.free : sessionCostAmount(total)}`, + clause(SESSION_COST_COPY.uncachedInput, uncachedInput), + clause(SESSION_COST_COPY.output, output), + clause(SESSION_COST_COPY.cacheRead, cacheRead), + clause(SESSION_COST_COPY.cacheWrite, cacheWrite), + ...notes, + ].join(' · ') + + return { + total, + value, + title, + rows, + notes, + free, + peak, + unpricedCacheWriteTokens, + approximate: resolved.approximate, + } +} + +/** + * The two nodes the text appended to the harness's token-usage pill is made of. + * + * The separator is a node of its own because it carries the shipped pill's + * SEPARATOR colour rather than its label colour; the pill is a flex row whose + * `gap` already spaces the appended item, so the separator is what makes the + * result read as one continuous run (`1.2M tokens · Cache hit 87% · $0.0123`) + * rather than as a value parked at the end of it. + */ +export interface SessionCostPillRun { + /** The shipped pill's own separator glyph. */ + separator: string + /** The amount, `≈` marker and free word included. */ + value: string +} + +/** Split the appended run into the two nodes the display creates. */ +export function sessionCostPillRun(view: SessionCostView): SessionCostPillRun { + return { separator: SESSION_COST_COPY.separator, value: view.value } +} + +/** + * How the harness's own usage dialog is decorated: one entry per row the + * shipped component renders, IN ITS OWN ORDER. + * + * The price goes on the right of the row it belongs to (`3,206,544 tok $0.07`) + * rather than in a block of our own rows beneath them, so the dialog keeps the + * harness's layout and gains nothing to read past. + * + * Two rows are special. `cacheHit` carries a percentage, not a count, so it is + * never priced. `cacheWrite` is HIDDEN and never priced: the pricing page + * publishes a cache-write rate for a minority of models, so its cell read + * `unpriced` far more often than a number; its tokens still count toward the + * total the pill shows. + */ +export type SessionCostShippedRow = 'cacheHit' | 'uncachedInput' | 'cacheRead' | 'cacheWrite' | 'output' + +/** One shipped dialog row and what to do with it. */ +export interface SessionCostRowDecoration { + /** Which shipped row this is. */ + row: SessionCostShippedRow + /** + * The exact token count that row must be showing, or undefined for a row that + * carries no count. The display layer verifies it before decorating anything: + * the shipped labels are the `chat` locale's own strings (never English), so + * rows are matched POSITIONALLY and this is what proves the position is right. + */ + tokens: number | undefined + /** The price to append to the row's value, or undefined to leave it alone. */ + amount: string | undefined + /** Whether the row is dropped from the dialog entirely. */ + hidden: boolean +} + +/** + * Decorate the harness's usage dialog, row by row. + * + * The row sequence mirrors the shipped component's conditions exactly — the + * cache-hit row exists while there is billed prompt input, the cache-write row + * while those tokens are non-zero — because the display layer matches what it + * finds positionally. A shape it cannot confirm is a shape it does not touch. + * + * A free model decorates nothing: every row's cost is zero by definition, the + * pill already says `Free`, and a column of `$0.00` would be noise. A bucket + * whose rate the page does not publish is likewise left unfilled rather than + * filled with an invented number. + */ +export function sessionCostRowDecorations(view: SessionCostView): SessionCostRowDecoration[] { + const bucket = (key: SessionCostBucketRow['key']): number => + view.rows.find((row) => row.key === key)?.tokens ?? 0 + const price = (key: SessionCostBucketRow['key']): string | undefined => { + if (view.free) return undefined + return view.rows.find((row) => row.key === key)?.costText + } + const uncachedInput = bucket('uncachedInput') + const cacheRead = bucket('cacheRead') + const cacheWrite = bucket('cacheWrite') + const output = bucket('output') + const plan: SessionCostRowDecoration[] = [] + if (uncachedInput + cacheRead + cacheWrite > 0) { + plan.push({ row: 'cacheHit', tokens: undefined, amount: undefined, hidden: false }) + } + plan.push({ row: 'uncachedInput', tokens: uncachedInput, amount: price('uncachedInput'), hidden: false }) + plan.push({ row: 'cacheRead', tokens: cacheRead, amount: price('cacheRead'), hidden: false }) + if (cacheWrite !== 0) { + plan.push({ row: 'cacheWrite', tokens: cacheWrite, amount: undefined, hidden: true }) + } + plan.push({ row: 'output', tokens: output, amount: price('output'), hidden: false }) + return plan +} diff --git a/src/client/sessions.ts b/src/client/sessions.ts index 316b9c4..b89eae0 100644 --- a/src/client/sessions.ts +++ b/src/client/sessions.ts @@ -21,18 +21,19 @@ * extra peer dependency into the package; the shapes are stable and the * client build inlines them anyway. * - * The wrapper takes a `getLocale` thunk because it is reached from a - * non-React path that has no `t` in scope; the supplied thunk reads the - * active locale at call time (typically `() => ctx.locale.getLocale().active` - * in the client entry), and the message template lives in the shared - * `commandcodeCommand` dictionary used by the Host-side `/commandcode` - * command — the same bilingual surface serves both. + * The replacement message is a plain English constant owned by this module: + * the rewrite runs on a non-React path with no `t` in scope, and like every + * other surface this plugin owns it reads the same on every locale. * * This module is deliberately free of React and other client-platform * imports so the node test runner can exercise it directly. */ -import { commandcodeCommand, type LocaleId } from '../command-locales.ts' +/** The friendly rewrite; `{model}` is replaced with the requested model id. */ +const IMAGE_GATE_MESSAGE = + 'This session already contains images, and model {model} does not accept' + + ' image input; please select an image-capable model, or remove the' + + ' images from the session first.' /** The `model-unavailable` error details: provider + model id. */ interface ModelUnavailableDetails { @@ -83,10 +84,7 @@ export function isImageSessionRejection( } /** Wrap the shared sessions API so selectModel failures read friendlier. */ -export function withFriendlyImageError( - sessions: SessionsLike, - getLocale: () => LocaleId, -): SessionsLike { +export function withFriendlyImageError(sessions: SessionsLike): SessionsLike { const selectModel = sessions.selectModel.bind(sessions) return { ...sessions, @@ -94,15 +92,13 @@ export function withFriendlyImageError( const result = await selectModel(payload, signal) if (!isImageSessionRejection(result)) return result const model = result.result.error.details?.model ?? payload.model - const template = commandcodeCommand[getLocale()].imageGate - ?? commandcodeCommand.en.imageGate return { ...result, result: { ...result.result, error: { ...result.result.error, - message: template.replace('{model}', model), + message: IMAGE_GATE_MESSAGE.replace('{model}', model), }, }, } @@ -127,13 +123,10 @@ export interface ConnectionLike { * a 0.1.2 connection has no `api.sessions`, and its absence must never block * the plugin from mounting its settings, credential, or usage surfaces. */ -export function installFriendlyImageError( - connection: ConnectionLike, - getLocale: () => LocaleId, -): boolean { +export function installFriendlyImageError(connection: ConnectionLike): boolean { const api = connection.api const sessions = api?.sessions if (api === undefined || sessions === undefined || typeof sessions.selectModel !== 'function') return false - api.sessions = withFriendlyImageError(sessions, getLocale) + api.sessions = withFriendlyImageError(sessions) return true } diff --git a/src/client/update.ts b/src/client/update.ts index 45e0681..ca8e150 100644 --- a/src/client/update.ts +++ b/src/client/update.ts @@ -36,7 +36,7 @@ export const FETCH_TIMEOUT_MS = 5000 * name is path-escaped (`%2F`) so no client normalizes the slash away. */ export const NPM_LATEST_URL = - 'https://registry.npmjs.org/@mars-sea%2Fdsh-commandcode-provider/latest' + 'https://registry.npmjs.org/@xer-on%2Fdsh-commandcode-provider/latest' /** * Compare two version strings (`major.minor.patch[-pre]`). Returns a negative @@ -149,7 +149,7 @@ export interface UpdateCheckStore { } /** The `localStorage` key holding {@link UpdateCheckRecord}. */ -export const UPDATE_CHECK_CACHE_KEY = '@mars-sea/dsh-commandcode-provider/update-check' +export const UPDATE_CHECK_CACHE_KEY = '@xer-on/dsh-commandcode-provider/update-check' /** * A {@link UpdateCheckStore} backed by `localStorage`. Tolerates a missing or diff --git a/src/client/usage.ts b/src/client/usage.ts index bedee87..737fe53 100644 --- a/src/client/usage.ts +++ b/src/client/usage.ts @@ -1,8 +1,8 @@ /** * Browser controller for the settings page's account-usage card. * - * The card renders the same account/usage/credit facts the `/commandcode` - * command prints, fetched Host-side through the `commandcode/report` Remote + * The card renders the account/usage/credit facts, fetched Host-side through + * the `commandcode/report` Remote * (the browser never holds the API key). This controller owns the fetch * lifecycle — idle/loading/ready/error, one in-flight request at a time, * stale-response dropping — and the display formatting, so the React @@ -13,7 +13,7 @@ * @module dsh-commandcode-provider/client/usage */ -import type { CommandCodeAccountsReport, CommandCodeCatalog } from '../usage-wire.ts' +import type { CommandCodeAccountsReport, CommandCodeCatalog, CommandCodePriceTable } from '../usage-wire.ts' import type { CommandCodeLoginStatus } from '../login-wire.ts' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' @@ -23,19 +23,21 @@ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' * typert.remote-client files use), so `ctx.remote.commandcode.*()` is typed * once each contribution is mounted. The `commandcode` namespace member is * declared exactly once (interface merging forbids duplicate members), so - * this one declaration carries the usage report, the model catalog, AND the - * login endpoints — the endpoint-level declarations live beside their - * controllers. + * this one declaration carries the usage report, the model catalog, the price + * table, AND the login endpoints — the endpoint-level declarations live beside + * their controllers. */ declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertRemoteMap { 'commandcode/report': () => Promise> 'commandcode/models': () => Promise> + 'commandcode/prices': () => Promise> } interface TypertRemoteNamespaceMap { commandcode: { report: () => Promise> models: () => Promise> + prices: () => Promise> loginBegin: () => Promise> loginStatus: () => Promise> loginCancel: () => Promise> @@ -43,7 +45,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { } } -/** The narrow slice of the mounted Remote this controller calls. */ +/** The narrow slice of the mounted Remote this plugin calls. */ export interface UsageRemote { report(): Promise< | { ok: true; value: CommandCodeAccountsReport } @@ -53,6 +55,10 @@ export interface UsageRemote { | { ok: true; value: CommandCodeCatalog } | { ok: false; error: { message: string } } > + prices(): Promise< + | { ok: true; value: CommandCodePriceTable } + | { ok: false; error: { message: string } } + > } /** The card's fetch lifecycle. */ diff --git a/src/client/version.ts b/src/client/version.ts index f16baa5..47720fa 100644 --- a/src/client/version.ts +++ b/src/client/version.ts @@ -23,5 +23,5 @@ export const PLUGIN_VERSION: string = pkg.version export const PLUGIN_RELEASES_URL: string = (() => { const repo: unknown = (pkg as { repository?: unknown }).repository const url = typeof repo === 'string' ? repo : (repo as { url?: unknown } | undefined)?.url - return `${typeof url === 'string' ? url.replace(/^git\+/, '').replace(/\.git$/, '') : 'https://github.com/Mars-Sea/dsh-commandcode-provider'}/releases` + return `${typeof url === 'string' ? url.replace(/^git\+/, '').replace(/\.git$/, '') : 'https://github.com/xer-on/dsh-commandcode'}/releases` })() diff --git a/src/command-locales.ts b/src/command-locales.ts deleted file mode 100644 index 47a8322..0000000 --- a/src/command-locales.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Locale copy for the `/commandcode` usage command and the friendly - * image-gate error rewrite. Distinct from `./client/locales.ts` (the - * settings-page namespace `settings.commandcode`): the command runs on the - * Host and has no access to the client's `ctx.locale`, so the dictionaries - * are exposed as plain constants for direct lookup; the resolver lives in - * `pickCommandLocale()`. The image-gate wrapper also lives on the client - * but is reached from a non-React path that has no `t` in scope, so the - * same dictionaries serve both surfaces. - * - * zh is the source of truth for the key set; en must carry the exact same - * keys — a mismatch is a compile error at the lookup site. - */ - -/** Active locale id recognized by the command and the image-gate wrapper. */ -export type LocaleId = 'zh' | 'en' - -/** Dictionary keys used by the `/commandcode` command and the image-gate wrapper. */ -export type CommandCodeCommandKey = - | 'title' // top heading of a single-account report - | 'accountTitle' // per-account heading in the multi-account view - | 'accountSeparator' // rule between accounts in the multi-account view - | 'activeBadge' // "currently serving" badge - | 'invalidCredentialBadge' // mark for an account whose key is invalid - | 'cooldownBadge' // mark for an account in rate-limit cooldown - | 'rateLimitBadge' // mark when the pool has marked a key rate-limited - | 'unconfigured' // one-account row when the slot has no key - | 'blockedInvalidKey' // top-of-report block when the whole account is 401 - | 'blockedServiceUnavailable' // 5xx - | 'blockedNetwork' // network unreachable - | 'planLine' // " 📦 套餐 {name}{status}{period}" - | 'planPeriodSuffix' // " · 账期截止 {date}" / " · period ends {date}" - | 'usageHeader' // "── 请求 ─────..." - | 'requestsLine' // " 💬 请求 {n} 次 / 失败 {f} 成功率 {r}%" - | 'costLine' // " 💰 花费 {money} ({credits} credits)" - | 'tokensLine' // " 🔤 Token {in} 入 / {out} 出" - | 'creditsHeader' // "── 信用 ─────..." - | 'monthlyLine' // " 💳 月额度 {monthly} (已购 {purchased} / 赠送 {free})" - | 'barLine' // " └ {bar} {pct}%" - | 'windowsHeader' // "── 窗口用量 ─────..." - | 'fiveHourLine' // " ⏱ 5 小时 {used} / {cap}{warn}" - | 'weeklyLine' // " 📅 每周 {used} / {cap}{warn}" - | 'windowBarLine' // " └ {bar} 重置 {when}" - | 'exceededWarning' // the trailing " ⚠️ 超限!" / " ⚠️ exceeded!" - | 'resetSuffix' // "重置 {when}" (the suffix after the bar) - | 'partialFailures' // "⚠️ 部分端点失败: {list}" - | 'noData' // "(no data — check your API key)" - | 'errorText' // "Could not fetch Command Code usage: {message}" - | 'imageGate' // image-gate rejection rewrite (with {model}) - -export const commandcodeCommand: Record> = { - zh: { - title: '📊 Command Code 用量{account}', - accountTitle: '📊 {label}{badges}', - accountSeparator: '────────────────────', - activeBadge: ' ✅ 当前使用', - invalidCredentialBadge: ' ⛔ 密钥无效', - cooldownBadge: ' ⏳ 限额冷却中,重置 {when}', - rateLimitBadge: ' ⏳ 已达限额(等待窗口探测)', - unconfigured: ' (未配置 API 密钥)', - blockedInvalidKey: - '⛔ API 密钥无效或已过期 — 服务端拒绝了全部请求(401),请检查该账户的密钥配置', - blockedServiceUnavailable: - '⚠️ Command Code 服务暂时不可用(5xx),稍后重试', - blockedNetwork: - '⚠️ 无法连接 Command Code 服务 — 请检查网络或 API 地址', - planLine: ' 📦 套餐 {name}{status}{period}', - planPeriodSuffix: ' · 账期截止 {date}', - usageHeader: '── 请求 ──────────────────────────────', - requestsLine: ' 💬 请求 {n} 次 / 失败 {f} 成功率 {r}%', - costLine: ' 💰 花费 {money} ({credits} credits)', - tokensLine: ' 🔤 Token {in} 入 / {out} 出', - creditsHeader: '── 信用 ──────────────────────────────', - monthlyLine: ' 💳 月额度 {monthly} (已购 {purchased} / 赠送 {free})', - barLine: ' └ {bar} {pct}%', - windowsHeader: '── 窗口用量 ──────────────────────────', - fiveHourLine: ' ⏱ 5 小时 {used} / {cap}{warn}', - weeklyLine: ' 📅 每周 {used} / {cap}{warn}', - windowBarLine: ' └ {bar} 重置 {when}', - exceededWarning: ' ⚠️ 超限!', - resetSuffix: '重置 {when}', - partialFailures: '⚠️ 部分端点失败: {list}', - noData: '(无数据 — 请检查 API 密钥)', - errorText: '获取 Command Code 用量失败:{message}', - imageGate: - '当前会话已包含图片,而模型 {model} 不支持图片输入;' - + '请选择支持图片的模型,或先移除会话中的图片。', - }, - en: { - title: '📊 Command Code usage{account}', - accountTitle: '📊 {label}{badges}', - accountSeparator: '────────────────────', - activeBadge: ' ✅ active', - invalidCredentialBadge: ' ⛔ invalid key', - cooldownBadge: ' ⏳ cooling down, resets {when}', - rateLimitBadge: ' ⏳ rate-limited (waiting for window probe)', - unconfigured: ' (no API key configured)', - blockedInvalidKey: - '⛔ API key invalid or expired — the server rejected every request (401); check the key configured for this account', - blockedServiceUnavailable: - '⚠️ Command Code service temporarily unavailable (5xx); try again later', - blockedNetwork: - '⚠️ could not reach the Command Code service — check your network or the API base setting', - planLine: ' 📦 Plan {name}{status}{period}', - planPeriodSuffix: ' · period ends {date}', - usageHeader: '── Requests ──────────────────────────', - requestsLine: ' 💬 Requests {n} / failed {f} success rate {r}%', - costLine: ' 💰 Spend {money} ({credits} credits)', - tokensLine: ' 🔤 Tokens {in} in / {out} out', - creditsHeader: '── Credits ───────────────────────────', - monthlyLine: ' 💳 Monthly {monthly} (purchased {purchased} / free {free})', - barLine: ' └ {bar} {pct}%', - windowsHeader: '── Window usage ──────────────────────', - fiveHourLine: ' ⏱ 5-hour {used} / {cap}{warn}', - weeklyLine: ' 📅 Weekly {used} / {cap}{warn}', - windowBarLine: ' └ {bar} resets {when}', - exceededWarning: ' ⚠️ exceeded!', - resetSuffix: 'resets {when}', - partialFailures: '⚠️ some endpoints failed: {list}', - noData: '(no data — check your API key)', - errorText: 'Could not fetch Command Code usage: {message}', - imageGate: - 'This session already contains images, and model {model} does not accept' - + ' image input; please select an image-capable model, or remove the' - + ' images from the session first.', - }, -} - -/** - * Resolve the active locale for a Host-side command run. - * - * Priority: explicit `override` (from `Config.lang`) → `LC_ALL` → `LANG` → - * the conventional fallback (`'zh'`, matching the existing single-language - * behavior so unconfigured deployments keep their current output). - * - * The values are matched on the leading tag only — `zh_CN.UTF-8`, - * `zh-Hans`, `zh` all map to `'zh'`; everything starting with `en` maps to - * `'en'`; anything else falls back to `'zh'` (a non-`en` shell that - * already has Chinese in the terminal is the closest sensible default; - * a Western shell that happens to be neither keeps the existing Chinese - * output rather than swapping to half-translated English). - */ -export function pickCommandLocale( - override: string | undefined, - env: Readonly> = process.env as Record, -): LocaleId { - if (override === 'zh' || override === 'en') return override - const raw = env.LC_ALL ?? env.LANG ?? '' - const tag = raw.toLowerCase().split(/[._-]/)[0] ?? '' - if (tag === 'en') return 'en' - return 'zh' -} - -/** Look up a key in the active locale, with an internal en fallback. */ -export function commandCopy(locale: LocaleId, key: CommandCodeCommandKey): string { - return commandcodeCommand[locale][key] ?? commandcodeCommand.en[key] ?? key -} diff --git a/src/commands.ts b/src/commands.ts deleted file mode 100644 index 15a6286..0000000 --- a/src/commands.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * `/commandcode` slash command — account usage dashboard. - * - * /commandcode show account, usage, and credit state - * /commandcode status same as bare `/commandcode` - * - * Backed by the Command Code account endpoints the official CLI uses - * (`/alpha/whoami`, `/alpha/usage/summary`, `/alpha/billing/credits`), - * exposed through `CommandCodeAdapter.getUsage()`. - * - * The command is Host-side and has no access to the client's `ctx.locale`; - * the active locale is resolved through `deps.getLocale()` (supplied by the - * plugin entry from `Config.lang` and the shell's `LC_ALL`/`LANG`, defaulting - * to `zh`). All user-facing copy lives in `./command-locales.ts`; a missing - * key in one dictionary falls back to the `en` copy rather than dropping - * text. - * - * @module dsh-commandcode-provider/commands - */ - -import type { Context } from '@deepseek-ai/cordis' -// Type-only import that loads the module augmentation (`ctx.commands`). -import type { CommandDefinition } from '@deepseek-ai/dsh-commands' -import { CommandCodeAdapter } from './adapter.ts' -import type { CommandCodeConnectionOptions, CommandCodeUsageReport } from './adapter.ts' -import type { CommandCodeAccountUsage, CommandCodeAccountsReport } from './usage-wire.ts' -import { commandCopy, type LocaleId } from './command-locales.ts' - -/** Everything the command needs beyond the adapter itself. */ -export interface CommandCodeCommandDeps { - /** The registered adapter (for getUsage / listModels). */ - adapter: CommandCodeAdapter - /** - * Multi-account report source (wired by the plugin entry). Absent in - * programmatic setups, the command falls back to a single - * `adapter.getUsage()` report. - */ - reports?: () => Promise - /** - * Resolve the active locale for one command run. The plugin entry wires - * this from `Config.lang` and the shell's `LC_ALL`/`LANG`. Absent in - * programmatic setups (notably the existing test), the command renders - * with the default locale (`'zh'`) — historically the only language the - * command ever shipped in. - */ - getLocale?: () => LocaleId -} - -// --------------------------------------------------------------------------- -// Number / time formatting (locale-independent; the locale only changes -// the surrounding labels) -// --------------------------------------------------------------------------- - -/** Format a dollar amount. */ -function money(value: number): string { - return `$${value.toFixed(4)}` -} - -/** Format a dollar amount compactly (2 decimals). */ -function moneyShort(value: number): string { - return `$${value.toFixed(2)}` -} - -/** Format a large token count compactly (1.9M style). */ -function tokensCompact(value: number): string { - if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B` - if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M` - if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K` - return String(value) -} - -/** - * Format a success-rate percentage (already in percent units): at most two - * decimals, trailing zeros trimmed — mirrors `formatSuccessRate` in - * `./client/usage.ts`, which the settings card uses. - */ -function successRateText(value: number): string { - return String(Number(value.toFixed(2))) -} - -/** Format a millis timestamp as a local date; `n/a` when unset. */ -function resetLabel(ms: number): string { - if (ms <= 0) return 'n/a' - return new Date(ms).toLocaleString() -} - -/** - * A 10-cell horizontal bar: `██████████` for 100%, `███░░░░░░░` for ~33%. - * Handles caps of 0 (no limit) and out-of-range values. - */ -function bar(used: number, cap: number): string { - if (cap <= 0) return '—' - const ratio = Math.max(0, Math.min(1, used / cap)) - const filled = Math.round(ratio * 10) - return '█'.repeat(filled) + '░'.repeat(10 - filled) -} - -// --------------------------------------------------------------------------- -// Report rendering -// --------------------------------------------------------------------------- - -/** Render one account's rotation mark / cooldown as a short badge. */ -function markLabel(entry: CommandCodeAccountUsage, locale: LocaleId): string { - if (entry.mark === 'invalid-credential') return commandCopy(locale, 'invalidCredentialBadge') - if (entry.cooldownUntil > 0) { - return commandCopy(locale, 'cooldownBadge').replace('{when}', resetLabel(entry.cooldownUntil)) - } - if (entry.mark === 'rate-limit') return commandCopy(locale, 'rateLimitBadge') - return '' -} - -/** Render the usage report as a structured, aligned, bar-chart text view. */ -function renderReport(report: CommandCodeUsageReport, locale: LocaleId, title?: string): string { - const lines: string[] = [] - const account = report.account ? ` (${report.account.userName || report.account.name})` : '' - - lines.push( - title ?? commandCopy(locale, 'title').replace('{account}', account), - '', - ) - - // A total failure names its cause up front; the per-endpoint failure list - // at the bottom would bury it. - if (report.blocked === 'invalid-key') { - lines.push(commandCopy(locale, 'blockedInvalidKey'), '') - } else if (report.blocked === 'service-unavailable') { - lines.push(commandCopy(locale, 'blockedServiceUnavailable'), '') - } else if (report.blocked === 'network') { - lines.push(commandCopy(locale, 'blockedNetwork'), '') - } - - if (report.plan && report.plan.name !== '') { - const p = report.plan - const status = p.status !== '' && p.status !== 'active' ? ` (${p.status})` : '' - const period = p.currentPeriodEnd > 0 - ? commandCopy(locale, 'planPeriodSuffix').replace('{date}', new Date(p.currentPeriodEnd).toLocaleDateString()) - : '' - lines.push(commandCopy(locale, 'planLine') - .replace('{name}', p.name) - .replace('{status}', status) - .replace('{period}', period), '') - } - - if (report.usage) { - const u = report.usage - lines.push( - commandCopy(locale, 'usageHeader'), - commandCopy(locale, 'requestsLine') - .replace('{n}', String(u.completedCount)) - .replace('{f}', String(u.failedCount)) - .replace('{r}', successRateText(u.successRate)), - commandCopy(locale, 'costLine') - .replace('{money}', money(u.totalCost)) - .replace('{credits}', moneyShort(u.totalCredits)), - commandCopy(locale, 'tokensLine') - .replace('{in}', tokensCompact(u.totalTokensIn)) - .replace('{out}', tokensCompact(u.totalTokensOut)), - '', - ) - } - - if (report.credits) { - const c = report.credits - const monthlyPct = c.monthlyCredits > 0 - ? `${((c.monthlyCredits / (c.monthlyCredits + c.purchasedCredits)) * 100).toFixed(0)}%` - : '—' - lines.push( - commandCopy(locale, 'creditsHeader'), - commandCopy(locale, 'monthlyLine') - .replace('{monthly}', moneyShort(c.monthlyCredits)) - .replace('{purchased}', moneyShort(c.purchasedCredits)) - .replace('{free}', moneyShort(c.freeCredits)), - commandCopy(locale, 'barLine') - .replace('{bar}', bar(c.monthlyCredits, c.monthlyCredits + c.purchasedCredits)) - .replace('{pct}', monthlyPct), - '', - commandCopy(locale, 'windowsHeader'), - commandCopy(locale, 'fiveHourLine') - .replace('{used}', moneyShort(c.fiveHour.used)) - .replace('{cap}', moneyShort(c.fiveHour.cap)) - .replace('{warn}', c.fiveHour.exceeded ? commandCopy(locale, 'exceededWarning') : ''), - commandCopy(locale, 'windowBarLine') - .replace('{bar}', bar(c.fiveHour.used, c.fiveHour.cap)) - .replace('{when}', resetLabel(c.fiveHour.resetAt)), - commandCopy(locale, 'weeklyLine') - .replace('{used}', moneyShort(c.weekly.used)) - .replace('{cap}', moneyShort(c.weekly.cap)) - .replace('{warn}', c.weekly.exceeded ? commandCopy(locale, 'exceededWarning') : ''), - commandCopy(locale, 'windowBarLine') - .replace('{bar}', bar(c.weekly.used, c.weekly.cap)) - .replace('{when}', resetLabel(c.weekly.resetAt)), - '', - ) - } - - if (report.failures.length > 0) { - lines.push(commandCopy(locale, 'partialFailures').replace('{list}', report.failures.join('; ')), '') - } - if (!report.account && !report.usage && !report.credits) { - lines.push(commandCopy(locale, 'noData'), '') - } - - return lines.join('\n').trimEnd() -} - -/** The one registered `/commandcode` command. */ -export function commandDefinition( - deps: CommandCodeCommandDeps, -): CommandDefinition { - const { adapter } = deps - return { - name: 'commandcode', - description: 'Command Code account usage dashboard', - input: { hint: '[status]' }, - handler: async () => { - const locale: LocaleId = deps.getLocale?.() ?? 'zh' - try { - if (deps.reports !== undefined) { - const { accounts } = await deps.reports() - const sections = accounts.map((entry) => { - const badges = `${entry.active ? commandCopy(locale, 'activeBadge') : ''}${markLabel(entry, locale)}` - const title = commandCopy(locale, 'accountTitle') - .replace('{label}', entry.label) - .replace('{badges}', badges) - if (!entry.configured) return `${title}\n\n${commandCopy(locale, 'unconfigured')}` - return renderReport(entry.report, locale, title) - }) - return { kind: 'success', text: sections.join(`\n\n${commandCopy(locale, 'accountSeparator')}\n\n`) } - } - const report = await adapter.getUsage() - return { kind: 'success', text: renderReport(report, locale) } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error) - return { - kind: 'error', - text: commandCopy(locale, 'errorText').replace('{message}', message), - } - } - }, - } -} - -/** Register the command on `ctx.commands` (called from the plugin entry). */ -export function applyCommands( - ctx: Context, - deps: CommandCodeCommandDeps, -): void { - ctx.commands.register(commandDefinition(deps)) -} diff --git a/src/index.ts b/src/index.ts index 0868eeb..030fb03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ /** * dsh-commandcode-provider — DeepSeek Harness LLM provider plugin for Command - * Code (unofficial; ported from pi-commandcode-provider@0.5.1). + * Code (unofficial, community-maintained). * * Registers the `commandcode` provider route on `ctx.llm` and declares it in * the configurable-provider directory, so the web Models page shows a @@ -12,7 +12,7 @@ * * ```yaml * - id: llm-commandcode - * name: "@mars-sea/dsh-commandcode-provider" + * name: "@xer-on/dsh-commandcode-provider" * config: * apiKeyEnv: COMMANDCODE_API_KEY * ``` @@ -42,12 +42,10 @@ import { DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS } from './ad import type { CommandCodeConnectionOptions, CommandCodeUsageReport } from './adapter.ts' import { CommandCodeAccountPool, accountUsable, selectActiveAccount } from './accounts.ts' import type { CommandCodeAccountConfig, CommandCodeAccountSlot, CommandCodeModelAccountRule } from './accounts.ts' -import { applyCommands } from './commands.ts' import { applyUsageRemote } from './usage-remote.ts' import type { CommandCodeAccountsReport, CommandCodeCatalog } from './usage-wire.ts' import { CommandCodeLoginFlow } from './login.ts' import type { CommandCodeLoginCredentials } from './login.ts' -import { pickCommandLocale, type LocaleId } from './command-locales.ts' import { CommandCodeSearchProvider, applyCommandCodeSearchSelection, commandCodeSearchSelection } from './web-search.ts' import { applyCommandCodeTuiSettings } from './tui-settings.ts' import { KNOWN_PLANS } from './capabilities.ts' @@ -86,8 +84,6 @@ export { } from './capabilities.ts' export type { CommandCodeAdapterDeps, CommandCodeConnectionOptions, CommandCodeUsageReport, ResolveAttachments } from './adapter.ts' export type { CommandCodeBillingAccess } from './capabilities.ts' -export { applyCommands, commandDefinition } from './commands.ts' -export type { CommandCodeCommandDeps } from './commands.ts' export { applyUsageRemote, CommandCodeUsageService } from './usage-remote.ts' export type { CommandCodeUsageDeps, LoginFlowFacade } from './usage-remote.ts' export { USAGE_REPORT_ENDPOINT, usageReportSchema } from './usage-wire.ts' @@ -124,7 +120,7 @@ export type { CommandCodeAccountConfig, CommandCodeAccountSlot, CommandCodeAccou export { CommandCodeSearchProvider, COMMANDCODE_SEARCH_PROVIDER_ID, DEFAULT_WEB_SEARCH_PROVIDER_ID, applyCommandCodeSearchSelection, commandCodeSearchSelection, selectCommandCodeSearchProvider } from './web-search.ts' export type { CommandCodeSearchSelection } from './web-search.ts' export type { CommandCodeSearchProviderDeps } from './web-search.ts' -export { ACTIVE_ACCOUNT_AUTO, LANG_AUTO, applyCommandCodeTuiSettings, buildCommandCodeTuiSection } from './tui-settings.ts' +export { ACTIVE_ACCOUNT_AUTO, applyCommandCodeTuiSettings, buildCommandCodeTuiSection } from './tui-settings.ts' export type { CommandCodeTuiSettingsDeps, TuiSettingsField, @@ -143,7 +139,7 @@ const DEFAULT_API_KEY_ENV = 'COMMANDCODE_API_KEY' /** The single provider route this plugin owns. */ export const PROVIDER = 'commandcode' -/** Default models cache path (mirrors the pi plugin's on-disk cache). */ +/** Default models cache path (mirrors the official CLI's on-disk cache). */ export const DEFAULT_MODELS_CACHE_PATH = join(homedir(), '.commandcode', 'models-cache.json') /** @@ -232,18 +228,6 @@ export interface Config { * Defaults to true. */ webSearch?: boolean - /** - * Language override for the `/commandcode` Host-side command's user-facing - * copy. Host commands cannot read the client's `ctx.locale`, so this is - * the explicit knob: `'zh'` or `'en'`. Unset means the command reads - * `LC_ALL`/`LANG` from the launching shell, falling back to `'zh'`. The - * web settings page is unaffected — it follows the browser's language - * preference on its own. Two surfaces, two independent locales. The - * declared type is `string` (the schemastery `pattern` cannot narrow - * literal types); an unknown value is treated as "unset" by - * `pickCommandLocale`. - */ - lang?: string } export const Config: z = z.object({ @@ -284,7 +268,6 @@ export const Config: z = z.object({ models: z.array(z.string()), account: z.string(), })), - lang: z.string().pattern(/^(zh|en)$/).default('zh' as const), }) /** One resolution's complete request facts: connection plus credential reference. */ @@ -475,9 +458,9 @@ export function apply(ctx: Context, config: Config): void { // The live route: this is what makes models requestable under `commandcode`. ctx.llm.registerAdapter([PROVIDER], adapter) - // Per-account usage for the /commandcode dashboard and the settings - // page's account card: every pool account (configured or not) gets one - // entry, each fetched with its own key so plan/credit facts never mix. + // Per-account usage for the settings page's account card and the sidebar + // panel: every pool account (configured or not) gets one entry, each + // fetched with its own key so plan/credit facts never mix. const usageReports = async (): Promise => { // describeAccounts (not deduped) so two slots sharing one credential are // both reported as configured; the active badge follows the deduped @@ -515,19 +498,6 @@ export function apply(ctx: Context, config: Config): void { return { accounts: entries } } - // The /commandcode usage command rides the optional `commands` service: a - // child fiber injects it, so it registers whenever the profile mounts - // dsh-commands and the fiber simply never activates when it does not. - // The command runs Host-side and has no access to the client's locale - // service, so its language is resolved here from `Config.lang` (explicit - // override) and the launching shell's `LC_ALL`/`LANG` (inferred default); - // resolved per invocation so a settings change reaches the next command - // run without a restart. - const commandLocale = (): LocaleId => pickCommandLocale(current().lang) - ctx.inject(['commands'], (commandCtx) => { - applyCommands(commandCtx, { adapter, reports: usageReports, getLocale: commandLocale }) - }) - // The settings page's account card: getUsage exposed to the browser through // the Typert Gateway (`commandcode/report`). Rides the optional `typert` // registry service, so profiles without the web stack never activate it. diff --git a/src/model-prices.ts b/src/model-prices.ts new file mode 100644 index 0000000..cb944f0 --- /dev/null +++ b/src/model-prices.ts @@ -0,0 +1,232 @@ +/** + * Vendored Command Code model prices: the per-token rates the official + * pricing page publishes, which is what lets the composer price a session in + * dollars. + * + * Source: the model array embedded in + * https://commandcode.ai/docs/resources/pricing-limits, read out of its + * Next.js flight payload rather than off the rendered rows (the rows are a + * trap — see the extraction note in AGENTS.md). Every figure is USD per + * 1,000,000 tokens, which the page confirms itself: its Go-plan estimate for + * DeepSeek V4 Flash (800 in / 200 out on a $10 budget) resolves to the ~42K + * requests the page states, and ~26K once its typical 50K cache reads are + * priced at the cacheRead rate. + * + * Three buckets are always published (inputCost, outputCost, cacheReadCost); + * cacheWriteCost is published for a minority of models. A model without it + * has UNPRICED cache-write tokens: never invent a multiplier for them, and + * never fold them into the input rate. + * + * Time-of-day models store a `peak` triplet only. The page repeats the flat + * rates inside its own offPeak block, so the top-level rates ARE the off-peak + * rates and only the peak override is worth keeping — the generator asserts + * that equality and refuses to emit otherwise. Whether "now" counts as peak is + * decided by `isPeakPricingHour()` in ./capabilities.ts, on the same + * Monday-Friday UTC schedule the model picker labels. + * + * Do not hand-edit the table below: run `node scripts/sync-model-prices.mjs`, + * which re-reads the page's embedded JSON, asserts it still duplicates its + * rates into `offPeak`, and rewrites only the rows. Everything else in this + * file — this doc, the types, the slug rules, the table builder — is written by + * hand and survives that rewrite. + * + * @module dsh-commandcode-provider/model-prices + */ + +import type { CommandCodeModelPrice, CommandCodeModelRates, CommandCodePriceTable } from './usage-wire.ts' +import { KNOWN_PLANS, PEAK_HOUR_RANGES, isFreeModel } from './capabilities.ts' + +/** + * One pricing-page row. `rates` is `[input, output, cacheRead, cacheWrite?]` + * and `peak` is the `[input, output, cacheRead]` triplet charged inside the + * peak windows. All figures are USD per 1,000,000 tokens. + */ +interface ModelPriceRow { + readonly id: string + readonly rates: readonly number[] + readonly peak?: readonly number[] +} + +/** Every price row the page publishes, ordered by its own slug. */ +const MODEL_PRICE_ROWS: readonly ModelPriceRow[] = [ + { id: 'claude-fable-5', rates: [10, 50, 1, 12.5] }, + { id: 'claude-fable-5-1', rates: [10, 50, 0.25, 12.5] }, + { id: 'claude-haiku-4-5', rates: [1, 5, 0.1, 1.25] }, + { id: 'claude-opus-4-6', rates: [5, 25, 0.5, 6.25] }, + { id: 'claude-opus-4-7', rates: [5, 25, 0.5, 6.25] }, + { id: 'claude-opus-4-8', rates: [5, 25, 0.5, 6.25] }, + { id: 'claude-opus-5', rates: [5, 25, 0.5, 6.25] }, + { id: 'claude-sonnet-4-6', rates: [3, 15, 0.3, 3.75] }, + { id: 'claude-sonnet-5', rates: [2, 10, 0.2, 2.5] }, + { id: 'deepseek-v4-flash', rates: [0.15, 0.6, 0.003], peak: [0.3, 1.2, 0.006] }, + { id: 'deepseek-v4-flash-fast', rates: [0.28, 0.56, 0.07] }, + { id: 'deepseek-v4-flash-vision-exp', rates: [0.22, 0.66, 0.007], peak: [0.44, 1.32, 0.014] }, + { id: 'deepseek-v4-pro', rates: [0.66, 1.98, 0.022], peak: [1.32, 3.96, 0.044] }, + { id: 'deepseek-v4.1-flash', rates: [0.15, 0.6, 0.003], peak: [0.3, 1.2, 0.006] }, + { id: 'fugu-ultra', rates: [5, 30, 0.5] }, + { id: 'gemini-3.1-flash-lite', rates: [0.25, 1.5, 0.03] }, + { id: 'gemini-3.5-flash', rates: [1.5, 9, 0.15] }, + { id: 'gemini-3.5-flash-lite', rates: [0.3, 2.5, 0.03] }, + { id: 'gemini-3.6-flash', rates: [1.5, 7.5, 0.15] }, + { id: 'gemini-3.7-flash', rates: [1.5, 7.5, 0.15, 0.08334] }, + { id: 'gemini-3.8-flash', rates: [1.5, 7.5, 0.15] }, + { id: 'glm-5', rates: [1, 3.2, 0.2] }, + { id: 'glm-5.1', rates: [1.4, 4.4, 0.26] }, + { id: 'glm-5.2', rates: [1.4, 4.4, 0.26] }, + { id: 'glm-5.2-fast', rates: [3, 10.25, 0.5] }, + { id: 'glm-5.3', rates: [1.4, 4.4, 0.26] }, + { id: 'glm-5.3-flash', rates: [0.15, 0.5, 0.03] }, + { id: 'gpt-5.3-codex', rates: [2, 8, 0.5, 0] }, + { id: 'gpt-5.4', rates: [2.5, 15, 0.25, 0] }, + { id: 'gpt-5.4-mini', rates: [0.75, 4.5, 0.075, 0] }, + { id: 'gpt-5.5', rates: [5, 30, 0.5, 0] }, + { id: 'gpt-5.6-luna', rates: [0.2, 1.2, 0.02, 0.25] }, + { id: 'gpt-5.6-sol', rates: [5, 30, 0.5, 6.25] }, + { id: 'gpt-5.6-terra', rates: [2, 12, 0.2, 2.5] }, + { id: 'gpt-6-astra', rates: [10, 50, 1, 12.5] }, + { id: 'grok-4.5', rates: [2, 6, 0.5] }, + { id: 'grok-4.6', rates: [2, 6, 0.5] }, + { id: 'inkling', rates: [1, 4.05, 0.17] }, + { id: 'inkling-small', rates: [0.5, 1.2, 0.1] }, + { id: 'kimi-k2.5', rates: [0.6, 3, 0.1] }, + { id: 'kimi-k2.6', rates: [0.95, 4, 0.16] }, + { id: 'kimi-k2.7-code', rates: [0.95, 4, 0.19] }, + { id: 'kimi-k2.7-code-highspeed', rates: [1.9, 8, 0.38] }, + { id: 'kimi-k3', rates: [3, 15, 0.3] }, + { id: 'mimo-v2.5', rates: [0.14, 0.28, 0.0028] }, + { id: 'mimo-v2.5-pro', rates: [0.435, 0.87, 0.0036] }, + { id: 'minimax-m2.5', rates: [0.3, 1.2, 0.03] }, + { id: 'minimax-m2.7', rates: [0.3, 1.2, 0.06] }, + { id: 'minimax-m3', rates: [0.3, 1.2, 0.06] }, + { id: 'muse-spark-1.1', rates: [1.25, 4.25, 0.15] }, + { id: 'muse-spark-1.2', rates: [1.25, 4.25, 0.15] }, + { id: 'muse-spark-1.2-contributor', rates: [0.1, 0.2, 0.002] }, + { id: 'muse-spark-1.3', rates: [1.25, 4.25, 0.15] }, + { id: 'muse-spark-1.3-contributor', rates: [0.1, 0.2, 0.002] }, + { id: 'nemotron-3-ultra', rates: [0.6, 2.4, 0.12] }, + { id: 'qwen-3.6-max', rates: [1.3, 7.8, 0.26, 1.63] }, + { id: 'qwen-3.6-plus', rates: [0.5, 3, 0.1] }, + { id: 'qwen-3.7-flash', rates: [0.03, 0.13, 0.006, 0.038] }, + { id: 'qwen-3.7-max', rates: [2.5, 7.5, 0.5, 3.13] }, + { id: 'qwen-3.7-plus', rates: [0.4, 1.6, 0.08, 0.5] }, + { id: 'qwen-3.8-27b', rates: [0.4, 3, 0.04] }, + { id: 'qwen-3.8-flash', rates: [0.16, 0.47, 0.016] }, + { id: 'qwen-3.8-max', rates: [2, 6, 0.25, 2.5] }, + { id: 'qwen-3.8-max-0902', rates: [2, 6, 0.25] }, + { id: 'step-3.5-flash', rates: [0.1, 0.3, 0.02] }, + { id: 'step-3.7-flash', rates: [0.2, 1.15, 0.04] }, + { id: 'tencent/hy3-paid', rates: [0.14, 0.58, 0.035] }, + { id: 'tencent/hy4-preview', rates: [0.834, 2.501, 0.042] }, +] + +/** + * Catalog ids no candidate rule can reach, because the page names the model + * differently from the catalog. `tests/model-prices.test.ts` fails whenever a + * catalog model has no price, so a new miss lands here as a visible decision + * rather than a silently unpriced model. + */ +const PRICE_SLUG_OVERRIDES: Readonly> = { + // The page lists it by its short name; the catalog carries the full one. + 'nvidia/nemotron-3-ultra-550b-a55b': 'nemotron-3-ultra', +} + +/** + * Plausible price slugs for one catalog model id, most specific first. + * + * The page normalizes to lowercase and usually drops the vendor segment, but + * not consistently: `tencent/hy4-preview` keeps its prefix while + * `Qwen/Qwen3.8-Max-0902` becomes `qwen-3.8-max-0902`, with a hyphen the + * catalog id does not have. Generating candidates and taking the first that + * exists in the vendored table absorbs that drift without a hand-maintained + * map of seventy ids. + */ +function priceSlugCandidates(modelId: string): string[] { + const lower = modelId.toLowerCase() + const bare = lower.includes('/') ? lower.slice(lower.indexOf('/') + 1) : lower + const out = new Set() + const add = (slug: string): void => { + const hyphenated = slug.replace(/^([a-z]+)(\d)/, '$1-$2') + out.add(slug) + out.add(hyphenated) + out.add(slug.replace(/-\d{8}$/, '')) + out.add(slug.replace(/-(preview|latest)$/, '')) + out.add(hyphenated.replace(/-\d{8}$/, '')) + out.add(hyphenated.replace(/-(preview|latest)$/, '')) + } + add(lower) + add(bare) + return [...out] +} + +/** The pricing-page slug for one catalog model id, or undefined when unpriced. */ +function priceSlugFor(modelId: string, known: ReadonlySet): string | undefined { + const override = PRICE_SLUG_OVERRIDES[modelId] + if (override !== undefined) return known.has(override) ? override : undefined + return priceSlugCandidates(modelId).find((slug) => known.has(slug)) +} + +/** Split a stored triplet/quadruplet into the wire rate shape. */ +function ratesOf(values: readonly number[]): CommandCodeModelRates { + // Indexed access is asserted rather than defaulted: the generator above only + // ever emits rows with the three published rates, so a short row is a broken + // table and must not silently become a free model. + const rates: CommandCodeModelRates = { + inputCost: values[0]!, + outputCost: values[1]!, + cacheReadCost: values[2]!, + } + if (values[3] !== undefined) rates.cacheWriteCost = values[3] + return rates +} + +/** Build the full price row the browser prices a session with. */ +function wireRow(id: string, slug: string, row: ModelPriceRow): CommandCodeModelPrice { + const price: CommandCodeModelPrice = { id, slug, ...ratesOf(row.rates) } + if (row.peak !== undefined) price.peak = ratesOf(row.peak) + return price +} + +/** + * Build the table the composer prices a session with. + * + * Rows are keyed by CATALOG id wherever the two namespaces reconcile, because + * that is what a session reports, and every row also carries its page slug as a + * second lookup key. Price rows no catalog model claims are served under the + * slug alone, so drift in either direction still prices: a page rename the + * catalog has not followed, or a model the catalog snapshot has not learned + * yet. Free models are served explicitly at zero so the composer can say so + * instead of showing nothing. + * + * The peak windows travel with the table, so the browser applies the very + * schedule this snapshot knows instead of restating it. + */ +export function modelPriceTable(): CommandCodePriceTable { + const bySlug = new Map(MODEL_PRICE_ROWS.map((row) => [row.id, row])) + const known = new Set(bySlug.keys()) + const models: CommandCodeModelPrice[] = [] + const claimed = new Set() + + for (const catalogId of Object.keys(KNOWN_PLANS)) { + if (isFreeModel(catalogId) || catalogId.endsWith(':free')) { + models.push({ id: catalogId, slug: catalogId, inputCost: 0, outputCost: 0, cacheReadCost: 0, free: true }) + continue + } + const slug = priceSlugFor(catalogId, known) + if (slug === undefined) continue + const row = bySlug.get(slug) + if (row === undefined) continue + claimed.add(slug) + models.push(wireRow(catalogId, slug, row)) + } + + for (const row of MODEL_PRICE_ROWS) { + if (claimed.has(row.id)) continue + models.push(wireRow(row.id, row.id, row)) + } + + return { + models, + peakHours: PEAK_HOUR_RANGES.map(([start, end]) => [start, end] as [number, number]), + } +} + diff --git a/src/tui-settings.ts b/src/tui-settings.ts index bbd5492..06db617 100644 --- a/src/tui-settings.ts +++ b/src/tui-settings.ts @@ -143,8 +143,6 @@ export interface TuiSettingsSectionsService { /** The selector value meaning "no pinned account — follow rotation order". */ export const ACTIVE_ACCOUNT_AUTO = 'auto' -/** The selector value meaning "no language override — follow the shell locale". */ -export const LANG_AUTO = 'auto' /** Tier display names, mirroring the web dropdown's headings (`model-select.ts`). */ const TIER_TITLES: Readonly> = { @@ -252,8 +250,8 @@ export interface CommandCodeTuiSettingsDeps { * Build the section descriptor. Pure, so tests can pin the exact fields * without a dsh-TUI host. * - * Field choices worth keeping: the two option-bearing fields (`activeAccount`, - * `lang`) are `text` + `options` rather than `select`, because a `select` + * Field choices worth keeping: the option-bearing field (`activeAccount`) is + * `text` + `options` rather than `select`, because a `select` * cannot express "unset" — cycling only ever lands on a declared option, so a * `select` would strand the user on a pinned value with no way back to * automatic. The `auto` sentinel plus a `parse` that clears the path keeps the @@ -284,13 +282,12 @@ export function buildCommandCodeTuiSection( .map((tier) => ({ id: `models-${tier}`, title: `${TIER_TITLES[tier] ?? tier} models`, - descriptions: { zh: `${TIER_TITLES[tier] ?? tier} 模型` }, })) // Models this snapshot cannot place (a tier added upstream) share the group // with the stored-but-unknown allowlist entries. const unranked = choices.filter((choice) => tierRank(choice.tier) === TIER_ORDER.length) const otherGroup = unranked.length > 0 || extras.length > 0 - ? [{ id: OTHER_GROUP_ID, title: 'Other models', descriptions: { zh: '其他模型' } }] + ? [{ id: OTHER_GROUP_ID, title: 'Other models' }] : [] /** @@ -335,13 +332,12 @@ export function buildCommandCodeTuiSection( return { ns: deps.ns, title: deps.title ?? 'Command Code', - descriptions: { zh: 'Command Code(非官方)' }, groups: [ - { id: 'connection', title: 'Connection', descriptions: { zh: '连接' } }, - { id: 'models', title: 'Models', descriptions: { zh: '模型' } }, + { id: 'connection', title: 'Connection' }, + { id: 'models', title: 'Models' }, ...tierGroups, ...otherGroup, - { id: 'advanced', title: 'Advanced', descriptions: { zh: '高级' } }, + { id: 'advanced', title: 'Advanced' }, ], fields: [ { @@ -349,20 +345,16 @@ export function buildCommandCodeTuiSection( group: 'connection', kind: 'text', label: 'API key', - descriptions: { zh: 'API 密钥' }, secret: { ref }, hint: `Stored in the credential store as ${ref}, never in settings.yaml.`, - hintDescriptions: { zh: `保存在凭据库(${ref}),不会写入 settings.yaml。` }, }, { path: ['apiBase'], group: 'connection', kind: 'text', label: 'API base', - descriptions: { zh: 'API 地址' }, placeholder: 'https://api.commandcode.ai', hint: 'Leave empty for the public Command Code Provider API.', - hintDescriptions: { zh: '留空即使用官方 Command Code Provider API。' }, parse: (text) => { const trimmed = text.trim() return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed } @@ -373,9 +365,7 @@ export function buildCommandCodeTuiSection( group: 'models', kind: 'boolean', label: 'Hide out-of-plan models', - descriptions: { zh: '隐藏套餐外的模型' }, hint: 'Keeps models above your subscription tier out of the picker. Fails open.', - hintDescriptions: { zh: '在选择器里隐藏超出当前订阅档位的模型;判断不出来时全部显示。' }, // Unset means "filter" at the adapter, so render the effective default // rather than letting the raw boolean format report an empty value. format: (value) => (value === false ? 'false' : 'true'), @@ -391,19 +381,15 @@ export function buildCommandCodeTuiSection( group: 'advanced', kind: 'text', label: 'Active account', - descriptions: { zh: '当前账号' }, hint: 'A pinned account id, or auto to follow the rotation order.', - hintDescriptions: { zh: '固定使用某个账号的 id;auto 表示按轮换顺序自动选择。' }, options: [ { value: ACTIVE_ACCOUNT_AUTO, label: 'Automatic (rotation order)', - descriptions: { zh: '自动(按轮换顺序)' }, }, ...slots.map((slot) => ({ value: slot.id, label: slot.label, - descriptions: { zh: slot.label }, })), ], format: (value) => (typeof value === 'string' && value.trim() !== '' ? value : ACTIVE_ACCOUNT_AUTO), @@ -414,27 +400,6 @@ export function buildCommandCodeTuiSection( : { kind: 'set', value: trimmed } }, }, - { - path: ['lang'], - group: 'advanced', - kind: 'text', - label: 'Command language', - descriptions: { zh: '命令语言' }, - hint: 'Language of the /commandcode dashboard; auto follows the shell locale.', - hintDescriptions: { zh: '/commandcode 用量面板的语言;auto 跟随终端 locale。' }, - options: [ - { value: LANG_AUTO, label: 'Automatic (shell locale)', descriptions: { zh: '自动(跟随终端 locale)' } }, - { value: 'zh', label: '中文' }, - { value: 'en', label: 'English' }, - ], - format: (value) => (value === 'zh' || value === 'en' ? value : LANG_AUTO), - parse: (text) => { - const trimmed = text.trim() - return trimmed === '' || trimmed === LANG_AUTO - ? { kind: 'clear' } - : { kind: 'set', value: trimmed } - }, - }, ], } } diff --git a/src/usage-remote.ts b/src/usage-remote.ts index d839466..9b499cd 100644 --- a/src/usage-remote.ts +++ b/src/usage-remote.ts @@ -1,16 +1,15 @@ /** * Host half of the account-usage Remote (`commandcode/report`). * - * The settings page's account card needs the same report the `/commandcode` - * command prints, but the browser never holds the API key — the fetch must - * run Host-side. This module exposes `adapter.getUsage()` through the Typert + * The settings page's account card needs per-account usage, but the browser + * never holds the API key — the fetch must run Host-side. This module exposes `adapter.getUsage()` through the Typert * Gateway: a `TypertRemoteService` provides the receiver the Gateway resolves, * and the shared strict descriptor (`src/usage-wire.ts`) is registered on the * `typert` registry so the Gateway claims the `commandcode/report` endpoint. * * The whole wiring rides an optional `ctx.inject(['typert'], ...)` fiber: a * profile without the web stack (no Typert registry, no Gateway) simply never - * activates it, exactly like the `/commandcode` command rides `commands`. + * activates it, exactly like the web-search provider rides `web`. * * @module dsh-commandcode-provider/usage-remote */ @@ -20,7 +19,9 @@ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type { CommandCodeAdapter, CommandCodeConnectionOptions } from './adapter.ts' import { USAGE_HOST_CONTRIBUTION } from './usage-wire.ts' import { MODELS_DESCRIPTOR } from './usage-wire.ts' -import type { CommandCodeAccountsReport, CommandCodeCatalog } from './usage-wire.ts' +import { PRICES_DESCRIPTOR } from './usage-wire.ts' +import type { CommandCodeAccountsReport, CommandCodeCatalog, CommandCodePriceTable } from './usage-wire.ts' +import { modelPriceTable } from './model-prices.ts' import { LOGIN_DESCRIPTORS } from './login-wire.ts' import type { CommandCodeLoginStatus } from './login-wire.ts' @@ -57,6 +58,13 @@ export interface CommandCodeUsageDeps Promise + /** + * Price-table source for the composer's session-cost figure. Defaults to the + * vendored snapshot, so the endpoint can never silently serve an empty table + * — an unpriced cost is the failure this feature is meant to remove. Override + * only to stub it in a test. + */ + prices?: () => CommandCodePriceTable /** * The browser-login flow (wired by the plugin entry). Absent means the * login endpoints answer `idle` / reject with a plain message — the page's @@ -102,8 +110,8 @@ export class CommandCodeUsageService { + return (this.deps.prices ?? modelPriceTable)() + } + /** * Start (or rejoin) a browser-login attempt and return its fresh status — * `waiting` carrying the Studio URL. Rejects when the flow cannot start @@ -177,13 +195,13 @@ export function applyUsageRemote( ctx.inject(['typert'], (remoteCtx) => { new CommandCodeUsageService(remoteCtx, deps) const registry = remoteCtx.typert as unknown as TypertContributionRegistry - // One registration carries the report endpoint, the models endpoint, and - // the login endpoints: the descriptors are unique per endpoint, and a - // single contribution keeps the Host's registry bookkeeping (and the - // Client mount) 1:1. + // One registration carries the report endpoint, the models endpoint, the + // price table, and the login endpoints: the descriptors are unique per + // endpoint, and a single contribution keeps the Host's registry + // bookkeeping (and the Client mount) 1:1. const unregister = registry.register({ ...USAGE_HOST_CONTRIBUTION, - invocations: [...USAGE_HOST_CONTRIBUTION.invocations, MODELS_DESCRIPTOR, ...LOGIN_DESCRIPTORS], + invocations: [...USAGE_HOST_CONTRIBUTION.invocations, MODELS_DESCRIPTOR, PRICES_DESCRIPTOR, ...LOGIN_DESCRIPTORS], }) // The registry's own effect would outlive this fiber; withdraw the // contribution when the plugin unloads. diff --git a/src/usage-wire.ts b/src/usage-wire.ts index f573c0d..1730286 100644 --- a/src/usage-wire.ts +++ b/src/usage-wire.ts @@ -2,9 +2,9 @@ * Wire contract for the Command Code account-usage Remote * (`commandcode/report`). * - * The settings page renders the same account/usage/credit facts the - * `/commandcode` command prints, but the browser never holds the API key — - * the report must be produced Host-side and cross the Connection RPC carrier. + * The settings page's account card renders per-account usage and credit + * facts, but the browser never holds the API key — the report must be + * produced Host-side and cross the Connection RPC carrier. * The harness exposes plugin-defined Host methods through the Typert Gateway: * the Host half registers a strict invocation descriptor against a Cordis * service (`src/usage-remote.ts`), and the browser half mounts the matching @@ -301,4 +301,154 @@ export const MODELS_DESCRIPTOR: InvocationDescriptor = export const MODELS_REMOTE_CONTRIBUTION: TypertRemoteContribution = { package: USAGE_REMOTE_PACKAGE, descriptors: [MODELS_DESCRIPTOR], +} + +// --------------------------------------------------------------------------- +// Model price table Remote (`commandcode/prices`) +// --------------------------------------------------------------------------- + +/** + * One model's per-token rates, in USD per 1,000,000 tokens — the unit the + * official pricing page publishes in. + */ +export interface CommandCodeModelRates { + /** Uncached (billed) input tokens. */ + inputCost: number + /** Completion tokens. */ + outputCost: number + /** Input tokens served from the provider's cache. */ + cacheReadCost: number + /** + * Input tokens written into the provider's cache. Present for a minority of + * models — the page publishes no cache-write rate for the rest, whose + * cache-write tokens are therefore UNPRICED. Do not substitute a multiple of + * the input rate for a missing value. + */ + cacheWriteCost?: number +} + +/** One model's rates plus the peak-hour override for time-of-day models. */ +export interface CommandCodeModelPrice extends CommandCodeModelRates { + /** + * Lookup key: the catalog model id when a catalog model maps to this row, + * otherwise the pricing page's own slug. A session reports catalog ids, so + * this is the primary key the browser looks up by. + */ + id: string + /** The pricing page's slug for this row — the secondary lookup key. */ + slug: string + /** + * Rates charged inside the peak windows. The row's own top-level rates are + * the off-peak rates, so a row WITH this block is time-of-day priced and a + * row without it is flat-priced. + */ + peak?: CommandCodeModelRates + /** + * Whether the model costs nothing on every plan right now (a free deal or a + * `:free` catalog variant). Served explicitly at zero rates so a surface can + * say "free" rather than showing nothing. + */ + free?: boolean +} + +/** The price-table Remote result: every known model's rates. */ +export interface CommandCodePriceTable { + /** + * Every priced model, keyed by {@link CommandCodeModelPrice.id} (catalog id + * first, pricing slug as the fallback) and carrying its slug as a second + * lookup key. A model absent from this list has no known price and must + * render no cost at all rather than a guess. + */ + models: CommandCodeModelPrice[] + /** + * Peak-pricing windows as `[startHour, endHour)` in UTC, end-exclusive, + * applying Monday–Friday only. Shipped with the table so the browser prices + * against the Host snapshot's schedule instead of restating it. + */ + peakHours: Array<[number, number]> +} + +/** Canonical `/` endpoint of the price-table Remote. */ +export const PRICES_ENDPOINT = 'commandcode/prices' + +/** + * The shared read/validate helpers for the price-table endpoint — its own + * instance so price boundary errors name `commandcode/prices`. + */ +const { + reject: priceReject, + record: priceRecord, + stringField: priceString, + numberField: priceNumber, + booleanField: priceBoolean, +} = makeBoundaryValidator('commandcode/prices result:') + +/** Parse one rate block (`rates`, or a model's `peak` override). */ +function parseRates(source: Record, field: string): CommandCodeModelRates { + const rates: CommandCodeModelRates = { + inputCost: priceNumber(source, 'inputCost', `${field}.inputCost`), + outputCost: priceNumber(source, 'outputCost', `${field}.outputCost`), + cacheReadCost: priceNumber(source, 'cacheReadCost', `${field}.cacheReadCost`), + } + // Optional on the wire: only a minority of models publish a cache-write + // rate, and a present non-number is a contract violation rather than a + // silent zero. + if (source.cacheWriteCost !== undefined) { + rates.cacheWriteCost = priceNumber(source, 'cacheWriteCost', `${field}.cacheWriteCost`) + } + return rates +} + +/** Parse one untrusted boundary value into a {@link CommandCodeModelPrice}. */ +function parseModelPrice(value: unknown): CommandCodeModelPrice { + const source = priceRecord(value, 'model') + const price: CommandCodeModelPrice = { + id: priceString(source, 'id', 'model.id'), + slug: priceString(source, 'slug', 'model.slug'), + ...parseRates(source, 'model'), + } + if (source.peak !== undefined) price.peak = parseRates(priceRecord(source.peak, 'model.peak'), 'model.peak') + if (source.free !== undefined) price.free = priceBoolean(source, 'free', 'model.free') + return price +} + +/** Parse the wire result into a {@link CommandCodePriceTable}. */ +function parsePriceTable(value: unknown): CommandCodePriceTable { + const source = priceRecord(value, 'result') + const models = source.models + if (!Array.isArray(models)) priceReject('models') + const peakHours = source.peakHours + if (!Array.isArray(peakHours)) priceReject('peakHours') + return { + models: (models as unknown[]).map(parseModelPrice), + peakHours: (peakHours as unknown[]).map((window) => { + if (!Array.isArray(window) || window.length !== 2) priceReject('peakHours[]') + const [start, end] = window as [unknown, unknown] + if (typeof start !== 'number' || typeof end !== 'number') priceReject('peakHours[]') + return [start, end] as [number, number] + }), + } +} + +/** The strict result codec for the price-table Remote. */ +export const pricesSchema: TypertSchema = { + parse: parsePriceTable, +} + +/** + * The price-table invocation descriptor, sharing the same `commandcodeUsage` + * service and `commandcode` namespace as the report and catalog endpoints. + */ +export const PRICES_DESCRIPTOR: InvocationDescriptor = + makeRemoteDescriptor( + PRICES_ENDPOINT, + 'prices', + `${USAGE_REMOTE_PACKAGE}#CommandCodePriceTable`, + pricesSchema, + ) + +/** The Client-face contribution for the price-table endpoint. */ +export const PRICES_REMOTE_CONTRIBUTION: TypertRemoteContribution = { + package: USAGE_REMOTE_PACKAGE, + descriptors: [PRICES_DESCRIPTOR], } \ No newline at end of file diff --git a/src/wire-shared.ts b/src/wire-shared.ts index 1a8e503..647c99a 100644 --- a/src/wire-shared.ts +++ b/src/wire-shared.ts @@ -17,7 +17,7 @@ import type { InvocationDescriptor, TypertSchema } from '@deepseek-ai/dsh-typert-protocol' /** The npm package identity every contribution and descriptor claims. */ -export const REMOTE_PACKAGE = '@mars-sea/dsh-commandcode-provider' +export const REMOTE_PACKAGE = '@xer-on/dsh-commandcode-provider' /** The Cordis service key the Gateway resolves every Command Code Remote from. */ export const REMOTE_SERVICE = 'commandcodeUsage'