Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
66546ce
Add favorites to model selector (#23)
shuv1337 Nov 15, 2025
03d3c80
revert desktop changes
shuv1337 Nov 17, 2025
76d48d8
revert desktop changes
shuv1337 Nov 17, 2025
2c264a7
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 21, 2025
a069899
Update Nix flake.lock and hashes
actions-user Nov 21, 2025
d9a0881
chore: format code
actions-user Nov 21, 2025
0a1f6d8
fix syntax error
shuv1337 Nov 21, 2025
15b72aa
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 21, 2025
16aed1b
chore: format code
actions-user Nov 21, 2025
e6b665e
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 21, 2025
a8832fa
Update Nix flake.lock and hashes
actions-user Nov 21, 2025
fd4d38b
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 21, 2025
b67a0bb
Local test merge of PR #27: Add model favorites
shuv1337 Nov 21, 2025
1dab0fa
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 21, 2025
e99a697
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 21, 2025
087f075
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 21, 2025
d162131
chore: format code
actions-user Nov 22, 2025
9cab986
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 22, 2025
5d587f0
Update Nix flake.lock and hashes
actions-user Nov 22, 2025
210b9b5
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 22, 2025
6a751a5
chore: format code
actions-user Nov 22, 2025
62370a3
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 22, 2025
78f0927
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 22, 2025
37aa350
chore: format code
actions-user Nov 22, 2025
f432c6b
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 22, 2025
ab3b2be
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 23, 2025
bc83f23
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 24, 2025
a909581
Update Nix flake.lock and hashes
actions-user Nov 24, 2025
82ba1b2
Update Nix flake.lock and hashes
actions-user Nov 24, 2025
cc49b53
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 24, 2025
138572b
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 24, 2025
7990520
chore: format code
actions-user Nov 24, 2025
28cd7be
chore: format code
actions-user Nov 24, 2025
1984ba5
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 24, 2025
d43386f
Update Nix flake.lock and hashes
actions-user Nov 24, 2025
b6bf1fc
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 24, 2025
912d7ec
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 25, 2025
12f6be4
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 25, 2025
7d85dac
trying different favorites layout
shuv1337 Nov 25, 2025
e747767
one more tweak
shuv1337 Nov 25, 2025
13b2839
increasing recent model storage to allow for 5 favorites + 5 non-favo…
shuv1337 Nov 25, 2025
b431e3f
continue limiting recents to 5
shuv1337 Nov 25, 2025
878b598
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 25, 2025
49915c0
Merge branch 'dev' into add-model-favorites
shuv1337 Nov 25, 2025
9a34f96
chore: format code
actions-user Nov 25, 2025
677cbc4
favorites/recent cleanup
shuv1337 Nov 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
164 changes: 87 additions & 77 deletions packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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 = <T extends ModelRef>(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 (
Expand All @@ -113,6 +116,13 @@ export function DialogModel() {
dialog.replace(() => <DialogProvider />)
},
},
{
keybind: Keybind.parse("ctrl+f")[0],
title: "Favorite",
onTrigger: (option) => {
local.model.toggleFavorite(option.value as ModelRef)
},
},
]}
ref={setRef}
title="Select model"
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
82 changes: 70 additions & 12 deletions packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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)!
Expand All @@ -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(() => {
Expand All @@ -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()
})
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
}
6 changes: 6 additions & 0 deletions packages/sdk/go/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading