diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index a1771bb5..3ec5198a 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -3,6 +3,6 @@ "version": "0.5.0", "private": true, "dependencies": { - "@opencode-ai/plugin": "1.1.30" + "@opencode-ai/plugin": "1.1.36" } } diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index d3cfeefc..10b6b325 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -16,6 +16,7 @@ const PreferencesSchema = z.object({ locale: z.string().optional(), environmentVariables: z.record(z.string()).default({}), modelRecents: z.array(ModelPreferenceSchema).default([]), + modelFavorites: z.array(ModelPreferenceSchema).default([]), modelThinkingSelections: z.record(z.string(), z.string()).default({}), diffViewMode: z.enum(["split", "unified"]).default("split"), toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), diff --git a/packages/ui/src/components/model-selector.tsx b/packages/ui/src/components/model-selector.tsx index 55078e1d..0c836813 100644 --- a/packages/ui/src/components/model-selector.tsx +++ b/packages/ui/src/components/model-selector.tsx @@ -1,10 +1,11 @@ import { Combobox } from "@kobalte/core/combobox" import { createEffect, createMemo, createSignal } from "solid-js" import { providers, fetchProviders } from "../stores/sessions" -import { ChevronDown } from "lucide-solid" +import { ChevronDown, Star } from "lucide-solid" import type { Model } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" +import { preferences, toggleFavoriteModelPreference } from "../stores/preferences" import Kbd from "./kbd" const log = getLogger("session") @@ -26,8 +27,19 @@ export default function ModelSelector(props: ModelSelectorProps) { const { t } = useI18n() const instanceProviders = () => providers().get(props.instanceId) || [] const [isOpen, setIsOpen] = createSignal(false) + const [manualAll, setManualAll] = createSignal(false) + const [explicitFavorites, setExplicitFavorites] = createSignal(false) + const [autoFavoritesEligibleAtOpen, setAutoFavoritesEligibleAtOpen] = createSignal(false) + const [searchDirty, setSearchDirty] = createSignal(false) + const [initialQuery, setInitialQuery] = createSignal("") + const [initialQueryReady, setInitialQueryReady] = createSignal(false) + const [inputValue, setInputValue] = createSignal("") let triggerRef!: HTMLButtonElement let searchInputRef!: HTMLInputElement + let listboxRef!: HTMLUListElement + let suppressNextClose = false + let wasFavoritesOnlyEnabled = false + let wasCurrentModelFavorite = false createEffect(() => { if (instanceProviders().length === 0) { @@ -46,61 +58,232 @@ export default function ModelSelector(props: ModelSelectorProps) { ), ) + const favoriteKeySet = createMemo(() => { + const result = new Set() + for (const item of preferences().modelFavorites ?? []) { + if (item.providerId && item.modelId) { + result.add(`${item.providerId}/${item.modelId}`) + } + } + return result + }) + + const favoriteModels = createMemo(() => { + const keys = favoriteKeySet() + if (keys.size === 0) return [] + return allModels().filter((m) => keys.has(m.key)) + }) + + const hasFavorites = createMemo(() => favoriteModels().length > 0) + const currentModelValue = createMemo(() => allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId), ) + const currentModelIsFavorite = createMemo(() => { + const current = props.currentModel + return favoriteKeySet().has(`${current.providerId}/${current.modelId}`) + }) + + const currentModelKey = createMemo(() => { + const current = props.currentModel + return `${current.providerId}/${current.modelId}` + }) + + const searchActive = createMemo(() => { + if (!searchDirty()) return false + const next = inputValue().trim() + return next.length > 0 + }) + + const favoritesOnlyEnabled = createMemo(() => { + if (searchActive()) return false + if (manualAll()) return false + if (!hasFavorites()) return false + return explicitFavorites() || autoFavoritesEligibleAtOpen() + }) + + const visibleOptions = createMemo(() => { + if (!favoritesOnlyEnabled()) { + return allModels() + } + return favoriteModels() + }) + const handleChange = async (value: FlatModel | null) => { if (!value) return await props.onModelChange({ providerId: value.providerId, modelId: value.id }) } - const customFilter = (option: FlatModel, inputValue: string) => { - return option.searchText.toLowerCase().includes(inputValue.toLowerCase()) + const customFilter = (option: FlatModel, rawInput: string) => { + if (!searchDirty()) return true + return option.searchText.toLowerCase().includes(rawInput.toLowerCase()) } createEffect(() => { if (isOpen()) { + setManualAll(false) + setExplicitFavorites(false) + setAutoFavoritesEligibleAtOpen(hasFavorites() && currentModelIsFavorite()) + setSearchDirty(false) + setInitialQuery("") + setInputValue("") + setInitialQueryReady(false) setTimeout(() => { + const seeded = searchInputRef?.value ?? "" + setInitialQuery(seeded) + setInputValue(seeded) + setInitialQueryReady(true) searchInputRef?.focus() + searchInputRef?.select() }, 100) + } else { + setInitialQueryReady(false) + setSearchDirty(false) + setAutoFavoritesEligibleAtOpen(false) } }) + createEffect(() => { + if (!isOpen()) { + wasFavoritesOnlyEnabled = favoritesOnlyEnabled() + wasCurrentModelFavorite = currentModelIsFavorite() + return + } + + const nowFavoritesOnlyEnabled = favoritesOnlyEnabled() + const nowCurrentModelFavorite = currentModelIsFavorite() + + if (wasFavoritesOnlyEnabled && !nowFavoritesOnlyEnabled && wasCurrentModelFavorite && !nowCurrentModelFavorite) { + setTimeout(() => { + const key = currentModelKey() + const target = listboxRef?.querySelector(`[data-key="${key}"]`) as HTMLElement | null + target?.scrollIntoView({ block: "nearest" }) + }, 0) + } + + wasFavoritesOnlyEnabled = nowFavoritesOnlyEnabled + wasCurrentModelFavorite = nowCurrentModelFavorite + }) + + const handleSearchInput = (event: InputEvent & { currentTarget: HTMLInputElement }) => { + const next = event.currentTarget.value + setInputValue(next) + if (!initialQueryReady()) return + if (searchDirty()) return + if (next !== initialQuery()) { + setSearchDirty(true) + } + } + + const preventListboxPress = (event: PointerEvent | MouseEvent) => { + event.preventDefault() + event.stopImmediatePropagation?.() + event.stopPropagation() + suppressNextClose = true + setTimeout(() => { + suppressNextClose = false + }, 0) + } + + const toggleFavoritesOnly = () => { + if (!hasFavorites()) return + if (searchActive()) return + + if (favoritesOnlyEnabled()) { + setManualAll(true) + setExplicitFavorites(false) + setAutoFavoritesEligibleAtOpen(false) + return + } + + setExplicitFavorites(true) + setManualAll(false) + } + + const showAllModels = () => { + setManualAll(true) + setExplicitFavorites(false) + setAutoFavoritesEligibleAtOpen(false) + setTimeout(() => searchInputRef?.focus(), 0) + } + return (