mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 05:02:07 +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:
|
||||
|
||||
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
28
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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",
|
||||
"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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue