mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:08:37 +00:00
fix (tui): harden searchable select ANSI-safe highlighting
This commit is contained in:
@@ -8,8 +8,11 @@ import {
|
|||||||
type SelectListTheme,
|
type SelectListTheme,
|
||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import { visibleWidth } from "../../terminal/ansi.js";
|
import { stripAnsi, visibleWidth } from "../../terminal/ansi.js";
|
||||||
import { findWordBoundaryIndex, fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.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 {
|
export interface SearchableSelectListTheme extends SelectListTheme {
|
||||||
searchPrompt: (text: string) => string;
|
searchPrompt: (text: string) => string;
|
||||||
@@ -80,12 +83,15 @@ export class SearchableSelectList implements Component {
|
|||||||
private smartFilter(query: string): SelectItem[] {
|
private smartFilter(query: string): SelectItem[] {
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
type ScoredItem = { item: SelectItem; tier: number; score: number };
|
type ScoredItem = { item: SelectItem; tier: number; score: number };
|
||||||
|
type FuzzyCandidate = { item: SelectItem; searchTextLower: string };
|
||||||
const scoredItems: ScoredItem[] = [];
|
const scoredItems: ScoredItem[] = [];
|
||||||
const fuzzyCandidates: SelectItem[] = [];
|
const fuzzyCandidates: FuzzyCandidate[] = [];
|
||||||
|
|
||||||
for (const item of this.items) {
|
for (const item of this.items) {
|
||||||
const label = item.label.toLowerCase();
|
const rawLabel = this.getItemLabel(item);
|
||||||
const desc = (item.description ?? "").toLowerCase();
|
const rawDesc = item.description ?? "";
|
||||||
|
const label = stripAnsi(rawLabel).toLowerCase();
|
||||||
|
const desc = stripAnsi(rawDesc).toLowerCase();
|
||||||
|
|
||||||
// Tier 1: Exact substring in label
|
// Tier 1: Exact substring in label
|
||||||
const labelIndex = label.indexOf(q);
|
const labelIndex = label.indexOf(q);
|
||||||
@@ -106,15 +112,20 @@ export class SearchableSelectList implements Component {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Tier 4: Fuzzy match (score 300+)
|
// 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);
|
scoredItems.sort(this.compareByScore);
|
||||||
|
const fuzzyMatches = fuzzyFilterLower(fuzzyCandidates, q);
|
||||||
const preparedCandidates = prepareSearchItems(fuzzyCandidates);
|
return [...scoredItems.map((s) => s.item), ...fuzzyMatches.map((entry) => entry.item)];
|
||||||
const fuzzyMatches = fuzzyFilterLower(preparedCandidates, q);
|
|
||||||
|
|
||||||
return [...scoredItems.map((s) => s.item), ...fuzzyMatches];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private escapeRegex(str: string): string {
|
private escapeRegex(str: string): string {
|
||||||
@@ -138,6 +149,25 @@ export class SearchableSelectList implements Component {
|
|||||||
return item.label || item.value;
|
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 {
|
private highlightMatch(text: string, query: string): string {
|
||||||
const tokens = query
|
const tokens = query
|
||||||
.trim()
|
.trim()
|
||||||
@@ -149,12 +179,26 @@ export class SearchableSelectList implements Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uniqueTokens = Array.from(new Set(tokens)).toSorted((a, b) => b.length - a.length);
|
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) {
|
for (const token of uniqueTokens) {
|
||||||
const regex = this.getCachedRegex(token);
|
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) {
|
setSelectedIndex(index: number) {
|
||||||
|
|||||||
Reference in New Issue
Block a user