diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 8f80e364..32829367 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -282,9 +282,6 @@ export class Editor implements Component, Focusable { /** Internal setText that doesn't reset history state - used by navigateHistory */ private setTextInternal(text: string): void { - // Reset kill ring state - external text changes break accumulation/yank chains - this.lastAction = null; - const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); this.state.lines = lines.length === 0 ? [""] : lines; this.state.cursorLine = this.state.lines.length - 1; @@ -805,13 +802,13 @@ export class Editor implements Component, Focusable { } setText(text: string): void { + this.lastAction = null; this.historyIndex = -1; // Exit history browsing mode // Push undo snapshot if content differs (makes programmatic changes undoable) if (this.getText() !== text) { this.pushUndoSnapshot(); } this.setTextInternal(text); - this.lastAction = null; } /** @@ -823,8 +820,55 @@ export class Editor implements Component, Focusable { if (!text) return; this.pushUndoSnapshot(); this.lastAction = null; - for (const char of text) { - this.insertCharacter(char, true); + this.historyIndex = -1; + this.insertTextAtCursorInternal(text); + } + + /** + * Internal text insertion at cursor. Handles single and multi-line text. + * Does not push undo snapshots or trigger autocomplete - caller is responsible. + * Normalizes line endings and calls onChange once at the end. + */ + private insertTextAtCursorInternal(text: string): void { + if (!text) return; + + // Normalize line endings + const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const insertedLines = normalized.split("\n"); + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const beforeCursor = currentLine.slice(0, this.state.cursorCol); + const afterCursor = currentLine.slice(this.state.cursorCol); + + if (insertedLines.length === 1) { + // Single line - insert at cursor position + this.state.lines[this.state.cursorLine] = beforeCursor + normalized + afterCursor; + this.state.cursorCol += normalized.length; + } else { + // Multi-line insertion + this.state.lines = [ + // All lines before current line + ...this.state.lines.slice(0, this.state.cursorLine), + + // The first inserted line merged with text before cursor + beforeCursor + insertedLines[0], + + // All middle inserted lines + ...insertedLines.slice(1, -1), + + // The last inserted line with text after cursor + insertedLines[insertedLines.length - 1] + afterCursor, + + // All lines after current line + ...this.state.lines.slice(this.state.cursorLine + 1), + ]; + + this.state.cursorLine += insertedLines.length - 1; + this.state.cursorCol = (insertedLines[insertedLines.length - 1] || "").length; + } + + if (this.onChange) { + this.onChange(this.getText()); } } @@ -836,7 +880,7 @@ export class Editor implements Component, Focusable { // - Consecutive word chars coalesce into one undo unit // - Space captures state before itself (so undo removes space+following word together) // - Each space is separately undoable - // Skip coalescing when called from atomic operations (paste, insertTextAtCursor) + // Skip coalescing when called from atomic operations (e.g., handlePaste) if (!skipUndoCoalescing) { if (isWhitespaceChar(char) || this.lastAction !== "type-word") { this.pushUndoSnapshot(); @@ -918,7 +962,7 @@ export class Editor implements Component, Focusable { } } - // Split into lines + // Split into lines to check for large paste const pastedLines = filteredText.split("\n"); // Check if this is a large paste (> 10 lines or > 1000 characters) @@ -934,61 +978,20 @@ export class Editor implements Component, Focusable { pastedLines.length > 10 ? `[paste #${pasteId} +${pastedLines.length} lines]` : `[paste #${pasteId} ${totalChars} chars]`; - for (const char of marker) { - this.insertCharacter(char, true); - } + this.insertTextAtCursorInternal(marker); return; } if (pastedLines.length === 1) { - // Single line - just insert each character - const text = pastedLines[0] || ""; - for (const char of text) { + // Single line - insert character by character to trigger autocomplete + for (const char of filteredText) { this.insertCharacter(char, true); } return; } - // Multi-line paste - be very careful with array manipulation - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const beforeCursor = currentLine.slice(0, this.state.cursorCol); - const afterCursor = currentLine.slice(this.state.cursorCol); - - // Build the new lines array step by step - const newLines: string[] = []; - - // Add all lines before current line - for (let i = 0; i < this.state.cursorLine; i++) { - newLines.push(this.state.lines[i] || ""); - } - - // Add the first pasted line merged with before cursor text - newLines.push(beforeCursor + (pastedLines[0] || "")); - - // Add all middle pasted lines - for (let i = 1; i < pastedLines.length - 1; i++) { - newLines.push(pastedLines[i] || ""); - } - - // Add the last pasted line with after cursor text - newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor); - - // Add all lines after current line - for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) { - newLines.push(this.state.lines[i] || ""); - } - - // Replace the entire lines array - this.state.lines = newLines; - - // Update cursor position to end of pasted content - this.state.cursorLine += pastedLines.length - 1; - this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length; - - // Notify of change - if (this.onChange) { - this.onChange(this.getText()); - } + // Multi-line paste - use direct state manipulation + this.insertTextAtCursorInternal(filteredText); } private addNewLine(): void { diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 7b660894..d233f421 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1466,6 +1466,44 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "hello| world"); }); + it("insertTextAtCursor handles multiline text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); // Move right 5 (after "hello", before space) + + // Insert multiline text + editor.insertTextAtCursor("line1\nline2\nline3"); + assert.strictEqual(editor.getText(), "helloline1\nline2\nline3 world"); + + // Cursor should be at end of inserted text (after "line3", before " world") + const cursor = editor.getCursor(); + assert.strictEqual(cursor.line, 2); + assert.strictEqual(cursor.col, 5); // "line3".length + + // Single undo should restore entire pre-insert state + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello world"); + }); + + it("insertTextAtCursor normalizes CRLF and CR line endings", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText(""); + + // Insert text with CRLF + editor.insertTextAtCursor("a\r\nb\r\nc"); + assert.strictEqual(editor.getText(), "a\nb\nc"); + + editor.handleInput("\x1b[45;5u"); // Undo + assert.strictEqual(editor.getText(), ""); + + // Insert text with CR only + editor.insertTextAtCursor("x\ry\rz"); + assert.strictEqual(editor.getText(), "x\ny\nz"); + }); + it("undoes setText to empty string", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme);