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(
+ );