From 7d2d170c1bf9099fc8066988959a51ccc12ba20c Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Sun, 25 Jan 2026 16:28:06 +0400 Subject: [PATCH 1/3] fix(tui): handle multi-line text in insertTextAtCursor() Add insertTextAtCursorInternal() to properly handle newlines by splitting lines at the cursor position. - Normalize line endings (CRLF/CR to LF) - Handle single-line and multi-line insertion - Position cursor at end of inserted text - Call onChange only once at the end Refactor handlePaste() to use insertTextAtCursorInternal() for multi-line pastes, while retaining character-by-character insertion for single-line pastes to preserve autocomplete triggering. --- packages/tui/src/components/editor.ts | 113 ++++++++++++++------------ packages/tui/test/editor.test.ts | 38 +++++++++ 2 files changed, 101 insertions(+), 50 deletions(-) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 8f80e364..a11c7dd1 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -823,8 +823,62 @@ 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 + 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 inserted line merged with text before cursor + newLines.push(beforeCursor + (insertedLines[0] || "")); + + // Add all middle inserted lines + for (let i = 1; i < insertedLines.length - 1; i++) { + newLines.push(insertedLines[i] || ""); + } + + // Add the last inserted line with text after cursor + newLines.push((insertedLines[insertedLines.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] || ""); + } + + this.state.lines = newLines; + this.state.cursorLine += insertedLines.length - 1; + this.state.cursorCol = (insertedLines[insertedLines.length - 1] || "").length; + } + + if (this.onChange) { + this.onChange(this.getText()); } } @@ -836,7 +890,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 +972,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 +988,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); From 609095b0b629fc43760e32a94667077cfebc1396 Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Sun, 25 Jan 2026 16:29:20 +0400 Subject: [PATCH 2/3] fix(tui): refactor the multi-line insertion handling --- packages/tui/src/components/editor.ts | 31 +++++++++++---------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index a11c7dd1..96d43e12 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -849,30 +849,23 @@ export class Editor implements Component, Focusable { this.state.cursorCol += normalized.length; } else { // Multi-line insertion - const newLines: string[] = []; + this.state.lines = [ + // All lines before current line + ...this.state.lines.slice(0, this.state.cursorLine), - // Add all lines before current line - for (let i = 0; i < this.state.cursorLine; i++) { - newLines.push(this.state.lines[i] || ""); - } + // The first inserted line merged with text before cursor + beforeCursor + insertedLines[0], - // Add the first inserted line merged with text before cursor - newLines.push(beforeCursor + (insertedLines[0] || "")); + // All middle inserted lines + ...insertedLines.slice(1, -1), - // Add all middle inserted lines - for (let i = 1; i < insertedLines.length - 1; i++) { - newLines.push(insertedLines[i] || ""); - } + // The last inserted line with text after cursor + insertedLines[insertedLines.length - 1] + afterCursor, - // Add the last inserted line with text after cursor - newLines.push((insertedLines[insertedLines.length - 1] || "") + afterCursor); + // All lines after current line + ...this.state.lines.slice(this.state.cursorLine + 1), + ]; - // 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] || ""); - } - - this.state.lines = newLines; this.state.cursorLine += insertedLines.length - 1; this.state.cursorCol = (insertedLines[insertedLines.length - 1] || "").length; } From beb1455cab4102c757e7dbc04a729f9e05292254 Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Sun, 25 Jan 2026 16:34:27 +0400 Subject: [PATCH 3/3] fix(tui): move lastAction handling out of setTextInternal() The caller is responsible for doing that. --- packages/tui/src/components/editor.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 96d43e12..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; } /**