From fdecd2cf84e6e13ab79f81ad8972792e5bb96485 Mon Sep 17 00:00:00 2001
From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Date: Fri, 16 Oct 2026 17:28:16 +0530
Subject: [PATCH] feat(tui): match model switcher to new designs
---
packages/tui/src/component/dialog-model.tsx | 96 ++++++++++++++++---
packages/tui/src/ui/dialog-select.tsx | 41 +++++++-
.../test/cli/cmd/tui/model-options.test.ts | 6 +-
3 files changed, 123 insertions(+), 20 deletions(-)
diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx
index 7ae17d5f46d3..0b085cc16e02 100644
--- a/packages/tui/src/component/dialog-model.tsx
+++ b/packages/tui/src/component/dialog-model.tsx
@@ -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 *
+ 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() ? () => : 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() ? () => : 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 (
+
+
+ {price()}
+
+
+
+ {(fact, index) => (
+ <>
+ {index() > 0 && · }
+ {fact}
+ >
+ )}
+
+
+
+ )
+}
+
+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)
+}
diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx
index c2d5c930b6b2..aa281020575b 100644
--- a/packages/tui/src/ui/dialog-select.tsx
+++ b/packages/tui/src/ui/dialog-select.tsx
@@ -65,7 +65,8 @@ export interface DialogSelectProps {
type DialogSelectActionBase = {
command: string
- title: string
+ /** A function title follows the selected option, e.g. "favorite" / "unfavorite". */
+ title: string | ((option: DialogSelectOption | undefined) => string)
bind?: string
/** Footer placement while the "new-designs" experiment is off. */
side?: "left" | "right"
@@ -106,6 +107,11 @@ export interface DialogSelectOption {
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(props: DialogSelectProps) {
}
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(props: DialogSelectProps) {
},
...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(props: DialogSelectProps) {
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(props: DialogSelectProps) {
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(props: DialogSelectProps) {
{moreBelow() ? "…" : " "}
+ {/* Reserved whenever any option has a detail, so the dialog does not resize as the selection moves. */}
+ option.detail !== undefined)}>
+
+
+ {detailText() || " "}
+
+ }
+ >
+ {(render) => render()}
+
+
+
diff --git a/packages/tui/test/cli/cmd/tui/model-options.test.ts b/packages/tui/test/cli/cmd/tui/model-options.test.ts
index 5a672ed5fa4e..b63fcb6c9733 100644
--- a/packages/tui/test/cli/cmd/tui/model-options.test.ts
+++ b/packages/tui/test/cli/cmd/tui/model-options.test.ts
@@ -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)