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] <opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-06-30 09:36:38 -05:00 committed by GitHub
commit 8f1db7d06d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 145 additions and 71 deletions

View file

@ -748,7 +748,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
suggested: !connected(), suggested: !connected(),
slashName: "connect", slashName: "connect",
run: () => { run: () => {
dialog.replace(() => <DialogIntegration />) dialog.replace(() => (
<DialogIntegration
onConnected={(providerID) => dialog.replace(() => <DialogModel providerID={providerID} />)}
/>
))
}, },
category: "Integration", category: "Integration",
}, },

View file

@ -24,6 +24,7 @@ const INTEGRATION_PRIORITY: Record<string, number> = {
type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }> type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
type IntegrationAttempt = IntegrationConnectOauthOutput["data"] type IntegrationAttempt = IntegrationConnectOauthOutput["data"]
type OnIntegrationConnected = (providerID?: string) => void
export function integrationOptions(list: IntegrationInfo[]) { export function integrationOptions(list: IntegrationInfo[]) {
return list.toSorted( return list.toSorted(
@ -52,7 +53,7 @@ export function connectionSummary(integration: IntegrationInfo) {
.join(", ") .join(", ")
} }
export function DialogIntegration() { export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) {
const data = useData() const data = useData()
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
@ -70,8 +71,8 @@ export function DialogIntegration() {
gutter: connected ? () => <text fg={theme.success}></text> : undefined, gutter: connected ? () => <text fg={theme.success}></text> : undefined,
onSelect: () => onSelect: () =>
credentialConnections(integration).length credentialConnections(integration).length
? manageConnections(integration, methods, dialog) ? manageConnections(integration, methods, dialog, props.onConnected)
: selectMethod(integration, methods, dialog), : selectMethod(integration, methods, dialog, props.onConnected),
} }
}), }),
) )
@ -89,6 +90,7 @@ function manageConnections(
integration: IntegrationInfo, integration: IntegrationInfo,
methods: ConnectMethod[], methods: ConnectMethod[],
dialog: ReturnType<typeof useDialog>, dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) { ) {
dialog.replace(() => { dialog.replace(() => {
const data = useData() const data = useData()
@ -103,7 +105,7 @@ function manageConnections(
{ {
title: "Add connection", title: "Add connection",
value: "add", 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<typeof useDialog>) { function selectMethod(
if (methods.length === 1) return openMethod(integration, methods[0], dialog) integration: IntegrationInfo,
methods: ConnectMethod[],
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
if (methods.length === 1) return openMethod(integration, methods[0], dialog, onConnected)
dialog.replace(() => ( dialog.replace(() => (
<DialogSelect <DialogSelect
title={`Connect ${integration.name}`} title={`Connect ${integration.name}`}
options={methods.map((method) => ({ options={methods.map((method) => ({
title: method.type === "key" ? (method.label ?? "API key") : method.label, title: method.type === "key" ? (method.label ?? "API key") : method.label,
value: method.type === "key" ? "key" : method.id, 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<typeof useDialog>) { function openMethod(
integration: IntegrationInfo,
method: ConnectMethod,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
if (method.type === "key") { if (method.type === "key") {
dialog.replace(() => <KeyMethod integration={integration} method={method} />) dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />)
return return
} }
void beginOAuth(integration, method, dialog) void beginOAuth(integration, method, dialog, onConnected)
} }
function KeyMethod(props: { integration: IntegrationInfo; method: Extract<ConnectMethod, { type: "key" }> }) { function KeyMethod(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "key" }>
onConnected?: OnIntegrationConnected
}) {
const data = useData() const data = useData()
const dialog = useDialog() const dialog = useDialog()
const sdk = useSDK() const sdk = useSDK()
@ -165,7 +181,7 @@ function KeyMethod(props: { integration: IntegrationInfo; method: Extract<Connec
location: location(data), location: location(data),
key, key,
}) })
.then(() => connected(props.integration.name, data, dialog, toast)) .then(() => connected(props.integration, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause))) .catch((cause) => setError(message(cause)))
}} }}
description={() => <Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>} description={() => <Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>}
@ -177,16 +193,20 @@ async function beginOAuth(
integration: IntegrationInfo, integration: IntegrationInfo,
method: IntegrationOAuthMethod, method: IntegrationOAuthMethod,
dialog: ReturnType<typeof useDialog>, dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) { ) {
const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {} const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {}
if (inputs === null) return if (inputs === null) return
dialog.replace(() => <OAuthStarting integration={integration} method={method} inputs={inputs} />) dialog.replace(() => (
<OAuthStarting integration={integration} method={method} inputs={inputs} onConnected={onConnected} />
))
} }
function OAuthStarting(props: { function OAuthStarting(props: {
integration: IntegrationInfo integration: IntegrationInfo
method: IntegrationOAuthMethod method: IntegrationOAuthMethod
inputs: Record<string, string> inputs: Record<string, string>
onConnected?: OnIntegrationConnected
}) { }) {
const data = useData() const data = useData()
const dialog = useDialog() const dialog = useDialog()
@ -204,12 +224,22 @@ function OAuthStarting(props: {
.then((result) => { .then((result) => {
if (result.data.mode === "code") { if (result.data.mode === "code") {
dialog.replace(() => ( dialog.replace(() => (
<OAuthCode integration={props.integration} title={props.method.label} attempt={result.data} /> <OAuthCode
integration={props.integration}
title={props.method.label}
attempt={result.data}
onConnected={props.onConnected}
/>
)) ))
return return
} }
dialog.replace(() => ( dialog.replace(() => (
<OAuthAuto integration={props.integration} title={props.method.label} attempt={result.data} /> <OAuthAuto
integration={props.integration}
title={props.method.label}
attempt={result.data}
onConnected={props.onConnected}
/>
)) ))
}) })
.catch((cause) => { .catch((cause) => {
@ -221,7 +251,12 @@ function OAuthStarting(props: {
return <OAuthView title={props.method.label} message="Starting authorization..." /> return <OAuthView title={props.method.label} message="Starting authorization..." />
} }
function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) { function OAuthAuto(props: {
integration: IntegrationInfo
title: string
attempt: IntegrationAttempt
onConnected?: OnIntegrationConnected
}) {
const data = useData() const data = useData()
const dialog = useDialog() const dialog = useDialog()
const sdk = useSDK() const sdk = useSDK()
@ -258,7 +293,7 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt
} }
settled = true settled = true
if (status.status === "complete") { if (status.status === "complete") {
void connected(props.integration.name, data, dialog, toast) void connected(props.integration, data, dialog, toast, props.onConnected)
return return
} }
toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" }) 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 data = useData()
const dialog = useDialog() const dialog = useDialog()
const sdk = useSDK() const sdk = useSDK()
@ -313,7 +353,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt
.attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code }) .attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code })
.then(() => { .then(() => {
settled = true settled = true
return connected(props.integration.name, data, dialog, toast) return connected(props.integration, data, dialog, toast, props.onConnected)
}) })
.catch((cause) => setError(message(cause))) .catch((cause) => setError(message(cause)))
}} }}
@ -407,20 +447,37 @@ async function promptInputs(
} }
async function connected( async function connected(
name: string, integration: IntegrationInfo,
data: ReturnType<typeof useData>, data: ReturnType<typeof useData>,
dialog: ReturnType<typeof useDialog>, dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>, toast: ReturnType<typeof useToast>,
onConnected?: OnIntegrationConnected,
) { ) {
await Promise.all([ await Promise.all([
data.location.integration.refresh(), data.location.integration.refresh(),
data.location.model.refresh(), data.location.model.refresh(),
data.location.provider.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() dialog.clear()
} }
function providerID(data: ReturnType<typeof useData>, 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( async function disconnected(
name: string, name: string,
data: ReturnType<typeof useData>, data: ReturnType<typeof useData>,

View file

@ -1,6 +1,5 @@
import { createMemo, createSignal } from "solid-js" import { createMemo, createSignal } from "solid-js"
import { useLocal } from "../context/local" import { useLocal } from "../context/local"
import { sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select" import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { DialogIntegration } from "./dialog-integration" import { DialogIntegration } from "./dialog-integration"
@ -62,19 +61,24 @@ export function DialogModel(props: { providerID?: string }) {
models() models()
.filter((model) => model.status !== "deprecated") .filter((model) => model.status !== "deprecated")
.filter((model) => (props.providerID ? model.providerID === props.providerID : true)) .filter((model) => (props.providerID ? model.providerID === props.providerID : true))
.map((model) => ({ .map((model) => {
value: { providerID: model.providerID, modelID: model.id }, const provider = providers().get(model.providerID)
title: model.name, return {
releaseDate: model.time.released, value: { providerID: model.providerID, modelID: model.id },
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id) providerID: model.providerID,
? "(Favorite)" providerName: provider?.name ?? model.providerID,
: undefined, title: model.name,
category: connected() ? (providers().get(model.providerID)?.name ?? model.providerID) : undefined, releaseDate: model.time.released,
footer: free(model) ? "Free" : undefined, description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
onSelect() { ? "(Favorite)"
onSelect(model.providerID, model.id) : undefined,
}, category: connected() ? (provider?.name ?? model.providerID) : undefined,
})) footer: free(model) ? "Free" : undefined,
onSelect() {
onSelect(model.providerID, model.id)
},
}
})
.filter((option) => { .filter((option) => {
if (!showSections) return true if (!showSections) return true
if ( if (
@ -89,7 +93,6 @@ export function DialogModel(props: { providerID?: string }) {
return false return false
return true return true
}), }),
props.providerID !== undefined,
) )
if (needle) { if (needle) {
@ -130,7 +133,11 @@ export function DialogModel(props: { providerID?: string }) {
command: "model.dialog.provider", command: "model.dialog.provider",
title: connected() ? "Connect integration" : "View all integrations", title: connected() ? "Connect integration" : "View all integrations",
onTrigger() { onTrigger() {
dialog.replace(() => <DialogIntegration />) dialog.replace(() => (
<DialogIntegration
onConnected={(providerID) => dialog.replace(() => <DialogModel providerID={providerID} />)}
/>
))
}, },
}, },
{ {
@ -151,17 +158,21 @@ export function DialogModel(props: { providerID?: string }) {
) )
} }
export function sortModelOptions<T extends { footer?: string; releaseDate: string | number; title: string }>( export function sortModelOptions<
options: T[], T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
newestFirst: boolean, >(options: T[]) {
) { return options.toSorted((a, b) => {
if (newestFirst) return sortBy(options, [(option) => option.releaseDate, "desc"], (option) => option.title) const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode")
return sortBy( if (provider !== 0) return provider
options,
(option) => option.footer !== "Free", const name = (a.providerName ?? "").localeCompare(b.providerName ?? "")
[(option) => option.releaseDate, "desc"], if (name !== 0) return name
(option) => option.title,
) 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 }> }) { function free(model: { cost: Array<{ input: number }> }) {

View file

@ -2,31 +2,33 @@ import { describe, expect, test } from "bun:test"
import { sortModelOptions } from "../../../../src/component/dialog-model" import { sortModelOptions } from "../../../../src/component/dialog-model"
describe("sortModelOptions", () => { describe("sortModelOptions", () => {
test("orders provider-scoped model choices by newest release first", () => { test("orders opencode models before other providers", () => {
const sorted = sortModelOptions( const sorted = sortModelOptions([
[ { providerID: "openai", providerName: "OpenAI", releaseDate: 3, title: "GPT 5" },
{ title: "GPT 5.2", releaseDate: "2025-12-11" }, { providerID: "opencode", providerName: "OpenCode", releaseDate: 1, title: "Claude Sonnet 4" },
{ title: "GPT 5.4", releaseDate: "2026-03-05" }, { providerID: "anthropic", providerName: "Anthropic", releaseDate: 2, title: "Claude Opus 4" },
{ title: "GPT 5.1", releaseDate: "2025-11-13" }, ])
],
true,
)
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", () => { test("orders provider groups by provider name and models by newest release", () => {
const sorted = sortModelOptions( const sorted = sortModelOptions([
[ { providerID: "google", providerName: "Google", releaseDate: 5, title: "Gemini 2.5 Pro" },
{ title: "GLM 5", releaseDate: "2025-07-28" }, { providerID: "anthropic", providerName: "Anthropic", releaseDate: 4, title: "Claude Sonnet 4" },
{ title: "GLM 5.1", releaseDate: "2025-12-09" }, { providerID: "anthropic", providerName: "Anthropic", releaseDate: 6, title: "Claude Opus 4" },
{ title: "GLM 5.2", releaseDate: "2026-02-16" }, { providerID: "openai", providerName: "OpenAI", releaseDate: 7, title: "GPT 5" },
{ title: "Free old", releaseDate: "2024-01-01", footer: "Free" }, ])
{ title: "Free new", releaseDate: "2025-01-01", footer: "Free" },
],
false,
)
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"])
}) })
}) })