From 8f1db7d06d0141ec8545eab7fa2205103ea73caf Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:36:38 -0500 Subject: [PATCH] fix(tui): align cli model picker behavior (#34571) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- packages/tui/src/app.tsx | 6 +- .../tui/src/component/dialog-integration.tsx | 99 +++++++++++++++---- packages/tui/src/component/dialog-model.tsx | 65 +++++++----- .../test/cli/cmd/tui/model-options.test.ts | 46 ++++----- 4 files changed, 145 insertions(+), 71 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 86f0edcac37..969d1664ed9 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -748,7 +748,11 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi suggested: !connected(), slashName: "connect", run: () => { - dialog.replace(() => ) + dialog.replace(() => ( + dialog.replace(() => )} + /> + )) }, category: "Integration", }, diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index a1ef0b05e79..3b4b922134c 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -24,6 +24,7 @@ const INTEGRATION_PRIORITY: Record = { type ConnectMethod = Exclude type IntegrationAttempt = IntegrationConnectOauthOutput["data"] +type OnIntegrationConnected = (providerID?: string) => void export function integrationOptions(list: IntegrationInfo[]) { return list.toSorted( @@ -52,7 +53,7 @@ export function connectionSummary(integration: IntegrationInfo) { .join(", ") } -export function DialogIntegration() { +export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) { const data = useData() const dialog = useDialog() const { theme } = useTheme() @@ -70,8 +71,8 @@ export function DialogIntegration() { gutter: connected ? () => : undefined, onSelect: () => credentialConnections(integration).length - ? manageConnections(integration, methods, dialog) - : selectMethod(integration, methods, dialog), + ? manageConnections(integration, methods, dialog, props.onConnected) + : selectMethod(integration, methods, dialog, props.onConnected), } }), ) @@ -89,6 +90,7 @@ function manageConnections( integration: IntegrationInfo, methods: ConnectMethod[], dialog: ReturnType, + onConnected?: OnIntegrationConnected, ) { dialog.replace(() => { const data = useData() @@ -103,7 +105,7 @@ function manageConnections( { title: "Add connection", value: "add", - onSelect: () => selectMethod(integration, methods, dialog), + onSelect: () => selectMethod(integration, methods, dialog, onConnected), }, ] : []), @@ -123,29 +125,43 @@ function manageConnections( }) } -function selectMethod(integration: IntegrationInfo, methods: ConnectMethod[], dialog: ReturnType) { - if (methods.length === 1) return openMethod(integration, methods[0], dialog) +function selectMethod( + integration: IntegrationInfo, + methods: ConnectMethod[], + dialog: ReturnType, + onConnected?: OnIntegrationConnected, +) { + if (methods.length === 1) return openMethod(integration, methods[0], dialog, onConnected) dialog.replace(() => ( ({ title: method.type === "key" ? (method.label ?? "API key") : method.label, value: method.type === "key" ? "key" : method.id, - onSelect: () => openMethod(integration, method, dialog), + onSelect: () => openMethod(integration, method, dialog, onConnected), }))} /> )) } -function openMethod(integration: IntegrationInfo, method: ConnectMethod, dialog: ReturnType) { +function openMethod( + integration: IntegrationInfo, + method: ConnectMethod, + dialog: ReturnType, + onConnected?: OnIntegrationConnected, +) { if (method.type === "key") { - dialog.replace(() => ) + dialog.replace(() => ) return } - void beginOAuth(integration, method, dialog) + void beginOAuth(integration, method, dialog, onConnected) } -function KeyMethod(props: { integration: IntegrationInfo; method: Extract }) { +function KeyMethod(props: { + integration: IntegrationInfo + method: Extract + onConnected?: OnIntegrationConnected +}) { const data = useData() const dialog = useDialog() const sdk = useSDK() @@ -165,7 +181,7 @@ function KeyMethod(props: { integration: IntegrationInfo; method: Extract connected(props.integration.name, data, dialog, toast)) + .then(() => connected(props.integration, data, dialog, toast, props.onConnected)) .catch((cause) => setError(message(cause))) }} description={() => {(value) => {value()}}} @@ -177,16 +193,20 @@ async function beginOAuth( integration: IntegrationInfo, method: IntegrationOAuthMethod, dialog: ReturnType, + onConnected?: OnIntegrationConnected, ) { const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {} if (inputs === null) return - dialog.replace(() => ) + dialog.replace(() => ( + + )) } function OAuthStarting(props: { integration: IntegrationInfo method: IntegrationOAuthMethod inputs: Record + onConnected?: OnIntegrationConnected }) { const data = useData() const dialog = useDialog() @@ -204,12 +224,22 @@ function OAuthStarting(props: { .then((result) => { if (result.data.mode === "code") { dialog.replace(() => ( - + )) return } dialog.replace(() => ( - + )) }) .catch((cause) => { @@ -221,7 +251,12 @@ function OAuthStarting(props: { return } -function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) { +function OAuthAuto(props: { + integration: IntegrationInfo + title: string + attempt: IntegrationAttempt + onConnected?: OnIntegrationConnected +}) { const data = useData() const dialog = useDialog() const sdk = useSDK() @@ -258,7 +293,7 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt } settled = true if (status.status === "complete") { - void connected(props.integration.name, data, dialog, toast) + void connected(props.integration, data, dialog, toast, props.onConnected) return } toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" }) @@ -289,7 +324,12 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt ) } -function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) { +function OAuthCode(props: { + integration: IntegrationInfo + title: string + attempt: IntegrationAttempt + onConnected?: OnIntegrationConnected +}) { const data = useData() const dialog = useDialog() const sdk = useSDK() @@ -313,7 +353,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt .attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code }) .then(() => { settled = true - return connected(props.integration.name, data, dialog, toast) + return connected(props.integration, data, dialog, toast, props.onConnected) }) .catch((cause) => setError(message(cause))) }} @@ -407,20 +447,37 @@ async function promptInputs( } async function connected( - name: string, + integration: IntegrationInfo, data: ReturnType, dialog: ReturnType, toast: ReturnType, + onConnected?: OnIntegrationConnected, ) { await Promise.all([ data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh(), ]) - toast.show({ variant: "success", message: `Connected ${name}` }) + toast.show({ variant: "success", message: `Connected ${integration.name}` }) + if (onConnected) { + onConnected(providerID(data, integration.id)) + return + } dialog.clear() } +function providerID(data: ReturnType, integrationID: string) { + const models = data.location.model.list() ?? [] + const matches = (data.location.provider.list() ?? []).filter( + (provider) => provider.integrationID === integrationID || provider.id === integrationID, + ) + return ( + matches.find((provider) => + models.some((model) => model.providerID === provider.id && model.status !== "deprecated"), + )?.id ?? matches[0]?.id + ) +} + async function disconnected( name: string, data: ReturnType, diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 065338f1cd7..fcbc4729654 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -1,6 +1,5 @@ import { createMemo, createSignal } from "solid-js" import { useLocal } from "../context/local" -import { sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { DialogIntegration } from "./dialog-integration" @@ -62,19 +61,24 @@ export function DialogModel(props: { providerID?: string }) { models() .filter((model) => model.status !== "deprecated") .filter((model) => (props.providerID ? model.providerID === props.providerID : true)) - .map((model) => ({ - value: { providerID: model.providerID, modelID: model.id }, - title: model.name, - releaseDate: model.time.released, - description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id) - ? "(Favorite)" - : undefined, - category: connected() ? (providers().get(model.providerID)?.name ?? model.providerID) : undefined, - footer: free(model) ? "Free" : undefined, - onSelect() { - onSelect(model.providerID, model.id) - }, - })) + .map((model) => { + const provider = providers().get(model.providerID) + return { + value: { providerID: model.providerID, modelID: model.id }, + providerID: model.providerID, + providerName: provider?.name ?? model.providerID, + title: model.name, + releaseDate: model.time.released, + description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id) + ? "(Favorite)" + : undefined, + category: connected() ? (provider?.name ?? model.providerID) : undefined, + footer: free(model) ? "Free" : undefined, + onSelect() { + onSelect(model.providerID, model.id) + }, + } + }) .filter((option) => { if (!showSections) return true if ( @@ -89,7 +93,6 @@ export function DialogModel(props: { providerID?: string }) { return false return true }), - props.providerID !== undefined, ) if (needle) { @@ -130,7 +133,11 @@ export function DialogModel(props: { providerID?: string }) { command: "model.dialog.provider", title: connected() ? "Connect integration" : "View all integrations", onTrigger() { - dialog.replace(() => ) + dialog.replace(() => ( + dialog.replace(() => )} + /> + )) }, }, { @@ -151,17 +158,21 @@ export function DialogModel(props: { providerID?: string }) { ) } -export function sortModelOptions( - options: T[], - newestFirst: boolean, -) { - if (newestFirst) return sortBy(options, [(option) => option.releaseDate, "desc"], (option) => option.title) - return sortBy( - options, - (option) => option.footer !== "Free", - [(option) => option.releaseDate, "desc"], - (option) => option.title, - ) +export function sortModelOptions< + T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string }, +>(options: T[]) { + return options.toSorted((a, b) => { + const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode") + if (provider !== 0) return provider + + const name = (a.providerName ?? "").localeCompare(b.providerName ?? "") + if (name !== 0) return name + + const release = Number(b.releaseDate) - Number(a.releaseDate) + if (release !== 0) return release + + return a.title.localeCompare(b.title) + }) } function free(model: { cost: Array<{ input: number }> }) { diff --git a/packages/tui/test/cli/cmd/tui/model-options.test.ts b/packages/tui/test/cli/cmd/tui/model-options.test.ts index 97bae7532fe..38aa2cff8a5 100644 --- a/packages/tui/test/cli/cmd/tui/model-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/model-options.test.ts @@ -2,31 +2,33 @@ import { describe, expect, test } from "bun:test" import { sortModelOptions } from "../../../../src/component/dialog-model" describe("sortModelOptions", () => { - test("orders provider-scoped model choices by newest release first", () => { - const sorted = sortModelOptions( - [ - { title: "GPT 5.2", releaseDate: "2025-12-11" }, - { title: "GPT 5.4", releaseDate: "2026-03-05" }, - { title: "GPT 5.1", releaseDate: "2025-11-13" }, - ], - true, - ) + test("orders opencode models before other providers", () => { + const sorted = sortModelOptions([ + { providerID: "openai", providerName: "OpenAI", releaseDate: 3, title: "GPT 5" }, + { providerID: "opencode", providerName: "OpenCode", releaseDate: 1, title: "Claude Sonnet 4" }, + { providerID: "anthropic", providerName: "Anthropic", releaseDate: 2, title: "Claude Opus 4" }, + ]) - expect(sorted.map((model) => model.title)).toEqual(["GPT 5.4", "GPT 5.2", "GPT 5.1"]) + expect(sorted.map((model) => model.title)).toEqual(["Claude Sonnet 4", "Claude Opus 4", "GPT 5"]) }) - test("orders regular model choices free-first and then newest-first", () => { - const sorted = sortModelOptions( - [ - { title: "GLM 5", releaseDate: "2025-07-28" }, - { title: "GLM 5.1", releaseDate: "2025-12-09" }, - { title: "GLM 5.2", releaseDate: "2026-02-16" }, - { title: "Free old", releaseDate: "2024-01-01", footer: "Free" }, - { title: "Free new", releaseDate: "2025-01-01", footer: "Free" }, - ], - false, - ) + test("orders provider groups by provider name and models by newest release", () => { + const sorted = sortModelOptions([ + { providerID: "google", providerName: "Google", releaseDate: 5, title: "Gemini 2.5 Pro" }, + { providerID: "anthropic", providerName: "Anthropic", releaseDate: 4, title: "Claude Sonnet 4" }, + { providerID: "anthropic", providerName: "Anthropic", releaseDate: 6, title: "Claude Opus 4" }, + { providerID: "openai", providerName: "OpenAI", releaseDate: 7, title: "GPT 5" }, + ]) - expect(sorted.map((model) => model.title)).toEqual(["Free new", "Free old", "GLM 5.2", "GLM 5.1", "GLM 5"]) + expect(sorted.map((model) => model.title)).toEqual(["Claude Opus 4", "Claude Sonnet 4", "Gemini 2.5 Pro", "GPT 5"]) + }) + + test("falls back to title when release dates match within a provider", () => { + const sorted = sortModelOptions([ + { providerID: "anthropic", providerName: "Anthropic", releaseDate: 5, title: "Claude Sonnet 4" }, + { providerID: "anthropic", providerName: "Anthropic", releaseDate: 5, title: "Claude Opus 4" }, + ]) + + expect(sorted.map((model) => model.title)).toEqual(["Claude Opus 4", "Claude Sonnet 4"]) }) })