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,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": {

View file

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

View file

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

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