mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-22 01:02:16 +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;
|
if (!text) return;
|
||||||
this.pushUndoSnapshot();
|
this.pushUndoSnapshot();
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
for (const char of text) {
|
this.historyIndex = -1;
|
||||||
this.insertCharacter(char, true);
|
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
|
// - Consecutive word chars coalesce into one undo unit
|
||||||
// - Space captures state before itself (so undo removes space+following word together)
|
// - Space captures state before itself (so undo removes space+following word together)
|
||||||
// - Each space is separately undoable
|
// - 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 (!skipUndoCoalescing) {
|
||||||
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
|
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
|
||||||
this.pushUndoSnapshot();
|
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");
|
const pastedLines = filteredText.split("\n");
|
||||||
|
|
||||||
// Check if this is a large paste (> 10 lines or > 1000 characters)
|
// 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
|
pastedLines.length > 10
|
||||||
? `[paste #${pasteId} +${pastedLines.length} lines]`
|
? `[paste #${pasteId} +${pastedLines.length} lines]`
|
||||||
: `[paste #${pasteId} ${totalChars} chars]`;
|
: `[paste #${pasteId} ${totalChars} chars]`;
|
||||||
for (const char of marker) {
|
this.insertTextAtCursorInternal(marker);
|
||||||
this.insertCharacter(char, true);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pastedLines.length === 1) {
|
if (pastedLines.length === 1) {
|
||||||
// Single line - just insert each character
|
// Single line - insert character by character to trigger autocomplete
|
||||||
const text = pastedLines[0] || "";
|
for (const char of filteredText) {
|
||||||
for (const char of text) {
|
|
||||||
this.insertCharacter(char, true);
|
this.insertCharacter(char, true);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-line paste - be very careful with array manipulation
|
// Multi-line paste - use direct state manipulation
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
this.insertTextAtCursorInternal(filteredText);
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private addNewLine(): void {
|
private addNewLine(): void {
|
||||||
|
|
|
||||||
|
|
@ -1466,6 +1466,44 @@ describe("Editor component", () => {
|
||||||
assert.strictEqual(editor.getText(), "hello| world");
|
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", () => {
|
it("undoes setText to empty string", () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue