mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 19:04:37 +00:00
Release v0.7.21
This commit is contained in:
parent
5112fc6ba9
commit
1b28780155
16 changed files with 346 additions and 341 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { Chalk } from "chalk";
|
||||
import { marked, type Token } from "marked";
|
||||
import type { Component } from "../tui.js";
|
||||
import { visibleWidth, wrapTextWithAnsi } from "../utils.js";
|
||||
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
|
||||
|
||||
// Use a chalk instance with color level 3 for consistent ANSI output
|
||||
const colorChalk = new Chalk({ level: 3 });
|
||||
|
|
@ -86,51 +86,41 @@ export class Markdown implements Component {
|
|||
renderedLines.push(...tokenLines);
|
||||
}
|
||||
|
||||
// Wrap lines to fit content width
|
||||
// Wrap lines (NO padding, NO background yet)
|
||||
const wrappedLines: string[] = [];
|
||||
for (const line of renderedLines) {
|
||||
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
|
||||
}
|
||||
|
||||
// Add padding and apply background color if specified
|
||||
const leftPad = " ".repeat(this.paddingX);
|
||||
const paddedLines: string[] = [];
|
||||
// Add margins and background to each wrapped line
|
||||
const leftMargin = " ".repeat(this.paddingX);
|
||||
const rightMargin = " ".repeat(this.paddingX);
|
||||
const bgRgb = this.defaultTextStyle?.bgColor ? this.parseBgColor() : undefined;
|
||||
const contentLines: string[] = [];
|
||||
|
||||
for (const line of wrappedLines) {
|
||||
// Calculate visible length
|
||||
const visibleLength = visibleWidth(line);
|
||||
// Right padding to fill to width (accounting for left padding and content)
|
||||
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
|
||||
const rightPad = " ".repeat(rightPadLength);
|
||||
const lineWithMargins = leftMargin + line + rightMargin;
|
||||
|
||||
// Add left padding, content, and right padding
|
||||
let paddedLine = leftPad + line + rightPad;
|
||||
|
||||
// Apply background color to entire line if specified
|
||||
if (this.defaultTextStyle?.bgColor) {
|
||||
paddedLine = this.applyBgColor(paddedLine);
|
||||
if (bgRgb) {
|
||||
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgRgb));
|
||||
} else {
|
||||
// No background - just pad to width
|
||||
const visibleLen = visibleWidth(lineWithMargins);
|
||||
const paddingNeeded = Math.max(0, width - visibleLen);
|
||||
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
|
||||
}
|
||||
|
||||
paddedLines.push(paddedLine);
|
||||
}
|
||||
|
||||
// Add top padding (empty lines)
|
||||
// Add top/bottom padding (empty lines)
|
||||
const emptyLine = " ".repeat(width);
|
||||
const topPadding: string[] = [];
|
||||
const emptyLines: string[] = [];
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
const paddedEmptyLine = this.defaultTextStyle?.bgColor ? this.applyBgColor(emptyLine) : emptyLine;
|
||||
topPadding.push(paddedEmptyLine);
|
||||
}
|
||||
|
||||
// Add bottom padding (empty lines)
|
||||
const bottomPadding: string[] = [];
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
const paddedEmptyLine = this.defaultTextStyle?.bgColor ? this.applyBgColor(emptyLine) : emptyLine;
|
||||
bottomPadding.push(paddedEmptyLine);
|
||||
const line = bgRgb ? applyBackgroundToLine(emptyLine, width, bgRgb) : emptyLine;
|
||||
emptyLines.push(line);
|
||||
}
|
||||
|
||||
// Combine top padding, content, and bottom padding
|
||||
const result = [...topPadding, ...paddedLines, ...bottomPadding];
|
||||
const result = [...emptyLines, ...contentLines, ...emptyLines];
|
||||
|
||||
// Update cache
|
||||
this.cachedText = this.text;
|
||||
|
|
@ -141,29 +131,43 @@ export class Markdown implements Component {
|
|||
}
|
||||
|
||||
/**
|
||||
* Apply only background color from default style.
|
||||
* Used for padding lines that don't have text content.
|
||||
* Parse background color from defaultTextStyle to RGB values
|
||||
*/
|
||||
private applyBgColor(text: string): string {
|
||||
private parseBgColor(): { r: number; g: number; b: number } | undefined {
|
||||
if (!this.defaultTextStyle?.bgColor) {
|
||||
return text;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.defaultTextStyle.bgColor.startsWith("#")) {
|
||||
// Hex color
|
||||
const hex = this.defaultTextStyle.bgColor.substring(1);
|
||||
const r = Number.parseInt(hex.substring(0, 2), 16);
|
||||
const g = Number.parseInt(hex.substring(2, 4), 16);
|
||||
const b = Number.parseInt(hex.substring(4, 6), 16);
|
||||
return colorChalk.bgRgb(r, g, b)(text);
|
||||
return {
|
||||
r: Number.parseInt(hex.substring(0, 2), 16),
|
||||
g: Number.parseInt(hex.substring(2, 4), 16),
|
||||
b: Number.parseInt(hex.substring(4, 6), 16),
|
||||
};
|
||||
}
|
||||
// Named background color (bgRed, bgBlue, etc.)
|
||||
return (colorChalk as any)[this.defaultTextStyle.bgColor](text);
|
||||
|
||||
// Named colors - map to RGB (common terminal colors)
|
||||
const colorMap: Record<string, { r: number; g: number; b: number }> = {
|
||||
bgBlack: { r: 0, g: 0, b: 0 },
|
||||
bgRed: { r: 255, g: 0, b: 0 },
|
||||
bgGreen: { r: 0, g: 255, b: 0 },
|
||||
bgYellow: { r: 255, g: 255, b: 0 },
|
||||
bgBlue: { r: 0, g: 0, b: 255 },
|
||||
bgMagenta: { r: 255, g: 0, b: 255 },
|
||||
bgCyan: { r: 0, g: 255, b: 255 },
|
||||
bgWhite: { r: 255, g: 255, b: 255 },
|
||||
};
|
||||
|
||||
return colorMap[this.defaultTextStyle.bgColor];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply default text style to a string.
|
||||
* This is the base styling applied to all text content.
|
||||
* NOTE: Background color is NOT applied here - it's applied at the padding stage
|
||||
* to ensure it extends to the full line width.
|
||||
*/
|
||||
private applyDefaultStyle(text: string): string {
|
||||
if (!this.defaultTextStyle) {
|
||||
|
|
@ -172,7 +176,7 @@ export class Markdown implements Component {
|
|||
|
||||
let styled = text;
|
||||
|
||||
// Apply color
|
||||
// Apply foreground color (NOT background - that's applied at padding stage)
|
||||
if (this.defaultTextStyle.color) {
|
||||
if (this.defaultTextStyle.color.startsWith("#")) {
|
||||
// Hex color
|
||||
|
|
@ -187,21 +191,6 @@ export class Markdown implements Component {
|
|||
}
|
||||
}
|
||||
|
||||
// Apply background color
|
||||
if (this.defaultTextStyle.bgColor) {
|
||||
if (this.defaultTextStyle.bgColor.startsWith("#")) {
|
||||
// Hex color
|
||||
const hex = this.defaultTextStyle.bgColor.substring(1);
|
||||
const r = Number.parseInt(hex.substring(0, 2), 16);
|
||||
const g = Number.parseInt(hex.substring(2, 4), 16);
|
||||
const b = Number.parseInt(hex.substring(4, 6), 16);
|
||||
styled = colorChalk.bgRgb(r, g, b)(styled);
|
||||
} else {
|
||||
// Named background color (bgRed, bgBlue, etc.)
|
||||
styled = (colorChalk as any)[this.defaultTextStyle.bgColor](styled);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply text decorations
|
||||
if (this.defaultTextStyle.bold) {
|
||||
styled = colorChalk.bold(styled);
|
||||
|
|
@ -338,12 +327,8 @@ export class Markdown implements Component {
|
|||
}
|
||||
|
||||
case "codespan":
|
||||
// Apply code styling, then reapply default style after
|
||||
result +=
|
||||
colorChalk.gray("`") +
|
||||
colorChalk.cyan(token.text) +
|
||||
colorChalk.gray("`") +
|
||||
this.applyDefaultStyle("");
|
||||
// Apply code styling without backticks
|
||||
result += colorChalk.cyan(token.text) + this.applyDefaultStyle("");
|
||||
break;
|
||||
|
||||
case "link": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import chalk from "chalk";
|
||||
import { Chalk } from "chalk";
|
||||
import type { Component } from "../tui.js";
|
||||
import { visibleWidth, wrapTextWithAnsi } from "../utils.js";
|
||||
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
|
||||
|
||||
const colorChalk = new Chalk({ level: 3 });
|
||||
|
||||
/**
|
||||
* Text component - displays multi-line text with word wrapping
|
||||
|
|
@ -30,7 +32,6 @@ export class Text implements Component {
|
|||
|
||||
setText(text: string): void {
|
||||
this.text = text;
|
||||
// Invalidate cache when text changes
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
|
|
@ -38,7 +39,6 @@ export class Text implements Component {
|
|||
|
||||
setCustomBgRgb(customBgRgb?: { r: number; g: number; b: number }): void {
|
||||
this.customBgRgb = customBgRgb;
|
||||
// Invalidate cache when color changes
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
|
|
@ -50,68 +50,53 @@ export class Text implements Component {
|
|||
return this.cachedLines;
|
||||
}
|
||||
|
||||
// Calculate available width for content (subtract horizontal padding)
|
||||
const contentWidth = Math.max(1, width - this.paddingX * 2);
|
||||
|
||||
// Don't render anything if there's no actual text
|
||||
if (!this.text || this.text.trim() === "") {
|
||||
const result: string[] = [];
|
||||
// Update cache
|
||||
this.cachedText = this.text;
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Replace tabs with 3 spaces for consistent rendering
|
||||
// Replace tabs with 3 spaces
|
||||
const normalizedText = this.text.replace(/\t/g, " ");
|
||||
|
||||
// Use shared ANSI-aware word wrapping
|
||||
const lines = wrapTextWithAnsi(normalizedText, contentWidth);
|
||||
// Calculate content width (subtract left/right margins)
|
||||
const contentWidth = Math.max(1, width - this.paddingX * 2);
|
||||
|
||||
// Add padding to each line
|
||||
const leftPad = " ".repeat(this.paddingX);
|
||||
const paddedLines: string[] = [];
|
||||
// Wrap text (this preserves ANSI codes but does NOT pad)
|
||||
const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
|
||||
|
||||
for (const line of lines) {
|
||||
// Calculate visible length (strip ANSI codes)
|
||||
const visibleLength = visibleWidth(line);
|
||||
// Right padding to fill to width (accounting for left padding and content)
|
||||
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
|
||||
const rightPad = " ".repeat(rightPadLength);
|
||||
let paddedLine = leftPad + line + rightPad;
|
||||
// Add margins and background to each line
|
||||
const leftMargin = " ".repeat(this.paddingX);
|
||||
const rightMargin = " ".repeat(this.paddingX);
|
||||
const contentLines: string[] = [];
|
||||
|
||||
// Apply background color if specified
|
||||
for (const line of wrappedLines) {
|
||||
// Add margins
|
||||
const lineWithMargins = leftMargin + line + rightMargin;
|
||||
|
||||
// Apply background if specified (this also pads to full width)
|
||||
if (this.customBgRgb) {
|
||||
paddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(paddedLine);
|
||||
contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgRgb));
|
||||
} else {
|
||||
// No background - just pad to width with spaces
|
||||
const visibleLen = visibleWidth(lineWithMargins);
|
||||
const paddingNeeded = Math.max(0, width - visibleLen);
|
||||
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
|
||||
}
|
||||
|
||||
paddedLines.push(paddedLine);
|
||||
}
|
||||
|
||||
// Add top padding (empty lines)
|
||||
// Add top/bottom padding (empty lines)
|
||||
const emptyLine = " ".repeat(width);
|
||||
const topPadding: string[] = [];
|
||||
const emptyLines: string[] = [];
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
let emptyPaddedLine = emptyLine;
|
||||
if (this.customBgRgb) {
|
||||
emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine);
|
||||
}
|
||||
topPadding.push(emptyPaddedLine);
|
||||
const line = this.customBgRgb ? applyBackgroundToLine(emptyLine, width, this.customBgRgb) : emptyLine;
|
||||
emptyLines.push(line);
|
||||
}
|
||||
|
||||
// Add bottom padding (empty lines)
|
||||
const bottomPadding: string[] = [];
|
||||
for (let i = 0; i < this.paddingY; i++) {
|
||||
let emptyPaddedLine = emptyLine;
|
||||
if (this.customBgRgb) {
|
||||
emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine);
|
||||
}
|
||||
bottomPadding.push(emptyPaddedLine);
|
||||
}
|
||||
|
||||
// Combine top padding, content, and bottom padding
|
||||
const result = [...topPadding, ...paddedLines, ...bottomPadding];
|
||||
const result = [...emptyLines, ...contentLines, ...emptyLines];
|
||||
|
||||
// Update cache
|
||||
this.cachedText = this.text;
|
||||
|
|
|
|||
|
|
@ -204,17 +204,28 @@ export class TUI extends Container {
|
|||
}
|
||||
|
||||
buffer += "\r"; // Move to column 0
|
||||
buffer += "\x1b[J"; // Clear from cursor to end of screen
|
||||
|
||||
// Render from first changed line to end
|
||||
// Render from first changed line to end, clearing each line before writing
|
||||
// This avoids the \x1b[J clear-to-end which can cause flicker in xterm.js
|
||||
for (let i = firstChanged; i < newLines.length; i++) {
|
||||
if (i > firstChanged) buffer += "\r\n";
|
||||
buffer += "\x1b[2K"; // Clear current line
|
||||
if (visibleWidth(newLines[i]) > width) {
|
||||
throw new Error(`Rendered line ${i} exceeds terminal width\n\n${newLines[i]}`);
|
||||
}
|
||||
buffer += newLines[i];
|
||||
}
|
||||
|
||||
// If we had more lines before, clear them and move cursor back
|
||||
if (this.previousLines.length > newLines.length) {
|
||||
const extraLines = this.previousLines.length - newLines.length;
|
||||
for (let i = newLines.length; i < this.previousLines.length; i++) {
|
||||
buffer += "\r\n\x1b[2K";
|
||||
}
|
||||
// Move cursor back to end of new content
|
||||
buffer += `\x1b[${extraLines}A`;
|
||||
}
|
||||
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
|
||||
// Write entire buffer at once
|
||||
|
|
|
|||
|
|
@ -1,22 +1,18 @@
|
|||
import { Chalk } from "chalk";
|
||||
import stringWidth from "string-width";
|
||||
|
||||
const colorChalk = new Chalk({ level: 3 });
|
||||
|
||||
/**
|
||||
* Calculate the visible width of a string in terminal columns.
|
||||
* This correctly handles:
|
||||
* - ANSI escape codes (ignored)
|
||||
* - Emojis and wide characters (counted as 2 columns)
|
||||
* - Combining characters (counted correctly)
|
||||
* - Tabs (replaced with 3 spaces for consistent width)
|
||||
*/
|
||||
export function visibleWidth(str: string): number {
|
||||
// Replace tabs with 3 spaces before measuring
|
||||
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] !== "[") {
|
||||
|
|
@ -39,167 +35,33 @@ function extractAnsiCode(str: string, pos: number): { code: string; length: numb
|
|||
}
|
||||
|
||||
/**
|
||||
* Track and manage active ANSI codes for preserving styling across wrapped lines.
|
||||
* Track active ANSI SGR codes to preserve styling across line breaks.
|
||||
*/
|
||||
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
|
||||
// Full reset clears everything
|
||||
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) {
|
||||
|
|
@ -214,7 +76,7 @@ function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
|
|||
}
|
||||
|
||||
/**
|
||||
* Split text into words while keeping ANSI codes attached to their words.
|
||||
* Split text into words while keeping ANSI codes attached.
|
||||
*/
|
||||
function splitIntoWordsWithAnsi(text: string): string[] {
|
||||
const words: string[] = [];
|
||||
|
|
@ -224,7 +86,6 @@ function splitIntoWordsWithAnsi(text: string): string[] {
|
|||
while (i < text.length) {
|
||||
const char = text[i];
|
||||
|
||||
// Check for ANSI code
|
||||
const ansiResult = extractAnsiCode(text, i);
|
||||
if (ansiResult) {
|
||||
currentWord += ansiResult.code;
|
||||
|
|
@ -232,7 +93,6 @@ function splitIntoWordsWithAnsi(text: string): string[] {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Check for space (word boundary)
|
||||
if (char === " ") {
|
||||
if (currentWord) {
|
||||
words.push(currentWord);
|
||||
|
|
@ -242,12 +102,10 @@ function splitIntoWordsWithAnsi(text: string): string[] {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Regular character
|
||||
currentWord += char;
|
||||
i++;
|
||||
}
|
||||
|
||||
// Add final word
|
||||
if (currentWord) {
|
||||
words.push(currentWord);
|
||||
}
|
||||
|
|
@ -256,63 +114,109 @@ function splitIntoWordsWithAnsi(text: string): string[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Break a long word that doesn't fit on a single line, character by character.
|
||||
* Wrap text with ANSI codes preserved.
|
||||
*
|
||||
* ONLY does word wrapping - NO padding, NO background colors.
|
||||
* Returns lines where each line is <= width visible chars.
|
||||
* Active ANSI codes are preserved across line breaks.
|
||||
*
|
||||
* @param text - Text to wrap (may contain ANSI codes and newlines)
|
||||
* @param width - Maximum visible width per line
|
||||
* @returns Array of wrapped lines (NOT padded to width)
|
||||
*/
|
||||
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;
|
||||
export function wrapTextWithAnsi(text: string, width: number): string[] {
|
||||
if (!text) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
// Add final line (don't close it, let the caller handle that)
|
||||
if (currentLine || lines.length === 0) {
|
||||
lines.push(currentLine);
|
||||
// Handle newlines by processing each line separately
|
||||
const inputLines = text.split("\n");
|
||||
const result: string[] = [];
|
||||
|
||||
for (const inputLine of inputLines) {
|
||||
result.push(...wrapSingleLine(inputLine, width));
|
||||
}
|
||||
|
||||
return lines;
|
||||
return result.length > 0 ? result : [""];
|
||||
}
|
||||
|
||||
function wrapSingleLine(line: string, width: number): string[] {
|
||||
if (!line) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
const visibleLength = visibleWidth(line);
|
||||
if (visibleLength <= width) {
|
||||
return [line];
|
||||
}
|
||||
|
||||
const wrapped: string[] = [];
|
||||
const tracker = new AnsiCodeTracker();
|
||||
const words = splitIntoWordsWithAnsi(line);
|
||||
|
||||
let currentLine = "";
|
||||
let currentVisibleLength = 0;
|
||||
|
||||
for (const word of words) {
|
||||
const wordVisibleLength = visibleWidth(word);
|
||||
|
||||
// Check if adding this word would exceed width
|
||||
const spaceNeeded = currentVisibleLength > 0 ? 1 : 0;
|
||||
const totalNeeded = currentVisibleLength + spaceNeeded + wordVisibleLength;
|
||||
|
||||
if (totalNeeded > width && currentVisibleLength > 0) {
|
||||
// Wrap to next line
|
||||
wrapped.push(currentLine);
|
||||
currentLine = tracker.getActiveCodes() + word;
|
||||
currentVisibleLength = wordVisibleLength;
|
||||
} else {
|
||||
// Add to current line
|
||||
if (currentVisibleLength > 0) {
|
||||
currentLine += " " + word;
|
||||
currentVisibleLength += 1 + wordVisibleLength;
|
||||
} else {
|
||||
currentLine += word;
|
||||
currentVisibleLength = wordVisibleLength;
|
||||
}
|
||||
}
|
||||
|
||||
updateTrackerFromText(word, tracker);
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
wrapped.push(currentLine);
|
||||
}
|
||||
|
||||
return wrapped.length > 0 ? wrapped : [""];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply background color to a line, padding to full width.
|
||||
*
|
||||
* Handles the tricky case where content contains \x1b[0m resets that would
|
||||
* kill the background color. We reapply the background after any reset.
|
||||
*
|
||||
* @param line - Line of text (may contain ANSI codes)
|
||||
* @param width - Total width to pad to
|
||||
* @param bgRgb - Background RGB color
|
||||
* @returns Line with background applied and padded to width
|
||||
*/
|
||||
export function applyBackgroundToLine(line: string, width: number, bgRgb: { r: number; g: number; b: number }): string {
|
||||
const bgStart = `\x1b[48;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||
const bgEnd = "\x1b[49m";
|
||||
|
||||
// Calculate padding needed
|
||||
const visibleLen = visibleWidth(line);
|
||||
const paddingNeeded = Math.max(0, width - visibleLen);
|
||||
const padding = " ".repeat(paddingNeeded);
|
||||
|
||||
// Strategy: wrap content + padding in background, then fix any 0m resets
|
||||
const withPadding = line + padding;
|
||||
const withBg = bgStart + withPadding + bgEnd;
|
||||
|
||||
// Find all \x1b[0m or \x1b[49m that would kill background
|
||||
// Replace with reset + background reapplication
|
||||
const fixedBg = withBg.replace(/\x1b\[0m/g, `\x1b[0m${bgStart}`);
|
||||
|
||||
return fixedBg;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue