mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 01:01:42 +00:00
Release v0.7.21
This commit is contained in:
parent
5112fc6ba9
commit
1b28780155
16 changed files with 346 additions and 341 deletions
|
|
@ -58,7 +58,7 @@ These commands:
|
||||||
|
|
||||||
Complete release process:
|
Complete release process:
|
||||||
|
|
||||||
1. **Update CHANGELOG.md** (for coding-agent releases):
|
1. **Update CHANGELOG.md** (if changes affect coding-agent):
|
||||||
```bash
|
```bash
|
||||||
# Add your changes to the [Unreleased] section in packages/coding-agent/CHANGELOG.md
|
# 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
|
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
|
```bash
|
||||||
# Move the [Unreleased] section to the new version number with today's date
|
# Move the [Unreleased] section to the new version number with today's date
|
||||||
# e.g., ## [0.7.16] - 2025-11-17
|
# e.g., ## [0.7.16] - 2025-11-17
|
||||||
|
|
|
||||||
28
package-lock.json
generated
28
package-lock.json
generated
|
|
@ -3195,11 +3195,11 @@
|
||||||
},
|
},
|
||||||
"packages/agent": {
|
"packages/agent": {
|
||||||
"name": "@mariozechner/pi-agent",
|
"name": "@mariozechner/pi-agent",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-ai": "^0.7.19",
|
"@mariozechner/pi-ai": "^0.7.20",
|
||||||
"@mariozechner/pi-tui": "^0.7.19"
|
"@mariozechner/pi-tui": "^0.7.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.3.0",
|
||||||
|
|
@ -3225,7 +3225,7 @@
|
||||||
},
|
},
|
||||||
"packages/ai": {
|
"packages/ai": {
|
||||||
"name": "@mariozechner/pi-ai",
|
"name": "@mariozechner/pi-ai",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.61.0",
|
"@anthropic-ai/sdk": "^0.61.0",
|
||||||
|
|
@ -3272,11 +3272,11 @@
|
||||||
},
|
},
|
||||||
"packages/coding-agent": {
|
"packages/coding-agent": {
|
||||||
"name": "@mariozechner/pi-coding-agent",
|
"name": "@mariozechner/pi-coding-agent",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-agent": "^0.7.19",
|
"@mariozechner/pi-agent": "^0.7.20",
|
||||||
"@mariozechner/pi-ai": "^0.7.19",
|
"@mariozechner/pi-ai": "^0.7.20",
|
||||||
"chalk": "^5.5.0",
|
"chalk": "^5.5.0",
|
||||||
"diff": "^8.0.2",
|
"diff": "^8.0.2",
|
||||||
"glob": "^11.0.3"
|
"glob": "^11.0.3"
|
||||||
|
|
@ -3319,10 +3319,10 @@
|
||||||
},
|
},
|
||||||
"packages/pods": {
|
"packages/pods": {
|
||||||
"name": "@mariozechner/pi",
|
"name": "@mariozechner/pi",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-agent": "^0.7.19",
|
"@mariozechner/pi-agent": "^0.7.20",
|
||||||
"chalk": "^5.5.0"
|
"chalk": "^5.5.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -3345,7 +3345,7 @@
|
||||||
},
|
},
|
||||||
"packages/proxy": {
|
"packages/proxy": {
|
||||||
"name": "@mariozechner/pi-proxy",
|
"name": "@mariozechner/pi-proxy",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.14.0",
|
"@hono/node-server": "^1.14.0",
|
||||||
"hono": "^4.6.16"
|
"hono": "^4.6.16"
|
||||||
|
|
@ -3361,7 +3361,7 @@
|
||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@mariozechner/pi-tui",
|
"name": "@mariozechner/pi-tui",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.4",
|
||||||
|
|
@ -3400,12 +3400,12 @@
|
||||||
},
|
},
|
||||||
"packages/web-ui": {
|
"packages/web-ui": {
|
||||||
"name": "@mariozechner/pi-web-ui",
|
"name": "@mariozechner/pi-web-ui",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lmstudio/sdk": "^1.5.0",
|
"@lmstudio/sdk": "^1.5.0",
|
||||||
"@mariozechner/pi-ai": "^0.7.19",
|
"@mariozechner/pi-ai": "^0.7.20",
|
||||||
"@mariozechner/pi-tui": "^0.7.19",
|
"@mariozechner/pi-tui": "^0.7.20",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide": "^0.544.0",
|
"lucide": "^0.544.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi-agent",
|
"name": "@mariozechner/pi-agent",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|
@ -18,8 +18,8 @@
|
||||||
"prepublishOnly": "npm run clean && npm run build"
|
"prepublishOnly": "npm run clean && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-ai": "^0.7.20",
|
"@mariozechner/pi-ai": "^0.7.21",
|
||||||
"@mariozechner/pi-tui": "^0.7.20"
|
"@mariozechner/pi-tui": "^0.7.21"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai",
|
"ai",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi-ai",
|
"name": "@mariozechner/pi-ai",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,14 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.7.20] - 2025-11-18
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi-coding-agent",
|
"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",
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -21,8 +21,8 @@
|
||||||
"prepublishOnly": "npm run clean && npm run build"
|
"prepublishOnly": "npm run clean && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-agent": "^0.7.20",
|
"@mariozechner/pi-agent": "^0.7.21",
|
||||||
"@mariozechner/pi-ai": "^0.7.20",
|
"@mariozechner/pi-ai": "^0.7.21",
|
||||||
"chalk": "^5.5.0",
|
"chalk": "^5.5.0",
|
||||||
"diff": "^8.0.2",
|
"diff": "^8.0.2",
|
||||||
"glob": "^11.0.3"
|
"glob": "^11.0.3"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import * as os from "node:os";
|
||||||
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
|
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import * as Diff from "diff";
|
import * as Diff from "diff";
|
||||||
|
import stripAnsi from "strip-ansi";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert absolute path to tilde notation if it's in home directory
|
* 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 textBlocks = this.result.content?.filter((c: any) => c.type === "text") || [];
|
||||||
const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
|
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
|
// Add indicator for images
|
||||||
if (imageBlocks.length > 0) {
|
if (imageBlocks.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi",
|
"name": "@mariozechner/pi",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"description": "CLI tool for managing vLLM deployments on GPU pods",
|
"description": "CLI tool for managing vLLM deployments on GPU pods",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-agent": "^0.7.20",
|
"@mariozechner/pi-agent": "^0.7.21",
|
||||||
"chalk": "^5.5.0"
|
"chalk": "^5.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {}
|
"devDependencies": {}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi-proxy",
|
"name": "@mariozechner/pi-proxy",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "CORS and authentication proxy for pi-ai",
|
"description": "CORS and authentication proxy for pi-ai",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi-tui",
|
"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",
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Chalk } from "chalk";
|
import { Chalk } from "chalk";
|
||||||
import { marked, type Token } from "marked";
|
import { marked, type Token } from "marked";
|
||||||
import type { Component } from "../tui.js";
|
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
|
// Use a chalk instance with color level 3 for consistent ANSI output
|
||||||
const colorChalk = new Chalk({ level: 3 });
|
const colorChalk = new Chalk({ level: 3 });
|
||||||
|
|
@ -86,51 +86,41 @@ export class Markdown implements Component {
|
||||||
renderedLines.push(...tokenLines);
|
renderedLines.push(...tokenLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap lines to fit content width
|
// Wrap lines (NO padding, NO background yet)
|
||||||
const wrappedLines: string[] = [];
|
const wrappedLines: string[] = [];
|
||||||
for (const line of renderedLines) {
|
for (const line of renderedLines) {
|
||||||
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
|
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add padding and apply background color if specified
|
// Add margins and background to each wrapped line
|
||||||
const leftPad = " ".repeat(this.paddingX);
|
const leftMargin = " ".repeat(this.paddingX);
|
||||||
const paddedLines: string[] = [];
|
const rightMargin = " ".repeat(this.paddingX);
|
||||||
|
const bgRgb = this.defaultTextStyle?.bgColor ? this.parseBgColor() : undefined;
|
||||||
|
const contentLines: string[] = [];
|
||||||
|
|
||||||
for (const line of wrappedLines) {
|
for (const line of wrappedLines) {
|
||||||
// Calculate visible length
|
const lineWithMargins = leftMargin + line + rightMargin;
|
||||||
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);
|
|
||||||
|
|
||||||
// Add left padding, content, and right padding
|
if (bgRgb) {
|
||||||
let paddedLine = leftPad + line + rightPad;
|
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgRgb));
|
||||||
|
} else {
|
||||||
// Apply background color to entire line if specified
|
// No background - just pad to width
|
||||||
if (this.defaultTextStyle?.bgColor) {
|
const visibleLen = visibleWidth(lineWithMargins);
|
||||||
paddedLine = this.applyBgColor(paddedLine);
|
const paddingNeeded = Math.max(0, width - visibleLen);
|
||||||
|
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
paddedLines.push(paddedLine);
|
// Add top/bottom padding (empty lines)
|
||||||
}
|
|
||||||
|
|
||||||
// Add top padding (empty lines)
|
|
||||||
const emptyLine = " ".repeat(width);
|
const emptyLine = " ".repeat(width);
|
||||||
const topPadding: string[] = [];
|
const emptyLines: string[] = [];
|
||||||
for (let i = 0; i < this.paddingY; i++) {
|
for (let i = 0; i < this.paddingY; i++) {
|
||||||
const paddedEmptyLine = this.defaultTextStyle?.bgColor ? this.applyBgColor(emptyLine) : emptyLine;
|
const line = bgRgb ? applyBackgroundToLine(emptyLine, width, bgRgb) : emptyLine;
|
||||||
topPadding.push(paddedEmptyLine);
|
emptyLines.push(line);
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine top padding, content, and bottom padding
|
// Combine top padding, content, and bottom padding
|
||||||
const result = [...topPadding, ...paddedLines, ...bottomPadding];
|
const result = [...emptyLines, ...contentLines, ...emptyLines];
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
this.cachedText = this.text;
|
this.cachedText = this.text;
|
||||||
|
|
@ -141,29 +131,43 @@ export class Markdown implements Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply only background color from default style.
|
* Parse background color from defaultTextStyle to RGB values
|
||||||
* Used for padding lines that don't have text content.
|
|
||||||
*/
|
*/
|
||||||
private applyBgColor(text: string): string {
|
private parseBgColor(): { r: number; g: number; b: number } | undefined {
|
||||||
if (!this.defaultTextStyle?.bgColor) {
|
if (!this.defaultTextStyle?.bgColor) {
|
||||||
return text;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.defaultTextStyle.bgColor.startsWith("#")) {
|
if (this.defaultTextStyle.bgColor.startsWith("#")) {
|
||||||
// Hex color
|
// Hex color
|
||||||
const hex = this.defaultTextStyle.bgColor.substring(1);
|
const hex = this.defaultTextStyle.bgColor.substring(1);
|
||||||
const r = Number.parseInt(hex.substring(0, 2), 16);
|
return {
|
||||||
const g = Number.parseInt(hex.substring(2, 4), 16);
|
r: Number.parseInt(hex.substring(0, 2), 16),
|
||||||
const b = Number.parseInt(hex.substring(4, 6), 16);
|
g: Number.parseInt(hex.substring(2, 4), 16),
|
||||||
return colorChalk.bgRgb(r, g, b)(text);
|
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.
|
* Apply default text style to a string.
|
||||||
* This is the base styling applied to all text content.
|
* 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 {
|
private applyDefaultStyle(text: string): string {
|
||||||
if (!this.defaultTextStyle) {
|
if (!this.defaultTextStyle) {
|
||||||
|
|
@ -172,7 +176,7 @@ export class Markdown implements Component {
|
||||||
|
|
||||||
let styled = text;
|
let styled = text;
|
||||||
|
|
||||||
// Apply color
|
// Apply foreground color (NOT background - that's applied at padding stage)
|
||||||
if (this.defaultTextStyle.color) {
|
if (this.defaultTextStyle.color) {
|
||||||
if (this.defaultTextStyle.color.startsWith("#")) {
|
if (this.defaultTextStyle.color.startsWith("#")) {
|
||||||
// Hex color
|
// 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
|
// Apply text decorations
|
||||||
if (this.defaultTextStyle.bold) {
|
if (this.defaultTextStyle.bold) {
|
||||||
styled = colorChalk.bold(styled);
|
styled = colorChalk.bold(styled);
|
||||||
|
|
@ -338,12 +327,8 @@ export class Markdown implements Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
case "codespan":
|
case "codespan":
|
||||||
// Apply code styling, then reapply default style after
|
// Apply code styling without backticks
|
||||||
result +=
|
result += colorChalk.cyan(token.text) + this.applyDefaultStyle("");
|
||||||
colorChalk.gray("`") +
|
|
||||||
colorChalk.cyan(token.text) +
|
|
||||||
colorChalk.gray("`") +
|
|
||||||
this.applyDefaultStyle("");
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "link": {
|
case "link": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import chalk from "chalk";
|
import { Chalk } from "chalk";
|
||||||
import type { Component } from "../tui.js";
|
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
|
* Text component - displays multi-line text with word wrapping
|
||||||
|
|
@ -30,7 +32,6 @@ export class Text implements Component {
|
||||||
|
|
||||||
setText(text: string): void {
|
setText(text: string): void {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
// Invalidate cache when text changes
|
|
||||||
this.cachedText = undefined;
|
this.cachedText = undefined;
|
||||||
this.cachedWidth = undefined;
|
this.cachedWidth = undefined;
|
||||||
this.cachedLines = undefined;
|
this.cachedLines = undefined;
|
||||||
|
|
@ -38,7 +39,6 @@ export class Text implements Component {
|
||||||
|
|
||||||
setCustomBgRgb(customBgRgb?: { r: number; g: number; b: number }): void {
|
setCustomBgRgb(customBgRgb?: { r: number; g: number; b: number }): void {
|
||||||
this.customBgRgb = customBgRgb;
|
this.customBgRgb = customBgRgb;
|
||||||
// Invalidate cache when color changes
|
|
||||||
this.cachedText = undefined;
|
this.cachedText = undefined;
|
||||||
this.cachedWidth = undefined;
|
this.cachedWidth = undefined;
|
||||||
this.cachedLines = undefined;
|
this.cachedLines = undefined;
|
||||||
|
|
@ -50,68 +50,53 @@ export class Text implements Component {
|
||||||
return this.cachedLines;
|
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
|
// Don't render anything if there's no actual text
|
||||||
if (!this.text || this.text.trim() === "") {
|
if (!this.text || this.text.trim() === "") {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
// Update cache
|
|
||||||
this.cachedText = this.text;
|
this.cachedText = this.text;
|
||||||
this.cachedWidth = width;
|
this.cachedWidth = width;
|
||||||
this.cachedLines = result;
|
this.cachedLines = result;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace tabs with 3 spaces for consistent rendering
|
// Replace tabs with 3 spaces
|
||||||
const normalizedText = this.text.replace(/\t/g, " ");
|
const normalizedText = this.text.replace(/\t/g, " ");
|
||||||
|
|
||||||
// Use shared ANSI-aware word wrapping
|
// Calculate content width (subtract left/right margins)
|
||||||
const lines = wrapTextWithAnsi(normalizedText, contentWidth);
|
const contentWidth = Math.max(1, width - this.paddingX * 2);
|
||||||
|
|
||||||
// Add padding to each line
|
// Wrap text (this preserves ANSI codes but does NOT pad)
|
||||||
const leftPad = " ".repeat(this.paddingX);
|
const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
|
||||||
const paddedLines: string[] = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
// Add margins and background to each line
|
||||||
// Calculate visible length (strip ANSI codes)
|
const leftMargin = " ".repeat(this.paddingX);
|
||||||
const visibleLength = visibleWidth(line);
|
const rightMargin = " ".repeat(this.paddingX);
|
||||||
// Right padding to fill to width (accounting for left padding and content)
|
const contentLines: string[] = [];
|
||||||
const rightPadLength = Math.max(0, width - this.paddingX - visibleLength);
|
|
||||||
const rightPad = " ".repeat(rightPadLength);
|
|
||||||
let paddedLine = leftPad + line + rightPad;
|
|
||||||
|
|
||||||
// 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) {
|
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/bottom padding (empty lines)
|
||||||
}
|
|
||||||
|
|
||||||
// Add top padding (empty lines)
|
|
||||||
const emptyLine = " ".repeat(width);
|
const emptyLine = " ".repeat(width);
|
||||||
const topPadding: string[] = [];
|
const emptyLines: string[] = [];
|
||||||
for (let i = 0; i < this.paddingY; i++) {
|
for (let i = 0; i < this.paddingY; i++) {
|
||||||
let emptyPaddedLine = emptyLine;
|
const line = this.customBgRgb ? applyBackgroundToLine(emptyLine, width, this.customBgRgb) : emptyLine;
|
||||||
if (this.customBgRgb) {
|
emptyLines.push(line);
|
||||||
emptyPaddedLine = chalk.bgRgb(this.customBgRgb.r, this.customBgRgb.g, this.customBgRgb.b)(emptyPaddedLine);
|
|
||||||
}
|
|
||||||
topPadding.push(emptyPaddedLine);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add bottom padding (empty lines)
|
const result = [...emptyLines, ...contentLines, ...emptyLines];
|
||||||
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];
|
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
this.cachedText = this.text;
|
this.cachedText = this.text;
|
||||||
|
|
|
||||||
|
|
@ -204,17 +204,28 @@ export class TUI extends Container {
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer += "\r"; // Move to column 0
|
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++) {
|
for (let i = firstChanged; i < newLines.length; i++) {
|
||||||
if (i > firstChanged) buffer += "\r\n";
|
if (i > firstChanged) buffer += "\r\n";
|
||||||
|
buffer += "\x1b[2K"; // Clear current line
|
||||||
if (visibleWidth(newLines[i]) > width) {
|
if (visibleWidth(newLines[i]) > width) {
|
||||||
throw new Error(`Rendered line ${i} exceeds terminal width\n\n${newLines[i]}`);
|
throw new Error(`Rendered line ${i} exceeds terminal width\n\n${newLines[i]}`);
|
||||||
}
|
}
|
||||||
buffer += 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
|
buffer += "\x1b[?2026l"; // End synchronized output
|
||||||
|
|
||||||
// Write entire buffer at once
|
// Write entire buffer at once
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,18 @@
|
||||||
|
import { Chalk } from "chalk";
|
||||||
import stringWidth from "string-width";
|
import stringWidth from "string-width";
|
||||||
|
|
||||||
|
const colorChalk = new Chalk({ level: 3 });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the visible width of a string in terminal columns.
|
* 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 {
|
export function visibleWidth(str: string): number {
|
||||||
// Replace tabs with 3 spaces before measuring
|
|
||||||
const normalized = str.replace(/\t/g, " ");
|
const normalized = str.replace(/\t/g, " ");
|
||||||
return stringWidth(normalized);
|
return stringWidth(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract ANSI escape sequences from a string at the given position.
|
* 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 {
|
function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
|
||||||
if (pos >= str.length || str[pos] !== "\x1b" || str[pos + 1] !== "[") {
|
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 {
|
class AnsiCodeTracker {
|
||||||
private activeAnsiCodes: string[] = [];
|
private activeAnsiCodes: string[] = [];
|
||||||
|
|
||||||
/**
|
|
||||||
* Process an ANSI code and update the active codes.
|
|
||||||
*/
|
|
||||||
process(ansiCode: string): void {
|
process(ansiCode: string): void {
|
||||||
// Check if it's a styling code (ends with 'm')
|
|
||||||
if (!ansiCode.endsWith("m")) {
|
if (!ansiCode.endsWith("m")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset code clears all active codes
|
// Full reset clears everything
|
||||||
if (ansiCode === "\x1b[0m" || ansiCode === "\x1b[m") {
|
if (ansiCode === "\x1b[0m" || ansiCode === "\x1b[m") {
|
||||||
this.activeAnsiCodes.length = 0;
|
this.activeAnsiCodes.length = 0;
|
||||||
} else {
|
} else {
|
||||||
// Add to active codes
|
|
||||||
this.activeAnsiCodes.push(ansiCode);
|
this.activeAnsiCodes.push(ansiCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all active ANSI codes as a single string.
|
|
||||||
*/
|
|
||||||
getActiveCodes(): string {
|
getActiveCodes(): string {
|
||||||
return this.activeAnsiCodes.join("");
|
return this.activeAnsiCodes.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if there are any active codes.
|
|
||||||
*/
|
|
||||||
hasActiveCodes(): boolean {
|
hasActiveCodes(): boolean {
|
||||||
return this.activeAnsiCodes.length > 0;
|
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 {
|
function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < text.length) {
|
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[] {
|
function splitIntoWordsWithAnsi(text: string): string[] {
|
||||||
const words: string[] = [];
|
const words: string[] = [];
|
||||||
|
|
@ -224,7 +86,6 @@ function splitIntoWordsWithAnsi(text: string): string[] {
|
||||||
while (i < text.length) {
|
while (i < text.length) {
|
||||||
const char = text[i];
|
const char = text[i];
|
||||||
|
|
||||||
// Check for ANSI code
|
|
||||||
const ansiResult = extractAnsiCode(text, i);
|
const ansiResult = extractAnsiCode(text, i);
|
||||||
if (ansiResult) {
|
if (ansiResult) {
|
||||||
currentWord += ansiResult.code;
|
currentWord += ansiResult.code;
|
||||||
|
|
@ -232,7 +93,6 @@ function splitIntoWordsWithAnsi(text: string): string[] {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for space (word boundary)
|
|
||||||
if (char === " ") {
|
if (char === " ") {
|
||||||
if (currentWord) {
|
if (currentWord) {
|
||||||
words.push(currentWord);
|
words.push(currentWord);
|
||||||
|
|
@ -242,12 +102,10 @@ function splitIntoWordsWithAnsi(text: string): string[] {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular character
|
|
||||||
currentWord += char;
|
currentWord += char;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add final word
|
|
||||||
if (currentWord) {
|
if (currentWord) {
|
||||||
words.push(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[] {
|
export function wrapTextWithAnsi(text: string, width: number): string[] {
|
||||||
const lines: string[] = [];
|
if (!text) {
|
||||||
let currentLine = tracker.getActiveCodes();
|
return [""];
|
||||||
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)
|
// Handle newlines by processing each line separately
|
||||||
const codePoint = word.charCodeAt(i);
|
const inputLines = text.split("\n");
|
||||||
let char: string;
|
const result: string[] = [];
|
||||||
let charByteLength: number;
|
|
||||||
|
|
||||||
if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < word.length) {
|
for (const inputLine of inputLines) {
|
||||||
// High surrogate - get the pair
|
result.push(...wrapSingleLine(inputLine, width));
|
||||||
char = word.substring(i, i + 2);
|
|
||||||
charByteLength = 2;
|
|
||||||
} else {
|
|
||||||
// Regular character
|
|
||||||
char = word[i];
|
|
||||||
charByteLength = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const charWidth = visibleWidth(char);
|
return result.length > 0 ? result : [""];
|
||||||
|
}
|
||||||
// Check if adding this character would exceed width
|
|
||||||
if (currentVisibleLength + charWidth > width) {
|
function wrapSingleLine(line: string, width: number): string[] {
|
||||||
// Need to wrap
|
if (!line) {
|
||||||
if (tracker.hasActiveCodes()) {
|
return [""];
|
||||||
lines.push(currentLine + tracker.getResetCode());
|
}
|
||||||
currentLine = tracker.getActiveCodes();
|
|
||||||
} else {
|
const visibleLength = visibleWidth(line);
|
||||||
lines.push(currentLine);
|
if (visibleLength <= width) {
|
||||||
currentLine = "";
|
return [line];
|
||||||
}
|
}
|
||||||
currentVisibleLength = 0;
|
|
||||||
}
|
const wrapped: string[] = [];
|
||||||
|
const tracker = new AnsiCodeTracker();
|
||||||
currentLine += char;
|
const words = splitIntoWordsWithAnsi(line);
|
||||||
currentVisibleLength += charWidth;
|
|
||||||
i += charByteLength;
|
let currentLine = "";
|
||||||
}
|
let currentVisibleLength = 0;
|
||||||
|
|
||||||
// Add final line (don't close it, let the caller handle that)
|
for (const word of words) {
|
||||||
if (currentLine || lines.length === 0) {
|
const wordVisibleLength = visibleWidth(word);
|
||||||
lines.push(currentLine);
|
|
||||||
}
|
// Check if adding this word would exceed width
|
||||||
|
const spaceNeeded = currentVisibleLength > 0 ? 1 : 0;
|
||||||
return lines;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
packages/tui/test/wrap-ansi.test.ts
Normal file
110
packages/tui/test/wrap-ansi.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@mariozechner/pi-web-ui",
|
"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",
|
"description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|
@ -18,8 +18,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lmstudio/sdk": "^1.5.0",
|
"@lmstudio/sdk": "^1.5.0",
|
||||||
"@mariozechner/pi-ai": "^0.7.20",
|
"@mariozechner/pi-ai": "^0.7.21",
|
||||||
"@mariozechner/pi-tui": "^0.7.20",
|
"@mariozechner/pi-tui": "^0.7.21",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide": "^0.544.0",
|
"lucide": "^0.544.0",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue