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 97a572f1cf2..bddf9de20dc 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/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 8fd652e9032..8bdeedfbb43 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -79,6 +79,21 @@ export function TerminalPanel() { ), ) + createEffect( + on( + () => opened(), + (isOpen, wasOpen) => { + if (!isDesktop() || !isOpen || wasOpen !== false) return + const activeId = terminal.active() + if (!activeId) return + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + setTimeout(() => focusTerminalById(activeId), 0) + }, + ), + ) + createEffect(() => { if (panel.open()) return const active = document.activeElement 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"),