From 1e5569d56ad7f09268d8c5a5282746c6cc4f5bb8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 19 Jan 2026 00:15:08 +0000 Subject: [PATCH] fix: refine TUI model search rendering --- CHANGELOG.md | 1 + .../components/searchable-select-list.test.ts | 26 ++++ src/tui/components/searchable-select-list.ts | 147 +++++++++++------- 3 files changed, 117 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a48418313..49e7643e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.clawd.bot - macOS: use label colors for session preview text so previews render in menu subviews. - macOS: suppress usage error text in the menubar cost view. - 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. - Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH. diff --git a/src/tui/components/searchable-select-list.test.ts b/src/tui/components/searchable-select-list.test.ts index 80f5e758b..791ecd556 100644 --- a/src/tui/components/searchable-select-list.test.ts +++ b/src/tui/components/searchable-select-list.test.ts @@ -80,6 +80,21 @@ describe("SearchableSelectList", () => { 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", () => { const list = new SearchableSelectList(testItems, 5, mockTheme); @@ -107,6 +122,17 @@ describe("SearchableSelectList", () => { 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", () => { const list = new SearchableSelectList(testItems, 5, mockTheme); diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index 53841acf9..5c4a37d18 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -8,6 +8,7 @@ import { type SelectListTheme, truncateToWidth, } from "@mariozechner/pi-tui"; +import { visibleWidth } from "../../terminal/ansi.js"; export interface SearchableSelectListTheme extends SelectListTheme { searchPrompt: (text: string) => string; @@ -63,8 +64,8 @@ export class SearchableSelectList implements Component { const q = query.toLowerCase(); type ScoredItem = { item: SelectItem; score: number }; const exactLabel: ScoredItem[] = []; - const wordBoundary: SelectItem[] = []; - const descriptionMatches: SelectItem[] = []; + const wordBoundary: ScoredItem[] = []; + const descriptionMatches: ScoredItem[] = []; const fuzzyCandidates: SelectItem[] = []; for (const item of this.items) { @@ -79,25 +80,29 @@ export class SearchableSelectList implements Component { continue; } // Tier 2: Word-boundary prefix in label (score 100-199) - if (this.matchesWordBoundary(label, q)) { - wordBoundary.push(item); + const wordBoundaryIndex = this.findWordBoundaryIndex(label, q); + if (wordBoundaryIndex !== null) { + wordBoundary.push({ item, score: wordBoundaryIndex }); continue; } // Tier 3: Exact substring in description (score 200-299) - if (desc.indexOf(q) !== -1) { - descriptionMatches.push(item); + const descIndex = desc.indexOf(q); + if (descIndex !== -1) { + descriptionMatches.push({ item, score: descIndex }); continue; } // Tier 4: Fuzzy match (score 300+) 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 ?? ""}`); return [ ...exactLabel.map((s) => s.item), - ...wordBoundary, - ...descriptionMatches, + ...wordBoundary.map((s) => s.item), + ...descriptionMatches.map((s) => s.item), ...fuzzyMatches, ]; } @@ -107,14 +112,53 @@ export class SearchableSelectList implements Component { * 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); + return this.findWordBoundaryIndex(text, query) !== null; + } + + 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 { 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) { this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); } @@ -127,13 +171,16 @@ export class SearchableSelectList implements Component { const lines: string[] = []; // Search input line - const prompt = this.theme.searchPrompt("search: "); - const inputWidth = Math.max(1, width - 8); + const promptText = "search: "; + const prompt = this.theme.searchPrompt(promptText); + const inputWidth = Math.max(1, width - visibleWidth(prompt)); const inputLines = this.searchInput.render(inputWidth); const inputText = inputLines[0] ?? ""; lines.push(`${prompt}${this.theme.searchInput(inputText)}`); lines.push(""); // Spacer + const query = this.searchInput.getValue().trim(); + // If no items match filter, show message if (this.filteredItems.length === 0) { lines.push(this.theme.noMatch(" No matching models")); @@ -152,50 +199,7 @@ export class SearchableSelectList implements Component { 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); + lines.push(this.renderItemLine(item, isSelected, width, query)); } // Show scroll indicator if needed @@ -207,6 +211,35 @@ export class SearchableSelectList implements Component { 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 { if (isKeyRelease(keyData)) return;