Add favorites to model selector (#23) (#4343)

Co-authored-by: Github Action <action@github.com>
This commit is contained in:
shuv 2025-11-25 22:41:41 -08:00 committed by GitHub
parent 73eae191e9
commit 335f46122b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 253 additions and 39 deletions

View file

@ -245,6 +245,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",

View file

@ -1,10 +1,11 @@
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 { map, 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"
export function DialogModel() {
const local = useLocal()
@ -16,14 +17,45 @@ 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)!
const query = ref()?.filter
const favorites = local.model.favorite()
const recents = local.model.recent()
const currentModel = local.model.current()
const orderedRecents = currentModel
? [
currentModel,
...recents.filter(
(item) => item.providerID !== currentModel.providerID || item.modelID !== currentModel.modelID,
),
]
: recents
const isCurrent = (item: { providerID: string; modelID: string }) =>
currentModel && item.providerID === currentModel.providerID && item.modelID === currentModel.modelID
const currentIsFavorite = currentModel && favorites.some((fav) => isCurrent(fav))
const recentList = orderedRecents
.filter((item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID))
.slice(0, 5)
const orderedFavorites = currentModel
? [...favorites.filter((item) => isCurrent(item)), ...favorites.filter((item) => !isCurrent(item))]
: favorites
const orderedRecentList =
currentModel && !currentIsFavorite
? [...recentList.filter((item) => isCurrent(item)), ...recentList.filter((item) => !isCurrent(item))]
: recentList
const favoriteOptions =
!query && favorites.length > 0
? orderedFavorites.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 []
@ -35,8 +67,9 @@ export function DialogModel() {
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
description: `${provider.name}`,
category: "Favorites",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
@ -51,7 +84,44 @@ export function DialogModel() {
},
]
})
: []),
: []
const recentOptions = !query
? orderedRecentList.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 []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []
return [
...favoriteOptions,
...recentOptions,
...pipe(
sync.data.provider,
sortBy(
@ -62,28 +132,46 @@ export function DialogModel() {
pipe(
provider.models,
entries(),
map(([model, info]) => ({
value: {
map(([model, info]) => {
const 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))),
}
const favorite = favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
return {
value,
title: info.name ?? model,
description: connected() ? `${provider.name}${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() {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model,
},
{ recent: true },
)
},
}
}),
filter((x) => {
if (query) return true
const value = x.value
const inFavorites = favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
const inRecents = orderedRecents.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
if (inFavorites) return false
if (inRecents) return false
return true
}),
sortBy((x) => x.title),
),
),
@ -113,6 +201,13 @@ export function DialogModel() {
dialog.replace(() => <DialogProvider />)
},
},
{
keybind: Keybind.parse("ctrl+f")[0],
title: "Favorite",
onTrigger: (option) => {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
},
]}
ref={setRef}
title="Select model"

View file

@ -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)!
@ -206,6 +225,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!val) return
setModelStore("model", agent.current().name, { ...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
setModelStore("model", agent.current().name, { ...next })
const uniq = uniqueBy([next, ...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(() => {
if (!isModelValid(model)) {
@ -219,17 +265,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
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()
if (uniq.length > 10) uniq.pop()
setModelStore("recent", uniq)
Bun.write(
file,
JSON.stringify({
recent: modelStore.recent,
}),
)
save()
}
})
},
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()
})
},
}
})

View file

@ -253,7 +253,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)}
</For>
</scrollbox>
<box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={1}>
<box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={2}>
<For each={props.keybind ?? []}>
{(item) => (
<text>

View file

@ -428,6 +428,8 @@ export namespace Config {
model_list: z.string().optional().default("<leader>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("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),

View file

@ -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

View file

@ -811,6 +811,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
*/

View file

@ -43,6 +43,8 @@ class KeybindsConfig:
model_list (Union[Unset, str]): List available models Default: '<leader>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: '<leader>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] = "<leader>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] = "<leader>a"
agent_cycle: Union[Unset, str] = "tab"
agent_cycle_reverse: Union[Unset, str] = "shift+tab"
@ -176,6 +180,10 @@ class KeybindsConfig:
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 @@ class KeybindsConfig:
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 @@ class KeybindsConfig:
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 @@ class KeybindsConfig:
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,

View file

@ -14,6 +14,7 @@ interface SelectDialogProps<T>
emptyMessage?: string
children: (item: T) => JSX.Element
onSelect?: (value: T | undefined) => void
onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
}
export function SelectDialog<T>(props: SelectDialogProps<T>) {
@ -65,9 +66,12 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
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)

View file

@ -38,6 +38,8 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
"model_list": "<leader>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": "<leader>a",
"agent_cycle": "tab",