feat(tui): add fuzzy search to model picker

- Add SearchableSelectList component with fuzzy filtering
- Integrate with /models command for quick model search
- Support up/down navigation while typing
- Uses pi-tui's fuzzyFilter for intelligent matching
This commit is contained in:
Vignesh Natarajan
2026-01-18 13:54:08 -08:00
committed by Peter Steinberger
parent c639b386da
commit de44e0ad33
5 changed files with 330 additions and 3 deletions

View File

@@ -0,0 +1,111 @@
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("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("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);
});
});

View File

@@ -0,0 +1,199 @@
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;
private searchQuery = "";
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();
this.searchQuery = query;
if (!query) {
this.filteredItems = this.items;
} else {
this.filteredItems = fuzzyFilter(this.items, query, (item) => `${item.label} ${item.description ?? ""}`);
}
// Reset selection when filter changes
this.selectedIndex = 0;
this.notifySelectionChange();
}
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 inputLines = this.searchInput.render(width - 8);
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;
}
}

View File

@@ -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,

View File

@@ -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)),
};

View File

@@ -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 {