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
13 changes: 8 additions & 5 deletions packages/app/src/components/dialog-select-model-unpaid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const DialogSelectModelUnpaid: Component = () => {
items={local.model.list}
current={local.model.current()}
key={(x) => `${x.provider.id}:${x.id}`}
sortBy={(a, b) => a.name.localeCompare(b.name)}
itemWrapper={(item, node) => (
<Tooltip
class="w-full"
Expand All @@ -63,11 +64,13 @@ 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>
</div>
)}
</List>
Expand Down
16 changes: 9 additions & 7 deletions packages/app/src/components/dialog-select-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,15 @@ 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>
</div>
)}
</List>
Expand Down
11 changes: 9 additions & 2 deletions packages/app/src/components/dialog-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@ import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"

export const DialogSettings: Component = () => {
export type SettingsTab = "general" | "shortcuts" | "providers" | "models"

export const DialogSettings: Component<{ tab?: SettingsTab }> = (props) => {
const language = useLanguage()
const platform = usePlatform()

return (
<Dialog size="x-large" transition>
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
<Tabs
orientation="vertical"
variant="settings"
defaultValue={props.tab ?? "general"}
class="h-full settings-dialog"
>
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-3 w-full pt-3">
Expand Down
29 changes: 25 additions & 4 deletions packages/app/src/components/settings-models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { type Component, For, Show } from "solid-js"
import { type Component, For, Show, createMemo } from "solid-js"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"

type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]

const ListLoadingState: Component<{ label: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
Expand Down Expand Up @@ -61,6 +60,7 @@ export const SettingsModels: Component = () => {
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<p class="text-13-regular text-text-weak">{language.t("settings.models.description")}</p>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
Expand Down Expand Up @@ -104,15 +104,36 @@ export const SettingsModels: Component = () => {
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
const visible = () => models.visible(key)
return (
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="min-w-0">
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
</div>
<div class="flex-shrink-0">
<div class="flex items-center gap-2 flex-shrink-0">
<Show when={visible()}>
<button
type="button"
title={language.t(
models.favorite.has(key) ? "dialog.model.unfavorite" : "dialog.model.favorite",
)}
aria-label={language.t(
models.favorite.has(key) ? "dialog.model.unfavorite" : "dialog.model.favorite",
)}
class="shrink-0 text-14-medium text-text-weak transition-opacity hover:text-text-strong"
classList={{
"opacity-100": models.favorite.has(key),
"opacity-30": !models.favorite.has(key),
}}
onClick={() => models.favorite.toggle(key)}
>
{models.favorite.has(key) ? "♥" : "♡"}
</button>
</Show>
<Switch
checked={models.visible(key)}
checked={visible()}
onChange={(checked) => {
if (!checked && models.favorite.has(key)) models.favorite.toggle(key)
models.setVisibility(key, checked)
}}
hideLabel
Expand Down
78 changes: 77 additions & 1 deletion packages/app/src/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,27 @@ 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 quick = createMemo(() =>
models.quick
.list()
.map(models.find)
.filter((item): item is NonNullable<typeof item> => !!item)
.filter((item) => models.visible({ providerID: item.provider.id, modelID: item.id })),
)

const cycle = (direction: 1 | -1) => {
const recentList = recent()
Expand Down Expand Up @@ -181,13 +201,69 @@ 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 },
)
}

const cycleQuick = (direction: 1 | -1) => {
const list = quick()
if (list.length < 2) return
const curr = current()
const index = curr ? list.findIndex((item) => item.provider.id === curr.provider.id && item.id === curr.id) : -1
const next =
index === -1 ? (direction === 1 ? 0 : list.length - 1) : (index + direction + list.length) % list.length
const item = list[next]
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,
quick: {
list: quick,
get: models.quick.get,
set: models.quick.set,
cycle: cycleQuick,
},
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
49 changes: 49 additions & 0 deletions packages/app/src/context/models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ type User = ModelKey & { visibility: Visibility; favorite?: boolean }
type Store = {
user: User[]
recent: ModelKey[]
quick: {
a?: ModelKey
b?: ModelKey
}
variant?: Record<string, string | undefined>
}

Expand All @@ -32,6 +36,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
createStore<Store>({
user: [],
recent: [],
quick: {},
variant: {},
}),
)
Expand Down Expand Up @@ -92,6 +97,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 +118,27 @@ 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 })
}

function same(a: ModelKey | undefined, b: ModelKey | undefined) {
if (!a || !b) return false
return a.providerID === b.providerID && a.modelID === b.modelID
}

function setQuick(slot: "a" | "b", model: ModelKey | undefined) {
const other = slot === "a" ? "b" : "a"
setStore("quick", slot, model)
if (same(model, store.quick[other])) setStore("quick", other, undefined)
if (model) update(model, "show")
}

const visible = (model: ModelKey) => {
const key = modelKey(model)
const state = visibility().get(key)
Expand Down Expand Up @@ -150,10 +178,31 @@ 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,
},
quick: {
list: createMemo(() => [store.quick.a, store.quick.b].filter((item): item is ModelKey => !!item)),
get(slot: "a" | "b") {
return store.quick[slot]
},
set: setQuick,
},
variant: {
get: getVariant,
set: setVariant,
Expand Down
10 changes: 9 additions & 1 deletion 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 Expand Up @@ -767,7 +775,7 @@ export const dict = {
"settings.providers.tag.custom": "Custom",
"settings.providers.tag.other": "Other",
"settings.models.title": "Models",
"settings.models.description": "Model settings will be configurable here.",
"settings.models.description": "Choose which models are enabled and mark favorites for faster switching.",
"settings.agents.title": "Agents",
"settings.agents.description": "Agent settings will be configurable here.",
"settings.commands.title": "Commands",
Expand Down
6 changes: 3 additions & 3 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings"
import { DialogSettings, type SettingsTab } from "@/components/dialog-settings"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
Expand Down Expand Up @@ -1098,8 +1098,8 @@ export default function Layout(props: ParentProps) {
dialog.show(() => <DialogSelectServer />)
}

function openSettings() {
dialog.show(() => <DialogSettings />)
function openSettings(tab?: SettingsTab) {
dialog.show(() => <DialogSettings tab={tab} />)
}

function projectRoot(directory: string) {
Expand Down
Loading
Loading