Release v0.7.21

This commit is contained in:
Mario Zechner 2025-11-19 00:56:16 +01:00
parent 5112fc6ba9
commit 1b28780155
16 changed files with 346 additions and 341 deletions

View file

@ -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;
}