mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 22:03:45 +00:00
Add ANSI-aware word wrapping to TUI components
- Created shared wrapTextWithAnsi() function in utils.ts - Handles word-based wrapping while preserving ANSI escape codes - Properly tracks active ANSI codes across wrapped lines - Supports multi-byte characters (emoji, surrogate pairs) - Updated Markdown and Text components to use shared wrapping - Removed duplicate wrapping logic (158 lines total)
This commit is contained in:
parent
22d8a0ae4a
commit
38ac29acfb
5 changed files with 389 additions and 278 deletions
|
|
@ -13,3 +13,306 @@ export function visibleWidth(str: string): number {
|
|||
const normalized = str.replace(/\t/g, " ");
|
||||
return stringWidth(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ANSI escape sequences from a string at the given position.
|
||||
* Returns the ANSI code and the length consumed, or null if no ANSI code found.
|
||||
*/
|
||||
function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
|
||||
if (pos >= str.length || str[pos] !== "\x1b" || str[pos + 1] !== "[") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let j = pos + 2;
|
||||
while (j < str.length && str[j] && !/[mGKHJ]/.test(str[j]!)) {
|
||||
j++;
|
||||
}
|
||||
|
||||
if (j < str.length) {
|
||||
return {
|
||||
code: str.substring(pos, j + 1),
|
||||
length: j + 1 - pos,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track and manage active ANSI codes for preserving styling across wrapped lines.
|
||||
*/
|
||||
class AnsiCodeTracker {
|
||||
private activeAnsiCodes: string[] = [];
|
||||
|
||||
/**
|
||||
* Process an ANSI code and update the active codes.
|
||||
*/
|
||||
process(ansiCode: string): void {
|
||||
// Check if it's a styling code (ends with 'm')
|
||||
if (!ansiCode.endsWith("m")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset code clears all active codes
|
||||
if (ansiCode === "\x1b[0m" || ansiCode === "\x1b[m") {
|
||||
this.activeAnsiCodes.length = 0;
|
||||
} else {
|
||||
// Add to active codes
|
||||
this.activeAnsiCodes.push(ansiCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active ANSI codes as a single string.
|
||||
*/
|
||||
getActiveCodes(): string {
|
||||
return this.activeAnsiCodes.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any active codes.
|
||||
*/
|
||||
hasActiveCodes(): boolean {
|
||||
return this.activeAnsiCodes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reset code.
|
||||
*/
|
||||
getResetCode(): string {
|
||||
return "\x1b[0m";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text lines with word-based wrapping while preserving ANSI escape codes.
|
||||
* This function properly handles:
|
||||
* - ANSI escape codes (preserved and tracked across lines)
|
||||
* - Word-based wrapping (breaks at spaces when possible)
|
||||
* - Multi-byte characters (emoji, surrogate pairs)
|
||||
* - Newlines within text
|
||||
*
|
||||
* @param text - The text to wrap (can contain ANSI codes and newlines)
|
||||
* @param width - The maximum width in terminal columns
|
||||
* @returns Array of wrapped lines with ANSI codes preserved
|
||||
*/
|
||||
export function wrapTextWithAnsi(text: string, width: number): string[] {
|
||||
if (!text) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
// Handle newlines by processing each line separately
|
||||
const inputLines = text.split("\n");
|
||||
const result: string[] = [];
|
||||
|
||||
for (const inputLine of inputLines) {
|
||||
result.push(...wrapSingleLineWithAnsi(inputLine, width));
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : [""];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a single line (no newlines) with word-based wrapping while preserving ANSI codes.
|
||||
*/
|
||||
function wrapSingleLineWithAnsi(line: string, width: number): string[] {
|
||||
if (!line) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
const visibleLength = visibleWidth(line);
|
||||
if (visibleLength <= width) {
|
||||
return [line];
|
||||
}
|
||||
|
||||
const wrapped: string[] = [];
|
||||
const tracker = new AnsiCodeTracker();
|
||||
|
||||
// First, split the line into words while preserving ANSI codes with their words
|
||||
const words = splitIntoWordsWithAnsi(line);
|
||||
|
||||
let currentLine = "";
|
||||
let currentVisibleLength = 0;
|
||||
|
||||
for (const word of words) {
|
||||
const wordVisibleLength = visibleWidth(word);
|
||||
|
||||
// If the word itself is longer than the width, we need to break it character by character
|
||||
if (wordVisibleLength > width) {
|
||||
// Flush current line if any
|
||||
if (currentLine) {
|
||||
wrapped.push(closeLineAndPrepareNext(currentLine, tracker));
|
||||
currentLine = tracker.getActiveCodes();
|
||||
currentVisibleLength = 0;
|
||||
}
|
||||
|
||||
// Break the long word
|
||||
const brokenLines = breakLongWordWithAnsi(word, width, tracker);
|
||||
wrapped.push(...brokenLines.slice(0, -1));
|
||||
currentLine = brokenLines[brokenLines.length - 1];
|
||||
currentVisibleLength = visibleWidth(currentLine);
|
||||
} else {
|
||||
// Check if adding this word would exceed the width
|
||||
const spaceNeeded = currentVisibleLength > 0 ? 1 : 0; // Space before word if not at line start
|
||||
const totalNeeded = currentVisibleLength + spaceNeeded + wordVisibleLength;
|
||||
|
||||
if (totalNeeded > width) {
|
||||
// Word doesn't fit, wrap to next line
|
||||
if (currentLine) {
|
||||
wrapped.push(closeLineAndPrepareNext(currentLine, tracker));
|
||||
}
|
||||
currentLine = tracker.getActiveCodes() + word;
|
||||
currentVisibleLength = wordVisibleLength;
|
||||
} else {
|
||||
// Word fits, add it
|
||||
if (currentVisibleLength > 0) {
|
||||
currentLine += " " + word;
|
||||
currentVisibleLength += 1 + wordVisibleLength;
|
||||
} else {
|
||||
currentLine += word;
|
||||
currentVisibleLength = wordVisibleLength;
|
||||
}
|
||||
}
|
||||
|
||||
// Update tracker with ANSI codes from this word
|
||||
updateTrackerFromText(word, tracker);
|
||||
}
|
||||
}
|
||||
|
||||
// Add final line
|
||||
if (currentLine) {
|
||||
wrapped.push(currentLine);
|
||||
}
|
||||
|
||||
return wrapped.length > 0 ? wrapped : [""];
|
||||
}
|
||||
|
||||
/**
|
||||
* Close current line with reset code if needed, and prepare the next line with active codes.
|
||||
*/
|
||||
function closeLineAndPrepareNext(line: string, tracker: AnsiCodeTracker): string {
|
||||
if (tracker.hasActiveCodes()) {
|
||||
return line + tracker.getResetCode();
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the ANSI code tracker by scanning through text.
|
||||
*/
|
||||
function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const ansiResult = extractAnsiCode(text, i);
|
||||
if (ansiResult) {
|
||||
tracker.process(ansiResult.code);
|
||||
i += ansiResult.length;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into words while keeping ANSI codes attached to their words.
|
||||
*/
|
||||
function splitIntoWordsWithAnsi(text: string): string[] {
|
||||
const words: string[] = [];
|
||||
let currentWord = "";
|
||||
let i = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
const char = text[i];
|
||||
|
||||
// Check for ANSI code
|
||||
const ansiResult = extractAnsiCode(text, i);
|
||||
if (ansiResult) {
|
||||
currentWord += ansiResult.code;
|
||||
i += ansiResult.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for space (word boundary)
|
||||
if (char === " ") {
|
||||
if (currentWord) {
|
||||
words.push(currentWord);
|
||||
currentWord = "";
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular character
|
||||
currentWord += char;
|
||||
i++;
|
||||
}
|
||||
|
||||
// Add final word
|
||||
if (currentWord) {
|
||||
words.push(currentWord);
|
||||
}
|
||||
|
||||
return words;
|
||||
}
|
||||
|
||||
/**
|
||||
* Break a long word that doesn't fit on a single line, character by character.
|
||||
*/
|
||||
function breakLongWordWithAnsi(word: string, width: number, tracker: AnsiCodeTracker): string[] {
|
||||
const lines: string[] = [];
|
||||
let currentLine = tracker.getActiveCodes();
|
||||
let currentVisibleLength = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < word.length) {
|
||||
// Check for ANSI code
|
||||
const ansiResult = extractAnsiCode(word, i);
|
||||
if (ansiResult) {
|
||||
currentLine += ansiResult.code;
|
||||
tracker.process(ansiResult.code);
|
||||
i += ansiResult.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get character (handle surrogate pairs)
|
||||
const codePoint = word.charCodeAt(i);
|
||||
let char: string;
|
||||
let charByteLength: number;
|
||||
|
||||
if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < word.length) {
|
||||
// High surrogate - get the pair
|
||||
char = word.substring(i, i + 2);
|
||||
charByteLength = 2;
|
||||
} else {
|
||||
// Regular character
|
||||
char = word[i];
|
||||
charByteLength = 1;
|
||||
}
|
||||
|
||||
const charWidth = visibleWidth(char);
|
||||
|
||||
// Check if adding this character would exceed width
|
||||
if (currentVisibleLength + charWidth > width) {
|
||||
// Need to wrap
|
||||
if (tracker.hasActiveCodes()) {
|
||||
lines.push(currentLine + tracker.getResetCode());
|
||||
currentLine = tracker.getActiveCodes();
|
||||
} else {
|
||||
lines.push(currentLine);
|
||||
currentLine = "";
|
||||
}
|
||||
currentVisibleLength = 0;
|
||||
}
|
||||
|
||||
currentLine += char;
|
||||
currentVisibleLength += charWidth;
|
||||
i += charByteLength;
|
||||
}
|
||||
|
||||
// Add final line (don't close it, let the caller handle that)
|
||||
if (currentLine || lines.length === 0) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue