fix: refine TUI model search rendering

This commit is contained in:
Peter Steinberger 2026-01-19 00:15:08 +00:00
parent 3ce1ee84ac
commit 1e5569d56a
3 changed files with 117 additions and 57 deletions

View File

@ -22,6 +22,7 @@ Docs: https://docs.clawd.bot
- macOS: use label colors for session preview text so previews render in menu subviews. - macOS: use label colors for session preview text so previews render in menu subviews.
- macOS: suppress usage error text in the menubar cost view. - macOS: suppress usage error text in the menubar cost view.
- Telegram: honor pairing allowlists for native slash commands. - Telegram: honor pairing allowlists for native slash commands.
- TUI: highlight model search matches and stabilize search ordering.
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras. - CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
- Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH. - Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.

View File

@ -80,6 +80,21 @@ describe("SearchableSelectList", () => {
expect(selected?.value).toBe("provider/opus-model"); expect(selected?.value).toBe("provider/opus-model");
}); });
it("orders description matches by earliest index", () => {
const items = [
{ value: "first", label: "first", description: "prefix opus value" },
{ value: "second", label: "second", description: "opus suffix value" },
];
const list = new SearchableSelectList(items, 5, mockTheme);
for (const ch of "opus") {
list.handleInput(ch);
}
const selected = list.getSelectedItem();
expect(selected?.value).toBe("second");
});
it("filters items with fuzzy matching", () => { it("filters items with fuzzy matching", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme); const list = new SearchableSelectList(testItems, 5, mockTheme);
@ -107,6 +122,17 @@ describe("SearchableSelectList", () => {
expect(selected?.value).toBe("gpt-4"); expect(selected?.value).toBe("gpt-4");
}); });
it("highlights matches in rendered output", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme);
for (const ch of "gpt") {
list.handleInput(ch);
}
const output = list.render(80).join("\n");
expect(output).toContain("*gpt*");
});
it("shows no match message when filter yields no results", () => { it("shows no match message when filter yields no results", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme); const list = new SearchableSelectList(testItems, 5, mockTheme);

View File

@ -8,6 +8,7 @@ import {
type SelectListTheme, type SelectListTheme,
truncateToWidth, truncateToWidth,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import { visibleWidth } from "../../terminal/ansi.js";
export interface SearchableSelectListTheme extends SelectListTheme { export interface SearchableSelectListTheme extends SelectListTheme {
searchPrompt: (text: string) => string; searchPrompt: (text: string) => string;
@ -63,8 +64,8 @@ export class SearchableSelectList implements Component {
const q = query.toLowerCase(); const q = query.toLowerCase();
type ScoredItem = { item: SelectItem; score: number }; type ScoredItem = { item: SelectItem; score: number };
const exactLabel: ScoredItem[] = []; const exactLabel: ScoredItem[] = [];
const wordBoundary: SelectItem[] = []; const wordBoundary: ScoredItem[] = [];
const descriptionMatches: SelectItem[] = []; const descriptionMatches: ScoredItem[] = [];
const fuzzyCandidates: SelectItem[] = []; const fuzzyCandidates: SelectItem[] = [];
for (const item of this.items) { for (const item of this.items) {
@ -79,25 +80,29 @@ export class SearchableSelectList implements Component {
continue; continue;
} }
// Tier 2: Word-boundary prefix in label (score 100-199) // Tier 2: Word-boundary prefix in label (score 100-199)
if (this.matchesWordBoundary(label, q)) { const wordBoundaryIndex = this.findWordBoundaryIndex(label, q);
wordBoundary.push(item); if (wordBoundaryIndex !== null) {
wordBoundary.push({ item, score: wordBoundaryIndex });
continue; continue;
} }
// Tier 3: Exact substring in description (score 200-299) // Tier 3: Exact substring in description (score 200-299)
if (desc.indexOf(q) !== -1) { const descIndex = desc.indexOf(q);
descriptionMatches.push(item); if (descIndex !== -1) {
descriptionMatches.push({ item, score: descIndex });
continue; continue;
} }
// Tier 4: Fuzzy match (score 300+) // Tier 4: Fuzzy match (score 300+)
fuzzyCandidates.push(item); fuzzyCandidates.push(item);
} }
exactLabel.sort((a, b) => a.score - b.score); exactLabel.sort(this.compareByScore);
wordBoundary.sort(this.compareByScore);
descriptionMatches.sort(this.compareByScore);
const fuzzyMatches = fuzzyFilter(fuzzyCandidates, query, (i) => `${i.label} ${i.description ?? ""}`); const fuzzyMatches = fuzzyFilter(fuzzyCandidates, query, (i) => `${i.label} ${i.description ?? ""}`);
return [ return [
...exactLabel.map((s) => s.item), ...exactLabel.map((s) => s.item),
...wordBoundary, ...wordBoundary.map((s) => s.item),
...descriptionMatches, ...descriptionMatches.map((s) => s.item),
...fuzzyMatches, ...fuzzyMatches,
]; ];
} }
@ -107,14 +112,53 @@ export class SearchableSelectList implements Component {
* E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary. * E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary.
*/ */
private matchesWordBoundary(text: string, query: string): boolean { private matchesWordBoundary(text: string, query: string): boolean {
const wordBoundaryRegex = new RegExp(`(?:^|[\\s\\-_./:])(${this.escapeRegex(query)})`, "i"); return this.findWordBoundaryIndex(text, query) !== null;
return wordBoundaryRegex.test(text); }
private findWordBoundaryIndex(text: string, query: string): number | null {
if (!query) return null;
const maxIndex = text.length - query.length;
if (maxIndex < 0) return null;
for (let i = 0; i <= maxIndex; i++) {
if (text.startsWith(query, i)) {
if (i === 0 || /[\s\-_./:]/.test(text[i - 1] ?? "")) {
return i;
}
}
}
return null;
} }
private escapeRegex(str: string): string { private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
private compareByScore = (a: { item: SelectItem; score: number }, b: { item: SelectItem; score: number }) => {
if (a.score !== b.score) return a.score - b.score;
return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item));
};
private getItemLabel(item: SelectItem): string {
return item.label || item.value;
}
private highlightMatch(text: string, query: string): string {
const tokens = query
.trim()
.split(/\s+/)
.map((token) => token.toLowerCase())
.filter((token) => token.length > 0);
if (tokens.length === 0) return text;
const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length);
let result = text;
for (const token of uniqueTokens) {
const regex = new RegExp(this.escapeRegex(token), "gi");
result = result.replace(regex, (match) => this.theme.matchHighlight(match));
}
return result;
}
setSelectedIndex(index: number) { setSelectedIndex(index: number) {
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
} }
@ -127,13 +171,16 @@ export class SearchableSelectList implements Component {
const lines: string[] = []; const lines: string[] = [];
// Search input line // Search input line
const prompt = this.theme.searchPrompt("search: "); const promptText = "search: ";
const inputWidth = Math.max(1, width - 8); const prompt = this.theme.searchPrompt(promptText);
const inputWidth = Math.max(1, width - visibleWidth(prompt));
const inputLines = this.searchInput.render(inputWidth); const inputLines = this.searchInput.render(inputWidth);
const inputText = inputLines[0] ?? ""; const inputText = inputLines[0] ?? "";
lines.push(`${prompt}${this.theme.searchInput(inputText)}`); lines.push(`${prompt}${this.theme.searchInput(inputText)}`);
lines.push(""); // Spacer lines.push(""); // Spacer
const query = this.searchInput.getValue().trim();
// If no items match filter, show message // If no items match filter, show message
if (this.filteredItems.length === 0) { if (this.filteredItems.length === 0) {
lines.push(this.theme.noMatch(" No matching models")); lines.push(this.theme.noMatch(" No matching models"));
@ -152,50 +199,7 @@ export class SearchableSelectList implements Component {
const item = this.filteredItems[i]; const item = this.filteredItems[i];
if (!item) continue; if (!item) continue;
const isSelected = i === this.selectedIndex; const isSelected = i === this.selectedIndex;
let line = ""; lines.push(this.renderItemLine(item, isSelected, width, query));
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 // Show scroll indicator if needed
@ -207,6 +211,35 @@ export class SearchableSelectList implements Component {
return lines; return lines;
} }
private renderItemLine(item: SelectItem, isSelected: boolean, width: number, query: string): string {
const prefix = isSelected ? "→ " : " ";
const prefixWidth = prefix.length;
const displayValue = this.getItemLabel(item);
if (item.description && width > 40) {
const maxValueWidth = Math.min(30, width - prefixWidth - 4);
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
const valueText = this.highlightMatch(truncatedValue, query);
const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText)));
const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length;
const remainingWidth = width - descriptionStart - 2;
if (remainingWidth > 10) {
const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
const descText = isSelected
? this.highlightMatch(truncatedDesc, query)
: this.highlightMatch(this.theme.description(truncatedDesc), query);
const line = `${prefix}${valueText}${spacing}${descText}`;
return isSelected ? this.theme.selectedText(line) : line;
}
}
const maxWidth = width - prefixWidth - 2;
const truncatedValue = truncateToWidth(displayValue, maxWidth, "");
const valueText = this.highlightMatch(truncatedValue, query);
const line = `${prefix}${valueText}`;
return isSelected ? this.theme.selectedText(line) : line;
}
handleInput(keyData: string): void { handleInput(keyData: string): void {
if (isKeyRelease(keyData)) return; if (isKeyRelease(keyData)) return;