From 9255f36654221d1d7608ceda45818a487b0f8dcb Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 14 Feb 2026 20:00:58 -0800 Subject: [PATCH] fix (tui): harden searchable select ANSI-safe highlighting --- src/tui/components/searchable-select-list.ts | 72 ++++++++++++++++---- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index 0d00fb81350..8a27858e367 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -8,8 +8,11 @@ import { type SelectListTheme, truncateToWidth, } from "@mariozechner/pi-tui"; -import { visibleWidth } from "../../terminal/ansi.js"; -import { findWordBoundaryIndex, fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js"; +import { stripAnsi, visibleWidth } from "../../terminal/ansi.js"; +import { findWordBoundaryIndex, fuzzyFilterLower } from "./fuzzy-filter.js"; + +const ANSI_ESCAPE = String.fromCharCode(27); +const ANSI_SGR_REGEX = new RegExp(`${ANSI_ESCAPE}\\[[0-9;]*m`, "g"); export interface SearchableSelectListTheme extends SelectListTheme { searchPrompt: (text: string) => string; @@ -80,12 +83,15 @@ export class SearchableSelectList implements Component { private smartFilter(query: string): SelectItem[] { const q = query.toLowerCase(); type ScoredItem = { item: SelectItem; tier: number; score: number }; + type FuzzyCandidate = { item: SelectItem; searchTextLower: string }; const scoredItems: ScoredItem[] = []; - const fuzzyCandidates: SelectItem[] = []; + const fuzzyCandidates: FuzzyCandidate[] = []; for (const item of this.items) { - const label = item.label.toLowerCase(); - const desc = (item.description ?? "").toLowerCase(); + const rawLabel = this.getItemLabel(item); + const rawDesc = item.description ?? ""; + const label = stripAnsi(rawLabel).toLowerCase(); + const desc = stripAnsi(rawDesc).toLowerCase(); // Tier 1: Exact substring in label const labelIndex = label.indexOf(q); @@ -106,15 +112,20 @@ export class SearchableSelectList implements Component { continue; } // Tier 4: Fuzzy match (score 300+) - fuzzyCandidates.push(item); + const searchText = (item as { searchText?: string }).searchText ?? ""; + fuzzyCandidates.push({ + item, + searchTextLower: [rawLabel, rawDesc, searchText] + .map((value) => stripAnsi(value)) + .filter(Boolean) + .join(" ") + .toLowerCase(), + }); } scoredItems.sort(this.compareByScore); - - const preparedCandidates = prepareSearchItems(fuzzyCandidates); - const fuzzyMatches = fuzzyFilterLower(preparedCandidates, q); - - return [...scoredItems.map((s) => s.item), ...fuzzyMatches]; + const fuzzyMatches = fuzzyFilterLower(fuzzyCandidates, q); + return [...scoredItems.map((s) => s.item), ...fuzzyMatches.map((entry) => entry.item)]; } private escapeRegex(str: string): string { @@ -138,6 +149,25 @@ export class SearchableSelectList implements Component { return item.label || item.value; } + private splitAnsiParts(text: string): Array<{ text: string; isAnsi: boolean }> { + const parts: Array<{ text: string; isAnsi: boolean }> = []; + ANSI_SGR_REGEX.lastIndex = 0; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = ANSI_SGR_REGEX.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push({ text: text.slice(lastIndex, match.index), isAnsi: false }); + } + parts.push({ text: match[0], isAnsi: true }); + lastIndex = match.index + match[0].length; + } + if (lastIndex < text.length) { + parts.push({ text: text.slice(lastIndex), isAnsi: false }); + } + return parts; + } + private highlightMatch(text: string, query: string): string { const tokens = query .trim() @@ -149,12 +179,26 @@ export class SearchableSelectList implements Component { } const uniqueTokens = Array.from(new Set(tokens)).toSorted((a, b) => b.length - a.length); - let result = text; + let parts = this.splitAnsiParts(text); for (const token of uniqueTokens) { const regex = this.getCachedRegex(token); - result = result.replace(regex, (match) => this.theme.matchHighlight(match)); + const nextParts: Array<{ text: string; isAnsi: boolean }> = []; + for (const part of parts) { + if (part.isAnsi) { + nextParts.push(part); + continue; + } + regex.lastIndex = 0; + const replaced = part.text.replace(regex, (match) => this.theme.matchHighlight(match)); + if (replaced === part.text) { + nextParts.push(part); + continue; + } + nextParts.push(...this.splitAnsiParts(replaced)); + } + parts = nextParts; } - return result; + return parts.map((part) => part.text).join(""); } setSelectedIndex(index: number) {