From bd2c3ab67ee08b8febd573cfe2ebc7bcfa5ea0be Mon Sep 17 00:00:00 2001 From: xu0o0 Date: Sat, 14 Mar 2026 00:42:59 +0800 Subject: [PATCH] feat(tui): treat paste markers as atomic segments in editor (#2111) * feat(tui): treat paste markers as atomic segments in editor Paste markers like `[paste #1 +123 lines]` are now treated as single atomic units for cursor movement, word navigation, deletion, and wrapping. Only markers with valid paste IDs (present in the editor's pastes Map) are treated atomically. * fix(tui): word-wrap oversized atomic segments in editor --- packages/tui/src/components/editor.ts | 176 ++++++++++-- packages/tui/test/editor.test.ts | 400 ++++++++++++++++++++++++++ 2 files changed, 554 insertions(+), 22 deletions(-) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 998892b24..dd6be6315 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -7,7 +7,75 @@ import { UndoStack } from "../undo-stack.js"; import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.js"; import { SelectList, type SelectListTheme } from "./select-list.js"; -const segmenter = getSegmenter(); +const baseSegmenter = getSegmenter(); + +/** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */ +const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g; + +/** Non-global version for single-segment testing. */ +const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/; + +/** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */ +function isPasteMarker(segment: string): boolean { + return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment); +} + +/** + * A segmenter that wraps Intl.Segmenter and merges graphemes that fall + * within paste markers into single atomic segments. This makes cursor + * movement, deletion, word-wrap, etc. treat paste markers as single units. + * + * Only markers whose numeric ID exists in `validIds` are merged. + */ +function segmentWithMarkers(text: string, validIds: Set): Iterable { + // Fast path: no paste markers in the text or no valid IDs. + if (validIds.size === 0 || !text.includes("[paste #")) { + return baseSegmenter.segment(text); + } + + // Find all marker spans with valid IDs. + const markers: Array<{ start: number; end: number }> = []; + for (const m of text.matchAll(PASTE_MARKER_REGEX)) { + const id = Number.parseInt(m[1]!, 10); + if (!validIds.has(id)) continue; + markers.push({ start: m.index, end: m.index + m[0].length }); + } + if (markers.length === 0) { + return baseSegmenter.segment(text); + } + + // Build merged segment list. + const baseSegments = baseSegmenter.segment(text); + const result: Intl.SegmentData[] = []; + let markerIdx = 0; + + for (const seg of baseSegments) { + // Skip past markers that are entirely before this segment. + while (markerIdx < markers.length && markers[markerIdx]!.end <= seg.index) { + markerIdx++; + } + + const marker = markerIdx < markers.length ? markers[markerIdx]! : null; + + if (marker && seg.index >= marker.start && seg.index < marker.end) { + // This segment falls inside a marker. + // If this is the first segment of the marker, emit a merged segment. + if (seg.index === marker.start) { + const markerText = text.slice(marker.start, marker.end); + result.push({ + segment: markerText, + index: marker.start, + input: text, + }); + } + // Otherwise skip (already merged into the first segment). + } else { + result.push(seg); + } + } + + return result; +} /** * Represents a chunk of text for word-wrap layout. @@ -26,9 +94,11 @@ export interface TextChunk { * * @param line - The text line to wrap * @param maxWidth - Maximum visible width per chunk + * @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness). + * When omitted the default Intl.Segmenter is used. * @returns Array of chunks with text and position information */ -export function wordWrapLine(line: string, maxWidth: number): TextChunk[] { +export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[] { if (!line || maxWidth <= 0) { return [{ text: "", startIndex: 0, endIndex: 0 }]; } @@ -39,7 +109,7 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] { } const chunks: TextChunk[] = []; - const segments = [...segmenter.segment(line)]; + const segments = preSegmented ?? [...baseSegmenter.segment(line)]; let currentWidth = 0; let chunkStart = 0; @@ -54,7 +124,7 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] { const grapheme = seg.segment; const gWidth = visibleWidth(grapheme); const charIndex = seg.index; - const isWs = isWhitespaceChar(grapheme); + const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme); // Overflow check before advancing. if (currentWidth + gWidth > maxWidth) { @@ -77,6 +147,24 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] { wrapOppIndex = -1; } + if (gWidth > maxWidth) { + // Single atomic segment wider than maxWidth (e.g. paste marker + // in a narrow terminal). Re-wrap it at grapheme granularity. + + // The segment remains logically atomic for cursor + // movement / editing — the split is purely visual for word-wrap layout. + const subChunks = wordWrapLine(grapheme, maxWidth); + for (let j = 0; j < subChunks.length - 1; j++) { + const sc = subChunks[j]!; + chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex }); + } + const last = subChunks[subChunks.length - 1]!; + chunkStart = charIndex + last.startIndex; + currentWidth = visibleWidth(last.text); + wrapOppIndex = -1; + continue; + } + // Advance. currentWidth += gWidth; @@ -84,7 +172,7 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] { // Multiple spaces join (no break between them); the break point is // after the last space before the next word. const next = segments[i + 1]; - if (isWs && next && !isWhitespaceChar(next.segment)) { + if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) { wrapOppIndex = next.index; wrapOppWidth = currentWidth; } @@ -188,6 +276,16 @@ export class Editor implements Component, Focusable { this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5; } + /** Set of currently valid paste IDs, for marker-aware segmentation. */ + private validPasteIds(): Set { + return new Set(this.pastes.keys()); + } + + /** Segment text with paste-marker awareness, only merging markers with valid IDs. */ + private segment(text: string): Iterable { + return segmentWithMarkers(text, this.validPasteIds()); + } + getPaddingX(): number { return this.paddingX; } @@ -364,7 +462,7 @@ export class Editor implements Component, Focusable { if (after.length > 0) { // Cursor is on a character (grapheme) - replace it with highlighted version // Get the first grapheme from 'after' - const afterGraphemes = [...segmenter.segment(after)]; + const afterGraphemes = [...this.segment(after)]; const firstGrapheme = afterGraphemes[0]?.segment || ""; const restAfter = after.slice(firstGrapheme.length); const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`; @@ -742,7 +840,7 @@ export class Editor implements Component, Focusable { } } else { // Line needs wrapping - use word-aware wrapping - const chunks = wordWrapLine(line, contentWidth); + const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]); for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; @@ -1084,7 +1182,7 @@ export class Editor implements Component, Focusable { const beforeCursor = line.slice(0, this.state.cursorCol); // Find the last grapheme in the text before cursor - const graphemes = [...segmenter.segment(beforeCursor)]; + const graphemes = [...this.segment(beforeCursor)]; const lastGrapheme = graphemes[graphemes.length - 1]; const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; @@ -1175,6 +1273,20 @@ export class Editor implements Component, Focusable { const targetCol = targetVL.startCol + moveToVisualCol; const logicalLine = this.state.lines[targetVL.logicalLine] || ""; this.state.cursorCol = Math.min(targetCol, logicalLine.length); + + // Snap cursor to atomic segment boundary (e.g. paste markers) + // so the cursor never lands in the middle of a multi-grapheme unit. + // Single-grapheme segments don't need snapping. + const segments = [...this.segment(logicalLine)]; + for (const seg of segments) { + if (seg.index > this.state.cursorCol) break; + if (seg.segment.length <= 1) continue; + if (this.state.cursorCol < seg.index + seg.segment.length) { + // jump to the start of the segment when moving up, to the end when moving down. + this.state.cursorCol = currentVisualLine > targetVisualLine ? seg.index : seg.index + seg.segment.length; + break; + } + } } } @@ -1409,7 +1521,7 @@ export class Editor implements Component, Focusable { const afterCursor = currentLine.slice(this.state.cursorCol); // Find the first grapheme at cursor - const graphemes = [...segmenter.segment(afterCursor)]; + const graphemes = [...this.segment(afterCursor)]; const firstGrapheme = graphemes[0]; const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; @@ -1466,7 +1578,7 @@ export class Editor implements Component, Focusable { visualLines.push({ logicalLine: i, startCol: 0, length: line.length }); } else { // Line needs wrapping - use word-aware wrapping - const chunks = wordWrapLine(line, width); + const chunks = wordWrapLine(line, width, [...this.segment(line)]); for (const chunk of chunks) { visualLines.push({ logicalLine: i, @@ -1524,7 +1636,7 @@ export class Editor implements Component, Focusable { // Moving right - move by one grapheme (handles emojis, combining characters, etc.) if (this.state.cursorCol < currentLine.length) { const afterCursor = currentLine.slice(this.state.cursorCol); - const graphemes = [...segmenter.segment(afterCursor)]; + const graphemes = [...this.segment(afterCursor)]; const firstGrapheme = graphemes[0]; this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1)); } else if (this.state.cursorLine < this.state.lines.length - 1) { @@ -1542,7 +1654,7 @@ export class Editor implements Component, Focusable { // Moving left - move by one grapheme (handles emojis, combining characters, etc.) if (this.state.cursorCol > 0) { const beforeCursor = currentLine.slice(0, this.state.cursorCol); - const graphemes = [...segmenter.segment(beforeCursor)]; + const graphemes = [...this.segment(beforeCursor)]; const lastGrapheme = graphemes[graphemes.length - 1]; this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1)); } else if (this.state.cursorLine > 0) { @@ -1586,19 +1698,30 @@ export class Editor implements Component, Focusable { } const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - const graphemes = [...segmenter.segment(textBeforeCursor)]; + const graphemes = [...this.segment(textBeforeCursor)]; let newCol = this.state.cursorCol; // Skip trailing whitespace - while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) { + while ( + graphemes.length > 0 && + !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") && + isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") + ) { newCol -= graphemes.pop()?.segment.length || 0; } if (graphemes.length > 0) { const lastGrapheme = graphemes[graphemes.length - 1]?.segment || ""; - if (isPunctuationChar(lastGrapheme)) { + if (isPasteMarker(lastGrapheme)) { + // Paste marker is a single atomic word + newCol -= graphemes.pop()?.segment.length || 0; + } else if (isPunctuationChar(lastGrapheme)) { // Skip punctuation run - while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) { + while ( + graphemes.length > 0 && + isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") && + !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") + ) { newCol -= graphemes.pop()?.segment.length || 0; } } else { @@ -1606,7 +1729,8 @@ export class Editor implements Component, Focusable { while ( graphemes.length > 0 && !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") && - !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") + !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") && + !isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") ) { newCol -= graphemes.pop()?.segment.length || 0; } @@ -1801,28 +1925,36 @@ export class Editor implements Component, Focusable { } const textAfterCursor = currentLine.slice(this.state.cursorCol); - const segments = segmenter.segment(textAfterCursor); + const segments = this.segment(textAfterCursor); const iterator = segments[Symbol.iterator](); let next = iterator.next(); let newCol = this.state.cursorCol; // Skip leading whitespace - while (!next.done && isWhitespaceChar(next.value.segment)) { + while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) { newCol += next.value.segment.length; next = iterator.next(); } if (!next.done) { const firstGrapheme = next.value.segment; - if (isPunctuationChar(firstGrapheme)) { + if (isPasteMarker(firstGrapheme)) { + // Paste marker is a single atomic word + newCol += firstGrapheme.length; + } else if (isPunctuationChar(firstGrapheme)) { // Skip punctuation run - while (!next.done && isPunctuationChar(next.value.segment)) { + while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) { newCol += next.value.segment.length; next = iterator.next(); } } else { // Skip word run - while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) { + while ( + !next.done && + !isWhitespaceChar(next.value.segment) && + !isPunctuationChar(next.value.segment) && + !isPasteMarker(next.value.segment) + ) { newCol += next.value.segment.length; next = iterator.next(); } diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 02fd77f8c..30fc3c66a 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -892,6 +892,128 @@ describe("Editor component", () => { const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); assert.strictEqual(reconstructed, line); }); + + it("splits oversized atomic segment across multiple chunks", () => { + // Simulate a paste marker wider than maxWidth by passing pre-segmented data + const marker = "[paste #1 +20 lines]"; // 21 chars + const line = `A${marker}B`; + const segments: Intl.SegmentData[] = [ + { segment: "A", index: 0, input: line }, + { segment: marker, index: 1, input: line }, + { segment: "B", index: 1 + marker.length, input: line }, + ]; + + const chunks = wordWrapLine(line, 10, segments); + + // Every chunk must fit within maxWidth + for (const chunk of chunks) { + assert.ok( + visibleWidth(chunk.text) <= 10, + `chunk "${chunk.text}" has visible width ${visibleWidth(chunk.text)}, expected <= 10`, + ); + } + + // Verify no content is lost + const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); + assert.strictEqual(reconstructed, line); + }); + + it("splits oversized atomic segment at start of line", () => { + const marker = "[paste #1 +20 lines]"; // 21 chars + const line = `${marker}B`; + const segments: Intl.SegmentData[] = [ + { segment: marker, index: 0, input: line }, + { segment: "B", index: marker.length, input: line }, + ]; + + const chunks = wordWrapLine(line, 10, segments); + + for (const chunk of chunks) { + assert.ok(visibleWidth(chunk.text) <= 10); + } + // "B" ends up on the last line (either alone or with the marker tail) + assert.strictEqual(chunks[chunks.length - 1]!.text.includes("B"), true); + + const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); + assert.strictEqual(reconstructed, line); + }); + + it("splits oversized atomic segment at end of line", () => { + const marker = "[paste #1 +20 lines]"; // 21 chars + const line = `A${marker}`; + const segments: Intl.SegmentData[] = [ + { segment: "A", index: 0, input: line }, + { segment: marker, index: 1, input: line }, + ]; + + const chunks = wordWrapLine(line, 10, segments); + + for (const chunk of chunks) { + assert.ok(visibleWidth(chunk.text) <= 10); + } + assert.strictEqual(chunks[0]!.text, "A"); + + const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); + assert.strictEqual(reconstructed, line); + }); + + it("splits consecutive oversized atomic segments", () => { + const m1 = "[paste #1 +20 lines]"; // 21 chars + const m2 = "[paste #2 +30 lines]"; // 21 chars + const line = `${m1}${m2}`; + const segments: Intl.SegmentData[] = [ + { segment: m1, index: 0, input: line }, + { segment: m2, index: m1.length, input: line }, + ]; + + const chunks = wordWrapLine(line, 10, segments); + + for (const chunk of chunks) { + assert.ok( + visibleWidth(chunk.text) <= 10, + `chunk "${chunk.text}" has visible width ${visibleWidth(chunk.text)}, expected <= 10`, + ); + } + + const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); + assert.strictEqual(reconstructed, line); + }); + + it("wraps normally after oversized atomic segment", () => { + const marker = "[paste #1 +20 lines]"; // 21 chars + const line = `${marker} hello world`; + const segments: Intl.SegmentData[] = [ + { segment: marker, index: 0, input: line }, + { segment: " ", index: marker.length, input: line }, + { segment: "h", index: marker.length + 1, input: line }, + { segment: "e", index: marker.length + 2, input: line }, + { segment: "l", index: marker.length + 3, input: line }, + { segment: "l", index: marker.length + 4, input: line }, + { segment: "o", index: marker.length + 5, input: line }, + { segment: " ", index: marker.length + 6, input: line }, + { segment: "w", index: marker.length + 7, input: line }, + { segment: "o", index: marker.length + 8, input: line }, + { segment: "r", index: marker.length + 9, input: line }, + { segment: "l", index: marker.length + 10, input: line }, + { segment: "d", index: marker.length + 11, input: line }, + ]; + + const chunks = wordWrapLine(line, 10, segments); + + // All chunks must fit + for (const chunk of chunks) { + assert.ok( + visibleWidth(chunk.text) <= 10, + `chunk "${chunk.text}" has visible width ${visibleWidth(chunk.text)}, expected <= 10`, + ); + } + + // Last chunk should contain "world" (normal wrapping resumes) + assert.strictEqual(chunks[chunks.length - 1]!.text, "world"); + + const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); + assert.strictEqual(reconstructed, line); + }); }); describe("Kill ring", () => { @@ -2989,4 +3111,282 @@ describe("Editor component", () => { assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 15 }); }); }); + + describe("Paste marker atomic behavior", () => { + /** Helper: simulate a large paste that creates a marker */ + function pasteWithMarker(editor: Editor): string { + const bigContent = "line\n".repeat(20).trimEnd(); // 20 lines + editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`); + // The editor replaces large pastes with a marker like "[paste #1 +20 lines]" + return editor.getText(); + } + + it("creates a paste marker for large pastes", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const text = pasteWithMarker(editor); + assert.match(text, /\[paste #\d+ \+\d+ lines\]/); + }); + + it("treats paste marker as single unit for right arrow", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput("A"); + pasteWithMarker(editor); + editor.handleInput("B"); + // Text: "A[paste #1 +20 lines]B", cursor at end + + // Go to start + editor.handleInput("\x01"); // Ctrl+A + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Right arrow: should move past "A" + editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); + + // Right arrow: should skip the entire marker + editor.handleInput("\x1b[C"); + const marker = editor.getText().match(/\[paste #\d+ \+\d+ lines\]/)![0]; + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length }); + + // Right arrow: should move past "B" + editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length + 1 }); + }); + + it("treats paste marker as single unit for left arrow", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput("A"); + pasteWithMarker(editor); + editor.handleInput("B"); + // Cursor at end + + // Left arrow: past "B" + editor.handleInput("\x1b[D"); + const text = editor.getText(); + const marker = text.match(/\[paste #\d+ \+\d+ lines\]/)![0]; + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length }); + + // Left arrow: skip the entire marker + editor.handleInput("\x1b[D"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); + + // Left arrow: past "A" + editor.handleInput("\x1b[D"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + }); + + it("treats paste marker as single unit for backspace", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput("A"); + pasteWithMarker(editor); + editor.handleInput("B"); + + const text = editor.getText(); + const marker = text.match(/\[paste #\d+ \+\d+ lines\]/)![0]; + + // Position cursor right after the marker (before "B") + editor.handleInput("\x01"); // Ctrl+A + // Move past "A" and the marker + editor.handleInput("\x1b[C"); // past "A" + editor.handleInput("\x1b[C"); // past marker + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length }); + + // Backspace: should delete the entire marker at once + editor.handleInput("\x7f"); + assert.strictEqual(editor.getText(), "AB"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); + }); + + it("treats paste marker as single unit for forward delete", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput("A"); + pasteWithMarker(editor); + editor.handleInput("B"); + + // Position cursor on "A" (col 0) then move right once to be just before marker + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput("\x1b[C"); // past "A", now at col 1 (start of marker) + + // Forward delete: should delete the entire marker at once + editor.handleInput("\x1b[3~"); // Delete key + assert.strictEqual(editor.getText(), "AB"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); + }); + + it("treats paste marker as single unit for word movement", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput("X"); + editor.handleInput(" "); + pasteWithMarker(editor); + editor.handleInput(" "); + editor.handleInput("Y"); + // Text: "X [paste #1 +20 lines] Y" + + const text = editor.getText(); + const marker = text.match(/\[paste #\d+ \+\d+ lines\]/)![0]; + + // Go to start + editor.handleInput("\x01"); // Ctrl+A + + // Ctrl+Right: skip "X" + editor.handleInput("\x1b[1;5C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); + + // Ctrl+Right: skip whitespace + marker (marker treated as single non-ws, non-punct unit) + editor.handleInput("\x1b[1;5C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 + marker.length }); + }); + + it("undo restores marker after backspace deletion", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput("A"); + pasteWithMarker(editor); + editor.handleInput("B"); + + const textBefore = editor.getText(); + + // Position after marker + editor.handleInput("\x01"); + editor.handleInput("\x1b[C"); // past A + editor.handleInput("\x1b[C"); // past marker + + // Delete marker + editor.handleInput("\x7f"); + assert.strictEqual(editor.getText(), "AB"); + + // Undo + editor.handleInput("\x1b[45;5u"); + assert.strictEqual(editor.getText(), textBefore); + }); + + it("handles multiple paste markers in same line", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + pasteWithMarker(editor); + editor.handleInput(" "); + pasteWithMarker(editor); + + const text = editor.getText(); + const markers = [...text.matchAll(/\[paste #\d+ \+\d+ lines\]/g)]; + assert.strictEqual(markers.length, 2); + + // Go to start + editor.handleInput("\x01"); + + // Right arrow: should skip first marker atomically + editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: markers[0]![0].length }); + + // Right arrow: past space + editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: markers[0]![0].length + 1 }); + + // Right arrow: should skip second marker atomically + editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { + line: 0, + col: markers[0]![0].length + 1 + markers[1]![0].length, + }); + }); + + it("does not treat manually typed marker-like text as atomic (no valid paste ID)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + // Type text that matches the pattern but was typed manually (no paste entry) + const fakeMarker = "[paste #99 +5 lines]"; + for (const ch of fakeMarker) editor.handleInput(ch); + + assert.strictEqual(editor.getText(), fakeMarker); + + // No paste with ID 99 exists, so the marker is NOT treated atomically. + // Right arrow should move one grapheme at a time. + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput("\x1b[C"); // Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); // Just past "[" + }); + + it("does not crash when paste marker is wider than terminal width", () => { + // Reproduce: terminal width 8, paste marker "[paste #1 +47 lines]" (21 chars) + const tui = createTestTUI(); + const editor = new Editor(tui, defaultEditorTheme); + const bigContent = "line\n".repeat(47).trimEnd(); + editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`); + + const text = editor.getText(); + const marker = text.match(/\[paste #\d+ \+\d+ lines\]/); + assert.ok(marker, "paste marker should be created"); + assert.ok(visibleWidth(marker[0]) > 8, "marker should be wider than render width"); + + // Render at very narrow width - should not throw + const lines = editor.render(8); + // Every rendered line must fit within the width (marker is split) + for (const line of lines) { + assert.ok( + visibleWidth(line) <= 8, + `line exceeds width 8: visible=${visibleWidth(line)} text=${JSON.stringify(line)}`, + ); + } + }); + + it("does not crash when text + paste marker exceeds terminal width with cursor on marker", () => { + // Reproduce: terminal width 54, text "b".repeat(35) + "[paste #1 +27 lines]" + "bbbb" + // Cursor lands on the paste marker after word-wrap, causing the rendered line + // to be 55 visible chars (1 over the width). + const tui = createTestTUI(); + const editor = new Editor(tui, defaultEditorTheme); + + // Type 35 'b' characters + for (let i = 0; i < 35; i++) editor.handleInput("b"); + + // Paste 27 lines + const bigContent = "line\n".repeat(27).trimEnd(); + editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`); + + // Type a few more characters + for (let i = 0; i < 4; i++) editor.handleInput("b"); + + // Move cursor left to land on the paste marker + editor.handleInput("\x1b[D"); // past last 'b' + editor.handleInput("\x1b[D"); // past last 'b' + editor.handleInput("\x1b[D"); // past last 'b' + editor.handleInput("\x1b[D"); // past last 'b' + editor.handleInput("\x1b[D"); // now on the paste marker + + // Render at width 54 - should not throw + const renderWidth = 54; + const lines = editor.render(renderWidth); + for (const line of lines) { + assert.ok( + visibleWidth(line) <= renderWidth, + `line exceeds width ${renderWidth}: visible=${visibleWidth(line)} text=${JSON.stringify(line)}`, + ); + } + }); + + it("wordWrapLine re-checks overflow after backtracking to wrap opportunity", () => { + // Reproduce crash #2: " " + "b".repeat(35) + atomic_marker(20 chars) + "bbbb" + // layoutWidth=53. After wrapping at the space, the remaining 35 b's + marker = 55 + // must trigger a second force-break instead of silently overflowing. + const tui = createTestTUI(); + const editor = new Editor(tui, defaultEditorTheme); + + // Type a space, then 35 b's + editor.handleInput(" "); + for (let i = 0; i < 35; i++) editor.handleInput("b"); + + // Paste 27 lines to create marker + const bigContent = "line\n".repeat(27).trimEnd(); + editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`); + + // Type trailing chars + for (let i = 0; i < 4; i++) editor.handleInput("b"); + + // Render at width 54 (contentWidth=54, layoutWidth=53 with paddingX=0) + const renderWidth = 54; + const lines = editor.render(renderWidth); + for (const line of lines) { + assert.ok( + visibleWidth(line) <= renderWidth, + `line exceeds width ${renderWidth}: visible=${visibleWidth(line)} text=${JSON.stringify(line)}`, + ); + } + }); + }); });