feat(tui): match model switcher to new designs (#56095)

This commit is contained in:
Aarav Sareen
2026-10-20 12:40:25 +05:30
committed by GitHub
Unverified
parent 6ea9cf5035
commit 1d2190beec
3 changed files with 123 additions and 20 deletions
+82 -14
View File
@@ -1,5 +1,7 @@
import { createMemo, createSignal } from "solid-js"
import type { ModelInfo } from "@opencode/client"
import { createMemo, createSignal, For } from "solid-js"
import { useLocal } from "../context/local"
import { useTheme } from "../context/theme"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { DialogIntegration } from "./dialog-integration"
@@ -17,8 +19,19 @@ export function DialogModel(props: { providerID?: string }) {
const data = useData()
const dialog = useDialog()
const location = useLocation()
const theme = useTheme().surface("dialog")
const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
// Under new designs, favoriting does not reorder the list until the dialog is reopened.
const favoritesAtOpen = [...local.model.favorite()]
const favoritePriority = new Set(favoritesAtOpen.map(modelPreferenceKey))
const isFavorite = (value: { providerID: string; modelID: string } | undefined) =>
value !== undefined &&
local.model.favorite().some((item) => item.providerID === value.providerID && item.modelID === value.modelID)
// A styled span keeps its color on the highlighted row, where plain trailing text turns secondary.
const trailing = (model: ModelInfo, favorite: boolean) => {
if (newDesigns() && favorite) return <span style={{ fg: theme.text.formfield.selected }}>*</span>
return free(model) ? "Free" : undefined
}
const connected = useConnected()
const providers = createMemo(
@@ -31,7 +44,7 @@ export function DialogModel(props: { providerID?: string }) {
const options = createMemo(() => {
const needle = query().trim()
const showSections = showExtra() && needle.length === 0
const favorites = connected() ? local.model.favorite() : []
const favorites = connected() ? (newDesigns() ? favoritesAtOpen : local.model.favorite()) : []
const recents = local.model.recent()
function toOptions(items: typeof favorites, category: string) {
@@ -48,7 +61,8 @@ export function DialogModel(props: { providerID?: string }) {
releaseDate: model.time.released,
description: provider?.name ?? model.providerID,
category,
footer: free(model) ? "Free" : undefined,
footer: trailing(model, isFavorite(item)),
detail: newDesigns() ? () => <ModelDetail model={model} /> : undefined,
onSelect: () => {
onSelect(model.providerID, model.id)
},
@@ -71,17 +85,19 @@ export function DialogModel(props: { providerID?: string }) {
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
.map((model) => {
const provider = providers().get(model.providerID)
const key = modelPreferenceKey({ providerID: model.providerID, modelID: model.id })
const favorite = favorites.some((item) => modelPreferenceKey(item) === key)
const value = { providerID: model.providerID, modelID: model.id }
const favorite = isFavorite(value)
return {
value: { providerID: model.providerID, modelID: model.id },
value,
providerID: model.providerID,
providerName: provider?.name ?? model.providerID,
title: model.name,
releaseDate: model.time.released,
description: favorite ? "(Favorite)" : undefined,
description: newDesigns() ? undefined : favorite ? "(Favorite)" : undefined,
category: connected() ? (provider?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined,
footer: trailing(model, favorite),
free: free(model),
detail: newDesigns() ? () => <ModelDetail model={model} /> : undefined,
onSelect() {
onSelect(model.providerID, model.id)
},
@@ -105,13 +121,17 @@ export function DialogModel(props: { providerID?: string }) {
)
if (needle) {
return prioritizeFavorites(
const results = prioritizeFavorites(
sortModelOptions(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
false,
),
favoritePriority,
)
// Search results name the provider inline. Without a category, the trailing slot keeps the favorite marker
// instead of falling back to the provider name.
if (!newDesigns()) return results
return results.map((option) => ({ ...option, description: option.providerName, category: undefined }))
}
return [...favoriteOptions, ...recentOptions, ...modelOptions]
@@ -166,7 +186,7 @@ export function DialogModel(props: { providerID?: string }) {
},
{
command: "model.dialog.favorite",
title: newDesigns() ? "favorite" : "Favorite",
title: newDesigns() ? (option) => (isFavorite(option?.value) ? "unfavorite" : "favorite") : "Favorite",
hidden: !connected(),
onTrigger: (option) => {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
@@ -197,7 +217,7 @@ export function sortModelOptions<
T extends {
providerID?: string
providerName?: string
footer?: string
free?: boolean
},
>(options: T[], grouped = true) {
return options.toSorted((a, b) => {
@@ -210,10 +230,58 @@ export function sortModelOptions<
const name = grouped ? (a.providerName ?? "").localeCompare(b.providerName ?? "") : 0
if (name !== 0) return name
return Number(b.footer === "Free") - Number(a.footer === "Free")
return Number(b.free === true) - Number(a.free === true)
})
}
function free(model: { cost: Array<{ input: number }> }) {
function free(model: { cost: ReadonlyArray<{ input: number }> }) {
return model.cost.length > 0 && model.cost.every((cost) => cost.input === 0)
}
/** Price, input modalities, context window and reasoning support for the selected model. */
function ModelDetail(props: { model: ModelInfo }) {
const theme = useTheme().surface("dialog")
const price = () => {
if (free(props.model)) return "Free"
const cost = props.model.cost[0]
if (!cost) return ""
return `$${dollars(cost.input)}/$${dollars(cost.output)}`
}
const facts = () => [
props.model.capabilities.input.map(modality).join(", "),
tokens(props.model.limit.context),
...(props.model.variants.length > 0 ? ["Reasoning"] : []),
]
return (
<box flexDirection="row" justifyContent="space-between" gap={2}>
<text fg={theme.text.faint} wrapMode="none">
{price()}
</text>
<text fg={theme.text.faint} wrapMode="none" flexShrink={0}>
<For each={facts().filter(Boolean)}>
{(fact, index) => (
<>
{index() > 0 && <span style={{ fg: theme.hue.neutral[400] }}> · </span>}
{fact}
</>
)}
</For>
</text>
</box>
)
}
function dollars(value: number) {
return String(Number(value.toFixed(2)))
}
function modality(value: string) {
if (value === "pdf") return "PDF"
return value.charAt(0).toUpperCase() + value.slice(1)
}
function tokens(value: number) {
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M`
if (value >= 1_000) return `${Math.round(value / 1_000)}K`
return String(value)
}
+38 -3
View File
@@ -65,7 +65,8 @@ export interface DialogSelectProps<T> {
type DialogSelectActionBase<T> = {
command: string
title: string
/** A function title follows the selected option, e.g. "favorite" / "unfavorite". */
title: string | ((option: DialogSelectOption<T> | undefined) => string)
bind?: string
/** Footer placement while the "new-designs" experiment is off. */
side?: "left" | "right"
@@ -106,6 +107,11 @@ export interface DialogSelectOption<T = any> {
fg?: RGBA
gutter?: (color: RGBA) => JSX.Element
margin?: JSX.Element
/**
* Shown below the list while this option is selected, under the "new-designs" experiment. A function is called
* each time the option becomes selected, because a rendered element cannot be shown again once it is removed.
*/
detail?: string | (() => JSX.Element)
onSelect?: (ctx: DialogContext) => void
}
@@ -262,6 +268,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
const selected = createMemo(() => flat()[store.selected])
const detailView = () => {
const detail = selected()?.detail
return typeof detail === "function" ? detail : undefined
}
const detailText = () => {
const detail = selected()?.detail
return typeof detail === "string" ? detail : undefined
}
createEffect(
on(
@@ -510,7 +524,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
},
...visible.map((item) => ({
id: item.command,
title: item.title,
// Resolved without a selection so moving through the list does not rebuild the layer.
title: typeof item.title === "function" ? item.title(undefined) : item.title,
group: "Dialog",
bind: item.bind,
run: () => trigger(item),
@@ -589,7 +604,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
side: item.side,
group: item.group ?? (item.selection === "none" ? "secondary" : "primary"),
action: {
title: item.title,
title: actionTitle(item),
key: item.label,
disabled: isActionDisabled(item),
onTrigger: () => trigger(item),
@@ -613,6 +628,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
item.onTrigger(option)
}
function actionTitle(item: Action) {
return typeof item.title === "function" ? item.title(selected()) : item.title
}
function isActionItem(item: VisibleAction): item is Action & { label: string } {
return "onTrigger" in item
}
@@ -797,6 +816,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<text fg={theme.text.faint}>{moreBelow() ? "…" : " "}</text>
</box>
</Show>
{/* Reserved whenever any option has a detail, so the dialog does not resize as the selection moves. */}
<Show when={newDesigns() && flat().some((option) => option.detail !== undefined)}>
<box paddingTop={1} paddingLeft={DIALOG_LIST_INSET} paddingRight={DIALOG_LIST_INSET} flexShrink={0}>
<Show
when={detailView()}
keyed
fallback={
<text fg={theme.text.faint} wrapMode="word">
{detailText() || " "}
</text>
}
>
{(render) => render()}
</Show>
</box>
</Show>
</Show>
</DialogBleed>
</DialogLayout>
@@ -28,7 +28,7 @@ describe("sortModelOptions", () => {
const options = [
{ providerID: "anthropic", title: "Claude Haiku 4.5" },
{ providerID: "opencode", title: "Claude Haiku 3" },
{ providerID: "anthropic", title: "Claude Haiku Free", footer: "Free" },
{ providerID: "anthropic", title: "Claude Haiku Free", free: true },
].map((item) => ({ ...item, providerID: mode === "provider" ? "anthropic" : item.providerID }))
const matches = mode === "search" ? go("haik", options, { key: "title" }).map((item) => item.obj) : options
const titles = sortModelOptions(matches, mode === "provider").map((item) => item.title)
@@ -52,7 +52,7 @@ describe("sortModelOptions", () => {
const sorted = sortModelOptions(
[
{ providerID: "opencode-go", title: "Go model" },
{ providerID: "opencode", title: "Free Zen model", footer: "Free" },
{ providerID: "opencode", title: "Free Zen model", free: true },
{ providerID: "anthropic", title: "Anthropic model" },
],
false,
@@ -96,7 +96,7 @@ describe("sortModelOptions", () => {
providerName: "OpenCode Zen",
releaseDate: index === 0 ? 1000 : 5000 - index,
title,
footer: "Free",
free: true,
}))
expect(sortModelOptions(options, false).map((model) => model.title)).toEqual(serverOrder)