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
parent b1ca070b3b
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(),
slashName: "connect",
run: () => {
dialog.replace(() => <DialogIntegration />)
dialog.replace(() => (
<DialogIntegration
onConnected={(providerID) => dialog.replace(() => <DialogModel providerID={providerID} />)}
/>
))
},
category: "Integration",
},

View file

@ -24,6 +24,7 @@ const INTEGRATION_PRIORITY: Record<string, number> = {
type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
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 ? () => <text fg={theme.success}></text> : 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<typeof useDialog>,
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<typeof useDialog>) {
if (methods.length === 1) return openMethod(integration, methods[0], dialog)
function selectMethod(
integration: IntegrationInfo,
methods: ConnectMethod[],
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
if (methods.length === 1) return openMethod(integration, methods[0], dialog, onConnected)
dialog.replace(() => (
<DialogSelect
title={`Connect ${integration.name}`}
options={methods.map((method) => ({
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<typeof useDialog>) {
function openMethod(
integration: IntegrationInfo,
method: ConnectMethod,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
if (method.type === "key") {
dialog.replace(() => <KeyMethod integration={integration} method={method} />)
dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />)
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 dialog = useDialog()
const sdk = useSDK()
@ -165,7 +181,7 @@ function KeyMethod(props: { integration: IntegrationInfo; method: Extract<Connec
location: location(data),
key,
})
.then(() => connected(props.integration.name, data, dialog, toast))
.then(() => connected(props.integration, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause)))
}}
description={() => <Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>}
@ -177,16 +193,20 @@ async function beginOAuth(
integration: IntegrationInfo,
method: IntegrationOAuthMethod,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {}
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: {
integration: IntegrationInfo
method: IntegrationOAuthMethod
inputs: Record<string, string>
onConnected?: OnIntegrationConnected
}) {
const data = useData()
const dialog = useDialog()
@ -204,12 +224,22 @@ function OAuthStarting(props: {
.then((result) => {
if (result.data.mode === "code") {
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
}
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) => {
@ -221,7 +251,12 @@ function OAuthStarting(props: {
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 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<typeof useData>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
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<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(
name: string,
data: ReturnType<typeof useData>,

View file

@ -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(() => <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 }>(
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 }> }) {

View file

@ -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"])
})
})