mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 12:41:37 +03:00
feat(tui): match model switcher to new designs (#56095)
This commit is contained in:
committed by
GitHub
Unverified
parent
6ea9cf5035
commit
1d2190beec
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user