This commit is contained in:
Tianyu Zhang 2026-01-30 17:05:45 +05:30 committed by GitHub
commit 1bce551ca2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -7,8 +7,8 @@ import {
type SelectItem, type SelectItem,
type SelectListTheme, type SelectListTheme,
truncateToWidth, truncateToWidth,
visibleWidth,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import { visibleWidth } from "../../terminal/ansi.js";
import { findWordBoundaryIndex, fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js"; import { findWordBoundaryIndex, fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js";
export interface SearchableSelectListTheme extends SelectListTheme { export interface SearchableSelectListTheme extends SelectListTheme {
@ -47,6 +47,8 @@ export class SearchableSelectList implements Component {
regex = new RegExp(this.escapeRegex(pattern), "gi"); regex = new RegExp(this.escapeRegex(pattern), "gi");
this.regexCache.set(pattern, regex); this.regexCache.set(pattern, regex);
} }
// Reset lastIndex to ensure consistent behavior (defensive)
regex.lastIndex = 0;
return regex; return regex;
} }
@ -54,7 +56,7 @@ export class SearchableSelectList implements Component {
const query = this.searchInput.getValue().trim(); const query = this.searchInput.getValue().trim();
if (!query) { if (!query) {
this.filteredItems = this.items; this.filteredItems = this.items ?? [];
} else { } else {
this.filteredItems = this.smartFilter(query); this.filteredItems = this.smartFilter(query);
} }
@ -139,8 +141,37 @@ export class SearchableSelectList implements Component {
const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length); const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length);
let result = text; let result = text;
for (const token of uniqueTokens) { for (const token of uniqueTokens) {
// CRITICAL FIX: Skip ANSI escape sequences to avoid breaking color codes
// Split text into ANSI and visible parts, only highlight visible parts
// eslint-disable-next-line no-control-regex -- intentional: matching ANSI escape sequences
const ansiRegex = /\x1b\[[0-9;]*m/g;
const parts: Array<{ text: string; isAnsi: boolean }> = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = ansiRegex.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 });
}
// Only highlight in non-ANSI parts
const regex = this.getCachedRegex(token); const regex = this.getCachedRegex(token);
result = result.replace(regex, (match) => this.theme.matchHighlight(match)); result = parts
.map((part) => {
if (part.isAnsi) return part.text;
regex.lastIndex = 0;
return part.text.replace(regex, (m) => this.theme.matchHighlight(m));
})
.join("");
// Update text for next token iteration
text = result;
} }
return result; return result;
} }
@ -200,6 +231,18 @@ export class SearchableSelectList implements Component {
return lines; return lines;
} }
private ensureLineWidth(text: string, width: number): string {
// Use pi-tui's visibleWidth for accurate measurement
const currentWidth = visibleWidth(text);
if (currentWidth <= width) {
return text;
}
// Use pi-tui's truncateToWidth to properly handle ANSI codes
return truncateToWidth(text, width, "");
}
private renderItemLine( private renderItemLine(
item: SelectItem, item: SelectItem,
isSelected: boolean, isSelected: boolean,
@ -211,20 +254,30 @@ export class SearchableSelectList implements Component {
const displayValue = this.getItemLabel(item); const displayValue = this.getItemLabel(item);
if (item.description && width > 40) { if (item.description && width > 40) {
// Fixed column for description (column 32)
const valueColumn = 32;
const maxValueWidth = Math.min(30, width - prefixWidth - 4); const maxValueWidth = Math.min(30, width - prefixWidth - 4);
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
const valueText = this.highlightMatch(truncatedValue, query); const valueText = this.highlightMatch(truncatedValue, query);
const spacingWidth = Math.max(1, 32 - visibleWidth(valueText));
const spacing = " ".repeat(spacingWidth); // Calculate spacing - value ends at column 32
const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length; let spacing = "";
const currentValueWidth = visibleWidth(valueText);
if (currentValueWidth < valueColumn - prefixWidth) {
spacing = " ".repeat(valueColumn - prefixWidth - currentValueWidth);
}
// Description starts after value and spacing
const descriptionStart = prefixWidth + currentValueWidth + spacing.length;
const remainingWidth = width - descriptionStart - 2; const remainingWidth = width - descriptionStart - 2;
if (remainingWidth > 10) { if (remainingWidth > 10) {
const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
const descText = isSelected // Highlight first, then apply theme - avoids breaking ANSI codes
? this.highlightMatch(truncatedDesc, query) const highlightedDesc = this.highlightMatch(truncatedDesc, query);
: this.highlightMatch(this.theme.description(truncatedDesc), query); const descText = isSelected ? highlightedDesc : this.theme.description(highlightedDesc);
const line = `${prefix}${valueText}${spacing}${descText}`; const line = `${prefix}${valueText}${spacing}${descText}`;
return isSelected ? this.theme.selectedText(line) : line; const rendered = isSelected ? this.theme.selectedText(line) : line;
return this.ensureLineWidth(rendered, width);
} }
} }
@ -232,7 +285,8 @@ export class SearchableSelectList implements Component {
const truncatedValue = truncateToWidth(displayValue, maxWidth, ""); const truncatedValue = truncateToWidth(displayValue, maxWidth, "");
const valueText = this.highlightMatch(truncatedValue, query); const valueText = this.highlightMatch(truncatedValue, query);
const line = `${prefix}${valueText}`; const line = `${prefix}${valueText}`;
return isSelected ? this.theme.selectedText(line) : line; const rendered = isSelected ? this.theme.selectedText(line) : line;
return this.ensureLineWidth(rendered, width);
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
@ -246,8 +300,11 @@ export class SearchableSelectList implements Component {
matchesKey(keyData, "ctrl+p") || matchesKey(keyData, "ctrl+p") ||
(allowVimNav && keyData === "k") (allowVimNav && keyData === "k")
) { ) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1); // Guard against empty list
this.notifySelectionChange(); if (this.filteredItems.length > 0) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.notifySelectionChange();
}
return; return;
} }
@ -256,8 +313,11 @@ export class SearchableSelectList implements Component {
matchesKey(keyData, "ctrl+n") || matchesKey(keyData, "ctrl+n") ||
(allowVimNav && keyData === "j") (allowVimNav && keyData === "j")
) { ) {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); // Guard against empty list: ensure selectedIndex stays non-negative
this.notifySelectionChange(); if (this.filteredItems.length > 0) {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
this.notifySelectionChange();
}
return; return;
} }
@ -271,7 +331,12 @@ export class SearchableSelectList implements Component {
const kb = getEditorKeybindings(); const kb = getEditorKeybindings();
if (kb.matches(keyData, "selectCancel")) { if (kb.matches(keyData, "selectCancel")) {
if (this.onCancel) { // First Escape clears the search filter, second Escape cancels
const hasFilter = this.searchInput.getValue().trim().length > 0;
if (hasFilter) {
this.searchInput.setValue("");
this.updateFilter();
} else if (this.onCancel) {
this.onCancel(); this.onCancel();
} }
return; return;