mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
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.
This commit is contained in:
parent
3635e45ffd
commit
7d2d170c1b
2 changed files with 101 additions and 50 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue