diff --git a/README.md b/README.md index 1000e0fbd..bac8f5937 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,10 @@ These commands: Complete release process: -1. **Update CHANGELOG.md** (if changes affect coding-agent): +1. **Add changes to CHANGELOG.md** (if changes affect coding-agent): ```bash # Add your changes to the [Unreleased] section in packages/coding-agent/CHANGELOG.md + # Always add new entries under [Unreleased], never under already-released versions ``` 2. **Bump version** (all packages): @@ -71,10 +72,11 @@ Complete release process: npm run version:major # For breaking changes ``` -3. **Update CHANGELOG.md version** (if changes affect coding-agent): +3. **Finalize CHANGELOG.md for release** (if changes affect coding-agent): ```bash - # Move the [Unreleased] section to the new version number with today's date + # Change [Unreleased] to the new version number with today's date # e.g., ## [0.7.16] - 2025-11-17 + # Then add a new empty [Unreleased] section at the top ``` 4. **Commit and tag**: @@ -91,6 +93,12 @@ Complete release process: npm run publish # Publish all packages to npm ``` +6. **Add new [Unreleased] section** (for next development cycle): + ```bash + # Add a new [Unreleased] section at the top of CHANGELOG.md + # Commit: git commit -am "Add [Unreleased] section" + ``` + ## License MIT \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 91007dbb3..8d1775c7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6074,11 +6074,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.11.3", + "version": "0.11.4", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.11.2", - "@mariozechner/pi-tui": "^0.11.2" + "@mariozechner/pi-ai": "^0.11.3", + "@mariozechner/pi-tui": "^0.11.3" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6108,7 +6108,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.11.3", + "version": "0.11.4", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -6149,12 +6149,12 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.11.3", + "version": "0.11.4", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.11.2", - "@mariozechner/pi-ai": "^0.11.2", - "@mariozechner/pi-tui": "^0.11.2", + "@mariozechner/pi-agent-core": "^0.11.3", + "@mariozechner/pi-ai": "^0.11.3", + "@mariozechner/pi-tui": "^0.11.3", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6191,12 +6191,12 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.11.3", + "version": "0.11.4", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.11.2", - "@mariozechner/pi-ai": "^0.11.2", + "@mariozechner/pi-agent-core": "^0.11.3", + "@mariozechner/pi-ai": "^0.11.3", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6234,10 +6234,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.11.3", + "version": "0.11.4", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.11.2", + "@mariozechner/pi-agent-core": "^0.11.3", "chalk": "^5.5.0" }, "bin": { @@ -6250,7 +6250,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.11.3", + "version": "0.11.4", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -6266,7 +6266,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.11.3", + "version": "0.11.4", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -6310,12 +6310,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.11.3", + "version": "0.11.4", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.11.2", - "@mariozechner/pi-tui": "^0.11.2", + "@mariozechner/pi-ai": "^0.11.3", + "@mariozechner/pi-tui": "^0.11.3", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", diff --git a/packages/agent/package.json b/packages/agent/package.json index f2e9a9180..db049c05d 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.11.3", + "version": "0.11.4", "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.11.3", - "@mariozechner/pi-tui": "^0.11.3" + "@mariozechner/pi-ai": "^0.11.4", + "@mariozechner/pi-tui": "^0.11.4" }, "keywords": [ "ai", diff --git a/packages/ai/package.json b/packages/ai/package.json index 3784b657e..05ed974f9 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.11.3", + "version": "0.11.4", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 69729beb3..970afd170 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.11.4] - 2025-12-01 + +### Improved + +- **TUI Crash Diagnostics**: When a render error occurs (line exceeds terminal width), all rendered lines are now written to `~/.pi/agent/pi-crash.log` with their indices and visible widths for easier debugging. + +### Fixed + +- **Session Selector Crash with Wide Characters**: Fixed crash when running `pi -r` to resume sessions containing emojis, CJK characters, or other wide Unicode characters. The session list was using character count instead of visible terminal width for truncation, causing lines to exceed terminal width. Added `truncateToWidth()` utility that properly handles ANSI codes and wide characters. ([#85](https://github.com/badlogic/pi-mono/issues/85)) + ## [0.11.3] - 2025-12-01 ### Added diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 1ba242d19..06f49314b 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.11.3", + "version": "0.11.4", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "bin": { @@ -22,9 +22,9 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.11.3", - "@mariozechner/pi-ai": "^0.11.3", - "@mariozechner/pi-tui": "^0.11.3", + "@mariozechner/pi-agent-core": "^0.11.4", + "@mariozechner/pi-ai": "^0.11.4", + "@mariozechner/pi-tui": "^0.11.4", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/coding-agent/src/tui/session-selector.ts b/packages/coding-agent/src/tui/session-selector.ts index 64c94efd4..b96c114df 100644 --- a/packages/coding-agent/src/tui/session-selector.ts +++ b/packages/coding-agent/src/tui/session-selector.ts @@ -1,4 +1,4 @@ -import { type Component, Container, Input, Spacer, Text } from "@mariozechner/pi-tui"; +import { type Component, Container, Input, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; import type { SessionManager } from "../session-manager.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -107,17 +107,17 @@ class SessionList implements Component { // Normalize first message to single line const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim(); - // First line: cursor + message + // First line: cursor + message (truncate to visible width) const cursor = isSelected ? theme.fg("accent", "› ") : " "; - const maxMsgWidth = width - 2; // Account for cursor - const truncatedMsg = normalizedMessage.substring(0, maxMsgWidth); + const maxMsgWidth = width - 2; // Account for cursor (2 visible chars) + const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, "..."); const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg); - // Second line: metadata (dimmed) + // Second line: metadata (dimmed) - also truncate for safety const modified = formatDate(session.modified); const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`; const metadata = ` ${modified} · ${msgCount}`; - const metadataLine = theme.fg("dim", metadata); + const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, "")); lines.push(messageLine); lines.push(metadataLine); @@ -126,7 +126,8 @@ class SessionList implements Component { // Add scroll indicator if needed if (startIndex > 0 || endIndex < this.filteredSessions.length) { - const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`); + const scrollText = ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`; + const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width, "")); lines.push(scrollInfo); } diff --git a/packages/mom/package.json b/packages/mom/package.json index 831a35855..6e3064811 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.11.3", + "version": "0.11.4", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -21,8 +21,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.11.3", - "@mariozechner/pi-ai": "^0.11.3", + "@mariozechner/pi-agent-core": "^0.11.4", + "@mariozechner/pi-ai": "^0.11.4", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/pods/package.json b/packages/pods/package.json index 70b053172..631893c90 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.11.3", + "version": "0.11.4", "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-core": "^0.11.3", + "@mariozechner/pi-agent-core": "^0.11.4", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 417aabb34..ff8c1cdb0 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.11.3", + "version": "0.11.4", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index 1a2866712..43ab9144c 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.11.3", + "version": "0.11.4", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index c06ceee6a..357a4fa7b 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,4 +1,5 @@ import type { Component } from "../tui.js"; +import { truncateToWidth } from "../utils.js"; export interface SelectItem { value: string; @@ -77,8 +78,8 @@ export class SelectList implements Component { if (item.description && width > 40) { // Calculate how much space we have for value + description - const maxValueLength = Math.min(displayValue.length, 30); - const truncatedValue = displayValue.substring(0, maxValueLength); + const maxValueWidth = Math.min(30, width - prefixWidth - 4); + const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); // Calculate remaining space for description using visible widths @@ -86,18 +87,18 @@ export class SelectList implements Component { const remainingWidth = width - descriptionStart - 2; // -2 for safety if (remainingWidth > 10) { - const truncatedDesc = item.description.substring(0, remainingWidth); + const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); // Apply selectedText to entire line content line = this.theme.selectedText("→ " + truncatedValue + spacing + truncatedDesc); } else { // Not enough space for description const maxWidth = width - prefixWidth - 2; - line = this.theme.selectedText("→ " + displayValue.substring(0, maxWidth)); + line = this.theme.selectedText("→ " + truncateToWidth(displayValue, maxWidth, "")); } } else { // No description or not enough width const maxWidth = width - prefixWidth - 2; - line = this.theme.selectedText("→ " + displayValue.substring(0, maxWidth)); + line = this.theme.selectedText("→ " + truncateToWidth(displayValue, maxWidth, "")); } } else { const displayValue = item.label || item.value; @@ -105,8 +106,8 @@ export class SelectList implements Component { if (item.description && width > 40) { // Calculate how much space we have for value + description - const maxValueLength = Math.min(displayValue.length, 30); - const truncatedValue = displayValue.substring(0, maxValueLength); + const maxValueWidth = Math.min(30, width - prefix.length - 4); + const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); // Calculate remaining space for description @@ -114,18 +115,18 @@ export class SelectList implements Component { const remainingWidth = width - descriptionStart - 2; // -2 for safety if (remainingWidth > 10) { - const truncatedDesc = item.description.substring(0, remainingWidth); + const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); const descText = this.theme.description(spacing + truncatedDesc); line = prefix + truncatedValue + descText; } else { // Not enough space for description const maxWidth = width - prefix.length - 2; - line = prefix + displayValue.substring(0, maxWidth); + line = prefix + truncateToWidth(displayValue, maxWidth, ""); } } else { // No description or not enough width const maxWidth = width - prefix.length - 2; - line = prefix + displayValue.substring(0, maxWidth); + line = prefix + truncateToWidth(displayValue, maxWidth, ""); } } @@ -136,9 +137,7 @@ export class SelectList implements Component { if (startIndex > 0 || endIndex < this.filteredItems.length) { const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`; // Truncate if too long for terminal - const maxWidth = width - 2; - const truncated = scrollText.substring(0, maxWidth); - lines.push(this.theme.scrollInfo(truncated)); + lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, ""))); } return lines; diff --git a/packages/tui/src/components/truncated-text.ts b/packages/tui/src/components/truncated-text.ts index be90b5c5c..12eac558d 100644 --- a/packages/tui/src/components/truncated-text.ts +++ b/packages/tui/src/components/truncated-text.ts @@ -1,5 +1,5 @@ import type { Component } from "../tui.js"; -import { visibleWidth } from "../utils.js"; +import { truncateToWidth, visibleWidth } from "../utils.js"; /** * Text component that truncates to fit viewport width @@ -41,46 +41,7 @@ export class TruncatedText implements Component { } // Truncate text if needed (accounting for ANSI codes) - let displayText = singleLineText; - const textVisibleWidth = visibleWidth(singleLineText); - - if (textVisibleWidth > availableWidth) { - // Need to truncate - walk through the string character by character - let currentWidth = 0; - let truncateAt = 0; - let i = 0; - const ellipsisWidth = 3; - const targetWidth = availableWidth - ellipsisWidth; - - while (i < singleLineText.length && currentWidth < targetWidth) { - // Skip ANSI escape sequences (include them in output but don't count width) - if (singleLineText[i] === "\x1b" && singleLineText[i + 1] === "[") { - let j = i + 2; - while (j < singleLineText.length && !/[a-zA-Z]/.test(singleLineText[j])) { - j++; - } - // Include the final letter of the escape sequence - j++; - truncateAt = j; - i = j; - continue; - } - - const char = singleLineText[i]; - const charWidth = visibleWidth(char); - - if (currentWidth + charWidth > targetWidth) { - break; - } - - currentWidth += charWidth; - truncateAt = i + 1; - i++; - } - - // Add reset code before ellipsis to prevent styling leaking into it - displayText = singleLineText.substring(0, truncateAt) + "\x1b[0m..."; - } + const displayText = truncateToWidth(singleLineText, availableWidth); // Add horizontal padding const leftPadding = " ".repeat(this.paddingX); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 9e1e0927a..f3a41c84e 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -20,4 +20,4 @@ export { TruncatedText } from "./components/truncated-text.js"; export { ProcessTerminal, type Terminal } from "./terminal.js"; export { type Component, Container, TUI } from "./tui.js"; // Utilities -export { visibleWidth } from "./utils.js"; +export { truncateToWidth, visibleWidth } from "./utils.js"; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 567c14690..051c703d0 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -2,6 +2,9 @@ * Minimal TUI implementation with differential rendering */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import type { Terminal } from "./terminal.js"; import { visibleWidth } from "./utils.js"; @@ -220,7 +223,20 @@ export class TUI extends Container { 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]}`); + // Log all lines to crash file for debugging + const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log"); + const crashData = [ + `Crash at ${new Date().toISOString()}`, + `Terminal width: ${width}`, + `Line ${i} visible width: ${visibleWidth(newLines[i])}`, + "", + "=== All rendered lines ===", + ...newLines.map((line, idx) => `[${idx}] (w=${visibleWidth(line)}) ${line}`), + "", + ].join("\n"); + fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); + fs.writeFileSync(crashLogPath, crashData); + throw new Error(`Rendered line ${i} exceeds terminal width. Debug log written to ${crashLogPath}`); } buffer += newLines[i]; } diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index d012eaf90..c73d51a30 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -285,3 +285,60 @@ export function applyBackgroundToLine(line: string, width: number, bgFn: (text: const withPadding = line + padding; return bgFn(withPadding); } + +/** + * Truncate text to fit within a maximum visible width, adding ellipsis if needed. + * Properly handles ANSI escape codes (they don't count toward width). + * + * @param text - Text to truncate (may contain ANSI codes) + * @param maxWidth - Maximum visible width + * @param ellipsis - Ellipsis string to append when truncating (default: "...") + * @returns Truncated text with ellipsis if it exceeded maxWidth + */ +export function truncateToWidth(text: string, maxWidth: number, ellipsis: string = "..."): string { + const textVisibleWidth = visibleWidth(text); + + if (textVisibleWidth <= maxWidth) { + return text; + } + + const ellipsisWidth = visibleWidth(ellipsis); + const targetWidth = maxWidth - ellipsisWidth; + + if (targetWidth <= 0) { + return ellipsis.substring(0, maxWidth); + } + + let currentWidth = 0; + let truncateAt = 0; + let i = 0; + + while (i < text.length && currentWidth < targetWidth) { + // Skip ANSI escape sequences (include them in output but don't count width) + if (text[i] === "\x1b" && text[i + 1] === "[") { + let j = i + 2; + while (j < text.length && !/[a-zA-Z]/.test(text[j]!)) { + j++; + } + // Include the final letter of the escape sequence + j++; + truncateAt = j; + i = j; + continue; + } + + const char = text[i]!; + const charWidth = visibleWidth(char); + + if (currentWidth + charWidth > targetWidth) { + break; + } + + currentWidth += charWidth; + truncateAt = i + 1; + i++; + } + + // Add reset code before ellipsis to prevent styling leaking into it + return text.substring(0, truncateAt) + "\x1b[0m" + ellipsis; +} diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 75fd75cce..51175ed2d 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.11.3", + "version": "0.11.4", "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.11.3", - "@mariozechner/pi-tui": "^0.11.3", + "@mariozechner/pi-ai": "^0.11.4", + "@mariozechner/pi-tui": "^0.11.4", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0",