diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model-utils.ts b/packages/opencode/src/cli/cmd/tui/component/dialog-model-utils.ts new file mode 100644 index 000000000000..5ff5f9bf718a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model-utils.ts @@ -0,0 +1,85 @@ +import { map, pipe, flatMap, entries, filter, sortBy } from "remeda" + +export type ModelRef = { + providerID: string + modelID: string +} + +export type ModelInfo = { + id: string + name?: string + providerID?: string + status?: string + cost?: { + input?: number + } +} + +export type ProviderInfo = { + id: string + name: string + models: Record +} + +export function buildSectionOptions(input: { + items: ModelRef[] + category: string + providers: ProviderInfo[] + showSections: boolean +}) { + if (!input.showSections) return [] + return input.items.flatMap((item) => { + const provider = input.providers.find((x) => x.id === item.providerID) + if (!provider) return [] + const model = provider.models[item.modelID] + if (!model) return [] + return [ + { + key: item, + value: { providerID: provider.id, modelID: model.id, section: input.category }, + title: model.name ?? item.modelID, + description: provider.name, + category: input.category, + disabled: provider.id === "opencode" && model.id.includes("-nano"), + footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + }, + ] + }) +} + +export function buildProviderOptions(input: { + providers: ProviderInfo[] + favorites: ModelRef[] + connected: boolean + providerID?: string +}) { + return pipe( + input.providers, + sortBy( + (provider) => provider.id !== "opencode", + (provider) => provider.name, + ), + flatMap((provider) => + pipe( + provider.models, + entries(), + filter(([_, info]) => info.status !== "deprecated"), + filter(([_, info]) => (input.providerID ? info.providerID === input.providerID : true)), + map(([model, info]) => ({ + value: { providerID: provider.id, modelID: model }, + title: info.name ?? model, + description: input.favorites.some((item) => item.providerID === provider.id && item.modelID === model) + ? "(Favorite)" + : undefined, + category: input.connected ? provider.name : undefined, + disabled: provider.id === "opencode" && model.includes("-nano"), + footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + })), + sortBy( + (x) => x.footer !== "Free", + (x) => x.title, + ), + ), + ), + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index c30b8d12a933..05e06ea05922 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -1,11 +1,12 @@ import { createMemo, createSignal } from "solid-js" import { useLocal } from "@tui/context/local" import { useSync } from "@tui/context/sync" -import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda" +import { map, pipe, take } from "remeda" import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { useKeybind } from "../context/keybind" +import { buildSectionOptions, buildProviderOptions } from "./dialog-model-utils" import * as fuzzysort from "fuzzysort" export function useConnected() { @@ -33,80 +34,46 @@ export function DialogModel(props: { providerID?: string }) { const favorites = connected() ? local.model.favorite() : [] const recents = local.model.recent() - function toOptions(items: typeof favorites, category: string) { - if (!showSections) return [] - return items.flatMap((item) => { - const provider = sync.data.provider.find((x) => x.id === item.providerID) - if (!provider) return [] - const model = provider.models[item.modelID] - if (!model) return [] - return [ - { - key: item, - value: { providerID: provider.id, modelID: model.id }, - title: model.name ?? item.modelID, - description: provider.name, - category, - disabled: provider.id === "opencode" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect: () => { - dialog.clear() - local.model.set({ providerID: provider.id, modelID: model.id }, { recent: true }) - }, - }, - ] - }) - } + const favoriteOptions = buildSectionOptions({ + items: favorites, + category: "Favorites", + providers: sync.data.provider, + showSections, + }).map((option) => ({ + ...option, + onSelect: () => { + dialog.clear() + local.model.set({ providerID: option.value.providerID, modelID: option.value.modelID }, { recent: true }) + }, + })) - const favoriteOptions = toOptions(favorites, "Favorites") - const recentOptions = toOptions( - recents.filter( + const recentOptions = buildSectionOptions({ + items: recents.filter( (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), ), - "Recent", - ) + category: "Recent", + providers: sync.data.provider, + showSections, + }).map((option) => ({ + ...option, + onSelect: () => { + dialog.clear() + local.model.set({ providerID: option.value.providerID, modelID: option.value.modelID }, { recent: true }) + }, + })) - const providerOptions = pipe( - sync.data.provider, - sortBy( - (provider) => provider.id !== "opencode", - (provider) => provider.name, - ), - flatMap((provider) => - pipe( - provider.models, - entries(), - filter(([_, info]) => info.status !== "deprecated"), - filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), - map(([model, info]) => ({ - value: { providerID: provider.id, modelID: model }, - title: info.name ?? model, - description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) - ? "(Favorite)" - : undefined, - category: connected() ? provider.name : undefined, - disabled: provider.id === "opencode" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect() { - dialog.clear() - local.model.set({ providerID: provider.id, modelID: model }, { recent: true }) - }, - })), - filter((x) => { - if (!showSections) return true - if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID)) - return false - if (recents.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID)) - return false - return true - }), - sortBy( - (x) => x.footer !== "Free", - (x) => x.title, - ), - ), - ), - ) + const providerOptions = buildProviderOptions({ + providers: sync.data.provider, + favorites, + connected: connected(), + providerID: props.providerID, + }).map((option) => ({ + ...option, + onSelect() { + dialog.clear() + local.model.set(option.value, { recent: true }) + }, + })) const popularProviders = !connected() ? pipe( @@ -151,7 +118,8 @@ export function DialogModel(props: { providerID?: string }) { title: "Favorite", disabled: !connected(), onTrigger: (option) => { - local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) + const value = option.value as { providerID: string; modelID: string } + local.model.toggleFavorite({ providerID: value.providerID, modelID: value.modelID }) }, }, ]} diff --git a/packages/opencode/test/cli/tui/dialog-model.test.ts b/packages/opencode/test/cli/tui/dialog-model.test.ts new file mode 100644 index 000000000000..4b026bee06c1 --- /dev/null +++ b/packages/opencode/test/cli/tui/dialog-model.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from "bun:test" +import { buildProviderOptions, buildSectionOptions } from "../../../src/cli/cmd/tui/component/dialog-model-utils" + +function provider(id: string, name: string, models: Record) { + return { id, name, models } +} + +describe("dialog-model", () => { + test("models in recents still appear in provider groups", () => { + const providers = [ + provider("anthropic", "Anthropic", { + "claude-3-5-sonnet": { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", providerID: "anthropic" }, + }), + ] + const recents = [{ providerID: "anthropic", modelID: "claude-3-5-sonnet" }] + + const recentOptions = buildSectionOptions({ + items: recents, + category: "Recent", + providers, + showSections: true, + }) + const providerOptions = buildProviderOptions({ + providers, + favorites: [], + connected: true, + }) + + expect(recentOptions).toHaveLength(1) + expect(providerOptions.map((x) => x.value)).toContainEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet" }) + }) + + test("section options have unique values with section discriminator", () => { + const providers = [ + provider("anthropic", "Anthropic", { + "claude-3-5-sonnet": { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", providerID: "anthropic" }, + }), + ] + const favorites = [{ providerID: "anthropic", modelID: "claude-3-5-sonnet" }] + const recents = [{ providerID: "anthropic", modelID: "claude-3-5-sonnet" }] + + const favoriteOptions = buildSectionOptions({ items: favorites, category: "Favorites", providers, showSections: true }) + const recentOptions = buildSectionOptions({ items: recents, category: "Recent", providers, showSections: true }) + const providerOptions = buildProviderOptions({ providers, favorites, connected: true }) + + // Section items must not share value identity with provider items + const providerValue = providerOptions[0]?.value + const favoriteValue = favoriteOptions[0]?.value + const recentValue = recentOptions[0]?.value + expect(favoriteValue).not.toEqual(providerValue) + expect(recentValue).not.toEqual(providerValue) + expect(favoriteValue).not.toEqual(recentValue) + + // Section values carry the section discriminator + expect(favoriteValue).toMatchObject({ providerID: "anthropic", modelID: "claude-3-5-sonnet", section: "Favorites" }) + expect(recentValue).toMatchObject({ providerID: "anthropic", modelID: "claude-3-5-sonnet", section: "Recent" }) + + // Provider values remain plain + expect(providerValue).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet" }) + }) + + test("models in favorites still appear in provider groups", () => { + const providers = [ + provider("anthropic", "Anthropic", { + "claude-3-5-sonnet": { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", providerID: "anthropic" }, + }), + ] + const favorites = [{ providerID: "anthropic", modelID: "claude-3-5-sonnet" }] + + const result = buildProviderOptions({ + providers, + favorites, + connected: true, + }) + + expect(result.map((x) => x.value)).toContainEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet" }) + expect(result[0]?.description).toBe("(Favorite)") + }) + + test("models in both recents and favorites still appear in provider groups", () => { + const providers = [ + provider("anthropic", "Anthropic", { + "claude-3-5-sonnet": { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", providerID: "anthropic" }, + }), + ] + const favorites = [{ providerID: "anthropic", modelID: "claude-3-5-sonnet" }] + const recents = [{ providerID: "anthropic", modelID: "claude-3-5-sonnet" }] + + const recentOptions = buildSectionOptions({ + items: recents, + category: "Recent", + providers, + showSections: true, + }) + const providerOptions = buildProviderOptions({ + providers, + favorites, + connected: true, + }) + + expect(recentOptions).toHaveLength(1) + expect(providerOptions.map((x) => x.value)).toContainEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet" }) + expect(providerOptions[0]?.description).toBe("(Favorite)") + }) + + test("empty recents and favorites keeps all provider models", () => { + const providers = [ + provider("anthropic", "Anthropic", { + "claude-3-5-sonnet": { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", providerID: "anthropic" }, + }), + provider("openai", "OpenAI", { + "gpt-4o": { id: "gpt-4o", name: "GPT-4o", providerID: "openai" }, + }), + ] + + const result = buildProviderOptions({ + providers, + favorites: [], + connected: true, + }) + + expect(result.map((x) => x.value)).toEqual([ + { providerID: "anthropic", modelID: "claude-3-5-sonnet" }, + { providerID: "openai", modelID: "gpt-4o" }, + ]) + }) + + test("deprecated models are excluded from provider groups", () => { + const providers = [ + provider("anthropic", "Anthropic", { + "claude-3-5-sonnet": { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", providerID: "anthropic" }, + "claude-2": { id: "claude-2", name: "Claude 2", providerID: "anthropic", status: "deprecated" }, + }), + ] + + const result = buildProviderOptions({ + providers, + favorites: [], + connected: true, + }) + + expect(result.map((x) => x.value)).toEqual([{ providerID: "anthropic", modelID: "claude-3-5-sonnet" }]) + }) + + test("provider ordering keeps opencode first then alphabetical", () => { + const providers = [ + provider("zeta", "Zeta", { + "zeta-model": { id: "zeta-model", providerID: "zeta" }, + }), + provider("opencode", "OpenCode", { + "opencode-base": { id: "opencode-base", providerID: "opencode", cost: { input: 0 } }, + }), + provider("anthropic", "Anthropic", { + "claude-3-5-sonnet": { id: "claude-3-5-sonnet", providerID: "anthropic" }, + }), + ] + + const result = buildProviderOptions({ + providers, + favorites: [], + connected: true, + }) + + expect(result.map((x) => x.category)).toEqual(["OpenCode", "Anthropic", "Zeta"]) + expect(result[0]?.footer).toBe("Free") + }) + + test("all composed option values produce unique JSON.stringify ids (no duplicate DOM ids)", () => { + const providers = [ + provider("anthropic", "Anthropic", { + "claude-3-5-sonnet": { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", providerID: "anthropic" }, + "claude-3-opus": { id: "claude-3-opus", name: "Claude 3 Opus", providerID: "anthropic" }, + }), + provider("openai", "OpenAI", { + "gpt-4o": { id: "gpt-4o", name: "GPT-4o", providerID: "openai" }, + }), + ] + const favorites = [{ providerID: "anthropic", modelID: "claude-3-5-sonnet" }] + const recents = [ + { providerID: "anthropic", modelID: "claude-3-5-sonnet" }, + { providerID: "openai", modelID: "gpt-4o" }, + ] + + const favoriteOptions = buildSectionOptions({ items: favorites, category: "Favorites", providers, showSections: true }) + const recentOptions = buildSectionOptions({ + items: recents.filter((r) => !favorites.some((f) => f.providerID === r.providerID && f.modelID === r.modelID)), + category: "Recent", + providers, + showSections: true, + }) + const providerOptions = buildProviderOptions({ providers, favorites, connected: true }) + + const all = [...favoriteOptions, ...recentOptions, ...providerOptions] + const ids = all.map((o) => JSON.stringify(o.value)) + expect(new Set(ids).size).toBe(ids.length) + }) +})