import { afterEach, describe, expect, it } from "bun:test"; import { stripVTControlCharacters } from "node:util"; import { CURSOR_MARKER } from "@oh-my-pi/pi-tui"; import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete"; import { Editor } from "@oh-my-pi/pi-tui/components/editor"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; import { setDefaultTabWidth } from "@oh-my-pi/pi-utils"; import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings"; import { defaultEditorTheme } from "./test-themes"; describe("Editor component", () => { afterEach(() => { setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS)); }); describe("Prompt history navigation", () => { it("does nothing on Up arrow when history is empty", () => { const editor = new Editor(defaultEditorTheme); editor.handleInput("\x1b[A"); // Up arrow expect(editor.getText()).toBe(""); }); it("shows most recent history entry on Up arrow when editor is empty", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("first prompt"); editor.addToHistory("second prompt"); editor.handleInput("\x1b[A"); // Up arrow expect(editor.getText()).toBe("second prompt"); }); it("cycles through history entries on repeated Up arrow", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("first"); editor.addToHistory("second"); editor.addToHistory("third"); editor.handleInput("\x1b[A"); // Up - shows "third" expect(editor.getText()).toBe("third"); editor.handleInput("\x1b[A"); // Up - shows "second" expect(editor.getText()).toBe("second"); editor.handleInput("\x1b[A"); // Up - shows "first" expect(editor.getText()).toBe("first"); editor.handleInput("\x1b[A"); // Up - stays at "first" (oldest) expect(editor.getText()).toBe("first"); }); it("returns to empty editor on Down arrow after browsing history", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("prompt"); editor.handleInput("\x1b[A"); // Up - shows "prompt" expect(editor.getText()).toBe("prompt"); editor.handleInput("\x1b[B"); // Down - clears editor expect(editor.getText()).toBe(""); }); it("navigates forward through history with Down arrow", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("first"); editor.addToHistory("second"); editor.addToHistory("third"); // Go to oldest editor.handleInput("\x1b[A"); // third editor.handleInput("\x1b[A"); // second editor.handleInput("\x1b[A"); // first // Navigate back editor.handleInput("\x1b[B"); // second expect(editor.getText()).toBe("second"); editor.handleInput("\x1b[B"); // third expect(editor.getText()).toBe("third"); editor.handleInput("\x1b[B"); // empty expect(editor.getText()).toBe(""); }); it("exits history mode when typing a character", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("old prompt"); editor.handleInput("\x1b[A"); // Up - shows "old prompt" editor.handleInput("x"); // Type a character - exits history mode expect(editor.getText()).toBe("old promptx"); }); it("exits history mode on setText", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("first"); editor.addToHistory("second"); editor.handleInput("\x1b[A"); // Up - shows "second" editor.setText(""); // External clear // Up should start fresh from most recent editor.handleInput("\x1b[A"); expect(editor.getText()).toBe("second"); }); it("exits history mode at the history edit anchor before public insertText", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("line1\nline2"); editor.handleInput("\x1b[A"); // Up - recalls at the top edit anchor expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); editor.insertText("[Image #1] "); expect(editor.getText()).toBe("line1\nline2[Image #1] "); expect(editor.getCursor()).toEqual({ line: 1, col: "line2[Image #1] ".length }); }); it("does not add empty strings to history", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory(""); editor.addToHistory(" "); editor.addToHistory("valid"); editor.handleInput("\x1b[A"); expect(editor.getText()).toBe("valid"); // Should not have more entries editor.handleInput("\x1b[A"); expect(editor.getText()).toBe("valid"); }); it("does not add consecutive duplicates to history", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("same"); editor.addToHistory("same"); editor.addToHistory("same"); editor.handleInput("\x1b[A"); // "same" expect(editor.getText()).toBe("same"); editor.handleInput("\x1b[A"); // stays at "same" (only one entry) expect(editor.getText()).toBe("same"); }); it("allows non-consecutive duplicates in history", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("first"); editor.addToHistory("second"); editor.addToHistory("first"); // Not consecutive, should be added editor.handleInput("\x1b[A"); // "first" expect(editor.getText()).toBe("first"); editor.handleInput("\x1b[A"); // "second" expect(editor.getText()).toBe("second"); editor.handleInput("\x1b[A"); // "first" (older one) expect(editor.getText()).toBe("first"); }); it("uses cursor movement instead of history when editor has content", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("history item"); editor.setText("line1\nline2"); // Cursor is at end of line2, Up should move to line1 editor.handleInput("\x1b[A"); // Up - cursor movement // Insert character to verify cursor position editor.handleInput("X"); // X should be inserted in line1, not replace with history expect(editor.getText()).toBe("line1X\nline2"); }); it("limits history to 100 entries", () => { const editor = new Editor(defaultEditorTheme); // Add 105 entries for (let i = 0; i < 105; i++) { editor.addToHistory(`prompt ${i}`); } // Navigate to oldest for (let i = 0; i < 100; i++) { editor.handleInput("\x1b[A"); } // Should be at entry 5 (oldest kept), not entry 0 expect(editor.getText()).toBe("prompt 5"); // One more Up should not change anything editor.handleInput("\x1b[A"); expect(editor.getText()).toBe("prompt 5"); }); it("anchors history entry at top when navigating with Up", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("line1\nline2\nline3"); editor.handleInput("\x1b[A"); expect(editor.getText()).toBe("line1\nline2\nline3"); expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); }); it("anchors history entry at bottom when navigating with Down", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("older"); editor.addToHistory("line1\nline2\nline3"); editor.handleInput("\x1b[A"); // latest, anchored at top editor.handleInput("\x1b[A"); // older, anchored at top expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); editor.handleInput("\x1b[B"); // newer, anchored at bottom expect(editor.getText()).toBe("line1\nline2\nline3"); expect(editor.getCursor()).toEqual({ line: 2, col: 5 }); }); it("still allows in-entry cursor movement while browsing history", () => { const editor = new Editor(defaultEditorTheme); editor.addToHistory("line1\nline2\nline3"); editor.handleInput("\x1b[A"); // top anchor editor.handleInput("\x1b[B"); // move within entry expect(editor.getCursor()).toEqual({ line: 1, col: 0 }); editor.handleInput("\x1b[B"); editor.handleInput("\x1b[B"); // at bottom, exit history expect(editor.getText()).toBe(""); }); }); describe("public state accessors", () => { it("returns cursor position", () => { const editor = new Editor(defaultEditorTheme); expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); editor.handleInput("a"); editor.handleInput("b"); editor.handleInput("c"); expect(editor.getCursor()).toEqual({ line: 0, col: 3 }); editor.handleInput("\x1b[D"); // Left expect(editor.getCursor()).toEqual({ line: 0, col: 2 }); }); it("moves cursor to message boundaries", () => { const editor = new Editor(defaultEditorTheme); editor.setText("first line\nsecond line\nthird"); editor.moveToMessageStart(); expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); editor.moveToMessageEnd(); expect(editor.getCursor()).toEqual({ line: 2, col: 5 }); }); it("returns lines as a defensive copy", () => { const editor = new Editor(defaultEditorTheme); editor.setText("a\nb"); const lines = editor.getLines(); expect(lines).toEqual(["a", "b"]); lines[0] = "mutated"; expect(editor.getLines()).toEqual(["a", "b"]); }); }); describe("autocomplete triggers", () => { it("triggers slash-command autocomplete when typing slash", async () => { const editor = new Editor(defaultEditorTheme); const { promise, resolve } = Promise.withResolvers(); editor.setAutocompleteProvider({ async getSuggestions(lines, cursorLine, cursorCol) { const currentLine = lines[cursorLine] ?? ""; resolve(currentLine.slice(0, cursorCol)); return { items: [{ label: "/help", value: "/help" }], prefix: "/" }; }, applyCompletion(lines, cursorLine, cursorCol) { return { lines, cursorLine, cursorCol }; }, }); editor.handleInput("/"); await expect(promise).resolves.toBe("/"); }); it("triggers file-reference autocomplete when typing at-sign", async () => { const editor = new Editor(defaultEditorTheme); const { promise, resolve } = Promise.withResolvers(); editor.setAutocompleteProvider({ async getSuggestions(lines, cursorLine, cursorCol) { const currentLine = lines[cursorLine] ?? ""; resolve(currentLine.slice(0, cursorCol)); return { items: [{ label: "src/", value: "src/" }], prefix: "@" }; }, applyCompletion(lines, cursorLine, cursorCol) { return { lines, cursorLine, cursorCol }; }, }); editor.handleInput("@"); await expect(promise).resolves.toBe("@"); }); it("chains into argument completions after tab-completing slash command names", async () => { const editor = new Editor(defaultEditorTheme); editor.setAutocompleteProvider( new CombinedAutocompleteProvider( [ { name: "model", description: "Select a model", getArgumentCompletions() { return [{ label: "claude-opus", value: "claude-opus" }]; }, }, { name: "help", description: "Show help" }, ], "/tmp", ), ); editor.handleInput("/"); await Bun.sleep(0); editor.handleInput("m"); editor.handleInput("o"); editor.handleInput("d"); await Bun.sleep(110); editor.handleInput(" "); await Bun.sleep(0); expect(editor.getText()).toBe("/model "); expect(editor.isShowingAutocomplete()).toBe(true); editor.handleInput(" "); expect(editor.getText()).toBe("/model claude-opus"); expect(editor.isShowingAutocomplete()).toBe(false); }); it("does not show argument completions when command has no argument completer", async () => { const editor = new Editor(defaultEditorTheme); editor.setAutocompleteProvider( new CombinedAutocompleteProvider( [ { name: "model", description: "Select a model", getArgumentCompletions() { return [{ label: "claude-opus", value: "claude-opus" }]; }, }, { name: "help", description: "Show help" }, ], "/tmp", ), ); editor.handleInput("/"); await Bun.sleep(0); editor.handleInput("h"); editor.handleInput("e"); await Bun.sleep(110); editor.handleInput(" "); await Bun.sleep(0); expect(editor.getText()).toBe("/help "); expect(editor.isShowingAutocomplete()).toBe(false); }); }); describe("Unicode text editing behavior", () => { it("inserts mixed ASCII, umlauts, and emojis as literal text", () => { const editor = new Editor(defaultEditorTheme); 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(); expect(text).toBe("Hello äöü 😀"); }); it("inserts NumLock keypad digits instead of treating them as navigation", () => { const editor = new Editor(defaultEditorTheme); editor.handleInput("a"); editor.handleInput("\x1b[57400;129u"); expect(editor.getText()).toBe("a1"); }); it("inserts a newline for Ctrl+Enter variants with NumLock or keypad Enter metadata", () => { const variants = ["\x1b[13;133u", "\x1b[57414;5u", "\x1b[57414;133u"]; for (const variant of variants) { const editor = new Editor(defaultEditorTheme); editor.handleInput("a"); editor.handleInput(variant); editor.handleInput("b"); expect(editor.getText()).toBe("a\nb"); } }); it("deletes single-code-unit unicode characters (umlauts) with Backspace", () => { const editor = new Editor(defaultEditorTheme); editor.handleInput("ä"); editor.handleInput("ö"); editor.handleInput("ü"); // Delete the last character (ü) editor.handleInput("\x7f"); // Backspace const text = editor.getText(); expect(text).toBe("äö"); }); it("deletes multi-code-unit emojis with single Backspace", () => { const editor = new Editor(defaultEditorTheme); editor.handleInput("😀"); editor.handleInput("👍"); // Delete the last emoji (👍) - single backspace deletes whole grapheme cluster editor.handleInput("\x7f"); // Backspace const text = editor.getText(); expect(text).toBe("😀"); }); it("inserts characters at the correct position after cursor movement over umlauts", () => { const editor = new Editor(defaultEditorTheme); 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(); expect(text).toBe("äxöü"); }); it("moves cursor across multi-code-unit emojis with single arrow key", () => { const editor = new Editor(defaultEditorTheme); editor.handleInput("😀"); editor.handleInput("👍"); editor.handleInput("🎉"); // Move cursor left over last emoji (🎉) - single arrow moves over whole grapheme editor.handleInput("\x1b[D"); // Left arrow // Move cursor left over second emoji (👍) editor.handleInput("\x1b[D"); // Insert 'x' between first and second emoji editor.handleInput("x"); const text = editor.getText(); expect(text).toBe("😀x👍🎉"); }); it("preserves umlauts across line breaks", () => { const editor = new Editor(defaultEditorTheme); editor.handleInput("ä"); editor.handleInput("ö"); editor.handleInput("ü"); editor.handleInput("\n"); // new line editor.handleInput("Ä"); editor.handleInput("Ö"); editor.handleInput("Ü"); const text = editor.getText(); expect(text).toBe("äöü\nÄÖÜ"); }); it("splits public insertText newlines into logical editor rows", () => { const editor = new Editor(defaultEditorTheme); editor.insertText("a\nb"); expect(editor.getText()).toBe("a\nb"); expect(editor.getCursor()).toEqual({ line: 1, col: 1 }); for (const renderedLine of editor.render(80)) { expect(renderedLine).not.toContain("\n"); } }); it("replaces the entire document with unicode text via setText (paste simulation)", () => { const editor = new Editor(defaultEditorTheme); // Simulate bracketed paste / programmatic replacement editor.setText("Hällö Wörld! 😀 äöüÄÖÜß"); const text = editor.getText(); expect(text).toBe("Hällö Wörld! 😀 äöüÄÖÜß"); }); it("uses the configured tab width when loading text programmatically", () => { const editor = new Editor(defaultEditorTheme); try { setDefaultTabWidth(5); editor.setText("foo\tbar"); expect(editor.getText()).toBe("foo bar"); } finally { setDefaultTabWidth(3); } }); it("strips control characters from programmatically loaded text before render", () => { const editor = new Editor(defaultEditorTheme); editor.setText("start\x1b[31mred\x1b[0m\u0007end"); expect(editor.getText()).toBe("start[31mred[0mend"); expect(editor.getText()).not.toContain("\x1b"); expect(editor.getText()).not.toContain("\u0007"); expect(editor.render(80).join("\n")).not.toContain("\x1b[31m"); }); it("moves cursor to document start on Ctrl+A and inserts at the beginning", () => { const editor = new Editor(defaultEditorTheme); editor.handleInput("a"); editor.handleInput("b"); editor.handleInput("\x01"); // Ctrl+A (move to start) editor.handleInput("x"); // Insert at start const text = editor.getText(); expect(text).toBe("xab"); }); it("deletes words correctly with Ctrl+W and Alt+Backspace", () => { const editor = new Editor(defaultEditorTheme); // Basic word deletion editor.setText("foo bar baz"); editor.handleInput("\x17"); // Ctrl+W expect(editor.getText()).toBe("foo bar "); // Trailing whitespace editor.setText("foo bar "); editor.handleInput("\x17"); expect(editor.getText()).toBe("foo "); // Punctuation run editor.setText("foo bar..."); editor.handleInput("\x17"); expect(editor.getText()).toBe("foo bar"); // Delete across multiple lines editor.setText("line one\nline two"); editor.handleInput("\x17"); expect(editor.getText()).toBe("line one\nline "); // Delete empty line (merge) editor.setText("line one\n"); editor.handleInput("\x17"); expect(editor.getText()).toBe("line one"); // Grapheme safety (emoji as a word) editor.setText("foo 😀😀 bar"); editor.handleInput("\x17"); expect(editor.getText()).toBe("foo 😀😀 "); editor.handleInput("\x17"); expect(editor.getText()).toBe("foo "); // Alt+Backspace editor.setText("foo bar"); editor.handleInput("\x1b\x7f"); // Alt+Backspace (legacy) expect(editor.getText()).toBe("foo "); }); it("navigates words correctly with Ctrl+Left/Right", () => { const editor = new Editor(defaultEditorTheme); editor.setText("foo bar... baz"); // Cursor at end // Move left over baz editor.handleInput("\x1b[1;5D"); // Ctrl+Left expect(editor.getCursor()).toEqual({ line: 0, col: 11 }); // after '...' // Move left over punctuation editor.handleInput("\x1b[1;5D"); // Ctrl+Left expect(editor.getCursor()).toEqual({ line: 0, col: 7 }); // after 'bar' // Move left over bar editor.handleInput("\x1b[1;5D"); // Ctrl+Left expect(editor.getCursor()).toEqual({ line: 0, col: 4 }); // after 'foo ' // Move right over bar editor.handleInput("\x1b[1;5C"); // Ctrl+Right expect(editor.getCursor()).toEqual({ line: 0, col: 7 }); // at end of 'bar' // Move right over punctuation run editor.handleInput("\x1b[1;5C"); // Ctrl+Right expect(editor.getCursor()).toEqual({ line: 0, col: 10 }); // after '...' // Move right skips space and lands after baz editor.handleInput("\x1b[1;5C"); // Ctrl+Right expect(editor.getCursor()).toEqual({ line: 0, col: 14 }); // end of line // Test forward from start with leading whitespace editor.setText(" foo bar"); editor.handleInput("\x01"); // Ctrl+A to go to start editor.handleInput("\x1b[1;5C"); // Ctrl+Right expect(editor.getCursor()).toEqual({ line: 0, col: 6 }); // after 'foo' }); }); describe("Grapheme-aware text wrapping", () => { it("wraps lines correctly when text contains wide emojis", () => { const editor = new Editor(defaultEditorTheme); const width = 20; // ✅ is 2 columns wide, so "Hello ✅ World" is 14 columns editor.setText("Hello ✅ World"); const lines = editor.render(width); // All content lines (between borders) should fit within width for (let i = 1; i < lines.length - 1; i++) { const lineWidth = visibleWidth(lines[i]!); expect(lineWidth).toBeLessThanOrEqual(width); } }); it("wraps long text with emojis at correct positions", () => { const editor = new Editor(defaultEditorTheme); const width = 10; // Each ✅ is 2 columns. "✅✅✅✅✅" = 10 columns, fits exactly // "✅✅✅✅✅✅" = 12 columns, needs wrap editor.setText("✅✅✅✅✅✅"); const lines = editor.render(width); // Should have 2 content lines (plus 2 border lines) // First line: 5 emojis (10 cols), second line: 1 emoji (2 cols) + padding for (let i = 1; i < lines.length - 1; i++) { const lineWidth = visibleWidth(lines[i]!); expect(lineWidth).toBeLessThanOrEqual(width); } }); it("wraps CJK characters correctly (each is 2 columns wide)", () => { const editor = new Editor(defaultEditorTheme); const width = 16; // 6 chars for borders, 10 for content // Each CJK char is 2 columns. "日本語テスト" = 6 chars = 12 columns editor.setText("日本語テスト"); const lines = editor.render(width); // All content lines (including last which has bottom border) should be correct width for (let i = 1; i < lines.length; i++) { const lineWidth = visibleWidth(lines[i]!); expect(lineWidth).toBeLessThanOrEqual(width); } // Verify content split correctly - extract content between borders // Middle lines use "│ " and " │", last line uses "╰─ " and " ─╯" const contentLines = lines.slice(1).map(l => { const stripped = stripVTControlCharacters(l); // Both border styles use 3 chars on each side return stripped.slice(3, -3).trim(); }); expect(contentLines.length).toBe(2); expect(contentLines[0]).toBe("日本語テス"); // 5 chars = 10 columns // Last line has cursor (|) which we need to strip expect(contentLines[1]?.replace("|", "")).toBe("ト"); // 1 char = 2 columns (+ cursor + padding) }); it("handles mixed ASCII and wide characters in wrapping", () => { const editor = new Editor(defaultEditorTheme); const width = 15; // "Test ✅ OK 日本" = 4 + 1 + 2 + 1 + 2 + 1 + 4 = 15 columns (fits exactly) editor.setText("Test ✅ OK 日本"); const lines = editor.render(width); // Should fit in one content line const contentLines = lines.slice(1, -1); expect(contentLines.length).toBe(1); const lineWidth = visibleWidth(contentLines[0]!); expect(lineWidth).toBeLessThanOrEqual(width); }); it("renders cursor correctly on wide characters", () => { const editor = new Editor(defaultEditorTheme); const width = 20; editor.setText("A✅B"); // Cursor should be at end (after B) const lines = editor.render(width); // The software cursor should be visible without SGR blink; Ghostty/cmux // can leave afterimages for blinking cells during rapid row repaints. const contentLine = lines[1]!; expect(contentLine).toContain(defaultEditorTheme.symbols.inputCursor); expect(contentLine).not.toContain("\x1b[5m"); // Line should still be correct width expect(visibleWidth(contentLine)).toBeLessThanOrEqual(width); }); it("shows cursor at end before wrap and wraps on next char", () => { for (const paddingX of [0, 1]) { const editor = new Editor({ ...defaultEditorTheme, editorPaddingX: paddingX }); const width = 20; const contentWidth = width - 2 * (paddingX + 1); const layoutWidth = Math.max(1, contentWidth - (paddingX === 0 ? 1 : 0)); const cursorToken = defaultEditorTheme.symbols.inputCursor; for (let i = 0; i < layoutWidth; i++) { editor.handleInput("a"); } let lines = editor.render(width); let contentLines = lines.slice(1); expect(contentLines.length).toBe(1); expect(contentLines[0]!.includes(cursorToken)).toBeTruthy(); editor.handleInput("a"); lines = editor.render(width); contentLines = lines.slice(1); expect(contentLines.length).toBe(2); } }); it("keeps a persistent prompt gutter visible after typing in borderless mode", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); editor.setUseTerminalCursor(true); for (const char of "hello") { editor.handleInput(char); } const [line] = editor.render(20); expect(stripVTControlCharacters(line!).startsWith("> hello")).toBeTrue(); expect(visibleWidth(line!)).toBeLessThanOrEqual(20); }); it("pads wrapped borderless lines to the prompt gutter width", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); editor.setUseTerminalCursor(true); editor.setText("abcdefghij"); const lines = editor.render(10).map(line => stripVTControlCharacters(line)); expect(lines).toHaveLength(2); expect(lines[0]).toBe("> abcdefgh"); expect(lines[1]).toBe(" ij "); }); it("keeps the prompt gutter visible when it consumes the full borderless width", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); let lines = editor.render(1).map(line => stripVTControlCharacters(line)); expect(lines).toEqual([">"]); expect(lines.every(line => visibleWidth(line) <= 1)).toBeTrue(); lines = editor.render(2).map(line => stripVTControlCharacters(line)); expect(lines).toEqual([`>${defaultEditorTheme.symbols.inputCursor}`]); expect(lines.every(line => visibleWidth(line) <= 2)).toBeTrue(); editor.handleInput("a"); lines = editor.render(2).map(line => stripVTControlCharacters(line)); expect(lines).toEqual([`>${defaultEditorTheme.symbols.inputCursor}`]); expect(lines.every(line => visibleWidth(line) <= 2)).toBeTrue(); }); it("keeps cursor-following movement stable when the prompt gutter consumes the full borderless width", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); editor.setMaxHeight(2); editor.focused = true; editor.setText("a\nb\nc"); let lines = editor.render(2); expect(lines).toHaveLength(2); expect(lines[0]).toBe("> "); expect(lines[1]).toBe(` ${defaultEditorTheme.symbols.inputCursor}${CURSOR_MARKER}`); editor.handleInput("\x1b[A"); expect(editor.getCursor()).toEqual({ line: 1, col: 1 }); lines = editor.render(2); expect(lines).toEqual([`>${defaultEditorTheme.symbols.inputCursor}${CURSOR_MARKER}`, " "]); }); it("keeps the prompt gutter visible at the borderless width limit", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); editor.focused = true; const width = 20; for (let i = 0; i < width - 2; i++) { editor.handleInput("a"); } const [line] = editor.render(width); expect(stripVTControlCharacters(line!).startsWith("> ")).toBeTrue(); expect(line).toContain(`\x1b[7ma\x1b[0m${CURSOR_MARKER}`); expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); }); it("keeps the prompt gutter visible on the first rendered row after scrolling", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); editor.setMaxHeight(3); editor.setText("l0\nl1\nl2\nl3"); const lines = editor.render(10).map(line => stripVTControlCharacters(line)); expect(lines).toHaveLength(3); expect(lines[0]?.startsWith("> l1")).toBeTrue(); expect(lines.slice(1).every(line => line.startsWith(" "))).toBeTrue(); expect(lines.every(line => visibleWidth(line) <= 10)).toBeTrue(); }); it("keeps the prompt gutter visible when scrolling starts on a wrapped continuation chunk", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); editor.setUseTerminalCursor(true); editor.setMaxHeight(2); editor.setText("abcdefghijklmno\nz"); const lines = editor.render(10).map(line => stripVTControlCharacters(line)); expect(lines).toHaveLength(2); expect(lines[0]).toBe("> ijklmno "); expect(lines[1]).toBe(" z "); expect(lines.every(line => visibleWidth(line) <= 10)).toBeTrue(); }); it("does not overflow width in borderless mode when the cursor reaches the line edge", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); const width = 20; for (let i = 0; i < width; i++) { editor.handleInput("a"); } const lines = editor.render(width); expect(lines).toHaveLength(1); expect(visibleWidth(lines[0]!)).toBeLessThanOrEqual(width); }); it("clamps the terminal cursor marker inside a full-width borderless row", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setUseTerminalCursor(true); editor.focused = true; const width = 3; editor.setText("abc"); const [line] = editor.render(width); const [beforeMarker] = line!.split(CURSOR_MARKER); expect(line).toContain(CURSOR_MARKER); expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe("abc"); expect(visibleWidth(beforeMarker!)).toBe(width - 1); expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBe(width); }); it("clamps the terminal cursor marker inside a full-width borderless prompt-gutter row", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); editor.setUseTerminalCursor(true); editor.focused = true; const width = 5; editor.setText("abc"); const [line] = editor.render(width); const [beforeMarker] = line!.split(CURSOR_MARKER); expect(line).toContain(CURSOR_MARKER); expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe("> abc"); expect(visibleWidth(beforeMarker!)).toBe(width - 1); expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBe(width); }); it("does not overflow prompt-gutter wraps when a wide grapheme lands in a 1-column content area", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); const width = 3; editor.setText("好a"); const lines = editor.render(width).map(line => stripVTControlCharacters(line.replaceAll(CURSOR_MARKER, ""))); expect(lines).toEqual(["> ", " a"]); expect(lines.every(line => visibleWidth(line) <= width)).toBeTrue(); }); it("clamps terminal-cursor rows when a wide grapheme lands in a 1-column prompt-gutter content area", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); editor.setUseTerminalCursor(true); editor.focused = true; const width = 3; editor.setText("好"); const [line] = editor.render(width); expect(line).toContain(CURSOR_MARKER); expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe("> "); expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); }); it("keeps a visible cursor marker when a focused borderless line is full width", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.focused = true; const width = 20; for (let i = 0; i < width; i++) { editor.handleInput("a"); } const [line] = editor.render(width); expect(line).toContain(`\x1b[7ma\x1b[0m${CURSOR_MARKER}`); expect(visibleWidth(line.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); }); it("preserves cursorOverride at the borderless width limit", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.cursorOverride = "\x1b[35m~\x1b[0m"; editor.cursorOverrideWidth = 1; editor.focused = true; const width = 20; for (let i = 0; i < width; i++) { editor.handleInput("a"); } const [line] = editor.render(width); expect(line).toContain(`${editor.cursorOverride}${CURSOR_MARKER}`); expect(visibleWidth(line.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); }); it("keeps the cursor marker at the full width when cursorOverride replaces a wide trailing glyph", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.cursorOverride = "\x1b[35m~\x1b[0m"; editor.cursorOverrideWidth = 1; editor.focused = true; const width = 20; editor.setText("aaaaaaaaaaaaaaaaaa✅"); const [line] = editor.render(width); const beforeMarker = line.split(CURSOR_MARKER)[0]; expect(line).toContain(`${editor.cursorOverride}${CURSOR_MARKER}`); expect(visibleWidth(beforeMarker!)).toBe(width); expect(visibleWidth(line.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); }); it("preserves visible trailing text when a wide cursorOverride cannot fit on a narrow borderless line", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.cursorOverride = "好"; editor.cursorOverrideWidth = 2; editor.focused = true; const width = 1; editor.setText("a"); const [line] = editor.render(width); expect(line).toContain(CURSOR_MARKER); expect(stripVTControlCharacters(line.replaceAll(CURSOR_MARKER, ""))).toBe("a"); expect(visibleWidth(line.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); }); it("keeps a visible fake cursor when the prompt gutter consumes the full borderless width", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); editor.focused = true; const [line] = editor.render(2); expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe( `>${defaultEditorTheme.symbols.inputCursor}`, ); expect(line).toContain(CURSOR_MARKER); expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(2); }); it("renders a fitting cursorOverride after the prompt glyph in a zero-content prompt gutter row", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); editor.cursorOverride = "\x1b[35m~\x1b[0m"; editor.cursorOverrideWidth = 1; editor.focused = true; const width = 2; const [line] = editor.render(width); expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe(">~"); expect(line).toContain(`${editor.cursorOverride}${CURSOR_MARKER}`); expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); }); it("highlights the only visible prompt-gutter cell when the zero-content prompt gutter truncates to one visible cell", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); const width = 1; const [baselineLine] = editor.render(width); const visibleCell = stripVTControlCharacters(baselineLine!); editor.focused = true; const [line] = editor.render(width); expect(line).toBe(`\x1b[7m${visibleCell}\x1b[0m${CURSOR_MARKER}`); expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe(visibleCell); expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); }); it("preserves the prompt glyph when a wide cursorOverride hits the zero-content prompt gutter", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPromptGutter("> "); editor.cursorOverride = "好"; editor.cursorOverrideWidth = 2; editor.focused = true; const width = 2; const [line] = editor.render(width); expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe( `>${defaultEditorTheme.symbols.inputCursor}`, ); expect(line).toContain(CURSOR_MARKER); expect(stripVTControlCharacters(line!)).not.toContain("好"); expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); }); it("falls back to a visible cursor when a wide cursorOverride cannot fit on an empty narrow borderless line", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.cursorOverride = "好"; editor.cursorOverrideWidth = 2; editor.focused = true; const width = 1; const [line] = editor.render(width); expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe( defaultEditorTheme.symbols.inputCursor, ); expect(line).toContain(CURSOR_MARKER); expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); }); it("falls back to the built-in cursor when a wide trailing grapheme cannot fit on a narrow borderless line", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.focused = true; const width = 1; editor.setText("好"); const [line] = editor.render(width); expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe( defaultEditorTheme.symbols.inputCursor, ); expect(line).toContain(CURSOR_MARKER); expect(stripVTControlCharacters(line!)).not.toContain("好"); expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); }); it("uses the full width in borderless mode when horizontal padding is zero", () => { const editor = new Editor(defaultEditorTheme); editor.setBorderVisible(false); editor.setPaddingX(0); const width = 20; for (let i = 0; i < width; i++) { editor.handleInput("a"); } const lines = editor.render(width); expect(lines).toHaveLength(1); expect(visibleWidth(lines[0]!)).toBeLessThanOrEqual(width); }); it("does not exceed terminal width with emoji at wrap boundary", () => { const editor = new Editor(defaultEditorTheme); const width = 11; // "0123456789✅" = 10 ASCII + 2-wide emoji = 12 columns // Should wrap before the emoji since it would exceed width editor.setText("0123456789✅"); const lines = editor.render(width); for (let i = 1; i < lines.length - 1; i++) { const lineWidth = visibleWidth(lines[i]!); expect(lineWidth <= width).toBeTruthy(); } }); }); describe("Word wrapping", () => { function renderContentLines(editor: Editor, width: number): string[] { // Move cursor to start so the rendered cursor does not affect line padding/borders. editor.handleInput("\x01"); // Ctrl+A const lines = editor.render(width); const paddingX = defaultEditorTheme.editorPaddingX ?? 2; const borderWidth = paddingX + 1; return lines.slice(1).map(l => stripVTControlCharacters(l).slice(borderWidth, -borderWidth).trimEnd()); } it("wraps at word boundaries instead of mid-word", () => { const editor = new Editor(defaultEditorTheme); const width = 40; editor.setText("Hello world this is a test of word wrapping functionality"); const lines = editor.render(width); // Check that all lines fit within width for (const line of lines) { const lineWidth = visibleWidth(line); expect(lineWidth).toBe(width); } // Extract text content (strip borders and control characters) const allText = lines .map(l => stripVTControlCharacters(l)) .join("") .replace(/[+\-|]/g, "") .replace(/\s+/g, " ") .trim(); // Should contain the full text (with normalized whitespace) expect(allText).toBe("Hello world this is a test of word wrapping functionality"); }); it("does not start lines with leading whitespace after word wrap", () => { const editor = new Editor(defaultEditorTheme); const width = 20; editor.setText("Word1 Word2 Word3 Word4 Word5 Word6"); const lines = editor.render(width); // Get content lines (between borders) const contentLines = lines.slice(1, -1); // No line should start with whitespace (except for padding at the end) for (let i = 0; i < contentLines.length; i++) { const line = stripVTControlCharacters(contentLines[i]!); const trimmedStart = line.trimStart(); // The line should either be all padding or start with a word character if (trimmedStart.length > 0) { expect(/^\s+\S/.test(line.trimEnd())).toBe(false); } } }); it("breaks long words (URLs) at character level", () => { const editor = new Editor(defaultEditorTheme); const width = 30; editor.setText("Check https://example.com/very/long/path/that/exceeds/width here"); const lines = editor.render(width); // All lines should fit within width for (let i = 1; i < lines.length - 1; i++) { const lineWidth = visibleWidth(lines[i]!); expect(lineWidth).toBe(width); } }); it("uses remaining width before breaking a long token", () => { const editor = new Editor(defaultEditorTheme); const width = 16; // 6 chars for borders, 10 for content editor.setText("word 一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十"); const contentLines = renderContentLines(editor, width); expect(contentLines.length).toBeGreaterThanOrEqual(2); // The first visual line should not waste remaining width by leaving just "word". expect(contentLines[0]?.includes("word")).toBeTruthy(); expect(contentLines[0]?.includes("一")).toBeTruthy(); }); it("uses remaining width before wrapping a short wide token (CJK)", () => { const editor = new Editor(defaultEditorTheme); const width = 16; // 6 chars for borders, 10 for content // This CJK token fits within maxWidth, but not within the remaining width after "word ". editor.setText("word 一二三四五"); const contentLines = renderContentLines(editor, width); expect(contentLines.length).toBeGreaterThanOrEqual(2); // Should fill the first line with as much of the CJK token as fits. expect(contentLines[0]?.includes("word")).toBeTruthy(); expect(contentLines[0]?.includes("一")).toBeTruthy(); expect(contentLines[0]?.includes("二")).toBeTruthy(); expect(contentLines.join("\n").includes("三")).toBeTruthy(); }); it("wraps a longer friendly Chinese sentence without wasting remaining width", () => { const editor = new Editor(defaultEditorTheme); const width = 16; // 6 chars for borders, 10 for content editor.setText( "word 愿世界各地的朋友都被善意连接,愿每个人都拥有幸福灿烂的人生;愿AI与人类相互成就、共同成长,携手创造更美好的明天与未来。", ); const contentLines = renderContentLines(editor, width); expect(contentLines.length).toBeGreaterThanOrEqual(2); // First line should not be just the ASCII prefix. expect(contentLines[0]?.includes("word")).toBeTruthy(); expect(contentLines[0]?.includes("愿")).toBeTruthy(); }); it("wraps Japanese kana/kanji without wasting remaining width", () => { const editor = new Editor(defaultEditorTheme); const width = 16; // 6 chars for borders, 10 for content const phrase = "天気がいいから、散歩しましょう!"; editor.setText(`word ${phrase}${phrase}${phrase}`); const contentLines = renderContentLines(editor, width); expect(contentLines.length).toBeGreaterThanOrEqual(2); expect(contentLines[0]?.includes("word")).toBeTruthy(); expect(contentLines[0]?.includes("天")).toBeTruthy(); }); it("uses remaining width when wrapping an emoji token", () => { const editor = new Editor(defaultEditorTheme); const width = 16; // 6 chars for borders, 10 for content editor.setText("word ✅✅✅✅✅ emoji-wrap-test with friends"); const contentLines = renderContentLines(editor, width); expect(contentLines.length).toBeGreaterThanOrEqual(2); // Each ✅ is 2 columns wide; remaining width should fit two of them. expect(contentLines[0]?.includes("word ✅✅")).toBeTruthy(); expect(contentLines.join("").includes("emoji-wrap-test")).toBeTruthy(); }); it("does not split narrow non-ASCII words (German)", () => { const editor = new Editor(defaultEditorTheme); const width = 14; // 6 chars for borders, 8 for content editor.setText("word über und danke fuer deine freundschaft"); const contentLines = renderContentLines(editor, width); expect(contentLines.length).toBeGreaterThanOrEqual(2); // "über" should wrap as a whole word, not be split into the remaining width. expect(contentLines[0]?.includes("ü")).toBe(false); expect(contentLines[1]?.startsWith("über")).toBeTruthy(); }); it("does not split narrow non-ASCII words (Russian)", () => { const editor = new Editor(defaultEditorTheme); const width = 14; // 6 chars for borders, 8 for content editor.setText("word привет мой друг и спасибо за дружбу"); const contentLines = renderContentLines(editor, width); expect(contentLines.length).toBeGreaterThanOrEqual(2); // "привет" should wrap as a whole word, not be split into the remaining width. expect(contentLines[0]?.includes("п")).toBe(false); expect(contentLines[1]?.includes("привет")).toBeTruthy(); }); it("uses remaining width for mixed wide and narrow graphemes in one token", () => { const editor = new Editor(defaultEditorTheme); const width = 16; // 6 chars for borders, 10 for content editor.setText("word 一a二b三c四d五e六f七g八h九"); const contentLines = renderContentLines(editor, width); expect(contentLines.length).toBeGreaterThanOrEqual(2); expect(contentLines[0]?.includes("word 一a二")).toBeTruthy(); expect(contentLines[1]?.startsWith("b三")).toBeTruthy(); }); it("preserves multiple spaces within words on same line", () => { const editor = new Editor(defaultEditorTheme); const width = 50; editor.setText("Word1 Word2 Word3"); const lines = editor.render(width); const contentLine = stripVTControlCharacters(lines[1]!).trim(); // Multiple spaces should be preserved expect(contentLine.includes("Word1 Word2")).toBeTruthy(); }); it("handles empty string", () => { const editor = new Editor(defaultEditorTheme); const width = 40; editor.setText(""); const lines = editor.render(width); // Should have at least 2 lines (borders) expect(lines.length).toBeGreaterThanOrEqual(2); // All lines should fit within width for (const line of lines) { const lineWidth = visibleWidth(line); expect(lineWidth).toBe(width); } }); it("handles single word that fits exactly", () => { const editor = new Editor(defaultEditorTheme); const width = 20; editor.setText("1234567890"); const lines = editor.render(width); // Check all lines fit within width for (const line of lines) { const lineWidth = visibleWidth(line); expect(lineWidth).toBe(width); } // Extract and verify content const allText = lines .map(l => stripVTControlCharacters(l)) .join("") .replace(/[+\-|]/g, "") .trim(); expect(allText).toBe("1234567890"); }); }); describe("Word navigation (Option/Alt + Left/Right)", () => { const wordLeft = "\x1bb"; // ESC-b (matches alt+left on most terminals / our matcher) const wordRight = "\x1bf"; // ESC-f (matches alt+right on most terminals / our matcher) it("moves by CJK and punctuation blocks in Chinese", () => { const editor = new Editor(defaultEditorTheme); const text = "天气不错,去散步吧!"; editor.setText(text); expect(editor.getCursor()).toEqual({ line: 0, col: text.length }); // ! is punctuation delimiter editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.length - "!".length }); // Jump over the CJK run "去散步吧" editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf(",") + 1 }); // Jump over the punctuation delimiter "," editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf(",") }); // Jump over the CJK run "天气不错" editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); // And forward again editor.handleInput(wordRight); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf(",") }); editor.handleInput(wordRight); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf(",") + 1 }); editor.handleInput(wordRight); expect(editor.getCursor()).toEqual({ line: 0, col: text.length - "!".length }); editor.handleInput(wordRight); expect(editor.getCursor()).toEqual({ line: 0, col: text.length }); }); it("moves by mixed kana/kanji blocks in Japanese", () => { const editor = new Editor(defaultEditorTheme); const text = "天気がいいから、散歩しましょう!"; editor.setText(text); expect(editor.getCursor()).toEqual({ line: 0, col: text.length }); // Skip the final delimiter editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.length - "!".length }); // Jump over the CJK run after the comma editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("、") + 1 }); // Skip the comma delimiter editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("、") }); // Jump over the first CJK run editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); }); it("moves by words and Unicode punctuation in Spanish", () => { const editor = new Editor(defaultEditorTheme); const text = "¿Cómo estás? ¡Muy bien!"; editor.setText(text); expect(editor.getCursor()).toEqual({ line: 0, col: text.length }); // Skip the final delimiter (!), then jump over "bien" editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("!") }); editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.lastIndexOf("bien") }); // Jump over "Muy" editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("Muy") }); // The inverted exclamation is a delimiter block editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("¡") }); // Skip space + '?' delimiter (block semantics) editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("?") }); // Then jump over "estás" editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("estás") }); }); it("treats NBSP as whitespace for word navigation", () => { const editor = new Editor(defaultEditorTheme); const nbsp = "\u00A0"; const text = `Hola${nbsp}mundo`; editor.setText(text); expect(editor.getCursor()).toEqual({ line: 0, col: text.length }); editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("mundo") }); }); it("keeps common joiners inside words", () => { const editor = new Editor(defaultEditorTheme); const text = "co-operate l’été"; editor.setText(text); expect(editor.getCursor()).toEqual({ line: 0, col: text.length }); // Jump over the last word as a single unit (keeps ’ inside) editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("l’été") }); // Then jump over the hyphenated word as a single unit editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); }); it("recognizes Unicode quotes and dashes as delimiter blocks", () => { const editor = new Editor(defaultEditorTheme); const text = "„überraschend“ — wirklich?"; editor.setText(text); expect(editor.getCursor()).toEqual({ line: 0, col: text.length }); // '?' delimiter editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("?") }); // jump over "wirklich" editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("wirklich") }); // em dash delimiter editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("—") }); }); it("recognizes Russian quotes and dashes as delimiter blocks", () => { const editor = new Editor(defaultEditorTheme); const text = "«Привет — мир»"; editor.setText(text); expect(editor.getCursor()).toEqual({ line: 0, col: text.length }); // closing quote delimiter editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("»") }); // jump over "мир" editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("мир") }); // em dash delimiter editor.handleInput(wordLeft); expect(editor.getCursor()).toEqual({ line: 0, col: text.indexOf("—") }); }); }); describe("Sticky column", () => { it("preserves target column when moving up through a shorter line", () => { const editor = new Editor(defaultEditorTheme); // Line 0: "2222222222x222" (x at col 10) // Line 1: "" (empty) // Line 2: "1111111111_111111111111" (_ at col 10) editor.setText("2222222222x222\n\n1111111111_111111111111"); // Position cursor on _ (line 2, col 10) expect(editor.getCursor()).toEqual({ line: 2, col: 23 }); // At end editor.handleInput("\x01"); // Ctrl+A - go to start of line for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C"); // Move right to col 10 expect(editor.getCursor()).toEqual({ line: 2, col: 10 }); // Press Up - should move to empty line (col clamped to 0) editor.handleInput("\x1b[A"); // Up arrow expect(editor.getCursor()).toEqual({ line: 1, col: 0 }); // Press Up again - should move to line 0 at col 10 (on 'x') editor.handleInput("\x1b[A"); // Up arrow expect(editor.getCursor()).toEqual({ line: 0, col: 10 }); }); it("preserves target column when moving down through a shorter line", () => { const editor = new Editor(defaultEditorTheme); editor.setText("1111111111_111\n\n2222222222x222222222222"); // Position cursor on _ (line 0, col 10) editor.handleInput("\x1b[A"); // Up to line 1 editor.handleInput("\x1b[A"); // Up to line 0 editor.handleInput("\x01"); // Ctrl+A for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C"); expect(editor.getCursor()).toEqual({ line: 0, col: 10 }); // Press Down - should move to empty line (col clamped to 0) editor.handleInput("\x1b[B"); // Down arrow expect(editor.getCursor()).toEqual({ line: 1, col: 0 }); // Press Down again - should move to line 2 at col 10 (on 'x') editor.handleInput("\x1b[B"); // Down arrow expect(editor.getCursor()).toEqual({ line: 2, col: 10 }); }); it("resets sticky column on horizontal movement (left arrow)", () => { const editor = new Editor(defaultEditorTheme); editor.setText("1234567890\n\n1234567890"); // Start at line 2, col 5 editor.handleInput("\x01"); // Ctrl+A for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); expect(editor.getCursor()).toEqual({ line: 2, col: 5 }); // Move up through empty line editor.handleInput("\x1b[A"); // Up - line 1, col 0 editor.handleInput("\x1b[A"); // Up - line 0, col 5 (sticky) expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); // Move left - resets sticky column editor.handleInput("\x1b[D"); // Left expect(editor.getCursor()).toEqual({ line: 0, col: 4 }); // Move down twice editor.handleInput("\x1b[B"); // Down - line 1, col 0 editor.handleInput("\x1b[B"); // Down - line 2, col 4 (new sticky from col 4) expect(editor.getCursor()).toEqual({ line: 2, col: 4 }); }); it("resets sticky column on horizontal movement (right arrow)", () => { const editor = new Editor(defaultEditorTheme); editor.setText("1234567890\n\n1234567890"); // Start at line 0, col 5 editor.handleInput("\x1b[A"); // Up to line 1 editor.handleInput("\x1b[A"); // Up to line 0 editor.handleInput("\x01"); // Ctrl+A for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); // Move down through empty line editor.handleInput("\x1b[B"); // Down - line 1, col 0 editor.handleInput("\x1b[B"); // Down - line 2, col 5 (sticky) expect(editor.getCursor()).toEqual({ line: 2, col: 5 }); // Move right - resets sticky column editor.handleInput("\x1b[C"); // Right expect(editor.getCursor()).toEqual({ line: 2, col: 6 }); // Move up twice editor.handleInput("\x1b[A"); // Up - line 1, col 0 editor.handleInput("\x1b[A"); // Up - line 0, col 6 (new sticky from col 6) expect(editor.getCursor()).toEqual({ line: 0, col: 6 }); }); it("resets sticky column on typing", () => { const editor = new Editor(defaultEditorTheme); editor.setText("1234567890\n\n1234567890"); // Start at line 2, col 8 editor.handleInput("\x01"); // Ctrl+A for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); // Move up through empty line editor.handleInput("\x1b[A"); // Up editor.handleInput("\x1b[A"); // Up - line 0, col 8 expect(editor.getCursor()).toEqual({ line: 0, col: 8 }); // Type a character - resets sticky column editor.handleInput("X"); expect(editor.getCursor()).toEqual({ line: 0, col: 9 }); // Move down twice editor.handleInput("\x1b[B"); // Down - line 1, col 0 editor.handleInput("\x1b[B"); // Down - line 2, col 9 (new sticky from col 9) expect(editor.getCursor()).toEqual({ line: 2, col: 9 }); }); it("resets sticky column on backspace", () => { const editor = new Editor(defaultEditorTheme); editor.setText("1234567890\n\n1234567890"); // Start at line 2, col 8 editor.handleInput("\x01"); // Ctrl+A for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); // Move up through empty line editor.handleInput("\x1b[A"); // Up editor.handleInput("\x1b[A"); // Up - line 0, col 8 expect(editor.getCursor()).toEqual({ line: 0, col: 8 }); // Backspace - resets sticky column editor.handleInput("\x7f"); // Backspace expect(editor.getCursor()).toEqual({ line: 0, col: 7 }); // Move down twice editor.handleInput("\x1b[B"); // Down - line 1, col 0 editor.handleInput("\x1b[B"); // Down - line 2, col 7 (new sticky from col 7) expect(editor.getCursor()).toEqual({ line: 2, col: 7 }); }); it("resets sticky column on Ctrl+A (move to line start)", () => { const editor = new Editor(defaultEditorTheme); editor.setText("1234567890\n\n1234567890"); // Start at line 2, col 8 editor.handleInput("\x01"); // Ctrl+A for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); // Move up - establishes sticky col 8 editor.handleInput("\x1b[A"); // Up - line 1, col 0 // Ctrl+A - resets sticky column to 0 editor.handleInput("\x01"); // Ctrl+A expect(editor.getCursor()).toEqual({ line: 1, col: 0 }); // Move up editor.handleInput("\x1b[A"); // Up - line 0, col 0 (new sticky from col 0) expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); }); it("resets sticky column on Ctrl+E (move to line end)", () => { const editor = new Editor(defaultEditorTheme); editor.setText("12345\n\n1234567890"); // Start at line 2, col 3 editor.handleInput("\x01"); // Ctrl+A for (let i = 0; i < 3; i++) editor.handleInput("\x1b[C"); // Move up through empty line - establishes sticky col 3 editor.handleInput("\x1b[A"); // Up - line 1, col 0 editor.handleInput("\x1b[A"); // Up - line 0, col 3 expect(editor.getCursor()).toEqual({ line: 0, col: 3 }); // Ctrl+E - resets sticky column to end editor.handleInput("\x05"); // Ctrl+E expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); // Move down twice editor.handleInput("\x1b[B"); // Down - line 1, col 0 editor.handleInput("\x1b[B"); // Down - line 2, col 5 (new sticky from col 5) expect(editor.getCursor()).toEqual({ line: 2, col: 5 }); }); it("resets sticky column on word movement (Ctrl+Left)", () => { const editor = new Editor(defaultEditorTheme); editor.setText("hello world\n\nhello world"); // Start at end of line 2 (col 11) expect(editor.getCursor()).toEqual({ line: 2, col: 11 }); // Move up through empty line - establishes sticky col 11 editor.handleInput("\x1b[A"); // Up - line 1, col 0 editor.handleInput("\x1b[A"); // Up - line 0, col 11 expect(editor.getCursor()).toEqual({ line: 0, col: 11 }); // Ctrl+Left - word movement resets sticky column editor.handleInput("\x1b[1;5D"); // Ctrl+Left expect(editor.getCursor()).toEqual({ line: 0, col: 6 }); // Before "world" // Move down twice editor.handleInput("\x1b[B"); // Down - line 1, col 0 editor.handleInput("\x1b[B"); // Down - line 2, col 6 (new sticky from col 6) expect(editor.getCursor()).toEqual({ line: 2, col: 6 }); }); it("resets sticky column on word movement (Ctrl+Right)", () => { const editor = new Editor(defaultEditorTheme); editor.setText("hello world\n\nhello world"); // Start at line 0, col 0 editor.handleInput("\x1b[A"); // Up editor.handleInput("\x1b[A"); // Up editor.handleInput("\x01"); // Ctrl+A expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); // Move down through empty line - establishes sticky col 0 editor.handleInput("\x1b[B"); // Down - line 1, col 0 editor.handleInput("\x1b[B"); // Down - line 2, col 0 expect(editor.getCursor()).toEqual({ line: 2, col: 0 }); // Ctrl+Right - word movement resets sticky column editor.handleInput("\x1b[1;5C"); // Ctrl+Right expect(editor.getCursor()).toEqual({ line: 2, col: 5 }); // After "hello" // Move up twice editor.handleInput("\x1b[A"); // Up - line 1, col 0 editor.handleInput("\x1b[A"); // Up - line 0, col 5 (new sticky from col 5) expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); }); it("resets sticky column on undo", () => { const editor = new Editor(defaultEditorTheme); editor.setText("1234567890\n\n1234567890"); // Go to line 0, col 8 editor.handleInput("\x1b[A"); // Up to line 1 editor.handleInput("\x1b[A"); // Up to line 0 editor.handleInput("\x01"); // Ctrl+A for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); expect(editor.getCursor()).toEqual({ line: 0, col: 8 }); // Move down through empty line - establishes sticky col 8 editor.handleInput("\x1b[B"); // Down - line 1, col 0 editor.handleInput("\x1b[B"); // Down - line 2, col 8 (sticky) expect(editor.getCursor()).toEqual({ line: 2, col: 8 }); // Type something to create undo state - this clears sticky and sets col to 9 editor.handleInput("X"); expect(editor.getText()).toBe("1234567890\n\n12345678X90"); expect(editor.getCursor()).toEqual({ line: 2, col: 9 }); // Move up - establishes new sticky col 9 editor.handleInput("\x1b[A"); // Up - line 1, col 0 editor.handleInput("\x1b[A"); // Up - line 0, col 9 expect(editor.getCursor()).toEqual({ line: 0, col: 9 }); // Undo - resets sticky column and restores cursor to line 2, col 8 editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) expect(editor.getText()).toBe("1234567890\n\n1234567890"); expect(editor.getCursor()).toEqual({ line: 2, col: 8 }); // Move up - should capture new sticky from restored col 8, not old col 9 editor.handleInput("\x1b[A"); // Up - line 1, col 0 editor.handleInput("\x1b[A"); // Up - line 0, col 8 (new sticky from restored position) expect(editor.getCursor()).toEqual({ line: 0, col: 8 }); }); it("uses the configured undo binding", () => { setKeybindings( new KeybindingsManager(TUI_KEYBINDINGS, { "tui.editor.undo": "f8", }), ); const editor = new Editor(defaultEditorTheme); editor.handleInput("a"); expect(editor.getText()).toBe("a"); editor.handleInput("\x1b[19~"); // F8 expect(editor.getText()).toBe(""); expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); }); it("does not swallow keys rebound to copy", () => { setKeybindings( new KeybindingsManager(TUI_KEYBINDINGS, { "tui.input.copy": "left", }), ); const editor = new Editor(defaultEditorTheme); editor.setText("ab"); editor.handleInput("\x1b[D"); // Left arrow editor.handleInput("X"); expect(editor.getText()).toBe("aXb"); expect(editor.getCursor()).toEqual({ line: 0, col: 2 }); }); it("undoes the last paste when a transient #undo trigger is executed", () => { const editor = new Editor(defaultEditorTheme); editor.handleInput("\x1b[200~pasted text\x1b[201~"); expect(editor.getText()).toBe("pasted text"); editor.handleInput("#"); editor.handleInput("u"); editor.handleInput("n"); editor.handleInput("d"); editor.handleInput("o"); expect(editor.getText()).toBe("pasted text#undo"); editor.undoPastTransientText("#undo"); expect(editor.getText()).toBe(""); expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); }); it("removes a transient undo trigger even when there is no earlier edit to restore", () => { const editor = new Editor(defaultEditorTheme); editor.handleInput("#"); editor.handleInput("u"); editor.handleInput("n"); editor.handleInput("d"); editor.handleInput("o"); expect(editor.getText()).toBe("#undo"); editor.undoPastTransientText("#undo"); expect(editor.getText()).toBe(""); expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); }); it("handles multiple consecutive up/down movements", () => { const editor = new Editor(defaultEditorTheme); editor.setText("1234567890\nab\ncd\nef\n1234567890"); // Start at line 4, col 7 editor.handleInput("\x01"); // Ctrl+A for (let i = 0; i < 7; i++) editor.handleInput("\x1b[C"); expect(editor.getCursor()).toEqual({ line: 4, col: 7 }); // Move up multiple times through short lines editor.handleInput("\x1b[A"); // Up - line 3, col 2 (clamped) editor.handleInput("\x1b[A"); // Up - line 2, col 2 (clamped) editor.handleInput("\x1b[A"); // Up - line 1, col 2 (clamped) editor.handleInput("\x1b[A"); // Up - line 0, col 7 (restored) expect(editor.getCursor()).toEqual({ line: 0, col: 7 }); // Move down multiple times - sticky should still be 7 editor.handleInput("\x1b[B"); // Down - line 1, col 2 editor.handleInput("\x1b[B"); // Down - line 2, col 2 editor.handleInput("\x1b[B"); // Down - line 3, col 2 editor.handleInput("\x1b[B"); // Down - line 4, col 7 (restored) expect(editor.getCursor()).toEqual({ line: 4, col: 7 }); }); it("supports PageUp/PageDown for faster visual navigation", () => { const editor = new Editor(defaultEditorTheme); editor.setMaxHeight(6); editor.setText("l0\nl1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9"); editor.handleInput("\x1b[5~"); // PageUp expect(editor.getCursor()).toEqual({ line: 6, col: 2 }); editor.handleInput("\x1b[5~"); // PageUp expect(editor.getCursor()).toEqual({ line: 3, col: 2 }); editor.handleInput("\x1b[6~"); // PageDown expect(editor.getCursor()).toEqual({ line: 6, col: 2 }); }); it("moves correctly through wrapped visual lines without getting stuck", () => { const editor = new Editor(defaultEditorTheme); // Line 0: short // Line 1: 30 chars = wraps to multiple visual lines at narrow width editor.setText("short\n123456789012345678901234567890"); editor.render(16); // Narrow width to force wrapping // Position at end of line 1 (col 30) expect(editor.getCursor()).toEqual({ line: 1, col: 30 }); // Move up repeatedly - should traverse all visual lines of the wrapped text // and eventually reach line 0 editor.handleInput("\x1b[A"); // Up - to previous visual line within line 1 expect(editor.getCursor().line).toBe(1); editor.handleInput("\x1b[A"); // Up - another visual line expect(editor.getCursor().line).toBe(1); editor.handleInput("\x1b[A"); // Up - should reach line 0 expect(editor.getCursor().line).toBe(0); }); it("handles setText resetting sticky column", () => { const editor = new Editor(defaultEditorTheme); editor.setText("1234567890\n\n1234567890"); // Establish sticky column editor.handleInput("\x01"); // Ctrl+A for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); editor.handleInput("\x1b[A"); // Up // setText should reset sticky column editor.setText("abcdefghij\n\nabcdefghij"); expect(editor.getCursor()).toEqual({ line: 2, col: 10 }); // At end // Move up - should capture new sticky from current position (10) editor.handleInput("\x1b[A"); // Up - line 1, col 0 editor.handleInput("\x1b[A"); // Up - line 0, col 10 expect(editor.getCursor()).toEqual({ line: 0, col: 10 }); }); it("sets preferredVisualCol when pressing right at end of prompt (last line)", () => { const editor = new Editor(defaultEditorTheme); // Line 0: 20 chars with 'x' at col 10 // Line 1: empty // Line 2: 10 chars ending with '_' editor.setText("111111111x1111111111\n\n333333333_"); // Go to line 0, press Ctrl+E (end of line) - col 20 editor.handleInput("\x1b[A"); // Up to line 1 editor.handleInput("\x1b[A"); // Up to line 0 editor.handleInput("\x05"); // Ctrl+E - move to end of line expect(editor.getCursor()).toEqual({ line: 0, col: 20 }); // Move down to line 2 - cursor clamped to col 10 (end of line) editor.handleInput("\x1b[B"); // Down to line 1, col 0 editor.handleInput("\x1b[B"); // Down to line 2, col 10 (clamped) expect(editor.getCursor()).toEqual({ line: 2, col: 10 }); // Press Right at end of prompt - nothing visible happens, but sets preferredVisualCol to 10 editor.handleInput("\x1b[C"); // Right - can't move, but sets preferredVisualCol expect(editor.getCursor()).toEqual({ line: 2, col: 10 }); // Still at same position // Move up twice to line 0 - should use preferredVisualCol (10) to land on 'x' editor.handleInput("\x1b[A"); // Up to line 1, col 0 editor.handleInput("\x1b[A"); // Up to line 0, col 10 (on 'x') expect(editor.getCursor()).toEqual({ line: 0, col: 10 }); }); it("handles editor resizes when preferredVisualCol is on the same line", () => { const editor = new Editor(defaultEditorTheme); editor.setText("12345678901234567890\n\n12345678901234567890"); // Start at line 2, col 15 editor.handleInput("\x01"); // Ctrl+A for (let i = 0; i < 15; i++) editor.handleInput("\x1b[C"); // Move up through empty line - establishes sticky col 15 editor.handleInput("\x1b[A"); // Up editor.handleInput("\x1b[A"); // Up - line 0, col 15 expect(editor.getCursor()).toEqual({ line: 0, col: 15 }); // Render with narrower width to simulate resize editor.render(17); // Width 17 -> layoutWidth 11 // Move down - sticky should be clamped to new width editor.handleInput("\x1b[B"); // Down - line 1 editor.handleInput("\x1b[B"); // Down - line 2, col should be clamped expect(editor.getCursor().col).toBe(4); }); it("handles editor resizes when preferredVisualCol is on a different line", () => { const editor = new Editor(defaultEditorTheme); // Create a line that wraps into multiple visual lines at width 10 // "12345678901234567890" = 20 chars, wraps to 2 visual lines at width 10 editor.setText("short\n12345678901234567890"); // Go to line 1, col 15 editor.handleInput("\x01"); // Ctrl+A for (let i = 0; i < 15; i++) editor.handleInput("\x1b[C"); expect(editor.getCursor()).toEqual({ line: 1, col: 15 }); // Move up to establish sticky col 15 editor.handleInput("\x1b[A"); // Up to line 0 // Line 0 has only 5 chars, so cursor at col 5 expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); // Narrow the editor editor.render(15); // Move down - preferredVisualCol was 15, but width is 10 // Should land on line 1, clamped to width (visual col 9, which is logical col 9) editor.handleInput("\x1b[B"); // Down to line 1 expect(editor.getCursor()).toEqual({ line: 1, col: 8 }); // Move up editor.handleInput("\x1b[A"); // Up - should go to line 0 expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); // Line 0 only has 5 chars // Restore the original width editor.render(80); // Move down - preferredVisualCol was kept at 15 editor.handleInput("\x1b[B"); // Down to line 1 expect(editor.getCursor()).toEqual({ line: 1, col: 15 }); }); it("expands large pasted content literally in getExpandedText", () => { const editor = new Editor(defaultEditorTheme); const pastedText = [ "line 1", "line 2", "line 3", "line 4", "line 5", "line 6", "line 7", "line 8", "line 9", "line 10", "tokens $1 $2 $& $$ $` $' end", ].join("\n"); editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`); expect(editor.getText()).toMatch(/\[paste #\d+ \+\d+ lines\]/); expect(editor.getExpandedText()).toBe(pastedText); }); it("submits large pasted content literally", () => { const editor = new Editor(defaultEditorTheme); const pastedText = [ "line 1", "line 2", "line 3", "line 4", "line 5", "line 6", "line 7", "line 8", "line 9", "line 10", "tokens $1 $2 $& $$ $` $' end", ].join("\n"); let submitted = ""; editor.onSubmit = text => { submitted = text; }; editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`); editor.handleInput("\r"); expect(submitted).toBe(pastedText); }); }); describe("Korean NFC paste normalization", () => { // macOS Finder drag-drops/Copy-As-Pathname emit Korean filenames as // NFD (decomposed) — e.g. `화` becomes `ᄒ`(U+1112) + `ᅪ`(U+116A). // `Bun.stringWidth` measures NFD jamo at 3 cells per syllable while // terminals render the precomposed syllable at 2 cells, so without // normalization the cursor column drifts past the visible filename // and subsequent input renders into the wrong row. The earlier fix // landed on the legacy `Input` component; OMP's interactive prompt // uses `Editor`, so the fix has to live here too. it("normalizes NFD Korean bracketed-paste to NFC", () => { const editor = new Editor(defaultEditorTheme); const nfcPath = "/Users/leo/Documents/260411_아빠-창고-미팅-1회차"; const nfdPath = nfcPath.normalize("NFD"); expect(nfdPath).not.toBe(nfcPath); expect(nfdPath.length).toBeGreaterThan(nfcPath.length); editor.handleInput(`\x1b[200~${nfdPath}\x1b[201~`); expect(editor.getText()).toBe(nfcPath); }); it("renders pasted Korean path as precomposed syllables, not NFD jamo", () => { const editor = new Editor(defaultEditorTheme); editor.focused = true; const nfdPath = "/Users/leo/화면 기록.mov".normalize("NFD"); editor.handleInput(`\x1b[200~${nfdPath}\x1b[201~`); const rendered = editor.render(120).join("\n"); // Precomposed syllables (`화`, `면`, `기`, `록`) must appear in the // rendered output. If NFC normalization is missing, the rendered // text contains NFD jamo (`ᄒ`+`ᅪ`+`ᇁ` etc.) instead. expect(rendered).toContain("화면"); expect(rendered).toContain("기록"); // The leading Hangul jamo block (U+1100..U+1112) only appears in // NFD output. The Editor must not leak it after normalization. expect(rendered).not.toMatch(/[\u1100-\u1112]/); }); }); });