fix(tui): prevent crash when search matches ANSI escape sequences
When using /model and searching for characters that appear in ANSI escape codes (like 'm', digits, or '['), the highlightMatch function would corrupt the escape sequences, causing width calculation errors and terminal crashes. Changes: - Skip ANSI escape sequences when applying search highlights - Add ensureLineWidth() as final safety net for line width - Use pi-tui's visibleWidth instead of local ansi.js import - Guard against empty list in navigation handlers - First Escape clears filter, second cancels (UX improvement) Fixes terminal crash: 'Rendered line exceeds terminal width'
This commit is contained in:
parent
6af205a13a
commit
6933def1d7
@ -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,36 @@ 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
|
||||||
|
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 +230,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 +253,32 @@ 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, "");
|
||||||
|
// Highlight first, then apply theme - avoids breaking ANSI codes
|
||||||
|
const highlightedDesc = this.highlightMatch(truncatedDesc, query);
|
||||||
const descText = isSelected
|
const descText = isSelected
|
||||||
? this.highlightMatch(truncatedDesc, query)
|
? highlightedDesc
|
||||||
: this.highlightMatch(this.theme.description(truncatedDesc), query);
|
: 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 +286,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 +301,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 +314,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 +332,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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user