From de09ebd31812713559e3b8df9bd3ca6af4f4c9f0 Mon Sep 17 00:00:00 2001 From: DanielFGray Date: Tue, 23 Dec 2025 12:02:38 -0600 Subject: [PATCH] fix: favorites and recents stay visible when filtering models Apply fuzzysort filtering per-section (favorites, recents, providers) while maintaining section order, and use onFilter callback to avoid reactivity loops from ref()?.filter reads. This ensures favorites and recent models remain visible when searching, instead of disappearing. --- .../cli/cmd/tui/component/dialog-model.tsx | 257 +++++++++--------- .../src/cli/cmd/tui/ui/dialog-select.tsx | 4 +- 2 files changed, 135 insertions(+), 126 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 38fd5745858..fc0559cd686 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -6,6 +6,7 @@ import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { Keybind } from "@/util/keybind" +import * as fuzzysort from "fuzzysort" export function useConnected() { const sync = useSync() @@ -19,6 +20,7 @@ export function DialogModel(props: { providerID?: string }) { const sync = useSync() const dialog = useDialog() const [ref, setRef] = createSignal>() + const [query, setQuery] = createSignal("") const connected = useConnected() const providers = createDialogProviderOptions() @@ -30,7 +32,7 @@ export function DialogModel(props: { providerID?: string }) { }) const options = createMemo(() => { - const query = ref()?.filter + const q = query() const favorites = showExtra() ? local.model.favorite() : [] const recents = local.model.recent() @@ -42,148 +44,151 @@ export function DialogModel(props: { providerID?: string }) { .slice(0, 5) : [] - const favoriteOptions = !query - ? favorites.flatMap((item) => { - const provider = sync.data.provider.find((x) => x.id === item.providerID) - if (!provider) return [] - const model = provider.models[item.modelID] - if (!model) return [] - return [ - { - key: item, - value: { + const favoriteOptions = favorites.flatMap((item) => { + const provider = sync.data.provider.find((x) => x.id === item.providerID) + if (!provider) return [] + const model = provider.models[item.modelID] + if (!model) return [] + return [ + { + key: item, + value: { + providerID: provider.id, + modelID: model.id, + }, + title: model.name ?? item.modelID, + description: provider.name, + category: "Favorites", + disabled: provider.id === "opencode" && model.id.includes("-nano"), + footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect: () => { + dialog.clear() + local.model.set( + { providerID: provider.id, modelID: model.id, }, - title: model.name ?? item.modelID, - description: provider.name, - category: "Favorites", - disabled: provider.id === "opencode" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect: () => { - dialog.clear() - local.model.set( - { - providerID: provider.id, - modelID: model.id, - }, - { recent: true }, - ) - }, - }, - ] - }) - : [] + { recent: true }, + ) + }, + }, + ] + }) - const recentOptions = !query - ? recentList.flatMap((item) => { - const provider = sync.data.provider.find((x) => x.id === item.providerID) - if (!provider) return [] - const model = provider.models[item.modelID] - if (!model) return [] - return [ - { - key: item, - value: { + const recentOptions = recentList.flatMap((item) => { + const provider = sync.data.provider.find((x) => x.id === item.providerID) + if (!provider) return [] + const model = provider.models[item.modelID] + if (!model) return [] + return [ + { + key: item, + value: { + providerID: provider.id, + modelID: model.id, + }, + title: model.name ?? item.modelID, + description: provider.name, + category: "Recent", + disabled: provider.id === "opencode" && model.id.includes("-nano"), + footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect: () => { + dialog.clear() + local.model.set( + { providerID: provider.id, modelID: model.id, }, - title: model.name ?? item.modelID, - description: provider.name, - category: "Recent", - disabled: provider.id === "opencode" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect: () => { + { recent: true }, + ) + }, + }, + ] + }) + + const providerOptions = pipe( + sync.data.provider, + sortBy( + (provider) => provider.id !== "opencode", + (provider) => provider.name, + ), + flatMap((provider) => + pipe( + provider.models, + entries(), + filter(([_, info]) => info.status !== "deprecated"), + filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), + map(([model, info]) => { + const value = { + providerID: provider.id, + modelID: model, + } + return { + value, + title: info.name ?? model, + description: favorites.some( + (item) => item.providerID === value.providerID && item.modelID === value.modelID, + ) + ? "(Favorite)" + : undefined, + category: connected() ? provider.name : undefined, + disabled: provider.id === "opencode" && model.includes("-nano"), + footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect() { dialog.clear() local.model.set( { providerID: provider.id, - modelID: model.id, + modelID: model, }, { recent: true }, ) }, - }, - ] - }) - : [] - - return [ - ...favoriteOptions, - ...recentOptions, - ...pipe( - sync.data.provider, - sortBy( - (provider) => provider.id !== "opencode", - (provider) => provider.name, - ), - flatMap((provider) => - pipe( - provider.models, - entries(), - filter(([_, info]) => info.status !== "deprecated"), - filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), - map(([model, info]) => { - const value = { - providerID: provider.id, - modelID: model, - } - return { - value, - title: info.name ?? model, - description: favorites.some( - (item) => item.providerID === value.providerID && item.modelID === value.modelID, - ) - ? "(Favorite)" - : undefined, - category: connected() ? provider.name : undefined, - disabled: provider.id === "opencode" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect() { - dialog.clear() - local.model.set( - { - providerID: provider.id, - modelID: model, - }, - { recent: true }, - ) - }, - } - }), - filter((x) => { - if (query) return true - const value = x.value - const inFavorites = favorites.some( - (item) => item.providerID === value.providerID && item.modelID === value.modelID, - ) - if (inFavorites) return false - const inRecents = recents.some( - (item) => item.providerID === value.providerID && item.modelID === value.modelID, - ) - if (inRecents) return false - return true - }), - sortBy( - (x) => x.footer !== "Free", - (x) => x.title, - ), + } + }), + filter((x) => { + const value = x.value + const inFavorites = favorites.some( + (item) => item.providerID === value.providerID && item.modelID === value.modelID, + ) + if (inFavorites) return false + const inRecents = recents.some( + (item) => item.providerID === value.providerID && item.modelID === value.modelID, + ) + if (inRecents) return false + return true + }), + sortBy( + (x) => x.footer !== "Free", + (x) => x.title, ), ), ), - ...(!connected() - ? pipe( - providers(), - map((option) => { - return { - ...option, - category: "Popular providers", - } - }), - take(6), - ) - : []), - ] + ) + + const popularProviders = !connected() + ? pipe( + providers(), + map((option) => { + return { + ...option, + category: "Popular providers", + } + }), + take(6), + ) + : [] + + // Apply fuzzy filtering to each section separately, maintaining section order + if (q) { + const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj) + const filteredRecents = fuzzysort.go(q, recentOptions, { keys: ["title"] }).map((x) => x.obj) + const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj) + const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj) + return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular] + } + + return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders] }) const provider = createMemo(() => @@ -215,6 +220,8 @@ export function DialogModel(props: { providerID?: string }) { }, ]} ref={setRef} + onFilter={setQuery} + skipFilter={true} title={title()} current={local.model.current()} options={options()} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index ff9745b90fe..1e764d66bba 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -19,6 +19,7 @@ export interface DialogSelectProps { onMove?: (option: DialogSelectOption) => void onFilter?: (query: string) => void onSelect?: (option: DialogSelectOption) => void + skipFilter?: boolean keybind?: { keybind: Keybind.Info title: string @@ -74,7 +75,8 @@ export function DialogSelect(props: DialogSelectProps) { const result = pipe( props.options, filter((x) => x.disabled !== true), - (x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)), + (x) => + !needle || props.skipFilter ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj), ) return result })