From 1b287801550fb7a4e4817854e856689b121a2edb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 19 Nov 2025 00:56:16 +0100 Subject: [PATCH] Release v0.7.21 --- README.md | 4 +- package-lock.json | 28 +- packages/agent/package.json | 6 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 8 + packages/coding-agent/package.json | 6 +- .../coding-agent/src/tui/tool-execution.ts | 4 +- packages/pods/package.json | 4 +- packages/proxy/package.json | 2 +- packages/tui/package.json | 2 +- packages/tui/src/components/markdown.ts | 109 +++---- packages/tui/src/components/text.ts | 73 ++--- packages/tui/src/tui.ts | 15 +- packages/tui/src/utils.ts | 308 ++++++------------ packages/tui/test/wrap-ansi.test.ts | 110 +++++++ packages/web-ui/package.json | 6 +- 16 files changed, 346 insertions(+), 341 deletions(-) create mode 100644 packages/tui/test/wrap-ansi.test.ts diff --git a/README.md b/README.md index 0acfc265..318ad518 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ These commands: Complete release process: -1. **Update CHANGELOG.md** (for coding-agent releases): +1. **Update CHANGELOG.md** (if changes affect coding-agent): ```bash # Add your changes to the [Unreleased] section in packages/coding-agent/CHANGELOG.md ``` @@ -70,7 +70,7 @@ Complete release process: npm run version:major # For breaking changes ``` -3. **Update CHANGELOG.md version** (for coding-agent): +3. **Update CHANGELOG.md version** (if changes affect coding-agent): ```bash # Move the [Unreleased] section to the new version number with today's date # e.g., ## [0.7.16] - 2025-11-17 diff --git a/package-lock.json b/package-lock.json index 0681a677..dccfb315 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3195,11 +3195,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent", - "version": "0.7.20", + "version": "0.7.21", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.7.19", - "@mariozechner/pi-tui": "^0.7.19" + "@mariozechner/pi-ai": "^0.7.20", + "@mariozechner/pi-tui": "^0.7.20" }, "devDependencies": { "@types/node": "^24.3.0", @@ -3225,7 +3225,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.7.20", + "version": "0.7.21", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -3272,11 +3272,11 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.7.20", + "version": "0.7.21", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent": "^0.7.19", - "@mariozechner/pi-ai": "^0.7.19", + "@mariozechner/pi-agent": "^0.7.20", + "@mariozechner/pi-ai": "^0.7.20", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -3319,10 +3319,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.7.20", + "version": "0.7.21", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent": "^0.7.19", + "@mariozechner/pi-agent": "^0.7.20", "chalk": "^5.5.0" }, "bin": { @@ -3345,7 +3345,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.7.20", + "version": "0.7.21", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -3361,7 +3361,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.7.20", + "version": "0.7.21", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -3400,12 +3400,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.7.20", + "version": "0.7.21", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.7.19", - "@mariozechner/pi-tui": "^0.7.19", + "@mariozechner/pi-ai": "^0.7.20", + "@mariozechner/pi-tui": "^0.7.20", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", diff --git a/packages/agent/package.json b/packages/agent/package.json index 06491355..3221444b 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent", - "version": "0.7.20", + "version": "0.7.21", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.7.20", - "@mariozechner/pi-tui": "^0.7.20" + "@mariozechner/pi-ai": "^0.7.21", + "@mariozechner/pi-tui": "^0.7.21" }, "keywords": [ "ai", diff --git a/packages/ai/package.json b/packages/ai/package.json index 737782ff..8e677d0b 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.7.20", + "version": "0.7.21", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 616a6ed3..d6d680c7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [0.7.21] - 2025-11-19 + +### Fixed + +- **Terminal Flicker**: Fixed flicker at bottom of viewport (especially editor component) in xterm.js-based terminals (VS Code, etc.) by using per-line clear instead of clear-to-end sequence. +- **Background Color Rendering**: Fixed black cells appearing at end of wrapped lines when using background colors. Completely rewrote text wrapping and background application to properly handle ANSI reset codes. +- **Tool Output**: Strip ANSI codes from bash/tool output before rendering to prevent conflicts with TUI styling. + ## [0.7.20] - 2025-11-18 ### Fixed diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 9986a1ea..01e6e8af 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.7.20", + "version": "0.7.21", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "bin": { @@ -21,8 +21,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent": "^0.7.20", - "@mariozechner/pi-ai": "^0.7.20", + "@mariozechner/pi-agent": "^0.7.21", + "@mariozechner/pi-ai": "^0.7.21", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/coding-agent/src/tui/tool-execution.ts b/packages/coding-agent/src/tui/tool-execution.ts index 095b8ad9..c2248b7b 100644 --- a/packages/coding-agent/src/tui/tool-execution.ts +++ b/packages/coding-agent/src/tui/tool-execution.ts @@ -2,6 +2,7 @@ import * as os from "node:os"; import { Container, Spacer, Text } from "@mariozechner/pi-tui"; import chalk from "chalk"; import * as Diff from "diff"; +import stripAnsi from "strip-ansi"; /** * Convert absolute path to tilde notation if it's in home directory @@ -175,7 +176,8 @@ export class ToolExecutionComponent extends Container { const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || []; const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || []; - let output = textBlocks.map((c: any) => c.text).join("\n"); + // Strip ANSI codes from raw output (bash may emit colors/formatting) + let output = textBlocks.map((c: any) => stripAnsi(c.text || "")).join("\n"); // Add indicator for images if (imageBlocks.length > 0) { diff --git a/packages/pods/package.json b/packages/pods/package.json index a2c64bf7..ddd2630f 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.7.20", + "version": "0.7.21", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent": "^0.7.20", + "@mariozechner/pi-agent": "^0.7.21", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index d5667020..db0fbc50 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.7.20", + "version": "0.7.21", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index 9dd3b3eb..903aca56 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.7.20", + "version": "0.7.21", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 07a3643f..018e7643 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -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 = { + 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": { diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index bd10f064..23513eb6 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -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; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 1f6d13ad..f49c3626 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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 diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 62089719..8769d8f8 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -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; } diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts new file mode 100644 index 00000000..a704ad57 --- /dev/null +++ b/packages/tui/test/wrap-ansi.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { Chalk } from "chalk"; + +// We'll implement these +import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../src/utils.js"; + +const chalk = new Chalk({ level: 3 }); + +describe("wrapTextWithAnsi", () => { + it("wraps plain text at word boundaries", () => { + const text = "hello world this is a test"; + const lines = wrapTextWithAnsi(text, 15); + + assert.strictEqual(lines.length, 2); + assert.strictEqual(lines[0], "hello world"); + assert.strictEqual(lines[1], "this is a test"); + }); + + it("preserves ANSI codes across wrapped lines", () => { + const text = chalk.bold("hello world this is bold text"); + const lines = wrapTextWithAnsi(text, 20); + + // Should have bold code at start of each line + assert.ok(lines[0].includes("\x1b[1m")); + assert.ok(lines[1].includes("\x1b[1m")); + + // Each line should be <= 20 visible chars + assert.ok(visibleWidth(lines[0]) <= 20); + assert.ok(visibleWidth(lines[1]) <= 20); + }); + + it("handles text with resets", () => { + const text = chalk.bold("bold ") + "normal " + chalk.cyan("cyan"); + const lines = wrapTextWithAnsi(text, 30); + + assert.strictEqual(lines.length, 1); + // Should contain the reset code from chalk + assert.ok(lines[0].includes("\x1b[")); + }); + + it("does NOT pad lines", () => { + const text = "hello"; + const lines = wrapTextWithAnsi(text, 20); + + assert.strictEqual(lines.length, 1); + assert.strictEqual(visibleWidth(lines[0]), 5); // NOT 20 + }); + + it("handles empty text", () => { + const lines = wrapTextWithAnsi("", 20); + assert.strictEqual(lines.length, 1); + assert.strictEqual(lines[0], ""); + }); + + it("handles newlines", () => { + const text = "line1\nline2\nline3"; + const lines = wrapTextWithAnsi(text, 20); + + assert.strictEqual(lines.length, 3); + assert.strictEqual(lines[0], "line1"); + assert.strictEqual(lines[1], "line2"); + assert.strictEqual(lines[2], "line3"); + }); +}); + +describe("applyBackgroundToLine", () => { + it("applies background to plain text and pads to width", () => { + const line = "hello"; + const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 }); + + // Should be exactly 20 visible chars + const stripped = result.replace(/\x1b\[[0-9;]*m/g, ""); + assert.strictEqual(stripped.length, 20); + + // Should have background codes + assert.ok(result.includes("\x1b[48;2;0;255;0m")); + assert.ok(result.includes("\x1b[49m")); + }); + + it("handles text with ANSI codes and resets", () => { + const line = chalk.bold("hello") + " world"; + const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 }); + + // Should be exactly 20 visible chars + const stripped = result.replace(/\x1b\[[0-9;]*m/g, ""); + assert.strictEqual(stripped.length, 20); + + // Should still have bold + assert.ok(result.includes("\x1b[1m")); + + // Should have background throughout (even after resets) + assert.ok(result.includes("\x1b[48;2;0;255;0m")); + }); + + it("handles text with 0m resets by reapplying background", () => { + // Simulate: bold text + reset + normal text + const line = "\x1b[1mhello\x1b[0m world"; + const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 }); + + // Should NOT have black cells (spaces without background) + // Pattern we DON'T want: 49m or 0m followed by spaces before bg reapplied + const blackCellPattern = /(\x1b\[49m|\x1b\[0m)\s+\x1b\[48;2/; + assert.ok(!blackCellPattern.test(result), `Found black cells in: ${JSON.stringify(result)}`); + + // Should be exactly 20 chars + const stripped = result.replace(/\x1b\[[0-9;]*m/g, ""); + assert.strictEqual(stripped.length, 20); + }); +}); diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index c1157e22..04871a09 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.7.20", + "version": "0.7.21", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.7.20", - "@mariozechner/pi-tui": "^0.7.20", + "@mariozechner/pi-ai": "^0.7.21", + "@mariozechner/pi-tui": "^0.7.21", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0",