diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ccb7eb39..653f8b47 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **Editor crash with emojis/CJK characters**: Fixed crash when pasting or typing text containing wide characters (emojis like ✅, CJK characters) that caused line width to exceed terminal width. The editor now uses grapheme-aware text wrapping with proper visible width calculation. + ## [0.12.14] - 2025-12-06 ### Added diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 0bf06d41..9f5ee2aa 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,7 +1,11 @@ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; import type { Component } from "../tui.js"; +import { visibleWidth } from "../utils.js"; import { SelectList, type SelectListTheme } from "./select-list.js"; +// Grapheme segmenter for proper Unicode iteration (handles emojis, etc.) +const segmenter = new Intl.Segmenter(); + interface EditorState { lines: string[]; cursorLine: number; @@ -146,7 +150,7 @@ export class Editor implements Component { // Render each layout line for (const layoutLine of layoutLines) { let displayText = layoutLine.text; - let visibleLength = layoutLine.text.length; + let lineVisibleWidth = visibleWidth(layoutLine.text); // Add cursor if this line has it if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) { @@ -154,34 +158,43 @@ export class Editor implements Component { const after = displayText.slice(layoutLine.cursorPos); if (after.length > 0) { - // Cursor is on a character - replace it with highlighted version - const cursor = `\x1b[7m${after[0]}\x1b[0m`; - const restAfter = after.slice(1); + // Cursor is on a character (grapheme) - replace it with highlighted version + // Get the first grapheme from 'after' + const afterGraphemes = [...segmenter.segment(after)]; + const firstGrapheme = afterGraphemes[0]?.segment || ""; + const restAfter = after.slice(firstGrapheme.length); + const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`; displayText = before + cursor + restAfter; - // visibleLength stays the same - we're replacing, not adding + // lineVisibleWidth stays the same - we're replacing, not adding } else { // Cursor is at the end - check if we have room for the space - if (layoutLine.text.length < width) { + if (lineVisibleWidth < width) { // We have room - add highlighted space const cursor = "\x1b[7m \x1b[0m"; displayText = before + cursor; - // visibleLength increases by 1 - we're adding a space - visibleLength = layoutLine.text.length + 1; + // lineVisibleWidth increases by 1 - we're adding a space + lineVisibleWidth = lineVisibleWidth + 1; } else { - // Line is at full width - use reverse video on last character if possible + // Line is at full width - use reverse video on last grapheme if possible // or just show cursor at the end without adding space - if (before.length > 0) { - const lastChar = before[before.length - 1]; - const cursor = `\x1b[7m${lastChar}\x1b[0m`; - displayText = before.slice(0, -1) + cursor; + const beforeGraphemes = [...segmenter.segment(before)]; + if (beforeGraphemes.length > 0) { + const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || ""; + const cursor = `\x1b[7m${lastGrapheme}\x1b[0m`; + // Rebuild 'before' without the last grapheme + const beforeWithoutLast = beforeGraphemes + .slice(0, -1) + .map((g) => g.segment) + .join(""); + displayText = beforeWithoutLast + cursor; } - // visibleLength stays the same + // lineVisibleWidth stays the same } } } - // Calculate padding based on actual visible length - const padding = " ".repeat(Math.max(0, width - visibleLength)); + // Calculate padding based on actual visible width + const padding = " ".repeat(Math.max(0, width - lineVisibleWidth)); // Render the line (no side borders, just horizontal lines above and below) result.push(displayText + padding); @@ -493,9 +506,9 @@ export class Editor implements Component { for (let i = 0; i < this.state.lines.length; i++) { const line = this.state.lines[i] || ""; const isCurrentLine = i === this.state.cursorLine; - const maxLineLength = contentWidth; + const lineVisibleWidth = visibleWidth(line); - if (line.length <= maxLineLength) { + if (lineVisibleWidth <= contentWidth) { // Line fits in one layout line if (isCurrentLine) { layoutLines.push({ @@ -510,35 +523,64 @@ export class Editor implements Component { }); } } else { - // Line needs wrapping - const chunks = []; - for (let pos = 0; pos < line.length; pos += maxLineLength) { - chunks.push(line.slice(pos, pos + maxLineLength)); + // Line needs wrapping - use grapheme-aware chunking + const chunks: { text: string; startIndex: number; endIndex: number }[] = []; + let currentChunk = ""; + let currentWidth = 0; + let chunkStartIndex = 0; + let currentIndex = 0; + + for (const seg of segmenter.segment(line)) { + const grapheme = seg.segment; + const graphemeWidth = visibleWidth(grapheme); + + if (currentWidth + graphemeWidth > contentWidth && currentChunk !== "") { + // Start a new chunk + chunks.push({ + text: currentChunk, + startIndex: chunkStartIndex, + endIndex: currentIndex, + }); + currentChunk = grapheme; + currentWidth = graphemeWidth; + chunkStartIndex = currentIndex; + } else { + currentChunk += grapheme; + currentWidth += graphemeWidth; + } + currentIndex += grapheme.length; + } + + // Push the last chunk + if (currentChunk !== "") { + chunks.push({ + text: currentChunk, + startIndex: chunkStartIndex, + endIndex: currentIndex, + }); } for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; if (!chunk) continue; - const chunkStart = chunkIndex * maxLineLength; - const chunkEnd = chunkStart + chunk.length; const cursorPos = this.state.cursorCol; const isLastChunk = chunkIndex === chunks.length - 1; - // For non-last chunks, cursor at chunkEnd belongs to the next chunk + // For non-last chunks, cursor at endIndex belongs to the next chunk const hasCursorInChunk = isCurrentLine && - cursorPos >= chunkStart && - (isLastChunk ? cursorPos <= chunkEnd : cursorPos < chunkEnd); + cursorPos >= chunk.startIndex && + (isLastChunk ? cursorPos <= chunk.endIndex : cursorPos < chunk.endIndex); if (hasCursorInChunk) { layoutLines.push({ - text: chunk, + text: chunk.text, hasCursor: true, - cursorPos: cursorPos - chunkStart, + cursorPos: cursorPos - chunk.startIndex, }); } else { layoutLines.push({ - text: chunk, + text: chunk.text, hasCursor: false, }); } @@ -917,16 +959,44 @@ export class Editor implements Component { for (let i = 0; i < this.state.lines.length; i++) { const line = this.state.lines[i] || ""; + const lineVisWidth = visibleWidth(line); if (line.length === 0) { // Empty line still takes one visual line visualLines.push({ logicalLine: i, startCol: 0, length: 0 }); - } else if (line.length <= width) { + } else if (lineVisWidth <= width) { visualLines.push({ logicalLine: i, startCol: 0, length: line.length }); } else { - // Line needs wrapping - for (let pos = 0; pos < line.length; pos += width) { - const segmentLength = Math.min(width, line.length - pos); - visualLines.push({ logicalLine: i, startCol: pos, length: segmentLength }); + // Line needs wrapping - use grapheme-aware chunking + let currentWidth = 0; + let chunkStartIndex = 0; + let currentIndex = 0; + + for (const seg of segmenter.segment(line)) { + const grapheme = seg.segment; + const graphemeWidth = visibleWidth(grapheme); + + if (currentWidth + graphemeWidth > width && currentIndex > chunkStartIndex) { + // Start a new chunk + visualLines.push({ + logicalLine: i, + startCol: chunkStartIndex, + length: currentIndex - chunkStartIndex, + }); + chunkStartIndex = currentIndex; + currentWidth = graphemeWidth; + } else { + currentWidth += graphemeWidth; + } + currentIndex += grapheme.length; + } + + // Push the last chunk + if (currentIndex > chunkStartIndex) { + visualLines.push({ + logicalLine: i, + startCol: chunkStartIndex, + length: currentIndex - chunkStartIndex, + }); } } } diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 2df17051..d0b3d2f7 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert"; import { describe, it } from "node:test"; +import { stripVTControlCharacters } from "node:util"; import { Editor } from "../src/components/editor.js"; +import { visibleWidth } from "../src/utils.js"; import { defaultEditorTheme } from "./test-themes.js"; describe("Editor component", () => { @@ -370,4 +372,105 @@ describe("Editor component", () => { assert.strictEqual(text, "xab"); }); }); + + describe("Grapheme-aware text wrapping", () => { + it("wraps lines correctly when text contains wide emojis", () => { + const editor = new Editor(defaultEditorTheme); + const width = 20; + + // ✅ is 2 columns wide, so "Hello ✅ World" is 14 columns + editor.setText("Hello ✅ World"); + const lines = editor.render(width); + + // All content lines (between borders) should fit within width + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`); + } + }); + + it("wraps long text with emojis at correct positions", () => { + const editor = new Editor(defaultEditorTheme); + const width = 10; + + // Each ✅ is 2 columns. "✅✅✅✅✅" = 10 columns, fits exactly + // "✅✅✅✅✅✅" = 12 columns, needs wrap + editor.setText("✅✅✅✅✅✅"); + const lines = editor.render(width); + + // Should have 2 content lines (plus 2 border lines) + // First line: 5 emojis (10 cols), second line: 1 emoji (2 cols) + padding + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`); + } + }); + + it("wraps CJK characters correctly (each is 2 columns wide)", () => { + const editor = new Editor(defaultEditorTheme); + const width = 10; + + // Each CJK char is 2 columns. "日本語テスト" = 6 chars = 12 columns + editor.setText("日本語テスト"); + const lines = editor.render(width); + + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`); + } + + // Verify content split correctly + const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim()); + assert.strictEqual(contentLines.length, 2); + assert.strictEqual(contentLines[0], "日本語テス"); // 5 chars = 10 columns + assert.strictEqual(contentLines[1], "ト"); // 1 char = 2 columns (+ padding) + }); + + it("handles mixed ASCII and wide characters in wrapping", () => { + const editor = new Editor(defaultEditorTheme); + const width = 15; + + // "Test ✅ OK 日本" = 4 + 1 + 2 + 1 + 2 + 1 + 4 = 15 columns (fits exactly) + editor.setText("Test ✅ OK 日本"); + const lines = editor.render(width); + + // Should fit in one content line + const contentLines = lines.slice(1, -1); + assert.strictEqual(contentLines.length, 1); + + const lineWidth = visibleWidth(contentLines[0]!); + assert.strictEqual(lineWidth, width); + }); + + it("renders cursor correctly on wide characters", () => { + const editor = new Editor(defaultEditorTheme); + const width = 20; + + editor.setText("A✅B"); + // Cursor should be at end (after B) + const lines = editor.render(width); + + // The cursor (reverse video space) should be visible + const contentLine = lines[1]!; + assert.ok(contentLine.includes("\x1b[7m"), "Should have reverse video cursor"); + + // Line should still be correct width + assert.strictEqual(visibleWidth(contentLine), width); + }); + + it("does not exceed terminal width with emoji at wrap boundary", () => { + const editor = new Editor(defaultEditorTheme); + const width = 11; + + // "0123456789✅" = 10 ASCII + 2-wide emoji = 12 columns + // Should wrap before the emoji since it would exceed width + editor.setText("0123456789✅"); + const lines = editor.render(width); + + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.ok(lineWidth <= width, `Line ${i} has width ${lineWidth}, exceeds max ${width}`); + } + }); + }); });