Compare commits

...
Author SHA1 Message Date
rekram1-node ebb71382a6 fix(ui): unify model picker ordering 2026-09-04 00:54:21 +00:00
17 changed files with 474 additions and 85 deletions
+2 -1
View File
@@ -16,6 +16,7 @@ import { DialogConnectProvider } from "@/providers/connect/dialog"
import { decode64 } from "@/runtime/persistence/base64"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { compareModels } from "./order"
import "@/settings/settings.css"
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
@@ -45,7 +46,7 @@ export const DialogManageModels: Component = () => {
items: () => local.model.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name),
sortBy: compareModels,
groupBy: (x) => x.provider.id,
sortGroupsBy: (a, b) => {
const aRank = popularProviders.indexOf(a.category)
@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test"
import { compareModels, isFreeModel } from "./order"
describe("model ordering", () => {
test("orders free first, then newest release, then name", () => {
const items = [
{ name: "A paid new", release_date: "2026-09-01", cost: { input: 1 } },
{ name: "A free old", release_date: "2026-01-01", cost: { input: 0 } },
{ name: "Z free new", release_date: "2026-08-01", cost: { input: 0 } },
{ name: "A free new", release_date: "2026-08-01", cost: { input: 0 } },
{ name: "A paid old", release_date: "2026-01-01", cost: { input: 1 } },
].map((item) => ({ ...item, provider: { id: "opencode" } }))
expect(items.toSorted(compareModels).map((item) => item.name)).toEqual([
"A free new",
"Z free new",
"A free old",
"A paid new",
"A paid old",
])
})
test("uses the existing free badge predicate, not zero cost on other providers", () => {
expect(isFreeModel({ provider: { id: "opencode" } })).toBe(true)
expect(isFreeModel({ provider: { id: "opencode" }, cost: { input: 0 } })).toBe(true)
expect(isFreeModel({ provider: { id: "opencode" }, cost: { input: 1 } })).toBe(false)
expect(isFreeModel({ provider: { id: "opencode-go" }, cost: { input: 0 } })).toBe(false)
expect(isFreeModel({ provider: { id: "custom" } })).toBe(false)
const free = { provider: { id: "opencode" }, name: "Z missing cost", release_date: "2026-01-01" }
const other = { provider: { id: "custom" }, name: "A zero cost", release_date: "2026-09-01", cost: { input: 0 } }
expect([other, free].sort(compareModels)).toEqual([free, other])
})
test("sorts missing and invalid release dates last with a name tie-breaker", () => {
const items = [
{ name: "B missing" },
{ name: "C empty", release_date: "" },
{ name: "A invalid", release_date: "not-a-date" },
{ name: "Z dated", release_date: "2026-01-01" },
].map((item) => ({ ...item, provider: { id: "opencode" } }))
expect(items.sort(compareModels).map((item) => item.name)).toEqual(["Z dated", "A invalid", "B missing", "C empty"])
})
test("compares parsed instants rather than release date strings", () => {
const older = { provider: { id: "custom" }, name: "A", release_date: "2026-08-02T00:00:00+10:00" }
const newer = { provider: { id: "custom" }, name: "Z", release_date: "2026-08-01T20:00:00Z" }
expect([older, newer].sort(compareModels)).toEqual([newer, older])
})
})
@@ -0,0 +1,17 @@
import { compareModelOrder } from "@opencode-ai/util/model-order"
type ModelOrderItem = {
provider: { id: string }
cost?: { input: number }
release_date?: string
name: string
}
export const isFreeModel = (item: Pick<ModelOrderItem, "provider" | "cost">) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
export const compareModels = (a: ModelOrderItem, b: ModelOrderItem) =>
compareModelOrder(
{ free: isFreeModel(a), released: Date.parse(a.release_date ?? "") || 0, name: a.name },
{ free: isFreeModel(b), released: Date.parse(b.release_date ?? "") || 0, name: b.name },
)
@@ -21,12 +21,10 @@ import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./search"
import { compareModels, isFreeModel } from "./order"
import { SettingsList } from "@/settings/list"
import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelItem = ReturnType<ModelState["list"]>[number]
@@ -64,11 +62,13 @@ const ModelList: Component<{
const models = createMemo(() => controller.models(store.search))
const groups = createMemo(() => controller.groups(models()))
const expanded = (provider: string) => store.search.length > 0 || !store.collapsed[provider]
const visibleModels = () => models().filter((item) => expanded(item.provider.id))
const visibleModels = () => groups().flatMap((group) => (expanded(group.category) ? group.items : []))
let scrollRef: HTMLDivElement | undefined
const setSearch = (value: string) => {
const first = controller.models(value).find((item) => value.length > 0 || !store.collapsed[item.provider.id])
const first = controller
.groups(controller.models(value))
.find((group) => value.length > 0 || !store.collapsed[group.category])?.items[0]
setStore({ search: value, active: first ? modelKey(first) : "" })
}
const moveActive = (delta: number) => {
@@ -187,17 +187,12 @@ const ModelList: Component<{
gutter={12}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} v2 />
}
>
<span class="min-w-0 truncate">{item.name}</span>
</Tooltip>
<Show when={isFree(item.provider.id, item.cost)}>
<Show when={isFreeModel(item)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
<Show when={item.latest}>
@@ -277,7 +272,7 @@ function createModelSelectorController(input: {
const filtered = query
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
: allModels()
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
return [...filtered].sort(compareModels)
},
groups: (models: ModelItem[]) => {
const byProvider = new Map<string, ModelItem[]>()
@@ -314,7 +309,7 @@ function ModelSelectorPopoverView(props: {
const models = createMemo(() => props.models(store.search))
const groups = createMemo(() => props.groups(models()))
const keys = () => [...models().map(modelKey), manageKey]
const keys = () => [...groups().flatMap((group) => group.items.map(modelKey)), manageKey]
const initialActive = () => {
const selected = props.current
const options = keys()
@@ -364,7 +359,7 @@ function ModelSelectorPopoverView(props: {
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const setSearch = (value: string) => {
const first = props.models(value)[0]
const first = props.groups(props.models(value))[0]?.items[0]
setStore({ search: value, active: first ? modelKey(first) : manageKey })
}
@@ -467,14 +462,7 @@ function ModelSelectorPopoverView(props: {
placement="right-start"
gutter={6}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} v2 />}
>
<Menu.RadioItem
value={modelKey(item)}
@@ -489,7 +477,7 @@ function ModelSelectorPopoverView(props: {
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate leading-5">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<Show when={isFreeModel(item)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
<Show when={item.latest}>
+3 -4
View File
@@ -11,6 +11,7 @@ import { useIntegrations } from "@/providers/catalog/integrations"
import { decode64 } from "@/runtime/persistence/base64"
import { useLanguage } from "@/runtime/i18n/language"
import { ModelTooltip } from "./tooltip"
import { compareModels, isFreeModel } from "./order"
type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
@@ -29,9 +30,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
const c = model.current()
return c ? `${c.provider.id}:${c.id}` : undefined
})
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
const freeModels = createMemo(() => model.list().filter(isFree))
const freeModels = createMemo(() => model.list().filter(isFreeModel).sort(compareModels))
const openProviders = (provider?: string) => {
void import("@/providers/connect/dialog").then((x) => {
@@ -94,7 +93,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<ModelTooltip
model={{ ...item, name: displayModelName(item.name) }}
latest={item.latest}
free={isFree(item)}
free={isFreeModel(item)}
v2
/>
}
+2 -1
View File
@@ -9,6 +9,7 @@ import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useLanguage } from "@/runtime/i18n/language"
import { useModels } from "@/providers/models/models"
import { compareModels } from "@/providers/models/order"
import { useServerSDK } from "@/runtime/server/client"
import { popularProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
@@ -39,7 +40,7 @@ export const SettingsModels: Component = () => {
items: (_filter) => models.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name),
sortBy: compareModels,
groupBy: (x) => x.provider.id,
sortGroupsBy: (a, b) => {
const aIndex = popularProviders.indexOf(a.category)
+19 -13
View File
@@ -4,7 +4,8 @@ import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { DialogIntegration } from "./dialog-integration"
import { DialogVariant } from "./dialog-variant"
import * as fuzzysort from "fuzzysort"
import { go } from "fuzzysort"
import { compareModelOrder } from "@opencode-ai/util/model-order"
import { useConnected } from "./use-connected"
import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference"
@@ -99,11 +100,15 @@ export function DialogModel(props: { providerID?: string }) {
return false
return true
}),
connected(),
)
if (needle) {
return prioritizeFavorites(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
sortModelOptions(
go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
false,
),
favoritePriority,
)
}
@@ -179,19 +184,20 @@ export function prioritizeFavorites<T extends { value: { providerID: string; mod
}
export function sortModelOptions<
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
>(options: T[]) {
T extends { providerID?: string; providerName?: string; releaseDate: number; title: string; footer?: string },
>(options: T[], grouped = true) {
return options.toSorted((a, b) => {
const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode")
if (provider !== 0) return provider
if (grouped) {
const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode")
if (provider !== 0) return provider
const name = (a.providerName ?? "").localeCompare(b.providerName ?? "")
if (name !== 0) return name
const release = Number(b.releaseDate) - Number(a.releaseDate)
if (release !== 0) return release
return a.title.localeCompare(b.title)
const name = (a.providerName ?? "").localeCompare(b.providerName ?? "")
if (name !== 0) return name
}
return compareModelOrder(
{ free: a.footer === "Free", released: a.releaseDate, name: a.title },
{ free: b.footer === "Free", released: b.releaseDate, name: b.title },
)
})
}
+1
View File
@@ -75,6 +75,7 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
const cost = defaultCost(model)
provider.models[model.id] = {
name: model.name,
time: { released: model.time.released },
cost: cost === undefined ? undefined : { input: cost },
limit: { context: model.limit.context },
status: model.status,
+47 -41
View File
@@ -3,6 +3,7 @@ import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/co
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { compareModelOrder } from "@opencode-ai/util/model-order"
import { Keymap } from "../context/keymap"
import { Config } from "../config"
import { OneCellSpinner } from "../component/one-cell-spinner"
@@ -51,6 +52,8 @@ type ModelEntry = PanelEntry & {
providerID: string
modelID: string
providerName: string
free: boolean
released: number
current: boolean
}
@@ -149,6 +152,7 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
onClose: () => void
onSelect: (item: T) => void
isCurrent?: (item: T) => boolean
compare?: (a: T, b: T, query: string) => number
closeOnFirstUp?: boolean
onKey?: (event: KeyEvent, item: T | undefined) => boolean
onRows?: (rows: number) => void
@@ -162,7 +166,12 @@ function createSearchablePanelController<T extends PanelEntry>(input: {
})
let field: InputRenderable | undefined
const [query, setQuery] = createSignal("")
const items = createMemo<T[]>(() => match(query(), input.entries()))
const items = createMemo<T[]>(() => {
const text = query().trim()
const matched = match(text, input.entries())
const compare = input.compare
return compare ? matched.toSorted((a, b) => compare(a, b, text)) : matched
})
const menu = createFooterMenuState({ count: () => items().length, limit: () => layout().limit })
const selected = () => items()[menu.selected()]
@@ -1154,46 +1163,29 @@ export function RunModelSelectBody(props: {
mono?: boolean
}) {
const entries = createMemo<ModelEntry[]>(() =>
(props.providers() ?? [])
.flatMap((provider) =>
Object.entries(provider.models)
.filter(([, model]) => model.status !== "deprecated")
.map(([modelID, model]) => {
const title = model.name ?? modelID
const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID
const footer = current
? "current"
: model.cost?.input === 0 && provider.id === "opencode"
? "Free"
: title !== modelID
? modelID
: undefined
return {
providerID: provider.id,
modelID,
providerName: provider.name,
category: provider.name,
display: title,
footer,
footerTone: current ? ("selection" as const) : undefined,
keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`,
current,
}
}),
)
.sort((a, b) => {
const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode")
if (provider !== 0) {
return provider
}
const name = a.providerName.localeCompare(b.providerName)
if (name !== 0) {
return name
}
return a.display.localeCompare(b.display)
}),
(props.providers() ?? []).flatMap((provider) =>
Object.entries(provider.models)
.filter(([, model]) => model.status !== "deprecated")
.map(([modelID, model]) => {
const title = model.name ?? modelID
const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID
const free = model.cost?.input === 0 && provider.id === "opencode"
const footer = current ? "current" : free ? "Free" : title !== modelID ? modelID : undefined
return {
providerID: provider.id,
modelID,
providerName: provider.name,
free,
released: model.time?.released ?? 0,
category: provider.name,
display: title,
footer,
footerTone: current ? ("selection" as const) : undefined,
keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`,
current,
}
}),
),
)
const controller = createSearchablePanelController({
entries,
@@ -1201,6 +1193,20 @@ export function RunModelSelectBody(props: {
onClose: props.onClose,
onSelect: (item) => props.onSelect({ providerID: item.providerID, modelID: item.modelID }),
isCurrent: (item) => item.current,
compare: (a, b, query) => {
if (!query) {
const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode")
if (provider !== 0) return provider
const name = a.providerName.localeCompare(b.providerName)
if (name !== 0) return name
}
return compareModelOrder(
{ free: a.free, released: a.released, name: a.display },
{ free: b.free, released: b.released, name: b.display },
)
},
})
return (
+3
View File
@@ -58,6 +58,9 @@ export type RunCommand = {
type RunProviderModel = {
name?: string
time?: {
released: number
}
cost?: {
input: number
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
import { go } from "fuzzysort"
describe("prioritizeFavorites", () => {
test("uses the favorite order captured when the dialog opened", () => {
@@ -23,6 +24,32 @@ describe("prioritizeFavorites", () => {
})
describe("sortModelOptions", () => {
test.each(["", "haik"])("orders matches free-first then newest for query %j", (query) => {
const options = [
{ providerID: "demo", title: "Claude Haiku 3", releaseDate: 1 },
{ providerID: "demo", title: "Claude Haiku 4.5", releaseDate: 2 },
{ providerID: "demo", title: "Claude Haiku Free", releaseDate: 0, footer: "Free" },
]
const matches = query ? go(query, options, { key: "title" }).map((item) => item.obj) : options
expect(sortModelOptions(matches, !query).map((item) => item.title)).toEqual([
"Claude Haiku Free",
"Claude Haiku 4.5",
"Claude Haiku 3",
])
})
test("flat search sorts across providers rather than by provider priority", () => {
expect(
sortModelOptions(
[
{ providerID: "opencode", providerName: "OpenCode", title: "Claude Haiku 3", releaseDate: 1 },
{ providerID: "anthropic", providerName: "Anthropic", title: "Claude Haiku 4.5", releaseDate: 2 },
],
false,
).map((item) => item.title),
).toEqual(["Claude Haiku 4.5", "Claude Haiku 3"])
})
test("orders opencode models before other providers", () => {
const sorted = sortModelOptions([
{ providerID: "openai", providerName: "OpenAI", releaseDate: 3, title: "GPT 5" },
@@ -58,6 +58,7 @@ describe("run catalog shared", () => {
models: {
"gpt-5": {
name: "Little Frank",
time: { released: 1 },
cost: {
input: 0,
},
+112 -1
View File
@@ -82,11 +82,13 @@ function model(input: {
id: string
name: string
status?: "active" | "deprecated"
released?: number
cost?: number
variants?: Record<string, Record<string, never>>
}) {
return {
name: input.name,
time: input.released === undefined ? undefined : { released: input.released },
cost: {
input: input.cost ?? 1,
},
@@ -1033,6 +1035,7 @@ test("direct skill panel renders searchable skill list", async () => {
command({ name: "review", description: "Review code" }),
command({ name: "internal", description: "Skill command", source: "skill" }),
command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
command({ name: "auto-formatter", description: "Format code", source: "skill" }),
])
const selected: string[] = []
@@ -1066,11 +1069,13 @@ test("direct skill panel renders searchable skill list", async () => {
expect(frame).toContain("formatter")
expect(frame).toContain("Apply formatter fixes")
expect(frame).not.toContain("review")
app.mockInput.pressEnter()
expect(selected).toEqual(["auto-formatter"])
await app.mockInput.typeText("format")
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("internal")
app.mockInput.pressEnter()
expect(selected).toEqual(["formatter"])
expect(selected).toEqual(["auto-formatter", "formatter"])
} finally {
app.renderer.destroy()
}
@@ -2463,6 +2468,112 @@ test("direct permission rejection submits through keymap return binding", async
}
})
test.each(["", "model"])(
"direct model panel orders free, release, and name with provider grouping only for blank query '%s'",
async (query) => {
const providers: RunProvider[] = [
{
id: "other",
name: "Another provider",
models: {
model: model({ id: "model", name: "Model", cost: 0, released: 1 }),
"model-e": model({ id: "model-e", name: "Model E", released: 1000 }),
},
},
{
id: "opencode",
name: "OpenCode",
models: {
"model-d": model({ id: "model-d", name: "Model D" }),
"model-c": model({ id: "model-c", name: "Model C", released: 500 }),
"b-model": model({ id: "b-model", name: "B Model", cost: 0, released: 200 }),
"a-model": model({ id: "a-model", name: "A Model", cost: 0, released: 200 }),
"z-model": model({ id: "z-model", name: "Z Model", cost: 0, released: 300 }),
old: model({ id: "old", name: "Old Model", cost: 0, released: 2000, status: "deprecated" }),
},
},
]
const selected: NonNullable<RunInput["model"]>[] = []
const current = { providerID: "opencode", modelID: "a-model" }
const app = await testRender(
() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunModelSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
providers={() => providers}
current={() => current}
onClose={() => {}}
onSelect={(value) => selected.push(value)}
/>
</box>
),
{ width: 100, height: RUN_COMMAND_PANEL_ROWS + 1 },
)
try {
await app.renderOnce()
if (query) await app.mockInput.typeText(query)
await app.renderOnce()
const names = query
? ["Z Model", "A Model", "B Model", "Model E", "Model C", "Model", "Model D"]
: ["Z Model", "A Model", "B Model", "Model C", "Model D", "Model E", "Model"]
const rows = app
.captureCharFrame()
.split("\n")
.map((row) => row.trim().split(/\s{2,}/)[0])
expect(rows.filter((row) => names.includes(row))).toEqual(names)
expect(app.captureCharFrame()).not.toContain("Old Model")
expect(rows.filter((row) => ["OpenCode", "Another provider"].includes(row))).toEqual(
query ? [] : ["OpenCode", "Another provider"],
)
if (!query) {
expect(app.captureCharFrame()).toContain("current")
expect(app.captureCharFrame().match(/\bFree\b/g)).toHaveLength(2)
}
app.mockInput.pressEnter()
expect(selected.at(-1)).toEqual(query ? { providerID: "opencode", modelID: "z-model" } : current)
app.mockInput.pressKey("HOME")
for (const [providerID, modelID] of [
["opencode", "z-model"],
["opencode", "a-model"],
["opencode", "b-model"],
...(query
? [
["other", "model-e"],
["opencode", "model-c"],
["other", "model"],
["opencode", "model-d"],
]
: [
["opencode", "model-c"],
["opencode", "model-d"],
["other", "model-e"],
["other", "model"],
]),
]) {
app.mockInput.pressEnter()
expect(selected.at(-1)).toEqual({ providerID, modelID })
app.mockInput.pressKey("ARROW_DOWN")
await app.renderOnce()
}
app.mockInput.pressKey("ARROW_UP")
app.mockInput.pressEnter()
expect(selected.at(-1)).toEqual({ providerID: "other", modelID: query ? "model" : "model-e" })
if (query) {
app.mockInput.pressKey("u", { ctrl: true })
await app.renderOnce()
app.mockInput.pressKey("HOME")
app.mockInput.pressEnter()
expect(selected.at(-1)).toEqual({ providerID: "opencode", modelID: "z-model" })
}
} finally {
app.renderer.destroy()
}
},
)
test("direct model panel keeps native V2 light search and options readable on a transparent background", async () => {
const theme = await nativeLightTheme()
const background = RGBA.fromHex("#ffffff")
@@ -89,6 +89,7 @@ describe("run runtime boot", () => {
models: {
"gpt-5": {
name: "gpt-5",
time: { released: 1 },
cost: {
input: 0,
},
@@ -0,0 +1,133 @@
import { expect, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { InputRenderable } from "@opentui/core"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test.each(["all", "connected", "connect"])(
"%s model picker keeps release order before and after searching",
async (entry) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 110, height: 40, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const location = { directory, project: { id: "proj_test", directory, canonical: directory } }
let connected = entry === "connected"
const calls = createFetch((url) => {
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/provider")
return json({
location,
data: [
{ id: "demo", name: "Demo" },
{ id: "other", name: "Other" },
],
})
if (url.pathname === "/api/integration")
return json({
location,
data: [
{
id: "demo",
name: "Demo",
methods: [{ type: "key" }],
connections: connected ? [{ type: "credential", id: "cred_demo", label: "Demo" }] : [],
},
],
})
if (url.pathname === "/api/integration/demo/connect/key") {
connected = true
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/model")
return json({
location,
data: [
{
id: "haiku-other",
providerID: "other",
name: "Other Haiku",
enabled: true,
variants: [],
cost: [],
time: { released: 0 },
},
...[
{ id: "haiku-3", name: "Claude Haiku 3", released: 1 },
{ id: "haiku-4-5", name: "Claude Haiku 4.5", released: 2 },
].map((model) => ({
id: model.id,
providerID: "demo",
name: model.name,
enabled: true,
variants: [],
cost: [],
time: { released: model.released },
})),
],
})
return undefined
}, createEventStream())
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await ready.promise
await setup.waitForFrame((frame) => frame.includes("Build ·"))
await setup.mockInput.typeText("/models")
setup.mockInput.pressEnter()
await setup.waitForFrame(
(frame) => frame.includes("Select model") && setup.renderer.currentFocusedRenderable instanceof InputRenderable,
)
if (entry === "connect") {
setup.mockInput.pressKey("a", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Connect an integration") && frame.includes("Services"))
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("API key"))
await setup.mockInput.typeText("fixture-key")
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Connected Demo") && frame.includes("Claude Haiku 4.5"))
expect(setup.captureCharFrame().split("Connect an integration")[0]).not.toContain("Other Haiku")
}
const initial = setup.captureCharFrame()
expect(initial.indexOf("Claude Haiku 4.5")).toBeLessThan(initial.indexOf("Claude Haiku 3"))
await setup.mockInput.typeText("haik")
const frame = await setup.waitForFrame((frame) => frame.includes("haik") && frame.includes("Claude Haiku 3"))
expect(frame.indexOf("Claude Haiku 4.5")).toBeLessThan(frame.indexOf("Claude Haiku 3"))
setup.mockInput.pressKey("u", { ctrl: true })
await setup.waitForFrame((frame) => !frame.includes("haik"))
expect(setup.captureCharFrame().indexOf("Claude Haiku 4.5")).toBeLessThan(
setup.captureCharFrame().indexOf("Claude Haiku 3"),
)
await setup.mockInput.typeText("no-such-model")
await setup.waitForFrame((frame) => frame.includes("No results found"))
setup.mockInput.pressKey("u", { ctrl: true })
await setup.mockInput.typeText("haik")
await setup.waitForFrame((frame) => frame.includes("haik") && frame.includes("Claude Haiku 4.5"))
setup.mockInput.pressEnter()
await setup.waitForFrame(
(frame) =>
!frame.includes("Select model") &&
!frame.includes("Connect an integration") &&
frame.includes("Build · Claude Haiku 4.5"),
)
} finally {
setup.renderer.destroy()
await task
await server.stop()
}
},
)
+10
View File
@@ -0,0 +1,10 @@
export type ModelOrder = {
free: boolean
released: number
name: string
}
/** Apply after filtering so fuzzy relevance cannot put older models first. */
export function compareModelOrder(a: ModelOrder, b: ModelOrder) {
return Number(b.free) - Number(a.free) || b.released - a.released || a.name.localeCompare(b.name)
}
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test"
import { compareModelOrder } from "../src/model-order.js"
test("model order is free first, newest release, then name", () => {
const models = [
{ name: "Paid newest", released: 30, free: false },
{ name: "Free older", released: 10, free: true },
{ name: "Paid unknown", released: 0, free: false },
{ name: "Free Z", released: 20, free: true },
{ name: "Free A", released: 20, free: true },
{ name: "Paid older", released: 10, free: false },
]
expect(models.toSorted(compareModelOrder).map((model) => model.name)).toEqual([
"Free A",
"Free Z",
"Free older",
"Paid newest",
"Paid older",
"Paid unknown",
])
expect(models[0].name).toBe("Paid newest")
})
test("equal model keys retain the caller's existing order", () => {
const models = ["provider-b", "provider-a"].map((provider) => ({
provider,
name: "Claude Haiku 4.5",
released: 20,
free: false,
}))
expect(compareModelOrder(models[0], models[1])).toBe(0)
expect(models.toSorted(compareModelOrder)).toEqual(models)
})