mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-04 07:56:23 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebb71382a6 |
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user