diff --git a/packages/tui/README.md b/packages/tui/README.md index 5ae89f3d..ef02d244 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -115,7 +115,7 @@ editor.setAutocompleteProvider(provider); **Key Bindings:** - `Enter` - Submit -- `Shift+Enter` or `Ctrl+Enter` - New line +- `Shift+Enter`, `Ctrl+Enter`, or `Alt+Enter` - New line (terminal-dependent, Alt+Enter most reliable) - `Tab` - Autocomplete - `Ctrl+K` - Delete line - `Ctrl+A` / `Ctrl+E` - Line start/end diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index f9457649..4374775d 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -333,8 +333,8 @@ export class Editor implements Component { // Left this.moveCursor(0, -1); } - // Regular characters (printable ASCII) - else if (data.charCodeAt(0) >= 32 && data.charCodeAt(0) <= 126) { + // Regular characters (printable characters and unicode, but not control characters) + else if (data.charCodeAt(0) >= 32) { this.insertCharacter(data); } } @@ -472,7 +472,7 @@ export class Editor implements Component { // Filter out non-printable characters except newlines const filteredText = tabExpandedText .split("") - .filter((char) => char === "\n" || (char >= " " && char <= "~")) + .filter((char) => char === "\n" || char.charCodeAt(0) >= 32) .join(""); // Split into lines diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts new file mode 100644 index 00000000..ab9b3af2 --- /dev/null +++ b/packages/tui/test/editor.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { Editor } from "../src/components/editor.js"; + +describe("Editor component", () => { + describe("Unicode text editing behavior", () => { + it("inserts mixed ASCII, umlauts, and emojis as literal text", () => { + const editor = new Editor(); + + editor.handleInput("H"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput(" "); + editor.handleInput("ä"); + editor.handleInput("ö"); + editor.handleInput("ü"); + editor.handleInput(" "); + editor.handleInput("😀"); + + const text = editor.getText(); + assert.strictEqual(text, "Hello äöü 😀"); + }); + + it("deletes single-code-unit unicode characters (umlauts) with Backspace", () => { + const editor = new Editor(); + + editor.handleInput("ä"); + editor.handleInput("ö"); + editor.handleInput("ü"); + + // Delete the last character (ü) + editor.handleInput("\x7f"); // Backspace + + const text = editor.getText(); + assert.strictEqual(text, "äö"); + }); + + it("deletes multi-code-unit emojis with repeated Backspace", () => { + const editor = new Editor(); + + editor.handleInput("😀"); + editor.handleInput("👍"); + + // Delete the last emoji (👍) - requires 2 backspaces since emojis are 2 code units + editor.handleInput("\x7f"); // Backspace + editor.handleInput("\x7f"); // Backspace + + const text = editor.getText(); + assert.strictEqual(text, "😀"); + }); + + it("inserts characters at the correct position after cursor movement over umlauts", () => { + const editor = new Editor(); + + editor.handleInput("ä"); + editor.handleInput("ö"); + editor.handleInput("ü"); + + // Move cursor left twice + editor.handleInput("\x1b[D"); // Left arrow + editor.handleInput("\x1b[D"); // Left arrow + + // Insert 'x' in the middle + editor.handleInput("x"); + + const text = editor.getText(); + assert.strictEqual(text, "äxöü"); + }); + + it("moves cursor in code units across multi-code-unit emojis before insertion", () => { + const editor = new Editor(); + + editor.handleInput("😀"); + editor.handleInput("👍"); + editor.handleInput("🎉"); + + // Move cursor left over last emoji (🎉) + editor.handleInput("\x1b[D"); // Left arrow + editor.handleInput("\x1b[D"); // Left arrow + + // Move cursor left over second emoji (👍) + editor.handleInput("\x1b[D"); + editor.handleInput("\x1b[D"); + + // Insert 'x' between first and second emoji + editor.handleInput("x"); + + const text = editor.getText(); + assert.strictEqual(text, "😀x👍🎉"); + }); + + it("preserves umlauts across line breaks", () => { + const editor = new Editor(); + + editor.handleInput("ä"); + editor.handleInput("ö"); + editor.handleInput("ü"); + editor.handleInput("\n"); // new line + editor.handleInput("Ä"); + editor.handleInput("Ö"); + editor.handleInput("Ü"); + + const text = editor.getText(); + assert.strictEqual(text, "äöü\nÄÖÜ"); + }); + + it("replaces the entire document with unicode text via setText (paste simulation)", () => { + const editor = new Editor(); + + // Simulate bracketed paste / programmatic replacement + editor.setText("Hällö Wörld! 😀 äöüÄÖÜß"); + + const text = editor.getText(); + assert.strictEqual(text, "Hällö Wörld! 😀 äöüÄÖÜß"); + }); + + it("moves cursor to document start on Ctrl+A and inserts at the beginning", () => { + const editor = new Editor(); + + editor.handleInput("a"); + editor.handleInput("b"); + editor.handleInput("\x01"); // Ctrl+A (move to start) + editor.handleInput("x"); // Insert at start + + const text = editor.getText(); + assert.strictEqual(text, "xab"); + }); + }); +});