import { Popover as Kobalte } from "@kobalte/core/popover" import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useDialog } from "@opencode-ai/ui/context/dialog" import { popularProviders } from "@/hooks/use-providers" import { Button } from "@opencode-ai/ui/button" import { IconButton } from "@opencode-ai/ui/icon-button" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { Tag } from "@opencode-ai/ui/tag" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Icon } from "@opencode-ai/ui/v2/icon" import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" import { decode64 } from "@/utils/base64" import { handleDocumentSearchKeydown } from "@/utils/search-keydown" import { createMenuDismissController } from "@/utils/menu-dismiss-controller" import { createEventListener } from "@solid-primitives/event-listener" import { matchesModelSearch } from "./dialog-select-model-search" const isFree = (provider: string, cost: { input: number } | undefined) => provider === "opencode" && (!cost || cost.input === 0) type ModelState = ReturnType["model"] type ModelItem = ReturnType[number] const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}` const manageKey = "action:manage" const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => { const aIndex = popularProviders.indexOf(a.category) const bIndex = popularProviders.indexOf(b.category) const aPopular = aIndex >= 0 const bPopular = bIndex >= 0 if (aPopular && !bPopular) return -1 if (!aPopular && bPopular) return 1 if (aPopular && bPopular) return aIndex - bIndex return a.items[0].provider.name.localeCompare(b.items[0].provider.name) } const ModelList: Component<{ provider?: string class?: string onSelect: () => void action?: JSX.Element model?: ModelState }> = (props) => { const model = props.model ?? useLocal().model const language = useLanguage() const models = createMemo(() => model .list() .filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id })) .filter((m) => (props.provider ? m.provider.id === props.provider : true)), ) return ( `${x.provider.id}:${x.id}`} items={models} current={model.current()} filterKeys={["provider.name", "name", "id"]} sortBy={(a, b) => a.name.localeCompare(b.name)} groupBy={(x) => x.provider.name} sortGroupsBy={(a, b) => { const aProvider = a.items[0].provider.id const bProvider = b.items[0].provider.id if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) }} itemWrapper={(item, node) => ( } > {node} )} onSelect={(x) => { model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, }) props.onSelect() }} > {(i) => (
{i.name} {language.t("model.tag.free")} {language.t("model.tag.latest")}
)}
) } type ModelSelectorTriggerProps = Omit, "as" | "ref"> type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element type Dismiss = "escape" | "outside" | "select" | "manage" | "provider" export function ModelSelectorPopover(props: { provider?: string model?: ModelState trigger: ModelSelectorTrigger onClose?: (cause: "escape" | "select") => void }) { const [store, setStore] = createStore<{ open: boolean dismiss: Dismiss | null }>({ open: false, dismiss: null, }) const dialog = useDialog() const local = useLocal() const directory = () => decode64(local.slug()) const close = (dismiss: Dismiss) => { setStore("dismiss", dismiss) setStore("open", false) } const handleManage = () => { close("manage") void import("./dialog-manage-models").then((x) => { dialog.show(() => ) }) } const handleConnectProvider = () => { close("provider") void import("./dialog-connect-provider").then((x) => { void dialog.show(() => ) }) } const language = useLanguage() return ( { if (next) setStore("dismiss", null) setStore("open", next) }} modal={false} placement="top-start" gutter={4} > { close("escape") event.preventDefault() event.stopPropagation() }} onPointerDownOutside={() => close("outside")} onFocusOutside={() => close("outside")} onCloseAutoFocus={(event) => { const dismiss = store.dismiss if (dismiss === "outside") event.preventDefault() if (dismiss === "escape" || dismiss === "select") { event.preventDefault() props.onClose?.(dismiss) } setStore("dismiss", null) }} > {language.t("dialog.model.select.title")} close("select")} class="p-1" action={
} />
) } export function ModelSelectorPopoverV2(props: { provider?: string model?: ModelState trigger: ModelSelectorTrigger onClose?: () => void }) { const dialog = useDialog() const controller = createModelSelectorController({ model: props.model, provider: () => props.provider, onSelect: () => props.onClose?.(), }) return ( { void import("./dialog-manage-models").then((module) => { void dialog.show(() => ) }) }} onClose={() => props.onClose?.()} /> ) } function createModelSelectorController(input: { provider: () => string | undefined model?: ModelState onSelect: () => void }) { const model = input.model ?? useLocal().model const allModels = createMemo(() => model .list() .filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id })) .filter((item) => (input.provider() ? item.provider.id === input.provider() : true)), ) return { models: (search: string) => { const query = search.trim() const filtered = query ? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name])) : allModels() return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) }, groups: (models: ModelItem[]) => { const byProvider = new Map() for (const item of models) { byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item]) } return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups) }, current: () => { const value = model.current() return value ? modelKey(value) : undefined }, select: (item: ModelItem) => { model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) input.onSelect() }, } } function ModelSelectorPopoverV2View(props: { trigger: ModelSelectorTrigger models: (search: string) => ModelItem[] groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[] current: () => string | undefined select: (item: ModelItem) => void onManage: () => void onClose: () => void }) { const language = useLanguage() const [store, setStore] = createStore({ open: false, search: "", active: "" }) let searchRef: HTMLInputElement | undefined let contentRef: HTMLDivElement | undefined const dismiss = createMenuDismissController(() => contentRef) const models = createMemo(() => props.models(store.search)) const groups = createMemo(() => props.groups(models())) const keys = () => [...models().map(modelKey), manageKey] const initialActive = () => { const selected = props.current() const options = keys() if (selected && options.includes(selected)) return selected return options[0] ?? "" } const activeItem = () => store.active ? contentRef?.querySelector(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined const setOpen = (open: boolean) => { if (open) { dismiss.allowTriggerRestore() setStore({ open: true, active: initialActive() }) setTimeout(() => requestAnimationFrame(() => { searchRef?.focus() activeItem()?.scrollIntoView({ block: "nearest" }) }), ) return } setStore({ open: false, search: "", active: "" }) } const selectModel = (item: ModelItem) => { dismiss.preventTriggerRestore() setOpen(false) dismiss.afterClose(() => props.select(item)) } const manage = () => { dismiss.preventTriggerRestore() setOpen(false) dismiss.afterClose(props.onManage) } const selectActive = () => { const item = models().find((item) => modelKey(item) === store.active) if (item) { selectModel(item) return } if (store.active === manageKey) manage() } const moveActive = (delta: number) => { const options = keys() if (options.length === 0) return const index = options.indexOf(store.active) const start = index === -1 ? 0 : index setStore("active", options[(start + delta + options.length) % options.length]) queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) } const setSearch = (value: string) => { const first = props.models(value)[0] setStore({ search: value, active: first ? modelKey(first) : manageKey }) } createEffect(() => { if (!store.open) return createEventListener( document, "keydown", (event: KeyboardEvent) => handleDocumentSearchKeydown(searchRef, event, store.search, setSearch), true, ) }) return ( (contentRef = element)} class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" onPointerDownOutside={dismiss.preventTriggerRestore} onFocusOutside={dismiss.preventTriggerRestore} onCloseAutoFocus={dismiss.onCloseAutoFocus} >
(searchRef = el)} value={store.search} placeholder={language.t("dialog.model.search.placeholder")} class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" spellcheck={false} autocorrect="off" autocomplete="off" autocapitalize="off" onInput={(event) => setSearch(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Tab") return event.stopPropagation() if (event.key === "Escape") { event.preventDefault() dismiss.preventTriggerRestore() setOpen(false) dismiss.afterClose(props.onClose) return } if (event.altKey || event.metaKey) return if (event.key === "ArrowDown") { event.preventDefault() moveActive(1) return } if (event.key === "ArrowUp") { event.preventDefault() moveActive(-1) return } if (event.key === "Enter" && !event.isComposing) { event.preventDefault() selectActive() } }} />
0} fallback={
{language.t("dialog.model.empty")}
} > {(group) => ( {group.items[0].provider.name} {(item) => ( } > { setStore("active", modelKey(item)) setTimeout(() => searchRef?.focus()) }} onSelect={() => selectModel(item)} > {item.name} {language.t("model.tag.free")} {language.t("model.tag.latest")} )} )}
{ setStore("active", manageKey) setTimeout(() => searchRef?.focus()) }} onSelect={manage} > {language.t("dialog.model.manage")}
) } export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { const dialog = useDialog() const language = useLanguage() const local = useLocal() const directory = () => decode64(local.slug()) const provider = () => { void import("./dialog-connect-provider").then((x) => { void dialog.show(() => ) }) } const manage = () => { void import("./dialog-manage-models").then((x) => { dialog.show(() => ) }) } return ( {language.t("command.provider.connect")} } > dialog.close()} /> ) }