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

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

28
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": {}

View file

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

View file

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

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

View file

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

View file

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