From 2f2ca0b57aeba00731c8adfbe121c18e3ac1b914 Mon Sep 17 00:00:00 2001 From: gitmotion <43588713+gitmotion@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:40:38 -0800 Subject: [PATCH] feat: add command palette fuzzy search Update command palette search to only show results based on parent update command palette selection hover color to brand color Update command palette scrollbar to be theme aware Update static actions update command palatte search to wordbased scores and minor refactors Fix command-palette view to adjust on mobile portrait/landscape add kbd styles to footer and update home/root name of command palette hide command palette footer on small width screens reorg command palette files to separate dir update model fetching to get from table rows with caching reorg models and providers as parents and list items by parent, refactor icons, add external link icon, etc rename view actions from view to Browse revert bun.lock Refactor sort by menu to pull dynamically remove unused code cleanup github logo usage Adjust mobile search icon and command palette chevron alignment --- .../command-palette-element.ts | 1026 +++++++++++++++++ .../command-palette/command-palette.ts | 346 ++++++ packages/web/src/index.css | 72 ++ packages/web/src/index.ts | 69 +- packages/web/src/render.tsx | 7 + 5 files changed, 1503 insertions(+), 17 deletions(-) create mode 100644 packages/web/src/components/command-palette/command-palette-element.ts create mode 100644 packages/web/src/components/command-palette/command-palette.ts diff --git a/packages/web/src/components/command-palette/command-palette-element.ts b/packages/web/src/components/command-palette/command-palette-element.ts new file mode 100644 index 000000000..612a58b56 --- /dev/null +++ b/packages/web/src/components/command-palette/command-palette-element.ts @@ -0,0 +1,1026 @@ +export interface CommandPaletteAction { + id: string; + title: string; + section?: string; + keywords?: string; + hotkey?: string; + icon?: string; + iconUrl?: string; + parent?: string; + children?: Array; + external?: boolean; + handler?: () => void | { keepOpen: boolean }; +} + +type OpenOptions = { parent?: string }; + +type ChangeDetail = { search: string; actions: CommandPaletteAction[] }; + +type SelectedDetail = { + search: string; + action: CommandPaletteAction | undefined; +}; + +function wordBasedScore(haystack: string, needle: string): number | null { + if (!needle) return 0; + if (!haystack) return null; + + // Check if the search term appears as a substring + const index = haystack.indexOf(needle); + if (index === -1) return null; + + let score = 100; // Base score for match + + // Bonus for exact word boundary matches + const beforeChar = index > 0 ? haystack[index - 1] : ""; + const afterChar = + index + needle.length < haystack.length + ? haystack[index + needle.length] + : ""; + + // Higher score if match is at word boundary + if ( + index === 0 || + beforeChar === " " || + beforeChar === "-" || + beforeChar === "_" || + beforeChar === "/" + ) { + score += 50; + } + + if ( + index + needle.length === haystack.length || + afterChar === " " || + afterChar === "-" || + afterChar === "_" || + afterChar === "/" + ) { + score += 30; + } + + // Prefer earlier matches + score += Math.max(0, 50 - index); + + return score; +} + +type HotkeyCombo = { + key: string; + meta: boolean; + ctrl: boolean; + alt: boolean; + shift: boolean; +}; + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName.toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select") return true; + if (target.isContentEditable) return true; + return false; +} + +function normalizeKey(raw: string): string { + const key = raw.toLowerCase(); + if (key === " ") return "space"; + if (key === "esc") return "escape"; + return key; +} + +function normalizeCombo(combo: HotkeyCombo): string { + return [ + combo.meta ? "meta" : "", + combo.ctrl ? "ctrl" : "", + combo.alt ? "alt" : "", + combo.shift ? "shift" : "", + combo.key, + ] + .filter(Boolean) + .join("+"); +} + +function parseHotkeyCombo(text: string): HotkeyCombo | null { + const tokens = text + .toLowerCase() + .split("+") + .map((t) => t.trim()) + .filter(Boolean); + + if (tokens.length === 0) return null; + + let meta = false; + let ctrl = false; + let alt = false; + let shift = false; + let key: string | null = null; + + for (const token of tokens) { + if (token === "cmd" || token === "command" || token === "meta") { + meta = true; + continue; + } + if (token === "ctrl" || token === "control") { + ctrl = true; + continue; + } + if (token === "alt" || token === "option") { + alt = true; + continue; + } + if (token === "shift") { + shift = true; + continue; + } + + key = token; + } + + if (!key) return null; + + // Normalize a few common names + if (key === "esc") key = "escape"; + if (key === "space") key = "space"; + + return { key, meta, ctrl, alt, shift }; +} + +function eventToCombo(event: KeyboardEvent): HotkeyCombo { + return { + key: normalizeKey(event.key), + meta: event.metaKey, + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + }; +} + +function clampIndex(index: number, length: number): number { + if (length <= 0) return -1; + if (index < 0) return length - 1; + if (index >= length) return 0; + return index; +} + +function svgIcon(name: string): string { + // Minimal inline icon set (lucide-like). Add more mappings as needed. + switch (name) { + case "providers": + return ``; + case "models_boxes": + return ``; + case "search": + return ``; + case "search_clear": + return ``; + case "sort_clear": + return ``; + case "search_reset": + return ``; + case "clear": + return ``; + case "sort": + return ``; + case "arrow_forward": + return ``; + case "sort_arrow_upward": + return ``; + case "sort_arrow_downward": + return ``; + case "sort_calendar_asc": + return ``; + case "sort_calendar_desc": + return ``; + case "help": + return ``; + case "external_link": + return ``; + case "github_logo": + return ``; + default: + return ""; + } +} + +const paletteStyles = ` + +`; + +const paletteHtml = ` + +`; + +const template = document.createElement("template"); +template.innerHTML = paletteStyles + paletteHtml; + +export class CommandPaletteElement extends HTMLElement { + private _root = this.attachShadow({ mode: "open" }); + private _overlay!: HTMLDivElement; + private _breadcrumbsEl!: HTMLDivElement; + private _input!: HTMLInputElement; + private _list!: HTMLDivElement; + + private _data: CommandPaletteAction[] = []; + private _flat: CommandPaletteAction[] = []; + private _byId = new Map(); + private _hotkeys = new Map(); + private _searchText = new Map(); + + private _visible = false; + private _search = ""; + private _currentRoot?: string; + private _selectedIndex = -1; + private _matches: CommandPaletteAction[] = []; + + private _placeholder = "Type a command or search..."; + + constructor() { + super(); + this._root.appendChild(template.content.cloneNode(true)); + } + + connectedCallback() { + this._overlay = this._root.querySelector(".overlay") as HTMLDivElement; + this._breadcrumbsEl = this._root.querySelector( + ".breadcrumbs", + ) as HTMLDivElement; + this._input = this._root.querySelector(".search") as HTMLInputElement; + this._list = this._root.querySelector(".body") as HTMLDivElement; + + this._input.placeholder = this._placeholder; + + this._overlay.addEventListener("click", (e) => { + if (e.target === this._overlay) this.close(); + }); + + this._input.addEventListener("input", () => { + this._search = this._input.value; + this._recomputeMatches(); + this._render(); + this.dispatchEvent( + new CustomEvent("change", { + detail: { search: this._search, actions: this._matches }, + bubbles: true, + composed: true, + }), + ); + }); + + this._input.addEventListener("keydown", (e) => { + // Prevent browser search shortcuts when typing inside. + if (this._isOpenHotkey(e)) e.preventDefault(); + }); + + window.addEventListener("keydown", this._onKeyDown, { capture: true }); + + this._recomputeMatches(); + this._render(); + } + + disconnectedCallback() { + window.removeEventListener("keydown", this._onKeyDown, { + capture: true, + } as any); + } + + get data(): CommandPaletteAction[] { + return this._data; + } + + set data(value: CommandPaletteAction[]) { + this._data = Array.isArray(value) ? value : []; + this._indexData(); + this._recomputeMatches(); + this._render(); + } + + get placeholder(): string { + return this._placeholder; + } + + set placeholder(value: string) { + this._placeholder = value || ""; + if (this._input) this._input.placeholder = this._placeholder; + } + + open(options: OpenOptions = {}) { + this._visible = true; + this._currentRoot = options.parent; + this._input.value = ""; + this._search = ""; + this._recomputeMatches(); + this._render(); + queueMicrotask(() => this._input.focus()); + } + + close() { + this._visible = false; + this._render(); + } + + private _isOpenHotkey(e: KeyboardEvent): boolean { + const key = normalizeKey(e.key); + if (key !== "k") return false; + if (e.metaKey && !e.ctrlKey) return true; + if (e.ctrlKey && !e.metaKey) return true; + return false; + } + + private _onKeyDown = (e: KeyboardEvent) => { + if (this._visible) { + // Palette-specific navigation + if (e.key === "Escape") { + e.preventDefault(); + this.close(); + return; + } + + if (e.key === "ArrowDown" || e.key === "Tab") { + e.preventDefault(); + this._selectedIndex = clampIndex( + this._selectedIndex + 1, + this._matches.length, + ); + this._renderSelectionOnly(); + return; + } + + if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { + e.preventDefault(); + this._selectedIndex = clampIndex( + this._selectedIndex - 1, + this._matches.length, + ); + this._renderSelectionOnly(); + return; + } + + if (e.key === "Enter") { + e.preventDefault(); + this._activateSelected(); + return; + } + + if (e.key === "Backspace" && this._search.length === 0) { + if (this._currentRoot) { + e.preventDefault(); + this._goBack(); + return; + } + } + + return; + } + + // Global open hotkey + if (this._isOpenHotkey(e)) { + if (isEditableTarget(e.target)) return; + e.preventDefault(); + this.open(); + return; + } + + // Optional: action hotkeys when palette is closed + if (isEditableTarget(e.target)) return; + const comboKey = normalizeCombo(eventToCombo(e)); + const action = this._hotkeys.get(comboKey); + if (!action) return; + + e.preventDefault(); + this._runAction(action); + }; + + private _indexData() { + // Build a flat array and parent mapping, supporting both flat and tree structures. + this._byId.clear(); + this._hotkeys.clear(); + this._searchText.clear(); + + const flatten = ( + items: CommandPaletteAction[], + parent?: string, + ): CommandPaletteAction[] => { + const children: CommandPaletteAction[] = []; + const mapped = items.map((item) => { + const m: CommandPaletteAction = { + ...item, + parent: item.parent || parent, + }; + const hasObjectChildren = + Array.isArray(m.children) && + m.children.some((c) => typeof c !== "string"); + + if ( + Array.isArray(m.children) && + m.children.length && + hasObjectChildren + ) { + const objectChildren = m.children.filter( + (c): c is CommandPaletteAction => typeof c !== "string", + ); + children.push(...objectChildren); + m.children = objectChildren.map((c) => c.id); + objectChildren.forEach((c) => { + c.parent = c.parent || m.id; + }); + } else if (Array.isArray(m.children)) { + // leave string children as-is + } else { + m.children = []; + } + + return m; + }); + + return mapped.concat(children.length ? flatten(children, parent) : []); + }; + + this._flat = flatten(this._data); + + for (const action of this._flat) { + this._byId.set(action.id, action); + + this._searchText.set( + action.id, + `${action.title} ${action.section ?? ""} ${action.keywords ?? ""} ${action.id}` + .toLowerCase() + .trim(), + ); + + if (action.hotkey) { + const combos = action.hotkey + .split(",") + .map((c) => c.trim()) + .filter(Boolean); + for (const combo of combos) { + const parsed = parseHotkeyCombo(combo); + if (!parsed) continue; + this._hotkeys.set(normalizeCombo(parsed), action); + } + } + } + } + + private get _breadcrumbs(): Array<{ id?: string; label: string }> { + const crumbs: Array<{ id?: string; label: string }> = [ + { id: undefined, label: "models.dev" }, + ]; + let current = this._currentRoot + ? this._byId.get(this._currentRoot) + : undefined; + const chain: CommandPaletteAction[] = []; + + while (current) { + chain.push(current); + current = current.parent ? this._byId.get(current.parent) : undefined; + } + + chain.reverse(); + for (const item of chain) { + crumbs.push({ id: item.id, label: item.title }); + } + + return crumbs; + } + + private _goBack() { + if (!this._currentRoot) return; + const current = this._byId.get(this._currentRoot); + this._currentRoot = current?.parent; + this._recomputeMatches(); + this._render(); + queueMicrotask(() => this._input.focus()); + } + + private _recomputeMatches() { + const term = this._search.trim(); + const search = term.toLowerCase(); + + // Always filter within the currently-visible menu. + // This keeps submenu-only actions (like Sort children) hidden unless you're inside that submenu. + const candidates = this._flat.filter( + (a) => (a.parent || undefined) === this._currentRoot, + ); + + if (!search) { + this._matches = candidates; + this._selectedIndex = candidates.length ? 0 : -1; + return; + } + + const scored: Array<{ action: CommandPaletteAction; score: number }> = []; + for (const action of candidates) { + const haystack = this._searchText.get(action.id) ?? ""; + const score = wordBasedScore(haystack, search); + if (score === null) continue; + scored.push({ action, score }); + } + + scored.sort((a, b) => b.score - a.score); + + const includeSearchByModelTerm = !this._currentRoot; + if (includeSearchByModelTerm) { + const searchByModelTerm: CommandPaletteAction = { + id: `search-model-term:${search}`, + title: `Search models for “${term}”`, + section: "Actions", + icon: "search", + keywords: "search models model term filter", + handler: () => { + const searchInput = document.getElementById( + "search", + ) as HTMLInputElement | null; + if (!searchInput) return; + searchInput.value = term; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + }, + }; + + this._matches = [searchByModelTerm, ...scored.map((s) => s.action)]; + } else { + this._matches = scored.map((s) => s.action); + } + this._selectedIndex = this._matches.length ? 0 : -1; + } + + private _render() { + this._overlay.dataset.visible = this._visible ? "true" : "false"; + this._overlay.setAttribute("aria-hidden", this._visible ? "false" : "true"); + + if (!this._visible) { + return; + } + + // Update placeholder based on current context + if (this._currentRoot) { + const path = this._breadcrumbs + .slice(1) // Skip "Home" + .map((crumb) => crumb.label.toLowerCase()) + .join(" > "); + if (path) { + this._input.placeholder = `Search ${path}`; + } else { + this._input.placeholder = this._placeholder; + } + } else { + this._input.placeholder = this._placeholder; + } + + // Breadcrumbs + this._breadcrumbsEl.textContent = ""; + const lastIndex = this._breadcrumbs.length - 1; + for (let i = 0; i < this._breadcrumbs.length; i++) { + const crumb = this._breadcrumbs[i]; + const btn = document.createElement("button"); + btn.className = "breadcrumb"; + btn.type = "button"; + btn.textContent = crumb.label; + btn.dataset.active = i === lastIndex ? "true" : "false"; + btn.addEventListener("click", () => { + this._currentRoot = crumb.id; + this._input.value = ""; + this._search = ""; + this._recomputeMatches(); + this._render(); + queueMicrotask(() => this._input.focus()); + }); + this._breadcrumbsEl.appendChild(btn); + } + + // List + this._list.textContent = ""; + + // Group by section + const groups = new Map(); + const sectionOrder: string[] = []; + for (const action of this._matches) { + const section = action.section || ""; + if (!groups.has(section)) { + groups.set(section, []); + sectionOrder.push(section); + } + groups.get(section)!.push(action); + } + + let rowIndex = 0; + for (const section of sectionOrder) { + if (section) { + const header = document.createElement("div"); + header.className = "groupHeader"; + header.textContent = section; + this._list.appendChild(header); + } + + const items = groups.get(section)!; + for (const action of items) { + const row = document.createElement("div"); + row.className = "row"; + row.dataset.index = String(rowIndex); + row.dataset.selected = + rowIndex === this._selectedIndex ? "true" : "false"; + + const thisRowIndex = rowIndex; + + // Icon + const iconWrap = document.createElement("span"); + iconWrap.className = "icon"; + if (action.iconUrl) { + const img = document.createElement("img"); + img.src = action.iconUrl; + img.alt = action.section ? `${action.section} logo` : ""; + img.loading = "lazy"; + img.decoding = "async"; + iconWrap.appendChild(img); + } else if (action.icon) { + // Check if icon is an SVG string + if (action.icon.includes(" p.trim()) + .filter(Boolean); + for (const part of parts) { + const kbd = document.createElement("kbd"); + kbd.textContent = part; + hotkey.appendChild(kbd); + } + } + + // Chevron for submenus or external link indicator + const chevron = document.createElement("span"); + chevron.className = "chevron"; + const hasChildren = + Array.isArray(action.children) && action.children.length > 0; + const isExternal = action.external === true; + if (hasChildren) { + chevron.innerHTML = svgIcon("arrow_forward"); + } else if (isExternal) { + chevron.innerHTML = svgIcon("external_link"); + } + + row.appendChild(iconWrap); + row.appendChild(title); + if (hotkey.childNodes.length) row.appendChild(hotkey); + if (hasChildren || isExternal) row.appendChild(chevron); + + row.addEventListener("mousemove", () => { + this._selectedIndex = thisRowIndex; + this._renderSelectionOnly(); + }); + + row.addEventListener("click", () => { + this._selectedIndex = thisRowIndex; + this._activateSelected(); + }); + + this._list.appendChild(row); + rowIndex += 1; + } + } + + this._scrollSelectedIntoView(); + } + + private _renderSelectionOnly() { + if (!this._visible) return; + const rows = this._list.querySelectorAll(".row"); + rows.forEach((row) => { + const idx = Number((row as HTMLElement).dataset.index); + (row as HTMLElement).dataset.selected = + idx === this._selectedIndex ? "true" : "false"; + }); + this._scrollSelectedIntoView(); + } + + private _scrollSelectedIntoView() { + if (this._selectedIndex < 0) return; + + // If first item is selected, scroll to top so provider header is visible + if (this._selectedIndex === 0) { + this._list.scrollTop = 0; + return; + } + + const selectedRow = this._list.querySelector( + `.row[data-index="${this._selectedIndex}"]`, + ) as HTMLElement | null; + if (!selectedRow) return; + selectedRow.scrollIntoView({ block: "nearest" }); + } + + private _activateSelected() { + if (this._selectedIndex < 0) return; + const action = this._matches[this._selectedIndex]; + if (!action) return; + this._runAction(action); + } + + private _runAction(action: CommandPaletteAction) { + const hasChildren = + Array.isArray(action.children) && action.children.length > 0; + if (hasChildren) { + this.open({ parent: action.id }); + return; + } + + const result = action.handler?.(); + + this.dispatchEvent( + new CustomEvent("selected", { + detail: { search: this._search, action }, + bubbles: true, + composed: true, + }), + ); + + if ( + result && + typeof result === "object" && + "keepOpen" in result && + result.keepOpen + ) { + this.open({ parent: this._currentRoot }); + return; + } + + this.close(); + } +} + +if (!customElements.get("command-palette")) { + customElements.define("command-palette", CommandPaletteElement); +} diff --git a/packages/web/src/components/command-palette/command-palette.ts b/packages/web/src/components/command-palette/command-palette.ts new file mode 100644 index 000000000..f7d837df4 --- /dev/null +++ b/packages/web/src/components/command-palette/command-palette.ts @@ -0,0 +1,346 @@ +import { sortTable } from "../../index.js"; +import "./command-palette-element.js"; + +import type { CommandPaletteAction } from "./command-palette-element.js"; + +type CommandPaletteActionLocal = CommandPaletteAction; + +interface ModelData { + id: string; + title: string; + section: string; + iconUrl: string; + keywords: string; + providerId: string; +} + +let cachedModelData: ModelData[] | null = null; + +function extractModelsFromTable(): ModelData[] { + // Return cached if available + if (cachedModelData) { + return cachedModelData; + } + + const modelData: ModelData[] = []; + const rows = document.querySelectorAll("table tbody tr"); + + rows.forEach((row) => { + const cells = row.querySelectorAll("td"); + if (cells.length >= 5) { + const provider = cells[0].textContent?.trim() || ""; + const name = cells[1].textContent?.trim() || ""; + const family = cells[2].textContent?.trim() || ""; + const providerId = cells[3].textContent?.trim() || ""; + const modelId = + cells[4].querySelector(".model-id-text")?.textContent?.trim() || ""; + + const providerSlug = encodeURIComponent(providerId); + modelData.push({ + id: `model-${modelId}-${providerId}`, + title: name, + section: provider, + iconUrl: `/logos/${providerSlug}.svg`, + keywords: + `${name} ${provider} ${family} ${providerId} ${modelId}`.toLowerCase(), + providerId: providerId, + }); + } + }); + + // Cache the results + cachedModelData = modelData; + return modelData; +} + +function buildProviderActions(): CommandPaletteActionLocal[] { + const modelData = extractModelsFromTable(); + + // Group models by provider + const providerMap = new Map< + string, + { name: string; iconUrl: string; models: ModelData[] } + >(); + + for (const model of modelData) { + if (!providerMap.has(model.providerId)) { + providerMap.set(model.providerId, { + name: model.section, + iconUrl: model.iconUrl, + models: [], + }); + } + providerMap.get(model.providerId)!.models.push(model); + } + + // Create provider actions with model children + const providerActions: CommandPaletteActionLocal[] = []; + + for (const [providerId, providerInfo] of providerMap.entries()) { + const providerAction: CommandPaletteActionLocal = { + id: `provider-${providerId}`, + title: providerInfo.name, + iconUrl: providerInfo.iconUrl, + parent: "browse-providers", + keywords: `${providerInfo.name} ${providerId}`.toLowerCase(), + children: providerInfo.models.map((model) => ({ + id: model.id, + title: model.title, + iconUrl: model.iconUrl, + keywords: model.keywords, + parent: `provider-${providerId}`, + handler: () => { + const searchInput = document.getElementById( + "search", + ) as HTMLInputElement; + if (searchInput) { + searchInput.value = model.title; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + } + }, + })), + }; + + providerActions.push(providerAction); + } + + return providerActions; +} + +function buildAllModelsActions(): CommandPaletteActionLocal[] { + const modelData = extractModelsFromTable(); + + return modelData.map((model) => ({ + id: model.id, + title: model.title, + section: model.section, + iconUrl: model.iconUrl, + keywords: model.keywords, + parent: "browse-models", + handler: () => { + const searchInput = document.getElementById("search") as HTMLInputElement; + if (searchInput) { + searchInput.value = model.title; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + } + }, + })); +} + +function buildSortActions(): CommandPaletteActionLocal[] { + const sortActions: CommandPaletteActionLocal[] = []; + const headers = document.querySelectorAll("th.sortable"); + + if (headers.length === 0) { + return sortActions; + } + + const columnIds: string[] = []; + + headers.forEach((header, index) => { + // Extract column title from header + const headerText = header.textContent?.trim() || ""; + const columnTitle = headerText.replace(/↑|↓/g, "").trim(); + + // Generate column ID from title + const columnId = columnTitle + .split(/\s+/) + .slice(0, 2) + .join("-") + .toLowerCase(); + columnIds.push(columnId); + + // Create parent column action + const columnAction: CommandPaletteActionLocal = { + id: columnId, + title: columnTitle, + parent: "sort", + icon: "sort", + children: [`${columnId}-asc`, `${columnId}-desc`], + handler: () => { + const palette = document.querySelector("command-palette") as any; + if (palette) { + palette.open({ parent: columnId }); + return { keepOpen: true }; + } + }, + }; + + // Create ascending action + const ascAction: CommandPaletteActionLocal = { + id: `${columnId}-asc`, + title: "Ascending", + parent: columnId, + icon: + columnId.includes("date") || columnId.includes("updated") + ? "sort_calendar_asc" + : "sort_arrow_upward", + handler: () => sortTable(index, "asc"), + }; + + // Create descending action + const descAction: CommandPaletteActionLocal = { + id: `${columnId}-desc`, + title: "Descending", + parent: columnId, + icon: + columnId.includes("date") || columnId.includes("updated") + ? "sort_calendar_desc" + : "sort_arrow_downward", + handler: () => sortTable(index, "desc"), + }; + + sortActions.push(columnAction, ascAction, descAction); + }); + + // Create root "Sort" action + const sortRootAction: CommandPaletteActionLocal = { + id: "sort", + title: "Sort by", + section: "Actions", + icon: "sort", + children: columnIds, + handler: () => { + const palette = document.querySelector("command-palette") as any; + if (palette) { + palette.open({ parent: "sort" }); + return { keepOpen: true }; + } + }, + }; + + return [sortRootAction, ...sortActions]; +} + +export async function initCommandPalette() { + const palette = document.querySelector("command-palette") as any; + + if (!palette) { + console.warn("command-palette element not found in DOM"); + return; + } + + // Build dynamic provider and model actions + const providerActions = buildProviderActions(); + const allModelsActions = buildAllModelsActions(); + const sortActions = buildSortActions(); + + // Add static utility actions + const staticActions: CommandPaletteActionLocal[] = [ + { + id: "browse-models", + title: "Browse Models", + section: "Browse", + icon: "models_boxes", + children: allModelsActions.map((m) => m.id), + }, + { + id: "browse-providers", + title: "Browse Providers", + section: "Browse", + icon: "providers", + children: providerActions.map((p) => p.id), + }, + { + id: "github", + title: "View on GitHub", + section: "Models.dev", + icon: "github_logo", + external: true, + handler: () => { + window.open("https://github.com/sst/models.dev", "_blank"); + }, + }, + { + id: "how-to-use", + title: "How to use", + section: "Models.dev", + icon: "help", + handler: () => { + const modal = document.getElementById("modal") as HTMLDialogElement; + if (modal) { + const y = window.scrollY; + document.body.style.position = "fixed"; + document.body.style.top = `-${y}px`; + modal.showModal(); + } + }, + }, + { + id: "clear-search", + title: "Clear search", + section: "Actions", + icon: "search_clear", + handler: () => { + // Clear search input only (keeps sort/filters) + const searchInput = document.getElementById( + "search", + ) as HTMLInputElement; + if (searchInput) { + searchInput.value = ""; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + } + }, + }, + { + id: "clear-sort", + title: "Clear sort", + section: "Actions", + icon: "sort_clear", + handler: () => { + // Reset table sort to provider ascending (column 0) while keeping search query + sortTable(0, "asc"); + }, + }, + { + id: "reset-search", + title: "Reset search", + section: "Actions", + icon: "search_reset", + handler: () => { + // Clear search input + const searchInput = document.getElementById( + "search", + ) as HTMLInputElement; + if (searchInput) { + searchInput.value = ""; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + } + + // Reset table sort to provider ascending (column 0) + sortTable(0, "asc"); + }, + }, + ]; + + // Combine all actions: static actions + sort actions + provider actions + all models actions + palette.data = [ + ...staticActions, + ...sortActions, + ...providerActions, + ...allModelsActions, + ]; + + // Customize placeholder + palette.placeholder = "Search by model, provider, family, or action"; +} + +export async function updateCommandPaletteModels() { + const palette = document.querySelector("command-palette") as any; + if (palette) { + // Clear cache and re-extract + cachedModelData = null; + const providerActions = buildProviderActions(); + const allModelsActions = buildAllModelsActions(); + + // Re-initialize with updated data + palette.data = [ + ...palette.data.filter( + (a: CommandPaletteActionLocal) => + !a.id.startsWith("provider-") && !a.id.startsWith("model-"), + ), + ...providerActions, + ...allModelsActions, + ]; + } +} diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 58a4a5c4e..98d84a521 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -194,6 +194,28 @@ header { } } + .search-icon-mobile { + display: none; + flex: 0 0 auto; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; + background: none; + color: var(--color-text-secondary); + height: 2rem; + padding: 0 0.5rem; + border-radius: 0.25rem; + + &:hover { + background-color: var(--color-surface); + } + + &:active { + opacity: 0.7; + } + } + @media (max-width: 45rem) { div.right { @@ -201,6 +223,10 @@ header { .search-container { display: none; } + + .search-icon-mobile { + display: flex; + } } } } @@ -545,5 +571,51 @@ dialog { } } } +} + +/* Command palette */ +command-palette { + --command-palette-accent-color: var(--color-brand); + --command-palette-text: var(--color-text); + --command-palette-secondary-text: var(--color-text-secondary); + --command-palette-secondary-bg: var(--color-surface); + --command-palette-scrollbar-track: color-mix(in srgb, + var(--color-background) 70%, + var(--color-surface)); + --command-palette-scrollbar-thumb: color-mix(in srgb, + var(--color-border) 55%, + var(--color-text-tertiary)); + --command-palette-scrollbar-thumb-hover: color-mix(in srgb, + var(--color-border) 35%, + var(--color-text-secondary)); + --command-palette-selected-bg: color-mix(in srgb, + var(--command-palette-accent-color) 22%, + var(--color-background)); + --command-palette-selected-text: var(--color-text); + --command-palette-border: 1px solid var(--color-border); + --command-palette-placeholder: var(--color-text-tertiary); + --command-palette-footer-bg: var(--color-alpha-background); + --command-palette-modal-bg: var(--color-alpha-background); + --command-palette-icon-filter: none; + --command-palette-icon-opacity: 0.95; + --command-palette-backdrop: rgba(0, 0, 0, 0.03); +} +@media (prefers-color-scheme: dark) { + command-palette { + --command-palette-backdrop: rgba(0, 0, 0, 0.7); + --command-palette-icon-filter: brightness(0) invert(1); + --command-palette-scrollbar-track: color-mix(in srgb, + var(--color-background) 60%, + var(--color-surface)); + --command-palette-scrollbar-thumb: color-mix(in srgb, + var(--color-border) 40%, + var(--color-text-tertiary)); + --command-palette-scrollbar-thumb-hover: color-mix(in srgb, + var(--color-border) 25%, + var(--color-text-secondary)); + --command-palette-selected-bg: color-mix(in srgb, + var(--command-palette-accent-color) 30%, + var(--color-background)); + } } \ No newline at end of file diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 81afb4342..f216f45f0 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -1,7 +1,10 @@ +import { initCommandPalette } from "./components/command-palette/command-palette.js"; + const modal = document.getElementById("modal") as HTMLDialogElement; const modalClose = document.getElementById("close")!; const help = document.getElementById("help")!; const search = document.getElementById("search")! as HTMLInputElement; +const searchIconMobile = document.getElementById("search-icon-mobile"); ///////////////////////// // URL State Management @@ -33,10 +36,20 @@ function getColumnNameForURL(headerEl: Element): string { function getColumnIndexByUrlName(name: string): number { const headers = document.querySelectorAll("th.sortable"); return Array.from(headers).findIndex( - (header) => getColumnNameForURL(header) === name + (header) => getColumnNameForURL(header) === name, ); } +///////////////////////// +// Handle "Search" button on mobile +///////////////////////// +if (searchIconMobile) { + searchIconMobile.addEventListener("click", () => { + const palette = document.querySelector("command-palette") as any; + if (palette) palette.open(); + }); +} + ///////////////////////// // Handle "How to use" ///////////////////////// @@ -67,7 +80,7 @@ modal.addEventListener("click", (e) => { //////////////////// let currentSort = { column: -1, direction: "asc" }; -function sortTable(column: number, direction: "asc" | "desc") { +export function sortTable(column: number, direction: "asc" | "desc") { const header = document.querySelectorAll("th.sortable")[column]; const columnType = header.getAttribute("data-type"); if (!columnType) return; @@ -82,7 +95,7 @@ function sortTable(column: number, direction: "asc" | "desc") { // sort rows const tbody = document.querySelector("table tbody")!; const rows = Array.from( - tbody.querySelectorAll("tr") + tbody.querySelectorAll("tr"), ) as HTMLTableRowElement[]; rows.sort((a, b) => { const aValue = getCellValue(a.cells[column], columnType); @@ -121,7 +134,7 @@ function sortTable(column: number, direction: "asc" | "desc") { function getCellValue( cell: HTMLTableCellElement, - type: string + type: string, ): string | number | undefined { if (type === "modalities") return cell.querySelectorAll(".modality-icon").length; @@ -147,17 +160,23 @@ document.querySelectorAll("th.sortable").forEach((header) => { // Handle Search /////////////////// function filterTable(value: string) { - const lowerCaseValues = value.toLowerCase().split(",").filter(str => str.trim() !== ""); + const lowerCaseValues = value + .toLowerCase() + .split(",") + .filter((str) => str.trim() !== ""); const rows = document.querySelectorAll( - "table tbody tr" + "table tbody tr", ) as NodeListOf; rows.forEach((row) => { const cellTexts = Array.from(row.cells).map((cell) => - cell.textContent!.toLowerCase() + cell.textContent!.toLowerCase(), ); - const isVisible = lowerCaseValues.length === 0 || - lowerCaseValues.some((lowerCaseValue) => cellTexts.some((text) => text.includes(lowerCaseValue))); + const isVisible = + lowerCaseValues.length === 0 || + lowerCaseValues.some((lowerCaseValue) => + cellTexts.some((text) => text.includes(lowerCaseValue)), + ); row.style.display = isVisible ? "" : "none"; }); @@ -168,12 +187,7 @@ search.addEventListener("input", () => { filterTable(search.value); }); -document.addEventListener("keydown", (e) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault(); - search.focus(); - } -}); +// Command palette handles Cmd/Ctrl+K automatically search.addEventListener("keydown", (e) => { if (e.key === "Escape") { @@ -182,12 +196,29 @@ search.addEventListener("keydown", (e) => { } }); +search.addEventListener("click", (e) => { + e.preventDefault(); + const palette = document.querySelector("command-palette") as any; + if (palette) { + palette.open(); + } +}); + +search.addEventListener("focus", (e) => { + e.preventDefault(); + search.blur(); + const palette = document.querySelector("command-palette") as any; + if (palette) { + palette.open(); + } +}); + /////////////////////////////////// // Handle Copy model ID function /////////////////////////////////// (window as any).copyModelId = async ( button: HTMLButtonElement, - modelId: string + modelId: string, ) => { try { if (navigator.clipboard) { @@ -236,5 +267,9 @@ function initializeFromURL() { })(); } -document.addEventListener("DOMContentLoaded", initializeFromURL); +document.addEventListener("DOMContentLoaded", () => { + initializeFromURL(); + initCommandPalette(); +}); + window.addEventListener("popstate", initializeFromURL); diff --git a/packages/web/src/render.tsx b/packages/web/src/render.tsx index a1bdfcc2f..09b17c700 100644 --- a/packages/web/src/render.tsx +++ b/packages/web/src/render.tsx @@ -206,6 +206,12 @@ export const Rendered = renderToString( > +
⌘K @@ -570,5 +576,6 @@ export const Rendered = renderToString(
+ );