mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-18 15:33:31 +00:00
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:
parent
b1ca070b3b
commit
8f1db7d06d
4 changed files with 145 additions and 71 deletions
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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 }> }) {
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue