diff --git a/README.md b/README.md index 1000e0fb..bac8f593 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 91007dbb..8d1775c7 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 f2e9a918..db049c05 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 3784b657..05ed974f 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 69729beb..970afd17 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 1ba242d1..06f49314 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 64c94efd..b96c114d 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 831a3585..6e306481 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 70b05317..631893c9 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 417aabb3..ff8c1cdb 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 1a286671..43ab9144 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 c06ceee6..357a4fa7 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 be90b5c5..12eac558 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 9e1e0927..f3a41c84 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 567c1469..051c703d 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 d012eaf9..c73d51a3 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 75fd75cc..51175ed2 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",