From f2b61e9edbbbb9ed3241b0c32eb5ed3854fec1ac Mon Sep 17 00:00:00 2001 From: anduimagui Date: Sat, 7 Mar 2026 06:07:11 +0000 Subject: [PATCH 1/3] feat(app): add favorite model cycling --- .../components/dialog-select-model-unpaid.tsx | 45 +++++++++++++++-- .../src/components/dialog-select-model.tsx | 50 ++++++++++++++++--- packages/app/src/context/local.tsx | 46 ++++++++++++++++- packages/app/src/context/models.tsx | 25 ++++++++++ packages/app/src/i18n/en.ts | 8 +++ .../pages/session/use-session-commands.tsx | 32 ++++++++++++ 6 files changed, 192 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index bcee3f501f5..5bd6207a320 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -1,6 +1,7 @@ import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" +import { IconButton } from "@opencode-ai/ui/icon-button" import { List, type ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tag } from "@opencode-ai/ui/tag" @@ -18,6 +19,13 @@ export const DialogSelectModelUnpaid: Component = () => { const dialog = useDialog() const providers = useProviders() const language = useLanguage() + const favorite = (providerID: string, modelID: string) => local.model.isFavorite({ providerID, modelID }) + + const toggleFavorite = (event: MouseEvent, providerID: string, modelID: string) => { + event.preventDefault() + event.stopPropagation() + local.model.toggleFavorite({ providerID, modelID }) + } let listRef: ListRef | undefined const handleKeyDown = (e: KeyboardEvent) => { @@ -38,6 +46,12 @@ export const DialogSelectModelUnpaid: Component = () => { items={local.model.list} current={local.model.current()} key={(x) => `${x.provider.id}:${x.id}`} + sortBy={(a, b) => { + const af = favorite(a.provider.id, a.id) + const bf = favorite(b.provider.id, b.id) + if (af !== bf) return af ? -1 : 1 + return a.name.localeCompare(b.name) + }} itemWrapper={(item, node) => ( { > {(i) => (
- {i.name} - {language.t("model.tag.free")} - - {language.t("model.tag.latest")} - +
+ {i.name} + {language.t("model.tag.free")} + + {language.t("model.tag.latest")} + +
+ + { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => toggleFavorite(event, i.provider.id, i.id)} + /> +
)} diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 9f7afb8cd27..4123e675794 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -34,6 +34,14 @@ const ModelList: Component<{ .filter((m) => (props.provider ? m.provider.id === props.provider : true)), ) + const favorite = (providerID: string, modelID: string) => local.model.isFavorite({ providerID, modelID }) + + const toggleFavorite = (event: MouseEvent, providerID: string, modelID: string) => { + event.preventDefault() + event.stopPropagation() + local.model.toggleFavorite({ providerID, modelID }) + } + return ( a.name.localeCompare(b.name)} + sortBy={(a, b) => { + const af = favorite(a.provider.id, a.id) + const bf = favorite(b.provider.id, b.id) + if (af !== bf) return af ? -1 : 1 + return a.name.localeCompare(b.name) + }} groupBy={(x) => x.provider.name} sortGroupsBy={(a, b) => { const aProvider = a.items[0].provider.id @@ -71,13 +84,34 @@ const ModelList: Component<{ > {(i) => (
- {i.name} - - {language.t("model.tag.free")} - - - {language.t("model.tag.latest")} - +
+ {i.name} + + {language.t("model.tag.free")} + + + {language.t("model.tag.latest")} + +
+ + { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => toggleFavorite(event, i.provider.id, i.id)} + /> +
)}
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 75d1334a5a5..526915d6163 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -144,7 +144,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return models.find(key) }) - const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean)) + const favorite = createMemo(() => + models.favorite + .list() + .map(models.find) + .filter((item): item is NonNullable => !!item), + ) + + const recent = createMemo(() => + models.recent + .list() + .map(models.find) + .filter((item): item is NonNullable => !!item), + ) const cycle = (direction: 1 | -1) => { const recentList = recent() @@ -181,13 +193,45 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setModel = set + const cycleFavorite = (direction: 1 | -1) => { + const list = favorite() + if (list.length === 0) return + const curr = current() + let index = -1 + + if (curr) { + index = list.findIndex((item) => item.provider.id === curr.provider.id && item.id === curr.id) + } + + if (index === -1) index = direction === 1 ? 0 : list.length - 1 + else index = (index + direction + list.length) % list.length + + const item = list[index] + if (!item) return + set( + { + providerID: item.provider.id, + modelID: item.id, + }, + { recent: true }, + ) + } + return { ready: models.ready, current, + favorite, recent, list: models.list, cycle, + cycleFavorite, set, + isFavorite(model: ModelKey) { + return models.favorite.has(model) + }, + toggleFavorite(model: ModelKey) { + models.favorite.toggle(model) + }, visible(model: ModelKey) { return models.visible(model) }, diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index 12ec8371add..a58d041e376 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -92,6 +92,8 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( return map }) + const favorite = createMemo(() => new Set(store.user.filter((item) => item.favorite).map(modelKey))) + const list = createMemo(() => available().map((m) => ({ ...m, @@ -111,6 +113,15 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( setStore("user", store.user.length, { ...model, visibility: state }) } + function toggleFavorite(model: ModelKey) { + const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID) + if (index >= 0) { + setStore("user", index, "favorite", (value) => (value ? undefined : true)) + return + } + setStore("user", store.user.length, { ...model, visibility: "show", favorite: true }) + } + const visible = (model: ModelKey) => { const key = modelKey(model) const state = visibility().get(key) @@ -150,6 +161,20 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( find, visible, setVisibility, + favorite: { + list: createMemo(() => + store.user + .filter((item) => item.favorite) + .map((item) => ({ + providerID: item.providerID, + modelID: item.modelID, + })), + ), + has(model: ModelKey) { + return favorite().has(modelKey(model)) + }, + toggle: toggleFavorite, + }, recent: { list: createMemo(() => store.recent), push, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c278d6270e5..b292c27d1a0 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -61,6 +61,10 @@ export const dict = { "command.message.next.description": "Go to the next user message", "command.model.choose": "Choose model", "command.model.choose.description": "Select a different model", + "command.model.favorite.cycle": "Cycle favorite models", + "command.model.favorite.cycle.description": "Switch to the next favorite model", + "command.model.favorite.cycle.reverse": "Cycle favorite models backwards", + "command.model.favorite.cycle.reverse.description": "Switch to the previous favorite model", "command.mcp.toggle": "Toggle MCPs", "command.mcp.toggle.description": "Toggle MCPs", "command.agent.cycle": "Cycle agent", @@ -111,6 +115,8 @@ export const dict = { "dialog.model.select.title": "Select model", "dialog.model.search.placeholder": "Search models", "dialog.model.empty": "No model results", + "dialog.model.favorite": "Favorite", + "dialog.model.unfavorite": "Unfavorite", "dialog.model.manage": "Manage models", "dialog.model.manage.description": "Customize which models appear in the model selector.", "dialog.model.manage.provider.toggle": "Toggle all {{provider}} models", @@ -423,6 +429,8 @@ export const dict = { "toast.model.none.title": "No model selected", "toast.model.none.description": "Connect a provider to summarize this session", + "toast.model.favorite.none.title": "No favorite models", + "toast.model.favorite.none.description": "Favorite a couple of models to cycle between them quickly.", "toast.file.loadFailed.title": "Failed to load file", "toast.file.listFailed.title": "Failed to list files", diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index b8ddeda8235..60aac0184c1 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -227,6 +227,38 @@ export const useSessionCommands = (actions: SessionCommandContext) => { slash: "model", onSelect: () => dialog.show(() => ), }), + modelCommand({ + id: "model.favorite.cycle", + title: language.t("command.model.favorite.cycle"), + description: language.t("command.model.favorite.cycle.description"), + keybind: "mod+shift+arrowdown", + onSelect: () => { + if (local.model.favorite().length === 0) { + showToast({ + title: language.t("toast.model.favorite.none.title"), + description: language.t("toast.model.favorite.none.description"), + }) + return + } + local.model.cycleFavorite(1) + }, + }), + modelCommand({ + id: "model.favorite.cycle.reverse", + title: language.t("command.model.favorite.cycle.reverse"), + description: language.t("command.model.favorite.cycle.reverse.description"), + keybind: "mod+shift+arrowup", + onSelect: () => { + if (local.model.favorite().length === 0) { + showToast({ + title: language.t("toast.model.favorite.none.title"), + description: language.t("toast.model.favorite.none.description"), + }) + return + } + local.model.cycleFavorite(-1) + }, + }), mcpCommand({ id: "mcp.toggle", title: language.t("command.mcp.toggle"), From e9ac86224fb4ed34b7f37416bcd36a474f850ea4 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Mon, 9 Mar 2026 21:23:57 +0000 Subject: [PATCH 2/3] fix(app): focus terminal when panel opens via toggle shortcut The focus effect only triggered when terminal.active() changed, not when the panel opened. This meant if a terminal was already active (restored from previous session), toggling the panel open wouldn't shift focus from the chat input to the terminal. Added a new effect that watches panel.open() and focuses the active terminal whenever the panel transitions from closed to open. --- packages/app/src/pages/session/terminal-panel.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 69c8aefcc50..a8f384608dd 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -76,6 +76,21 @@ export function TerminalPanel() { ), ) + createEffect( + on( + () => open(), + (isOpen, wasOpen) => { + if (!isOpen || wasOpen) return + const activeId = terminal.active() + if (!activeId) return + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + setTimeout(() => focusTerminalById(activeId), 0) + }, + ), + ) + createEffect(() => { const dir = params.dir if (!dir) return From e75d46bbdd06f78bd70589e819a526a6e855311b Mon Sep 17 00:00:00 2001 From: anduimagui Date: Tue, 10 Mar 2026 11:38:14 +0000 Subject: [PATCH 3/3] fix(app): only refocus terminal on reopen The terminal panel focus effect should only run for an actual closed to open toggle. Skip the initial opened state so session load and desktop media query changes do not steal focus from the composer. --- packages/app/src/pages/session/terminal-panel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 5255a9d320a..8bdeedfbb43 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -81,9 +81,9 @@ export function TerminalPanel() { createEffect( on( - () => open(), + () => opened(), (isOpen, wasOpen) => { - if (!isOpen || wasOpen) return + if (!isDesktop() || !isOpen || wasOpen !== false) return const activeId = terminal.active() if (!activeId) return if (document.activeElement instanceof HTMLElement) {