mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 09:30:59 +00:00
fix (tui): harden searchable select ANSI-safe highlighting
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user