diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ff0512ee..1a5a7f2b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - **Print mode error handling**: `-p` flag now outputs error messages and exits with code 1 when requests fail, instead of silently producing no output. +- **Branch selector crash**: Fixed TUI crash when user messages contained Unicode characters (like `✔` or `›`) that caused line width to exceed terminal width. Now uses proper `truncateToWidth` instead of `substring`. ### Added diff --git a/packages/coding-agent/src/tui/user-message-selector.ts b/packages/coding-agent/src/tui/user-message-selector.ts index 1d9d7bfe..51f39418 100644 --- a/packages/coding-agent/src/tui/user-message-selector.ts +++ b/packages/coding-agent/src/tui/user-message-selector.ts @@ -1,4 +1,4 @@ -import { type Component, Container, Spacer, Text } from "@mariozechner/pi-tui"; +import { type Component, Container, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -54,8 +54,8 @@ class UserMessageList implements Component { // First line: cursor + message 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 chars) + const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth); const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg); lines.push(messageLine); diff --git a/packages/coding-agent/test/truncate-to-width.test.ts b/packages/coding-agent/test/truncate-to-width.test.ts new file mode 100644 index 00000000..4714c1d2 --- /dev/null +++ b/packages/coding-agent/test/truncate-to-width.test.ts @@ -0,0 +1,81 @@ +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { describe, expect, it } from "vitest"; + +/** + * Tests for truncateToWidth behavior with Unicode characters. + * + * These tests verify that truncateToWidth properly handles text with + * Unicode characters that have different byte vs display widths. + */ +describe("truncateToWidth", () => { + it("should truncate messages with Unicode characters correctly", () => { + // This message contains a checkmark (✔) which may have display width > 1 byte + const message = '✔ script to run › dev $ concurrently "vite" "node --import tsx ./'; + const width = 67; + const maxMsgWidth = width - 2; // Account for cursor + + const truncated = truncateToWidth(message, maxMsgWidth); + const truncatedWidth = visibleWidth(truncated); + + expect(truncatedWidth).toBeLessThanOrEqual(maxMsgWidth); + }); + + it("should handle emoji characters", () => { + const message = "🎉 Celebration! 🚀 Launch 📦 Package ready for deployment now"; + const width = 40; + const maxMsgWidth = width - 2; + + const truncated = truncateToWidth(message, maxMsgWidth); + const truncatedWidth = visibleWidth(truncated); + + expect(truncatedWidth).toBeLessThanOrEqual(maxMsgWidth); + }); + + it("should handle mixed ASCII and wide characters", () => { + const message = "Hello 世界 Test 你好 More text here that is long"; + const width = 30; + const maxMsgWidth = width - 2; + + const truncated = truncateToWidth(message, maxMsgWidth); + const truncatedWidth = visibleWidth(truncated); + + expect(truncatedWidth).toBeLessThanOrEqual(maxMsgWidth); + }); + + it("should not truncate messages that fit", () => { + const message = "Short message"; + const width = 50; + const maxMsgWidth = width - 2; + + const truncated = truncateToWidth(message, maxMsgWidth); + + expect(truncated).toBe(message); + expect(visibleWidth(truncated)).toBeLessThanOrEqual(maxMsgWidth); + }); + + it("should add ellipsis when truncating", () => { + const message = "This is a very long message that needs to be truncated"; + const width = 30; + const maxMsgWidth = width - 2; + + const truncated = truncateToWidth(message, maxMsgWidth); + + expect(truncated).toContain("..."); + expect(visibleWidth(truncated)).toBeLessThanOrEqual(maxMsgWidth); + }); + + it("should handle the exact crash case from issue report", () => { + // Terminal width was 67, line had visible width 68 + // The problematic text contained "✔" and "›" characters + const message = '✔ script to run › dev $ concurrently "vite" "node --import tsx ./server.ts"'; + const terminalWidth = 67; + const cursorWidth = 2; // "› " or " " + const maxMsgWidth = terminalWidth - cursorWidth; + + const truncated = truncateToWidth(message, maxMsgWidth); + const finalWidth = visibleWidth(truncated); + + // The final line (cursor + message) must not exceed terminal width + expect(finalWidth + cursorWidth).toBeLessThanOrEqual(terminalWidth); + }); +}); diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 8dbbb540..718ac99a 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -309,36 +309,53 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string return ellipsis.substring(0, maxWidth); } - let currentWidth = 0; - let truncateAt = 0; + // Separate ANSI codes from visible content using grapheme segmentation let i = 0; + const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = []; - 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++; + while (i < text.length) { + const ansiResult = extractAnsiCode(text, i); + if (ansiResult) { + segments.push({ type: "ansi", value: ansiResult.code }); + i += ansiResult.length; + } else { + // Find the next ANSI code or end of string + let end = i; + while (end < text.length) { + const nextAnsi = extractAnsiCode(text, end); + if (nextAnsi) break; + end++; } - // Include the final letter of the escape sequence - j++; - truncateAt = j; - i = j; + // Segment this non-ANSI portion into graphemes + const textPortion = text.slice(i, end); + for (const seg of segmenter.segment(textPortion)) { + segments.push({ type: "grapheme", value: seg.segment }); + } + i = end; + } + } + + // Build truncated string from segments + let result = ""; + let currentWidth = 0; + + for (const seg of segments) { + if (seg.type === "ansi") { + result += seg.value; continue; } - const char = text[i]!; - const charWidth = visibleWidth(char); + const grapheme = seg.value; + const graphemeWidth = visibleWidth(grapheme); - if (currentWidth + charWidth > targetWidth) { + if (currentWidth + graphemeWidth > targetWidth) { break; } - currentWidth += charWidth; - truncateAt = i + 1; - i++; + result += grapheme; + currentWidth += graphemeWidth; } // Add reset code before ellipsis to prevent styling leaking into it - return text.substring(0, truncateAt) + "\x1b[0m" + ellipsis; + return result + "\x1b[0m" + ellipsis; }