Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline e82bfee934 fix(tui): align cli model picker behavior
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 05:37:13 +00:00
4 changed files with 130 additions and 69 deletions
+5 -1
View File
@@ -720,7 +720,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",
},
@@ -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>,
+32 -25
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,23 @@ 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,
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 +92,6 @@ export function DialogModel(props: { providerID?: string }) {
return false
return true
}),
props.providerID !== undefined,
)
if (needle) {
@@ -130,7 +132,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 +157,18 @@ export function DialogModel(props: { providerID?: string }) {
)
}
export function sortModelOptions<T extends { footer?: string; releaseDate: string | number; title: string }>(
export function sortModelOptions<T extends { providerID?: string; providerName?: string; 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,
)
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
return a.title.localeCompare(b.title)
})
}
function free(model: { cost: Array<{ input: number }> }) {
@@ -2,31 +2,24 @@ 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", title: "GPT 5" },
{ providerID: "opencode", providerName: "OpenCode", title: "Claude Sonnet 4" },
{ providerID: "anthropic", providerName: "Anthropic", 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 title", () => {
const sorted = sortModelOptions([
{ providerID: "google", providerName: "Google", title: "Gemini 2.5 Pro" },
{ providerID: "anthropic", providerName: "Anthropic", title: "Claude Sonnet 4" },
{ providerID: "anthropic", providerName: "Anthropic", title: "Claude Opus 4" },
{ providerID: "openai", providerName: "OpenAI", 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"])
})
})