diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 33be73ca2d1..f8d8c640f81 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -248,6 +248,24 @@ function App() { local.model.cycle(-1) }, }, + { + title: "Favorite cycle", + value: "model.cycle_favorite", + keybind: "model_cycle_favorite", + category: "Agent", + onSelect: () => { + local.model.cycleFavorite(1) + }, + }, + { + title: "Favorite cycle reverse", + value: "model.cycle_favorite_reverse", + keybind: "model_cycle_favorite_reverse", + category: "Agent", + onSelect: () => { + local.model.cycleFavorite(-1) + }, + }, { title: "Switch agent", value: "agent.list", 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 c25e7e370ab..eaee92b9572 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -1,10 +1,13 @@ import { createMemo, createSignal } from "solid-js" import { useLocal } from "@tui/context/local" import { useSync } from "@tui/context/sync" -import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy, take } from "remeda" +import { pipe, flatMap, entries, filter, sortBy, take } from "remeda" import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" +import { Keybind } from "@/util/keybind" + +type ModelRef = { providerID: string; modelID: string } export function DialogModel() { const local = useLocal() @@ -16,91 +19,91 @@ export function DialogModel() { sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), ) - const showRecent = createMemo(() => !ref()?.filter && local.model.recent().length > 0 && connected()) const providers = createDialogProviderOptions() const options = createMemo(() => { - return [ - ...(showRecent() - ? local.model.recent().flatMap((item) => { - const provider = sync.data.provider.find((x) => x.id === item.providerID)! - if (!provider) return [] - const model = provider.models[item.modelID] - if (!model) return [] + const query = ref()?.filter + const favorites = local.model.favorite() + const recents = local.model.recent() + const current = local.model.current() + + const isMatch = (a: ModelRef, b: ModelRef) => a.providerID === b.providerID && a.modelID === b.modelID + const isFavorite = (m: ModelRef) => favorites.some((f) => isMatch(f, m)) + const isRecent = (m: ModelRef) => recents.some((r) => isMatch(r, m)) + const isCurrent = (m: ModelRef) => current && isMatch(current, m) + + const selectModel = (m: ModelRef) => { + dialog.clear() + local.model.set(m, { recent: true }) + } + + const toOption = (m: ModelRef, category: string, star?: boolean) => { + const provider = sync.data.provider.find((x) => x.id === m.providerID) + if (!provider) return + const model = provider.models[m.modelID] + if (!model) return + return { + key: m, + value: m, + title: model.name ?? m.modelID, + description: `${provider.name}${star ? " ★" : ""}`, + category, + disabled: provider.id === "opencode" && model.id.includes("-nano"), + footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect: () => selectModel(m), + } + } + + // Sort with current first + const sortCurrent = (list: T[]) => + current ? [...list.filter((x) => isCurrent(x)), ...list.filter((x) => !isCurrent(x))] : list + + const favoriteOptions = + !query && favorites.length > 0 ? sortCurrent(favorites).flatMap((m) => toOption(m, "Favorites", true) ?? []) : [] + + const recentList = recents.filter((m) => !isFavorite(m)).slice(0, 5) + const recentOptions = !query ? sortCurrent(recentList).flatMap((m) => toOption(m, "Recent") ?? []) : [] + + const allModels = pipe( + sync.data.provider, + sortBy( + (p) => p.id !== "opencode", + (p) => p.name, + ), + flatMap((provider) => + pipe( + provider.models, + entries(), + filter(([, info]) => !query || !!(info.name ?? "").toLowerCase().includes(query.toLowerCase())), + sortBy(([, info]) => info.name ?? ""), + flatMap(([modelID, info]) => { + const m = { providerID: provider.id, modelID } + if (!query && (isFavorite(m) || isRecent(m))) return [] return [ { - key: item, - value: { - providerID: provider.id, - modelID: model.id, - }, - title: model.name ?? item.modelID, - description: provider.name, - category: "Recent", - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect: () => { - dialog.clear() - local.model.set( - { - providerID: provider.id, - modelID: model.id, - }, - { recent: true }, - ) - }, + value: m, + title: info.name ?? modelID, + description: connected() ? `${provider.name}${isFavorite(m) ? " ★" : ""}` : undefined, + category: connected() ? provider.name : undefined, + disabled: provider.id === "opencode" && modelID.includes("-nano"), + footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect: () => selectModel(m), }, ] - }) - : []), - ...pipe( - sync.data.provider, - sortBy( - (provider) => provider.id !== "opencode", - (provider) => provider.name, - ), - flatMap((provider) => - pipe( - provider.models, - entries(), - map(([model, info]) => ({ - value: { - providerID: provider.id, - modelID: model, - }, - title: info.name ?? model, - description: connected() ? provider.name : undefined, - category: connected() ? provider.name : undefined, - disabled: provider.id === "opencode" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect() { - dialog.clear() - local.model.set( - { - providerID: provider.id, - modelID: model, - }, - { recent: true }, - ) - }, - })), - filter((x) => !showRecent() || !local.model.recent().find((y) => isDeepEqual(y, x.value))), - sortBy((x) => x.title), - ), + }), ), ), - ...(!connected() - ? pipe( - providers(), - map((option) => { - return { - ...option, - category: "Popular providers", - } - }), - take(6), - ) - : []), - ] + ) + + const providerOptions = !connected() + ? pipe( + providers(), + take(6), + flatMap((opt) => [{ ...opt, category: "Popular providers" }]), + ) + : [] + + return [...favoriteOptions, ...recentOptions, ...allModels, ...providerOptions] }) return ( @@ -113,6 +116,13 @@ export function DialogModel() { dialog.replace(() => ) }, }, + { + keybind: Keybind.parse("ctrl+f")[0], + title: "Favorite", + onTrigger: (option) => { + local.model.toggleFavorite(option.value as ModelRef) + }, + }, ]} ref={setRef} title="Select model" diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index fdf5b5b52af..d1c3228e18c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -475,6 +475,8 @@ export function Prompt(props: PromptProps) { }, }) } + // Track model as recently used when actually sending a message + local.model.addRecent(local.model.current()) history.append(store.prompt) input.extmarks.clear() setStore("prompt", { diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index c3b38aab2a2..dcb8e90cfb1 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -114,18 +114,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ providerID: string modelID: string }[] + favorite: { + providerID: string + modelID: string + }[] }>({ ready: false, model: {}, recent: [], + favorite: [], }) const file = Bun.file(path.join(Global.Path.state, "model.json")) + function save() { + Bun.write( + file, + JSON.stringify({ + recent: modelStore.recent, + favorite: modelStore.favorite, + }), + ) + } + file .json() .then((x) => { - setModelStore("recent", x.recent) + if (Array.isArray(x.recent)) setModelStore("recent", x.recent) + if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite) }) .catch(() => {}) .finally(() => { @@ -184,6 +200,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ recent() { return modelStore.recent }, + favorite() { + return modelStore.favorite + }, parsed: createMemo(() => { const value = currentModel() const provider = sync.data.provider.find((x) => x.id === value.providerID)! @@ -204,7 +223,36 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (next >= recent.length) next = 0 const val = recent[next] if (!val) return - setModelStore("model", agent.current().name, { ...val }) + this.set(val) + }, + cycleFavorite(direction: 1 | -1) { + const favorites = modelStore.favorite.filter((item) => isModelValid(item)) + if (!favorites.length) { + toast.show({ + variant: "info", + message: "Add a favorite model to use this shortcut", + duration: 3000, + }) + return + } + const current = currentModel() + let index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) + if (index === -1) { + index = direction === 1 ? 0 : favorites.length - 1 + } else { + index += direction + if (index < 0) index = favorites.length - 1 + if (index >= favorites.length) index = 0 + } + const next = favorites[index] + if (!next) return + this.set(next, { recent: true }) + }, + addRecent(model: { providerID: string; modelID: string }) { + const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID) + if (uniq.length > 10) uniq.pop() + setModelStore("recent", uniq) + save() }, set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { batch(() => { @@ -217,17 +265,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return } setModelStore("model", agent.current().name, model) - if (options?.recent) { - const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID) - if (uniq.length > 5) uniq.pop() - setModelStore("recent", uniq) - Bun.write( - file, - JSON.stringify({ - recent: modelStore.recent, - }), - ) + if (options?.recent) this.addRecent(model) + }) + }, + toggleFavorite(model: { providerID: string; modelID: string }) { + batch(() => { + if (!isModelValid(model)) { + toast.show({ + message: `Model ${model.providerID}/${model.modelID} is not valid`, + variant: "warning", + duration: 3000, + }) + return } + const exists = modelStore.favorite.some( + (x) => x.providerID === model.providerID && x.modelID === model.modelID, + ) + const next = exists + ? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID) + : [model, ...modelStore.favorite] + setModelStore("favorite", next) + save() }) }, } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index ca4ec322eaf..b8d2a5b144e 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -253,7 +253,7 @@ export function DialogSelect(props: DialogSelectProps) { )} - + {(item) => ( diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 28b8ca3b2ae..04b2c4dac27 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -428,6 +428,8 @@ export namespace Config { model_list: z.string().optional().default("m").describe("List available models"), model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), + model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), + model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), command_list: z.string().optional().default("ctrl+p").describe("List available commands"), agent_list: z.string().optional().default("a").describe("List agents"), agent_cycle: z.string().optional().default("tab").describe("Next agent"), diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a3be82bda94..a5a51b0ac28 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/go/config.go b/packages/sdk/go/config.go index 02460fb5df9..87663b96262 100644 --- a/packages/sdk/go/config.go +++ b/packages/sdk/go/config.go @@ -1935,6 +1935,10 @@ type KeybindsConfig struct { ModelCycleRecent string `json:"model_cycle_recent"` // Previous recent model ModelCycleRecentReverse string `json:"model_cycle_recent_reverse"` + // Next favorite model + ModelCycleFavorite string `json:"model_cycle_favorite"` + // Previous favorite model + ModelCycleFavoriteReverse string `json:"model_cycle_favorite_reverse"` // List available models ModelList string `json:"model_list"` // Create/update AGENTS.md @@ -2008,6 +2012,8 @@ type keybindsConfigJSON struct { MessagesUndo apijson.Field ModelCycleRecent apijson.Field ModelCycleRecentReverse apijson.Field + ModelCycleFavorite apijson.Field + ModelCycleFavoriteReverse apijson.Field ModelList apijson.Field ProjectInit apijson.Field SessionChildCycle apijson.Field diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 8b525478de8..b6d4f3e1dc9 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -26,4 +26,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 2de8ca2f1cf..77b7a2da0a4 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -803,6 +803,14 @@ export type KeybindsConfig = { * Previous recently used model */ model_cycle_recent_reverse?: string + /** + * Next favorite model + */ + model_cycle_favorite?: string + /** + * Previous favorite model + */ + model_cycle_favorite_reverse?: string /** * List available commands */ diff --git a/packages/sdk/python/src/opencode_ai/models/keybinds_config.py b/packages/sdk/python/src/opencode_ai/models/keybinds_config.py index f98b3b78e7d..034e9171cbc 100644 --- a/packages/sdk/python/src/opencode_ai/models/keybinds_config.py +++ b/packages/sdk/python/src/opencode_ai/models/keybinds_config.py @@ -43,6 +43,8 @@ class KeybindsConfig: model_list (Union[Unset, str]): List available models Default: 'm'. model_cycle_recent (Union[Unset, str]): Next recent model Default: 'f2'. model_cycle_recent_reverse (Union[Unset, str]): Previous recent model Default: 'shift+f2'. + model_cycle_favorite (Union[Unset, str]): Next favorite model Default: 'none'. + model_cycle_favorite_reverse (Union[Unset, str]): Previous favorite model Default: 'none'. agent_list (Union[Unset, str]): List agents Default: 'a'. agent_cycle (Union[Unset, str]): Next agent Default: 'tab'. agent_cycle_reverse (Union[Unset, str]): Previous agent Default: 'shift+tab'. @@ -95,6 +97,8 @@ class KeybindsConfig: model_list: Union[Unset, str] = "m" model_cycle_recent: Union[Unset, str] = "f2" model_cycle_recent_reverse: Union[Unset, str] = "shift+f2" + model_cycle_favorite: Union[Unset, str] = "none" + model_cycle_favorite_reverse: Union[Unset, str] = "none" agent_list: Union[Unset, str] = "a" agent_cycle: Union[Unset, str] = "tab" agent_cycle_reverse: Union[Unset, str] = "shift+tab" @@ -176,6 +180,10 @@ def to_dict(self) -> dict[str, Any]: model_cycle_recent_reverse = self.model_cycle_recent_reverse + model_cycle_favorite = self.model_cycle_favorite + + model_cycle_favorite_reverse = self.model_cycle_favorite_reverse + agent_list = self.agent_list agent_cycle = self.agent_cycle @@ -277,6 +285,10 @@ def to_dict(self) -> dict[str, Any]: field_dict["model_cycle_recent"] = model_cycle_recent if model_cycle_recent_reverse is not UNSET: field_dict["model_cycle_recent_reverse"] = model_cycle_recent_reverse + if model_cycle_favorite is not UNSET: + field_dict["model_cycle_favorite"] = model_cycle_favorite + if model_cycle_favorite_reverse is not UNSET: + field_dict["model_cycle_favorite_reverse"] = model_cycle_favorite_reverse if agent_list is not UNSET: field_dict["agent_list"] = agent_list if agent_cycle is not UNSET: @@ -381,6 +393,10 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: model_cycle_recent_reverse = d.pop("model_cycle_recent_reverse", UNSET) + model_cycle_favorite = d.pop("model_cycle_favorite", UNSET) + + model_cycle_favorite_reverse = d.pop("model_cycle_favorite_reverse", UNSET) + agent_list = d.pop("agent_list", UNSET) agent_cycle = d.pop("agent_cycle", UNSET) @@ -450,6 +466,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: model_list=model_list, model_cycle_recent=model_cycle_recent, model_cycle_recent_reverse=model_cycle_recent_reverse, + model_cycle_favorite=model_cycle_favorite, + model_cycle_favorite_reverse=model_cycle_favorite_reverse, agent_list=agent_list, agent_cycle=agent_cycle, agent_cycle_reverse=agent_cycle_reverse, diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index c54f4ec9d12..4806a12dd3a 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -14,6 +14,7 @@ interface SelectDialogProps emptyMessage?: string children: (item: T) => JSX.Element onSelect?: (value: T | undefined) => void + onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void } export function SelectDialog(props: SelectDialogProps) { @@ -65,9 +66,12 @@ export function SelectDialog(props: SelectDialogProps) { setStore("mouseActive", false) if (e.key === "Escape") return + const all = flat() + const selected = all.find((x) => others.key(x) === active()) + props.onKeyEvent?.(e, selected) + if (e.key === "Enter") { e.preventDefault() - const selected = flat().find((x) => others.key(x) === active()) if (selected) handleSelect(selected) } else { onKeyDown(e) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index afcff3a0edd..989745c312c 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -38,6 +38,8 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf "model_list": "m", "model_cycle_recent": "f2", "model_cycle_recent_reverse": "shift+f2", + "model_cycle_favorite": "none", + "model_cycle_favorite_reverse": "none", "command_list": "ctrl+p", "agent_list": "a", "agent_cycle": "tab",