diff --git a/CHANGELOG.md b/CHANGELOG.md index f09fb1dd9..00bbb461d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.clawd.bot ### Changes - Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.). - Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. +- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07. - Docs: clarify allowlist input types and onboarding behavior for messaging channels. ### Fixes diff --git a/src/tui/components/searchable-select-list.test.ts b/src/tui/components/searchable-select-list.test.ts new file mode 100644 index 000000000..80f5e758b --- /dev/null +++ b/src/tui/components/searchable-select-list.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { SearchableSelectList, type SearchableSelectListTheme } from "./searchable-select-list.js"; + +const mockTheme: SearchableSelectListTheme = { + selectedPrefix: (t) => `[${t}]`, + selectedText: (t) => `**${t}**`, + description: (t) => `(${t})`, + scrollInfo: (t) => `~${t}~`, + noMatch: (t) => `!${t}!`, + searchPrompt: (t) => `>${t}<`, + searchInput: (t) => `|${t}|`, + matchHighlight: (t) => `*${t}*`, +}; + +const testItems = [ + { value: "anthropic/claude-3-opus", label: "anthropic/claude-3-opus", description: "Claude 3 Opus" }, + { value: "anthropic/claude-3-sonnet", label: "anthropic/claude-3-sonnet", description: "Claude 3 Sonnet" }, + { value: "openai/gpt-4", label: "openai/gpt-4", description: "GPT-4" }, + { value: "openai/gpt-4-turbo", label: "openai/gpt-4-turbo", description: "GPT-4 Turbo" }, + { value: "google/gemini-pro", label: "google/gemini-pro", description: "Gemini Pro" }, +]; + +describe("SearchableSelectList", () => { + it("renders all items when no filter is applied", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + const output = list.render(80); + + // Should have search prompt line, spacer, and items + expect(output.length).toBeGreaterThanOrEqual(3); + expect(output[0]).toContain("search"); + }); + + it("filters items when typing", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + + // Simulate typing "gemini" - unique enough to narrow down + list.handleInput("g"); + list.handleInput("e"); + list.handleInput("m"); + list.handleInput("i"); + list.handleInput("n"); + list.handleInput("i"); + + const selected = list.getSelectedItem(); + expect(selected?.value).toBe("google/gemini-pro"); + }); + + it("prioritizes exact substring matches over fuzzy matches", () => { + // Add items where one has early exact match, others are fuzzy or late matches + const items = [ + { value: "openrouter/auto", label: "openrouter/auto", description: "Routes to best" }, + { value: "opus-direct", label: "opus-direct", description: "Direct opus model" }, + { value: "anthropic/claude-3-opus", label: "anthropic/claude-3-opus", description: "Claude 3 Opus" }, + ]; + const list = new SearchableSelectList(items, 5, mockTheme); + + // Type "opus" - should match "opus-direct" first (earliest exact substring) + for (const ch of "opus") { + list.handleInput(ch); + } + + // First result should be "opus-direct" where "opus" appears at position 0 + const selected = list.getSelectedItem(); + expect(selected?.value).toBe("opus-direct"); + }); + + it("exact label match beats description match", () => { + const items = [ + { value: "provider/other", label: "provider/other", description: "This mentions opus in description" }, + { value: "provider/opus-model", label: "provider/opus-model", description: "Something else" }, + ]; + const list = new SearchableSelectList(items, 5, mockTheme); + + for (const ch of "opus") { + list.handleInput(ch); + } + + // Label match should win over description match + const selected = list.getSelectedItem(); + expect(selected?.value).toBe("provider/opus-model"); + }); + + it("filters items with fuzzy matching", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + + // Simulate typing "gpt" which should match openai/gpt-4 models + list.handleInput("g"); + list.handleInput("p"); + list.handleInput("t"); + + const selected = list.getSelectedItem(); + expect(selected?.value).toContain("gpt"); + }); + + it("preserves fuzzy ranking when only fuzzy matches exist", () => { + const items = [ + { value: "xg---4", label: "xg---4", description: "Worse fuzzy match" }, + { value: "gpt-4", label: "gpt-4", description: "Better fuzzy match" }, + ]; + const list = new SearchableSelectList(items, 5, mockTheme); + + for (const ch of "g4") { + list.handleInput(ch); + } + + const selected = list.getSelectedItem(); + expect(selected?.value).toBe("gpt-4"); + }); + + it("shows no match message when filter yields no results", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + + // Type something that won't match + list.handleInput("x"); + list.handleInput("y"); + list.handleInput("z"); + + const output = list.render(80); + expect(output.some((line) => line.includes("No matching"))).toBe(true); + }); + + it("navigates with arrow keys", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + + // Initially first item is selected + expect(list.getSelectedItem()?.value).toBe("anthropic/claude-3-opus"); + + // Press down arrow (escape sequence for down arrow) + list.handleInput("\x1b[B"); + + expect(list.getSelectedItem()?.value).toBe("anthropic/claude-3-sonnet"); + }); + + it("calls onSelect when enter is pressed", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + let selectedValue: string | undefined; + + list.onSelect = (item) => { + selectedValue = item.value; + }; + + // Press enter + list.handleInput("\r"); + + expect(selectedValue).toBe("anthropic/claude-3-opus"); + }); + + it("calls onCancel when escape is pressed", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + let cancelled = false; + + list.onCancel = () => { + cancelled = true; + }; + + // Press escape + list.handleInput("\x1b"); + + expect(cancelled).toBe(true); + }); +}); diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts new file mode 100644 index 000000000..53841acf9 --- /dev/null +++ b/src/tui/components/searchable-select-list.ts @@ -0,0 +1,261 @@ +import { + type Component, + fuzzyFilter, + Input, + isKeyRelease, + matchesKey, + type SelectItem, + type SelectListTheme, + truncateToWidth, +} from "@mariozechner/pi-tui"; + +export interface SearchableSelectListTheme extends SelectListTheme { + searchPrompt: (text: string) => string; + searchInput: (text: string) => string; + matchHighlight: (text: string) => string; +} + +/** + * A select list with a search input at the top for fuzzy filtering. + */ +export class SearchableSelectList implements Component { + private items: SelectItem[]; + private filteredItems: SelectItem[]; + private selectedIndex = 0; + private maxVisible: number; + private theme: SearchableSelectListTheme; + private searchInput: Input; + + onSelect?: (item: SelectItem) => void; + onCancel?: () => void; + onSelectionChange?: (item: SelectItem) => void; + + constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) { + this.items = items; + this.filteredItems = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.searchInput = new Input(); + } + + private updateFilter() { + const query = this.searchInput.getValue().trim(); + + if (!query) { + this.filteredItems = this.items; + } else { + this.filteredItems = this.smartFilter(query); + } + + // Reset selection when filter changes + this.selectedIndex = 0; + this.notifySelectionChange(); + } + + /** + * Smart filtering that prioritizes: + * 1. Exact substring match in label (highest priority) + * 2. Word-boundary prefix match in label + * 3. Exact substring match in description + * 4. Fuzzy match (lowest priority) + */ + private smartFilter(query: string): SelectItem[] { + const q = query.toLowerCase(); + type ScoredItem = { item: SelectItem; score: number }; + const exactLabel: ScoredItem[] = []; + const wordBoundary: SelectItem[] = []; + const descriptionMatches: SelectItem[] = []; + const fuzzyCandidates: SelectItem[] = []; + + for (const item of this.items) { + const label = item.label.toLowerCase(); + const desc = (item.description ?? "").toLowerCase(); + + // Tier 1: Exact substring in label (score 0-99) + const labelIndex = label.indexOf(q); + if (labelIndex !== -1) { + // Earlier match = better score + exactLabel.push({ item, score: labelIndex }); + continue; + } + // Tier 2: Word-boundary prefix in label (score 100-199) + if (this.matchesWordBoundary(label, q)) { + wordBoundary.push(item); + continue; + } + // Tier 3: Exact substring in description (score 200-299) + if (desc.indexOf(q) !== -1) { + descriptionMatches.push(item); + continue; + } + // Tier 4: Fuzzy match (score 300+) + fuzzyCandidates.push(item); + } + + exactLabel.sort((a, b) => a.score - b.score); + const fuzzyMatches = fuzzyFilter(fuzzyCandidates, query, (i) => `${i.label} ${i.description ?? ""}`); + return [ + ...exactLabel.map((s) => s.item), + ...wordBoundary, + ...descriptionMatches, + ...fuzzyMatches, + ]; + } + + /** + * Check if query matches at a word boundary in text. + * E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary. + */ + private matchesWordBoundary(text: string, query: string): boolean { + const wordBoundaryRegex = new RegExp(`(?:^|[\\s\\-_./:])(${this.escapeRegex(query)})`, "i"); + return wordBoundaryRegex.test(text); + } + + private escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + setSelectedIndex(index: number) { + this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); + } + + invalidate() { + this.searchInput.invalidate(); + } + + render(width: number): string[] { + const lines: string[] = []; + + // Search input line + const prompt = this.theme.searchPrompt("search: "); + const inputWidth = Math.max(1, width - 8); + const inputLines = this.searchInput.render(inputWidth); + const inputText = inputLines[0] ?? ""; + lines.push(`${prompt}${this.theme.searchInput(inputText)}`); + lines.push(""); // Spacer + + // If no items match filter, show message + if (this.filteredItems.length === 0) { + lines.push(this.theme.noMatch(" No matching models")); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); + + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = this.filteredItems[i]; + if (!item) continue; + const isSelected = i === this.selectedIndex; + let line = ""; + + if (isSelected) { + const prefixWidth = 2; + const displayValue = item.label || item.value; + if (item.description && width > 40) { + const maxValueWidth = Math.min(30, width - prefixWidth - 4); + const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); + const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); + const descriptionStart = prefixWidth + truncatedValue.length + spacing.length; + const remainingWidth = width - descriptionStart - 2; + if (remainingWidth > 10) { + const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); + line = this.theme.selectedText(`→ ${truncatedValue}${spacing}${truncatedDesc}`); + } else { + const maxWidth = width - prefixWidth - 2; + line = this.theme.selectedText(`→ ${truncateToWidth(displayValue, maxWidth, "")}`); + } + } else { + const maxWidth = width - prefixWidth - 2; + line = this.theme.selectedText(`→ ${truncateToWidth(displayValue, maxWidth, "")}`); + } + } else { + const displayValue = item.label || item.value; + const prefix = " "; + if (item.description && width > 40) { + const maxValueWidth = Math.min(30, width - prefix.length - 4); + const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); + const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); + const descriptionStart = prefix.length + truncatedValue.length + spacing.length; + const remainingWidth = width - descriptionStart - 2; + if (remainingWidth > 10) { + const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); + line = `${prefix}${truncatedValue}${spacing}${this.theme.description(truncatedDesc)}`; + } else { + const maxWidth = width - prefix.length - 2; + line = `${prefix}${truncateToWidth(displayValue, maxWidth, "")}`; + } + } else { + const maxWidth = width - prefix.length - 2; + line = `${prefix}${truncateToWidth(displayValue, maxWidth, "")}`; + } + } + lines.push(line); + } + + // Show scroll indicator if needed + if (this.filteredItems.length > this.maxVisible) { + const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`; + lines.push(this.theme.scrollInfo(` ${scrollInfo}`)); + } + + return lines; + } + + handleInput(keyData: string): void { + if (isKeyRelease(keyData)) return; + + // Navigation keys + if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p")) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.notifySelectionChange(); + return; + } + + if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n")) { + this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); + this.notifySelectionChange(); + return; + } + + if (matchesKey(keyData, "enter")) { + const item = this.filteredItems[this.selectedIndex]; + if (item && this.onSelect) { + this.onSelect(item); + } + return; + } + + if (matchesKey(keyData, "escape")) { + if (this.onCancel) { + this.onCancel(); + } + return; + } + + // Pass other keys to search input + const prevValue = this.searchInput.getValue(); + this.searchInput.handleInput(keyData); + const newValue = this.searchInput.getValue(); + + if (prevValue !== newValue) { + this.updateFilter(); + } + } + + private notifySelectionChange() { + const item = this.filteredItems[this.selectedIndex]; + if (item && this.onSelectionChange) { + this.onSelectionChange(item); + } + } + + getSelectedItem(): SelectItem | null { + return this.filteredItems[this.selectedIndex] ?? null; + } +} diff --git a/src/tui/components/selectors.ts b/src/tui/components/selectors.ts index f7b01a4ec..f56d24e3b 100644 --- a/src/tui/components/selectors.ts +++ b/src/tui/components/selectors.ts @@ -1,10 +1,15 @@ import { type SelectItem, SelectList, type SettingItem, SettingsList } from "@mariozechner/pi-tui"; -import { selectListTheme, settingsListTheme } from "../theme/theme.js"; +import { searchableSelectListTheme, selectListTheme, settingsListTheme } from "../theme/theme.js"; +import { SearchableSelectList } from "./searchable-select-list.js"; export function createSelectList(items: SelectItem[], maxVisible = 7) { return new SelectList(items, maxVisible, selectListTheme); } +export function createSearchableSelectList(items: SelectItem[], maxVisible = 7) { + return new SearchableSelectList(items, maxVisible, searchableSelectListTheme); +} + export function createSettingsList( items: SettingItem[], onChange: (id: string, value: string) => void, diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 22293f184..b8072fda3 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -5,6 +5,7 @@ import type { SettingsListTheme, } from "@mariozechner/pi-tui"; import chalk from "chalk"; +import type { SearchableSelectListTheme } from "../components/searchable-select-list.js"; const palette = { text: "#E8E3D5", @@ -92,3 +93,14 @@ export const editorTheme: EditorTheme = { borderColor: (text) => fg(palette.border)(text), selectList: selectListTheme, }; + +export const searchableSelectListTheme: SearchableSelectListTheme = { + selectedPrefix: (text) => fg(palette.accent)(text), + selectedText: (text) => chalk.bold(fg(palette.accent)(text)), + description: (text) => fg(palette.dim)(text), + scrollInfo: (text) => fg(palette.dim)(text), + noMatch: (text) => fg(palette.dim)(text), + searchPrompt: (text) => fg(palette.accentSoft)(text), + searchInput: (text) => fg(palette.text)(text), + matchHighlight: (text) => chalk.bold(fg(palette.accent)(text)), +}; diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 1d5e34eaf..d48094ae7 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -7,7 +7,7 @@ import { import { normalizeAgentId } from "../routing/session-key.js"; import { helpText, parseCommand } from "./commands.js"; import type { ChatLog } from "./components/chat-log.js"; -import { createSelectList, createSettingsList } from "./components/selectors.js"; +import { createSearchableSelectList, createSelectList, createSettingsList } from "./components/selectors.js"; import type { GatewayChatClient } from "./gateway-chat.js"; import { formatStatusSummary } from "./tui-status-summary.js"; import type { @@ -72,7 +72,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { label: `${model.provider}/${model.id}`, description: model.name && model.name !== model.id ? model.name : "", })); - const selector = createSelectList(items, 9); + const selector = createSearchableSelectList(items, 9); selector.onSelect = (item) => { void (async () => { try {