Merge pull request #1198 from vignesh07/feat/tui-model-picker-search
feat(tui): add fuzzy search to model picker 🔍
This commit is contained in:
@@ -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
|
||||
|
||||
161
src/tui/components/searchable-select-list.test.ts
Normal file
161
src/tui/components/searchable-select-list.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
261
src/tui/components/searchable-select-list.ts
Normal file
261
src/tui/components/searchable-select-list.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user