diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index ace79e38a7..2618247919 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -9,6 +9,7 @@ import { popularProviders } from "@/hooks/use-providers" import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectProvider } from "./dialog-select-provider" +import { sortModels } from "@/utils/model-sort" export const DialogManageModels: Component = () => { const local = useLocal() @@ -44,7 +45,7 @@ export const DialogManageModels: Component = () => { key={(x) => `${x?.provider?.id}:${x?.id}`} items={local.model.list()} filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} + sortBy={sortModels} groupBy={(x) => x.provider.id} groupHeader={(group) => { const provider = group.items[0].provider diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index fdef866a79..55098f38bb 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -12,9 +12,7 @@ import { List } from "@opencode-ai/ui/list" import { Tooltip } from "@opencode-ai/ui/tooltip" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" - -const isFree = (provider: string, cost: { input: number } | undefined) => - provider === "opencode" && (!cost || cost.input === 0) +import { isFreeModel, sortModels } from "@/utils/model-sort" type ModelState = ReturnType["model"] @@ -44,7 +42,7 @@ const ModelList: Component<{ items={models} current={model.current()} filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} + sortBy={sortModels} groupBy={(x) => x.provider.name} sortGroupsBy={(a, b) => { const aProvider = a.items[0].provider.id @@ -58,7 +56,7 @@ const ModelList: Component<{ class="w-full" placement="right-start" gutter={12} - value={} + value={} > {node} @@ -73,7 +71,7 @@ const ModelList: Component<{ {(i) => (
{i.name} - + {language.t("model.tag.free")} diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 14667338e9..ac66223421 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" import { popularProviders } from "@/hooks/use-providers" import { SettingsList } from "./settings-list" +import { sortModels } from "@/utils/model-sort" type ModelItem = ReturnType["list"]>[number] @@ -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: sortModels, groupBy: (x) => x.provider.id, sortGroupsBy: (a, b) => { const aIndex = popularProviders.indexOf(a.category) diff --git a/packages/app/src/utils/model-sort.test.ts b/packages/app/src/utils/model-sort.test.ts new file mode 100644 index 0000000000..b8c4f419d6 --- /dev/null +++ b/packages/app/src/utils/model-sort.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { sortModels } from "./model-sort" + +function model(input: { id: string; name: string; release_date: string; cost?: number }) { + return { + id: input.id, + name: input.name, + release_date: input.release_date, + provider: { id: "opencode" }, + cost: { input: input.cost ?? 0 }, + } +} + +describe("sortModels", () => { + test("pins Big Pickle before free models sorted by release date", () => { + expect( + [ + model({ id: "older-free", name: "Older Free", release_date: "2026-01-01" }), + model({ id: "paid", name: "Paid", release_date: "2026-05-01", cost: 1 }), + model({ id: "newer-free", name: "Newer Free", release_date: "2026-05-01" }), + model({ id: "big-pickle", name: "Big Pickle", release_date: "2025-10-17" }), + ].sort(sortModels).map((item) => item.id), + ).toEqual(["big-pickle", "newer-free", "older-free", "paid"]) + }) +}) diff --git a/packages/app/src/utils/model-sort.ts b/packages/app/src/utils/model-sort.ts new file mode 100644 index 0000000000..c7faf29306 --- /dev/null +++ b/packages/app/src/utils/model-sort.ts @@ -0,0 +1,26 @@ +type SortableModel = { + id: string + name: string + release_date: string + provider: { id: string } + cost?: { input: number } +} + +export function isFreeModel(model: { provider: { id: string }; cost?: { input: number } }) { + return model.provider.id === "opencode" && (!model.cost || model.cost.input === 0) +} + +export function sortModels(a: SortableModel, b: SortableModel) { + const aFree = isFreeModel(a) + const bFree = isFreeModel(b) + if (aFree && !bFree) return -1 + if (!aFree && bFree) return 1 + if (aFree && bFree) { + if (a.id !== b.id) { + if (a.id === "big-pickle") return -1 + if (b.id === "big-pickle") return 1 + } + return b.release_date.localeCompare(a.release_date) || a.name.localeCompare(b.name) + } + return a.name.localeCompare(b.name) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index cf6822c066..ef129c6baf 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -7,6 +7,7 @@ import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./ import { formatBindings } from "./keymap.shared" import type { RunFooterTheme } from "./theme" import type { FooterKeybinds, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types" +import { sortModelOptions } from "@/cli/util/model-sort" type PanelEntry = RunFooterMenuItem & { category: string @@ -25,6 +26,8 @@ type ModelEntry = PanelEntry & { providerID: string modelID: string providerName: string + releaseDate: string + free: boolean current: boolean } @@ -674,9 +677,10 @@ export function RunModelSelectBody(props: { .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" - : model.cost?.input === 0 && provider.id === "opencode" + : free ? "Free" : title !== modelID ? modelID @@ -688,6 +692,9 @@ export function RunModelSelectBody(props: { category: provider.name, display: title, footer, + title, + releaseDate: model.release_date, + free, keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`, current, } @@ -704,7 +711,7 @@ export function RunModelSelectBody(props: { return name } - return a.display.localeCompare(b.display) + return sortModelOptions(a, b) }), ) const items = createMemo(() => match(query(), entries())) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 4b4484b798..9025de8e63 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -70,20 +70,25 @@ export function DialogModel(props: { providerID?: string }) { entries(), filter(([_, info]) => info.status !== "deprecated"), filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), - map(([model, info]) => ({ - value: { providerID: provider.id, modelID: model }, - title: info.name ?? model, - releaseDate: info.release_date, - description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) - ? "(Favorite)" - : undefined, - category: connected() ? provider.name : undefined, - disabled: provider.id === "opencode" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect() { - onSelect(provider.id, model) - }, - })), + map(([model, info]) => { + const free = info.cost?.input === 0 && provider.id === "opencode" + return { + value: { providerID: provider.id, modelID: model }, + title: info.name ?? model, + description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) + ? "(Favorite)" + : undefined, + category: connected() ? provider.name : undefined, + disabled: provider.id === "opencode" && model.includes("-nano"), + footer: free ? "Free" : undefined, + modelID: model, + releaseDate: info.release_date, + free, + onSelect() { + onSelect(provider.id, model) + }, + } + }), filter((x) => { if (!showSections) return true if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID)) @@ -172,14 +177,23 @@ export function DialogModel(props: { providerID?: string }) { ) } -export function sortModelOptions( +export function sortModelOptions< + T extends { footer?: string; releaseDate: string; title: string; modelID?: string; free?: boolean }, +>( options: T[], newestFirst: boolean, ) { - if (newestFirst) return sortBy(options, [(option) => option.releaseDate, "desc"], (option) => option.title) + if (newestFirst) + return sortBy( + options, + (option) => (option.modelID === "big-pickle" ? 0 : 1), + [(option) => option.releaseDate, "desc"], + (option) => option.title, + ) return sortBy( options, - (option) => option.footer !== "Free", + (option) => (option.modelID === "big-pickle" ? 0 : option.free || option.footer === "Free" ? 1 : 2), + [(option) => (option.free || option.footer === "Free" ? option.releaseDate : ""), "desc"], (option) => option.title, ) } diff --git a/packages/opencode/src/cli/util/model-sort.ts b/packages/opencode/src/cli/util/model-sort.ts new file mode 100644 index 0000000000..68bb7ecaf9 --- /dev/null +++ b/packages/opencode/src/cli/util/model-sort.ts @@ -0,0 +1,19 @@ +type SortableModelOption = { + modelID: string + title: string + releaseDate: string + free: boolean +} + +export function sortModelOptions(a: SortableModelOption, b: SortableModelOption) { + if (a.free && !b.free) return -1 + if (!a.free && b.free) return 1 + if (a.free && b.free) { + if (a.modelID !== b.modelID) { + if (a.modelID === "big-pickle") return -1 + if (b.modelID === "big-pickle") return 1 + } + return b.releaseDate.localeCompare(a.releaseDate) || a.title.localeCompare(b.title) + } + return a.title.localeCompare(b.title) +} diff --git a/packages/opencode/test/cli/model-sort.test.ts b/packages/opencode/test/cli/model-sort.test.ts new file mode 100644 index 0000000000..4a51f21db2 --- /dev/null +++ b/packages/opencode/test/cli/model-sort.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { sortModelOptions } from "@/cli/util/model-sort" + +function model(input: { modelID: string; title: string; releaseDate: string; free?: boolean }) { + return { + modelID: input.modelID, + title: input.title, + releaseDate: input.releaseDate, + free: input.free ?? true, + } +} + +describe("sortModelOptions", () => { + test("pins Big Pickle before free models sorted by release date", () => { + expect( + [ + model({ modelID: "older-free", title: "Older Free", releaseDate: "2026-01-01" }), + model({ modelID: "paid", title: "Paid", releaseDate: "2026-05-01", free: false }), + model({ modelID: "newer-free", title: "Newer Free", releaseDate: "2026-05-01" }), + model({ modelID: "big-pickle", title: "Big Pickle", releaseDate: "2025-10-17" }), + ].sort(sortModelOptions).map((item) => item.modelID), + ).toEqual(["big-pickle", "newer-free", "older-free", "paid"]) + }) +})