Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 40 additions & 5 deletions packages/app/src/components/dialog-select-model-unpaid.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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) => {
Expand All @@ -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) => (
<Tooltip
class="w-full"
Expand All @@ -63,11 +77,32 @@ export const DialogSelectModelUnpaid: Component = () => {
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Tag>{language.t("model.tag.free")}</Tag>
<Show when={i.latest}>
<Tag>{language.t("model.tag.latest")}</Tag>
</Show>
<div class="min-w-0 flex-1 flex items-center gap-x-2.5">
<span class="truncate">{i.name}</span>
<Tag>{language.t("model.tag.free")}</Tag>
<Show when={i.latest}>
<Tag>{language.t("model.tag.latest")}</Tag>
</Show>
</div>
<Tooltip
value={language.t(favorite(i.provider.id, i.id) ? "dialog.model.unfavorite" : "dialog.model.favorite")}
>
<IconButton
icon="circle-check"
variant="ghost"
size="small"
class="shrink-0"
classList={{
"opacity-100": favorite(i.provider.id, i.id),
"opacity-30": !favorite(i.provider.id, i.id),
}}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => toggleFavorite(event, i.provider.id, i.id)}
/>
</Tooltip>
</div>
)}
</List>
Expand Down
50 changes: 42 additions & 8 deletions packages/app/src/components/dialog-select-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<List
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
Expand All @@ -43,7 +51,12 @@ const ModelList: Component<{
items={models}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => 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
Expand Down Expand Up @@ -71,13 +84,34 @@ const ModelList: Component<{
>
{(i) => (
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={isFree(i.provider.id, i.cost)}>
<Tag>{language.t("model.tag.free")}</Tag>
</Show>
<Show when={i.latest}>
<Tag>{language.t("model.tag.latest")}</Tag>
</Show>
<div class="min-w-0 flex-1 flex items-center gap-x-2">
<span class="truncate">{i.name}</span>
<Show when={isFree(i.provider.id, i.cost)}>
<Tag>{language.t("model.tag.free")}</Tag>
</Show>
<Show when={i.latest}>
<Tag>{language.t("model.tag.latest")}</Tag>
</Show>
</div>
<Tooltip
value={language.t(favorite(i.provider.id, i.id) ? "dialog.model.unfavorite" : "dialog.model.favorite")}
>
<IconButton
icon="circle-check"
variant="ghost"
size="small"
class="shrink-0"
classList={{
"opacity-100": favorite(i.provider.id, i.id),
"opacity-30": !favorite(i.provider.id, i.id),
}}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => toggleFavorite(event, i.provider.id, i.id)}
/>
</Tooltip>
</div>
)}
</List>
Expand Down
46 changes: 45 additions & 1 deletion packages/app/src/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof item> => !!item),
)

const recent = createMemo(() =>
models.recent
.list()
.map(models.find)
.filter((item): item is NonNullable<typeof item> => !!item),
)

const cycle = (direction: 1 | -1) => {
const recentList = recent()
Expand Down Expand Up @@ -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)
},
Expand Down
25 changes: 25 additions & 0 deletions packages/app/src/context/models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions packages/app/src/pages/session/terminal-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions packages/app/src/pages/session/use-session-commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,38 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />),
}),
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"),
Expand Down
Loading