diff options
Diffstat (limited to 'src/features/chat/model-select.ts')
| -rw-r--r-- | src/features/chat/model-select.ts | 147 |
1 files changed, 141 insertions, 6 deletions
diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts index 602e0ef..bebad2c 100644 --- a/src/features/chat/model-select.ts +++ b/src/features/chat/model-select.ts @@ -29,15 +29,80 @@ export function joinModelName(key: string, model: string): string { /** Distinct keys across all models, in first-seen order. */ export function modelKeys(models: readonly string[]): string[] { const seen = new Set<string>(); - const out: string[] = []; + const openCodeKeys: Array<{ key: string; n: number }> = []; + const otherKeys: string[] = []; for (const full of models) { const { key } = splitModelName(full); - if (!seen.has(key)) { - seen.add(key); - out.push(key); - } + if (seen.has(key)) continue; + seen.add(key); + const n = openCodeNumber(key); + if (n !== null) openCodeKeys.push({ key, n }); + else otherKeys.push(key); } - return out; + // Numbered OpenCode credentials sort NUMERICALLY (#2 before #10), placed + // before other providers (the primary credential in this app). Others keep + // first-seen order. + openCodeKeys.sort((a, b) => a.n - b.n); + return [...openCodeKeys.map((e) => e.key), ...otherKeys]; +} + +/** + * The stable numeric identity of a numbered OpenCode provider id/key + * (`opencode-1` → 1, `opencode-10` → 10), or `null` when the id is not a + * numbered OpenCode key. Numbers are canonical positive integers with no + * leading zeros. + */ +export function openCodeNumber(key: string): number | null { + const m = /^opencode-([1-9][0-9]*)$/.exec(key); + return m === null ? null : Number(m[1]); +} + +/** True iff `id` is a numbered OpenCode provider/key (`opencode-<n>`). */ +export function isNumberedOpenCode(id: string): boolean { + return openCodeNumber(id) !== null; +} + +/** + * The credential key of the Claude Code subscription (the external Claude + * extension — `auth-claude` + `provider-anthropic`). The backend registers it + * under `DISPATCH_CLAUDE_CREDENTIAL`, default `"claude"`, so its models arrive + * as `claude/<model>`. + */ +export const CLAUDE_SUBSCRIPTION_KEY = "claude"; + +/** + * True iff `full` is a model served through the Claude Code subscription + * (`claude/<model>`). Gates the cache timer (that subscription's prompt cache + * expires after 5 minutes). A bare key with no model is not a model. + */ +export function isClaudeSubscriptionModel(full: string | undefined): boolean { + if (full === undefined) return false; + const { key, model } = splitModelName(full); + return key === CLAUDE_SUBSCRIPTION_KEY && model !== ""; +} + +/** + * A friendly human label for a model key/provider id: `opencode-<n>` → + * "OpenCode #<n>"; any other id is returned unchanged. The RAW id is still what + * gets selected/persisted — this only affects display. + */ +export function modelKeyLabel(key: string): string { + const n = openCodeNumber(key); + return n === null ? key : `OpenCode #${n}`; +} + +/** + * Migration helper: rewrite a legacy `opencode/<model>` full model name to the + * numbered `opencode-1/<model>` namespace. Returns the input unchanged for + * anything that is not `opencode/`-prefixed (already-numbered names, other + * providers). Idempotent. This is the FE-side persisted-state migration; the + * backend performs its own equivalent on its own store. + */ +export function migrateLegacyModelName(full: string): string { + const legacyPrefix = "opencode/"; + if (!full.startsWith(legacyPrefix)) return full; + const model = full.slice(legacyPrefix.length); + return model.length === 0 ? full : `opencode-1/${model}`; } /** The model suffixes available under a given key, in order. */ @@ -62,3 +127,73 @@ export function isVisionModel( ): boolean { return modelInfo[fullName]?.vision === true; } + +// ── Named Agents synthetic provider (plan §6) ──────────────────────────────── +// +// The regular model picker gains a "Named Agents" provider at the TOP of the +// provider list. Selecting it + a Named Agent name resolves to that agent's raw +// model for the conversation AND records a snapshot so the selection survives +// later edits to the shared preset. These helpers are structural (they take a +// minimal NamedAgent-ish shape) so `model-select` stays decoupled from the +// named-agents feature up the chain; the picker binds the real types. + +/** A minimal Named Agent shape the synthetic provider needs (structural). */ +export interface NamedAgentLike { + readonly id: string; + readonly name: string; + readonly model: string; +} + +/** A minimal per-conversation Named Agent selection snapshot (structural). */ +export interface NamedAgentSnapshotLike { + readonly namedAgentId: string; + readonly namedAgentName: string; + readonly model: string; +} + +/** + * The synthetic "Named Agents" provider id used by the regular model picker + * (plan §6). Deliberately a value that can never collide with a real provider + * (`<credentialName>`), so the provider dropdown treats it as the first option. + */ +export const NAMED_AGENTS_PROVIDER = "__named_agents__"; + +/** The friendly label of the synthetic Named Agents provider. */ +export function namedAgentsProviderLabel(): string { + return "Named Agents"; +} + +/** + * The provider dropdown options for the picker: the synthetic "Named Agents" + * provider ALWAYS first, then the raw providers (via `modelKeys`). The synthetic + * entry stays present even with zero Named Agents (plan §6 shows a disabled + * "No named agents configured" model option). + */ +export function providerKeysWithNamedAgents(models: readonly string[]): string[] { + return [NAMED_AGENTS_PROVIDER, ...modelKeys(models)]; +} + +/** The Named Agent names offered in the model dropdown when the synthetic provider is selected. */ +export function namedAgentNames(agents: readonly NamedAgentLike[]): string[] { + return agents.map((a) => a.name); +} + +/** + * Whether a full raw model string corresponds to a Named Agent snapshot — + * i.e. the conversation was selected via a Named Agent whose captured raw model + * equals `selected`. When true, the picker displays `Named Agents -> <name>`. + */ +export function isNamedAgentSelection( + selected: string, + snapshot: NamedAgentSnapshotLike | null, +): boolean { + return snapshot !== null && snapshot.model === selected; +} + +/** + * The snapshot label shown in the model dropdown for a Named Agent selection: + * "Named Agents -> <name>". Pure display. + */ +export function namedAgentSelectionLabel(name: string): string { + return `${namedAgentsProviderLabel()} -> ${name}`; +} |
