tui: streamline model selection and provider discovery

- Show unconnected providers in the list so users can find and connect new services without leaving the workflow.
- Group recently used models at the top for faster access to common configurations.
- Add a 'Free' badge to opencode models to clearly identify no-cost options.
- Adjust dialog sizing to improve readability of the expanded model list.
This commit is contained in:
Dax Raad 2025-11-19 21:45:16 -05:00
parent 54f48ec66b
commit 1bebe26454
4 changed files with 64 additions and 18 deletions

View file

@ -2,7 +2,7 @@ import { createMemo, createSignal } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useTheme } from "../context/theme"
@ -10,6 +10,15 @@ function Free() {
const { theme } = useTheme()
return <span style={{ fg: theme.secondary }}>Free</span>
}
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
anthropic: 1,
"github-copilot": 2,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
}
export function DialogModel() {
const local = useLocal()
@ -17,9 +26,15 @@ export function DialogModel() {
const dialog = useDialog()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
const connected = createMemo(() =>
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
const showRecent = createMemo(() => !ref()?.filter && local.model.recent().length > 0 && connected())
const options = createMemo(() => {
return [
...(!ref()?.filter
...(showRecent()
? local.model.recent().flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)!
if (!provider) return []
@ -36,6 +51,16 @@ export function DialogModel() {
description: provider.name,
category: "Recent",
footer: model.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
@ -57,27 +82,37 @@ export function DialogModel() {
},
title: info.name ?? model,
description: provider.name,
category: provider.name,
category: connected() ? provider.name : undefined,
footer: info.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined,
onSelect() {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model,
},
{ recent: true },
)
},
})),
filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
filter((x) => !showRecent() || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
sortBy((x) => x.title),
),
),
),
...(!connected()
? pipe(
sync.data.provider_next.all,
map((provider) => ({
title: provider.name,
category: "Connect a provider",
value: provider.id,
})),
sortBy((x) => PROVIDER_PRIORITY[x.value] ?? 99),
)
: []),
]
})
return (
<DialogSelect
ref={setRef}
title="Select model"
current={local.model.current()}
options={options()}
onSelect={(option) => {
dialog.clear()
local.model.set(option.value, { recent: true })
}}
/>
)
return <DialogSelect ref={setRef} title="Select model" current={local.model.current()} options={options()} />
}

View file

@ -18,7 +18,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
console.log("event", event.type)
emitter.emit(event.type, event)
}
})

View file

@ -12,6 +12,7 @@ import type {
McpStatus,
FormatterStatus,
SessionStatus,
ProviderListResponse,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
@ -28,6 +29,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
status: "loading" | "partial" | "complete"
provider: Provider[]
provider_default: Record<string, string>
provider_next: ProviderListResponse
agent: Agent[]
command: Command[]
permission: {
@ -56,6 +58,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
formatter: FormatterStatus[]
}>({
provider_next: {
all: [],
default: {},
connected: [],
},
config: {},
status: "loading",
agent: [],
@ -241,6 +248,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setStore("provider_default", x.data!.default)
})
}),
sdk.client.provider.list({ throwOnError: true }).then((x) => {
batch(() => {
setStore("provider_next", x.data!)
})
}),
sdk.client.app.agents({ throwOnError: true }).then((x) => setStore("agent", x.data ?? [])),
sdk.client.config.get({ throwOnError: true }).then((x) => setStore("config", x.data!)),
])

View file

@ -24,7 +24,7 @@ export function DialogPrompt(props: DialogPromptProps) {
})
onMount(() => {
dialog.setSize("large")
dialog.setSize("medium")
setTimeout(() => {
textarea.focus()
}, 1)