Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 82 additions & 14 deletions packages/tui/src/component/dialog-model.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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(
Expand All @@ -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) {
Expand All @@ -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)
},
Expand All @@ -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)
},
Expand All @@ -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]
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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) => {
Expand All @@ -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)
}
41 changes: 38 additions & 3 deletions packages/tui/src/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand All @@ -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
}
Expand Down Expand Up @@ -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>
Expand Down
6 changes: 3 additions & 3 deletions packages/tui/test/cli/cmd/tui/model-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading