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)