caace28295
The multiline editor's key dispatch checked a hardcoded Ctrl/Shift+Enter -> newline branch before the config-driven tui.input.submit branch, so a user remap of submit onto Ctrl+Enter was swallowed as a newline and never submitted. Gate the hardcoded newline fallbacks behind an explicit submit binding; the bare-LF (iTerm2 Shift+Enter) case stays exempt because its canonical form is indistinguishable from plain Enter. Fixes #8906
2906 lines
106 KiB
TypeScript
2906 lines
106 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||
import * as fs from "node:fs/promises";
|
||
import * as os from "node:os";
|
||
import * as path from "node:path";
|
||
import { stripVTControlCharacters } from "node:util";
|
||
import { CURSOR_MARKER, TUI } 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 { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui/keybindings";
|
||
import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
|
||
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
|
||
import { defaultEditorTheme } from "./test-themes";
|
||
import { VirtualTerminal } from "./virtual-terminal";
|
||
|
||
describe("Editor component", () => {
|
||
afterEach(() => {
|
||
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
|
||
});
|
||
|
||
it("advances its width-epoch revision for text changes but not cursor movement", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const initial = editor.getNativeScrollbackWidthEpochRevision();
|
||
editor.setText("draft");
|
||
const changed = editor.getNativeScrollbackWidthEpochRevision();
|
||
expect(changed).toBeGreaterThan(initial);
|
||
editor.moveToLineStart();
|
||
expect(editor.getNativeScrollbackWidthEpochRevision()).toBe(changed);
|
||
});
|
||
|
||
it("advances its width-epoch revision when max height exposes more draft rows", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("draft-0\ndraft-1\ndraft-2\ndraft-3");
|
||
editor.setMaxHeight(3);
|
||
const clippedRows = editor.render(40).length;
|
||
const clippedRevision = editor.getNativeScrollbackWidthEpochRevision();
|
||
|
||
editor.setMaxHeight(6);
|
||
|
||
expect(editor.render(40).length).toBeGreaterThan(clippedRows);
|
||
expect(editor.getNativeScrollbackWidthEpochRevision()).toBeGreaterThan(clippedRevision);
|
||
});
|
||
|
||
it("advances its width-epoch revision when terminal-cursor layout adds a row", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.focused = true;
|
||
editor.setText("draft");
|
||
editor.setImeSafeCursorLayout(true);
|
||
const inlineRows = editor.render(40).length;
|
||
const inlineRevision = editor.getNativeScrollbackWidthEpochRevision();
|
||
|
||
editor.setUseTerminalCursor(true);
|
||
|
||
expect(editor.render(40).length).toBeGreaterThan(inlineRows);
|
||
const terminalCursorRevision = editor.getNativeScrollbackWidthEpochRevision();
|
||
expect(terminalCursorRevision).toBeGreaterThan(inlineRevision);
|
||
editor.setUseTerminalCursor(true);
|
||
expect(editor.getNativeScrollbackWidthEpochRevision()).toBe(terminalCursorRevision);
|
||
});
|
||
|
||
it("advances its width-epoch revision when border visibility adds rows", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("draft");
|
||
editor.setBorderVisible(false);
|
||
const borderlessRows = editor.render(40).length;
|
||
const borderlessRevision = editor.getNativeScrollbackWidthEpochRevision();
|
||
|
||
editor.setBorderVisible(true);
|
||
|
||
expect(editor.render(40).length).toBeGreaterThan(borderlessRows);
|
||
const borderedRevision = editor.getNativeScrollbackWidthEpochRevision();
|
||
expect(borderedRevision).toBeGreaterThan(borderlessRevision);
|
||
editor.setBorderVisible(true);
|
||
expect(editor.getNativeScrollbackWidthEpochRevision()).toBe(borderedRevision);
|
||
});
|
||
|
||
it("tracks lazy top-border changes independently of width reflow", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
let status = "idle";
|
||
let revision = 0;
|
||
editor.setTopBorderProvider(availableWidth => {
|
||
const content = `${status}:${availableWidth}`;
|
||
return { content, width: visibleWidth(content), revision };
|
||
});
|
||
editor.render(40);
|
||
const idleRevision = editor.getNativeScrollbackWidthEpochRevision();
|
||
|
||
status = "streaming";
|
||
revision++;
|
||
editor.render(30);
|
||
const streamingRevision = editor.getNativeScrollbackWidthEpochRevision();
|
||
expect(streamingRevision).toBeGreaterThan(idleRevision);
|
||
|
||
editor.render(50);
|
||
expect(editor.getNativeScrollbackWidthEpochRevision()).toBe(streamingRevision);
|
||
});
|
||
|
||
it("advances its width-epoch revision when autocomplete changes without changing text", async () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const { promise: autocompleteUpdated, resolve: resolveAutocompleteUpdated } = Promise.withResolvers<void>();
|
||
editor.setAutocompleteProvider({
|
||
async getSuggestions() {
|
||
return {
|
||
items: Array.from({ length: 8 }, (_value, index) => ({
|
||
label: `/item-${index}`,
|
||
value: `/item-${index}`,
|
||
description:
|
||
index === 1
|
||
? "A deliberately long description that wraps across several narrow popup rows."
|
||
: "Short",
|
||
})),
|
||
prefix: "/",
|
||
};
|
||
},
|
||
applyCompletion(lines, cursorLine, cursorCol) {
|
||
return { lines, cursorLine, cursorCol };
|
||
},
|
||
});
|
||
editor.onAutocompleteUpdate = resolveAutocompleteUpdated;
|
||
editor.handleInput("/");
|
||
const textRevision = editor.getNativeScrollbackWidthEpochRevision();
|
||
|
||
await autocompleteUpdated;
|
||
const popupRevision = editor.getNativeScrollbackWidthEpochRevision();
|
||
expect(editor.getText()).toBe("/");
|
||
expect(popupRevision).toBeGreaterThan(textRevision);
|
||
const initialPopupRows = editor.render(30).length;
|
||
|
||
editor.handleInput("\x1b[B");
|
||
const selectedRevision = editor.getNativeScrollbackWidthEpochRevision();
|
||
expect(selectedRevision).toBeGreaterThan(popupRevision);
|
||
|
||
editor.setAutocompleteMaxVisible(8);
|
||
const resizedRevision = editor.getNativeScrollbackWidthEpochRevision();
|
||
expect(resizedRevision).toBeGreaterThan(selectedRevision);
|
||
expect(editor.render(30).length).toBeGreaterThan(initialPopupRows);
|
||
|
||
editor.handleInput("\x1b");
|
||
expect(editor.isShowingAutocomplete()).toBe(false);
|
||
expect(editor.getNativeScrollbackWidthEpochRevision()).toBeGreaterThan(resizedRevision);
|
||
});
|
||
|
||
describe("Word delete keybindings", () => {
|
||
it("honors a keybindings.yml remap of deleteWordBackward in the multi-line editor", () => {
|
||
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS, { "tui.editor.deleteWordBackward": "alt+g" }));
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("alfa beta gamma");
|
||
editor.handleInput("\x1bg"); // Alt+G
|
||
expect(editor.getText()).toBe("alfa beta ");
|
||
});
|
||
|
||
it("stops firing a hardcoded chord once the config replaces the action's keys", () => {
|
||
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS, { "tui.editor.deleteWordBackward": "alt+g" }));
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("alfa beta gamma");
|
||
editor.handleInput("\x17"); // Ctrl+W, no longer bound to deleteWordBackward
|
||
expect(editor.getText()).toBe("alfa beta gamma");
|
||
});
|
||
|
||
it("deletes a word on ctrl+backspace via its registry default key", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("alfa beta gamma");
|
||
editor.handleInput("\x1b[127;5u"); // kitty CSI-u ctrl+backspace
|
||
expect(editor.getText()).toBe("alfa beta ");
|
||
});
|
||
});
|
||
|
||
describe("Submit/newline keybindings", () => {
|
||
it("submits on Ctrl+Enter when tui.input.submit is remapped to it (#8906)", () => {
|
||
setKeybindings(
|
||
new KeybindingsManager(TUI_KEYBINDINGS, {
|
||
"tui.input.submit": "ctrl+enter",
|
||
"tui.input.newLine": "enter",
|
||
}),
|
||
);
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("hello");
|
||
let submitted: string | undefined;
|
||
editor.onSubmit = text => {
|
||
submitted = text;
|
||
};
|
||
editor.handleInput("\x1b[13;5u"); // kitty CSI-u Ctrl+Enter
|
||
expect(submitted).toBe("hello");
|
||
expect(editor.getText()).toBe("");
|
||
});
|
||
|
||
it("still inserts a newline on Ctrl+Enter under the default bindings", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("hello");
|
||
let submitted: string | undefined;
|
||
editor.onSubmit = text => {
|
||
submitted = text;
|
||
};
|
||
editor.handleInput("\x1b[13;5u"); // kitty CSI-u Ctrl+Enter
|
||
expect(submitted).toBeUndefined();
|
||
expect(editor.getText()).toBe("hello\n");
|
||
});
|
||
});
|
||
|
||
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 without losing the hardware cursor anchor", async () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.focused = true;
|
||
editor.setUseTerminalCursor(true);
|
||
const { promise, resolve } = Promise.withResolvers<string>();
|
||
const { promise: autocompleteUpdated, resolve: resolveAutocompleteUpdated } = Promise.withResolvers<void>();
|
||
|
||
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.onAutocompleteUpdate = resolveAutocompleteUpdated;
|
||
|
||
editor.handleInput("/");
|
||
|
||
await expect(promise).resolves.toBe("/");
|
||
await autocompleteUpdated;
|
||
expect(editor.isShowingAutocomplete()).toBe(true);
|
||
expect(editor.render(80).some(line => line.includes(CURSOR_MARKER))).toBe(true);
|
||
});
|
||
|
||
it("wraps long slash-command descriptions instead of dropping the tail", async () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const longDescription =
|
||
"Plan and execute non-trivial architectural improvements to the codebase. Use this skill when you need to refactor existing systems.";
|
||
editor.setAutocompleteProvider(
|
||
new CombinedAutocompleteProvider(
|
||
[{ name: "improve-codebase-architecture", description: longDescription }],
|
||
"/tmp",
|
||
),
|
||
);
|
||
|
||
const { promise: autocompleteUpdated, resolve: resolveAutocompleteUpdated } = Promise.withResolvers<void>();
|
||
editor.onAutocompleteUpdate = resolveAutocompleteUpdated;
|
||
|
||
editor.handleInput("/");
|
||
await autocompleteUpdated;
|
||
|
||
const rendered = editor.render(80).map(line => stripVTControlCharacters(line));
|
||
expect(rendered.some(line => line.includes("improve-codebase-architecture"))).toBe(true);
|
||
expect(rendered.join("\n")).toContain("refactor existing systems.");
|
||
});
|
||
|
||
it("triggers file-reference autocomplete when typing at-sign", async () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const { promise, resolve } = Promise.withResolvers<string>();
|
||
|
||
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);
|
||
});
|
||
|
||
it("does not open file autocomplete after tab-completing no-arg slash commands", async () => {
|
||
vi.useFakeTimers();
|
||
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "slash-tab-no-arg-"));
|
||
try {
|
||
await Bun.write(path.join(baseDir, "visible-file.ts"), "export {};\n");
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setAutocompleteProvider(
|
||
new CombinedAutocompleteProvider([{ name: "quit", description: "Quit", allowArgs: false }], baseDir),
|
||
);
|
||
|
||
let nextUpdate = Promise.withResolvers<void>();
|
||
editor.onAutocompleteUpdate = () => nextUpdate.resolve();
|
||
editor.handleInput("/");
|
||
await nextUpdate.promise;
|
||
|
||
nextUpdate = Promise.withResolvers<void>();
|
||
editor.onAutocompleteUpdate = () => nextUpdate.resolve();
|
||
editor.handleInput("q");
|
||
vi.advanceTimersByTime(100);
|
||
await nextUpdate.promise;
|
||
|
||
const chainedUpdates = Promise.withResolvers<void>();
|
||
let updateCount = 0;
|
||
editor.onAutocompleteUpdate = () => {
|
||
updateCount += 1;
|
||
if (updateCount === 2) {
|
||
chainedUpdates.resolve();
|
||
}
|
||
};
|
||
editor.handleInput(" ");
|
||
await chainedUpdates.promise;
|
||
|
||
expect(editor.getText()).toBe("/quit ");
|
||
expect(editor.isShowingAutocomplete()).toBe(false);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
await fs.rm(baseDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|
||
|
||
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 keypad digits instead of treating them as navigation", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.handleInput("a");
|
||
editor.handleInput("\x1b[57400u");
|
||
editor.handleInput("\x1b[57400;129u");
|
||
|
||
expect(editor.getText()).toBe("a11");
|
||
});
|
||
|
||
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("expands tabs to the fixed display width when loading text programmatically", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("foo\tbar");
|
||
expect(editor.getText()).toBe("foo bar");
|
||
});
|
||
|
||
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");
|
||
|
||
// snake_case identifier deletes as a single word (issue #4776)
|
||
editor.setText("allowed_openai_params");
|
||
editor.handleInput("\x17");
|
||
expect(editor.getText()).toBe("");
|
||
|
||
// 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 ");
|
||
|
||
// Issue #2064: Ghostty on macOS reports Option+Backspace as `ESC [127;11u`
|
||
// (kitty modifier 11 wire = super(8)|alt(2)). Without super support the
|
||
// editor used to ignore this entirely and the previous word survived.
|
||
setKittyProtocolActive(true);
|
||
editor.setText("foo bar");
|
||
editor.handleInput("\x1b[F"); // End — park cursor at EOL
|
||
editor.handleInput("\x1b[127;11u"); // Ghostty Option+Backspace
|
||
expect(editor.getText()).toBe("foo ");
|
||
setKittyProtocolActive(false);
|
||
});
|
||
|
||
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("keeps the bordered editor inside `width` when the cursor lands past a wide trailing grapheme (#3431)", () => {
|
||
// Regression: typing a fullwidth char (e.g. CJK comma `,`, U+FF0C) at the end
|
||
// of the input used to push the bottom-right `─╯` 1–2 cells past the terminal
|
||
// edge, wrapping `╯` to its own row. The end-of-line cursor glyph + wide grapheme
|
||
// extends into the right padding zone; the right chrome must shrink by the exact
|
||
// overflow cell count.
|
||
for (const paddingX of [1, 2]) {
|
||
const theme = { ...defaultEditorTheme, editorPaddingX: paddingX };
|
||
const minContentWidth = 2 * (paddingX + 1) + 3; // chrome + "," (2) + cursor (1)
|
||
for (let width = minContentWidth; width <= minContentWidth + 6; width++) {
|
||
const editor = new Editor(theme);
|
||
editor.focused = true;
|
||
for (const c of "asd,") editor.handleInput(c);
|
||
const lines = editor.render(width);
|
||
for (const line of lines) {
|
||
const stripped = line.replaceAll(CURSOR_MARKER, "");
|
||
expect(visibleWidth(stripped)).toBeLessThanOrEqual(width);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
it("keeps the terminal-cursor editor compact by default", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.focused = true;
|
||
editor.setUseTerminalCursor(true);
|
||
editor.setText("ast");
|
||
|
||
const lines = editor.render(20).map(line => stripVTControlCharacters(line.replaceAll(CURSOR_MARKER, "")));
|
||
expect(lines).toEqual(["+------------------+", "+- ast -+"]);
|
||
});
|
||
|
||
it("keeps terminal-local IME preedit from displacing the editor border (#5563)", async () => {
|
||
const width = 20;
|
||
const terminal = new VirtualTerminal(width, 6, 1_000);
|
||
const tui = new TUI(terminal, true);
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setImeSafeCursorLayout(true);
|
||
tui.addChild(editor);
|
||
tui.setFocus(editor);
|
||
|
||
try {
|
||
tui.start();
|
||
await terminal.waitForRender();
|
||
for (const char of "ast") editor.handleInput(char);
|
||
tui.requestRender();
|
||
await terminal.waitForRender(() => terminal.getViewport()[1]?.includes("ast") === true);
|
||
|
||
const beforePreedit = terminal.getViewport().map(row => row.trimEnd());
|
||
expect(beforePreedit.slice(0, 3)).toEqual(["+------------------+", "| ast", "+------------------+"]);
|
||
|
||
// macOS Terminal renders marked text locally in insertion mode before
|
||
// committed bytes reach OMP. The open cursor row must not carry right
|
||
// chrome that the marked text can shift onto another row.
|
||
terminal.write("\x1b[4hast,\x1b[4l");
|
||
const afterPreedit = terminal.getViewport().map(row => row.trimEnd());
|
||
expect(afterPreedit[1]).toBe("| astast,");
|
||
expect(afterPreedit[2]).toBe(beforePreedit[2]);
|
||
} finally {
|
||
tui.stop();
|
||
}
|
||
});
|
||
|
||
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("strips control characters from pasted text but keeps newlines", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
// BEL (\x07) and NUL (\x00) must be removed; the newline must survive.
|
||
editor.handleInput("\x1b[200~a\x07b\x00c\ndef\x1b[201~");
|
||
expect(editor.getText()).toBe("abc\ndef");
|
||
});
|
||
|
||
it("decodes tmux xterm-format re-encoded control bytes in bracketed paste (kitty+tmux)", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
// tmux extended-keys-format=xterm (the default under kitty) re-encodes the
|
||
// newline (Ctrl+J) inside the paste as ESC[27;5;106~. It must land as a real
|
||
// newline, not leak the literal escape tail "[27;5;106~" into the buffer.
|
||
editor.handleInput("\x1b[200~line1\x1b[27;5;106~line2\x1b[201~");
|
||
expect(editor.getText()).toBe("line1\nline2");
|
||
});
|
||
|
||
it("decodes tmux csi-u-format re-encoded control bytes in bracketed paste", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
// tmux extended-keys-format=csi-u re-encodes the newline (Ctrl+J) as ESC[106;5u.
|
||
editor.handleInput("\x1b[200~line1\x1b[106;5uline2\x1b[201~");
|
||
expect(editor.getText()).toBe("line1\nline2");
|
||
});
|
||
|
||
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("PageUp/PageDown on an idle editor never step prompt history (#4754)", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
|
||
editor.addToHistory("first prompt");
|
||
editor.addToHistory("second prompt");
|
||
editor.render(80);
|
||
expect(editor.getText()).toBe("");
|
||
|
||
editor.handleInput("\x1b[5~"); // PageUp on empty editor
|
||
expect(editor.getText()).toBe("");
|
||
|
||
editor.handleInput("\x1b[6~"); // PageDown on empty editor
|
||
expect(editor.getText()).toBe("");
|
||
|
||
// While browsing history (entered via Up), PageUp must not advance it.
|
||
editor.handleInput("\x1b[A"); // Up - shows "second prompt"
|
||
expect(editor.getText()).toBe("second prompt");
|
||
editor.handleInput("\x1b[5~"); // PageUp - stays put
|
||
expect(editor.getText()).toBe("second prompt");
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
it("formats a large single-line paste as a char-count marker", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const pastedText = "a".repeat(1500);
|
||
|
||
editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`);
|
||
|
||
expect(editor.getText()).toMatch(/^\[Paste #\d+, 1500 chars\]$/);
|
||
expect(editor.getExpandedText()).toBe(pastedText);
|
||
});
|
||
|
||
it("deletes an entire paste marker on a single backspace when atomicTokenPattern is set", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.atomicTokenPattern = /\[(?:Image|Paste) #\d+(?:,[^\]\n]*)?\]/g;
|
||
const pastedText = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join("\n");
|
||
|
||
editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`);
|
||
expect(editor.getText()).toMatch(/^\[Paste #\d+, \+\d+ lines\]$/);
|
||
|
||
// Cursor sits just after the marker; one backspace removes the whole token
|
||
// rather than corrupting it into stray `[Paste #1, +12 lines` text.
|
||
editor.handleInput("\x7f");
|
||
expect(editor.getText()).toBe("");
|
||
});
|
||
|
||
it("deletes an entire marker on forward-delete from its start", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.atomicTokenPattern = /\[(?:Image|Paste) #\d+(?:,[^\]\n]*)?\]/g;
|
||
editor.setText("[Image #1, 800x600]");
|
||
|
||
editor.handleInput("\x01"); // Ctrl+A → start of line
|
||
editor.handleInput("\x1b[3~"); // Delete (forward)
|
||
expect(editor.getText()).toBe("");
|
||
});
|
||
|
||
it("removes only the marker and keeps surrounding text on atomic backspace", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.atomicTokenPattern = /\[(?:Image|Paste) #\d+(?:,[^\]\n]*)?\]/g;
|
||
editor.setText("a [Paste #1, +12 lines] b");
|
||
|
||
editor.handleInput("\x05"); // Ctrl+E → end of line
|
||
editor.handleInput("\x1b[D"); // left over 'b'
|
||
editor.handleInput("\x1b[D"); // left over ' ' — cursor now just after ']'
|
||
editor.handleInput("\x7f"); // Backspace deletes the whole marker
|
||
expect(editor.getText()).toBe("a b");
|
||
});
|
||
|
||
it("deletes a marker character-by-character when no atomicTokenPattern is set", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("[Paste #1, +12 lines]");
|
||
|
||
editor.handleInput("\x05"); // Ctrl+E → end of line
|
||
editor.handleInput("\x7f"); // Backspace removes only the closing bracket
|
||
expect(editor.getText()).toBe("[Paste #1, +12 lines");
|
||
});
|
||
|
||
it("lets onLargePaste intercept a marker-sized paste, suppressing the default marker", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const seen: Array<{ text: string; lineCount: number }> = [];
|
||
editor.onLargePaste = (text, lineCount) => {
|
||
seen.push({ text, lineCount });
|
||
return true;
|
||
};
|
||
const pastedText = Array.from({ length: 1200 }, (_, i) => `line ${i + 1}`).join("\n");
|
||
|
||
editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`);
|
||
|
||
// Hook intercepted: nothing inserted, no paste marker, hook saw the full text + line count.
|
||
expect(editor.getText()).toBe("");
|
||
expect(seen).toHaveLength(1);
|
||
expect(seen[0].text).toBe(pastedText);
|
||
expect(seen[0].lineCount).toBe(1200);
|
||
});
|
||
|
||
it("falls back to the default marker when onLargePaste declines", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.onLargePaste = () => false;
|
||
const pastedText = Array.from({ length: 1200 }, (_, i) => `line ${i + 1}`).join("\n");
|
||
|
||
editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`);
|
||
|
||
expect(editor.getText()).toMatch(/^\[Paste #\d+, \+1200 lines\]$/);
|
||
expect(editor.getExpandedText()).toBe(pastedText);
|
||
});
|
||
|
||
it("does not call onLargePaste for a sub-marker paste", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
let calls = 0;
|
||
editor.onLargePaste = () => {
|
||
calls++;
|
||
return true;
|
||
};
|
||
|
||
editor.handleInput("\x1b[200~just a short paste\x1b[201~");
|
||
|
||
expect(calls).toBe(0);
|
||
expect(editor.getText()).toBe("just a short paste");
|
||
});
|
||
|
||
it("insertPaste collapses content to a marker that expands on submit", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
let submitted = "";
|
||
editor.onSubmit = text => {
|
||
submitted = text;
|
||
};
|
||
const wrapped = `\`\`\`\n${Array.from({ length: 30 }, (_, i) => `row ${i}`).join("\n")}\n\`\`\``;
|
||
|
||
editor.insertPaste(wrapped);
|
||
|
||
expect(editor.getText()).toMatch(/^\[Paste #\d+, \+\d+ lines\]$/);
|
||
expect(editor.getExpandedText()).toBe(wrapped);
|
||
|
||
editor.handleInput("\r");
|
||
expect(submitted).toBe(wrapped);
|
||
});
|
||
});
|
||
|
||
describe("Bulk input fast path and paste iteration", () => {
|
||
it("produces identical state for a chunked paste and a single-sequence paste", () => {
|
||
const content = "alpha beta\ngamma delta\nepsilon";
|
||
const single = new Editor(defaultEditorTheme);
|
||
single.handleInput(`\x1b[200~${content}\x1b[201~`);
|
||
|
||
const chunked = new Editor(defaultEditorTheme);
|
||
chunked.handleInput("\x1b[200~");
|
||
for (const ch of content) chunked.handleInput(ch);
|
||
chunked.handleInput("\x1b[201~");
|
||
|
||
expect(chunked.getText()).toBe(single.getText());
|
||
expect(chunked.getCursor()).toEqual(single.getCursor());
|
||
});
|
||
|
||
it("normalizes CRLF identically for single and chunked paste delivery", () => {
|
||
const single = new Editor(defaultEditorTheme);
|
||
single.handleInput("\x1b[200~one\r\ntwo\rthree\x1b[201~");
|
||
|
||
const chunked = new Editor(defaultEditorTheme);
|
||
chunked.handleInput("\x1b[200~one\r");
|
||
chunked.handleInput("\ntwo");
|
||
chunked.handleInput("\rthree\x1b[201~");
|
||
|
||
expect(single.getText()).toBe("one\ntwo\nthree");
|
||
expect(chunked.getText()).toBe(single.getText());
|
||
expect(chunked.getCursor()).toEqual(single.getCursor());
|
||
});
|
||
|
||
it("processes paste remainders iteratively, applying every trailing paste and keystroke", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
// One read carrying two complete pastes plus trailing typed text: the
|
||
// remainder after each paste loops back through input handling.
|
||
editor.handleInput("\x1b[200~ab\x1b[201~\x1b[200~cd\x1b[201~ef");
|
||
expect(editor.getText()).toBe("abcdef");
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 6 });
|
||
});
|
||
|
||
it("handles a long train of pastes in one read without recursing per remainder", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.handleInput("\x1b[200~x\x1b[201~".repeat(2000));
|
||
expect(editor.getText()).toBe("x".repeat(2000));
|
||
});
|
||
|
||
it("inserts a plain printable run identically to per-scalar delivery", () => {
|
||
const run = "The quick brown fox 123 -_. naïve 😀 path";
|
||
const bulk = new Editor(defaultEditorTheme);
|
||
bulk.handleInput(run);
|
||
|
||
const perChar = new Editor(defaultEditorTheme);
|
||
for (const ch of run) perChar.handleInput(ch);
|
||
|
||
expect(bulk.getText()).toBe(perChar.getText());
|
||
expect(bulk.getCursor()).toEqual(perChar.getCursor());
|
||
});
|
||
|
||
it("keeps escape sequences interleaved with printable runs on the dispatch path", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.handleInput("abc");
|
||
editor.handleInput("\x1b[D"); // Left
|
||
editor.handleInput("XY"); // bulk run lands before "c"
|
||
expect(editor.getText()).toBe("abXYc");
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 4 });
|
||
});
|
||
|
||
it("opens @ autocomplete when the trigger arrives inside a bulk printable run", async () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const { promise: autocompleteUpdated, resolve: resolveAutocompleteUpdated } = Promise.withResolvers<void>();
|
||
editor.setAutocompleteProvider({
|
||
async getSuggestions() {
|
||
return { items: [{ label: "src/", value: "src/" }], prefix: "@sr" };
|
||
},
|
||
applyCompletion(lines, cursorLine, cursorCol) {
|
||
return { lines, cursorLine, cursorCol };
|
||
},
|
||
});
|
||
editor.onAutocompleteUpdate = resolveAutocompleteUpdated;
|
||
|
||
editor.handleInput("see @sr"); // one bulk run ending in an @-token
|
||
|
||
await autocompleteUpdated;
|
||
expect(editor.isShowingAutocomplete()).toBe(true);
|
||
});
|
||
|
||
it("opens @ autocomplete after a bracketed paste ending in a trigger token", async () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
const { promise: autocompleteUpdated, resolve: resolveAutocompleteUpdated } = Promise.withResolvers<void>();
|
||
editor.setAutocompleteProvider({
|
||
async getSuggestions() {
|
||
return { items: [{ label: "src/", value: "src/" }], prefix: "@sr" };
|
||
},
|
||
applyCompletion(lines, cursorLine, cursorCol) {
|
||
return { lines, cursorLine, cursorCol };
|
||
},
|
||
});
|
||
editor.onAutocompleteUpdate = resolveAutocompleteUpdated;
|
||
|
||
editor.handleInput("\x1b[200~see @sr\x1b[201~");
|
||
|
||
await autocompleteUpdated;
|
||
expect(editor.isShowingAutocomplete()).toBe(true);
|
||
});
|
||
});
|
||
|
||
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]/);
|
||
});
|
||
});
|
||
|
||
describe("Grapheme-aware vertical movement", () => {
|
||
it("snaps vertical movement to grapheme boundaries instead of splitting surrogate pairs", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("ab\n😀😀");
|
||
|
||
editor.handleInput("\x1b[A"); // Up to line 0
|
||
editor.handleInput("\x01"); // Ctrl+A
|
||
editor.handleInput("\x1b[C"); // Right → col 1
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 1 });
|
||
|
||
// Down: visual col 1 is inside the first 😀 (2 cells, surrogate pair).
|
||
// The cursor must snap to a grapheme boundary, never land mid-pair.
|
||
editor.handleInput("\x1b[B");
|
||
expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
|
||
|
||
// Typing here must not corrupt the emoji buffer
|
||
editor.handleInput("X");
|
||
expect(editor.getText()).toBe("ab\nX😀😀");
|
||
});
|
||
|
||
it("preserves the visual column across lines of different glyph widths", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("ああああ\nabcdefgh");
|
||
|
||
editor.handleInput("\x01"); // Ctrl+A on line 1
|
||
for (let i = 0; i < 4; i++) editor.handleInput("\x1b[C"); // Right ×4 → col 4
|
||
expect(editor.getCursor()).toEqual({ line: 1, col: 4 });
|
||
|
||
// Up: visual col 4 on the CJK line is two double-width glyphs → logical col 2,
|
||
// not col 4 (which would be visual col 8 / end of line).
|
||
editor.handleInput("\x1b[A");
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 2 });
|
||
});
|
||
});
|
||
|
||
describe("Whitespace trimmed at wrap points", () => {
|
||
it("maps cursor positions inside wrap-trimmed whitespace to a layout line", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setText("aaaa bbbb\nzzzz");
|
||
editor.render(10); // layoutWidth 4 → "aaaa bbbb" wraps at the space
|
||
|
||
// Up from "zzzz" lands on the second visual segment of line 0
|
||
editor.handleInput("\x1b[A");
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 9 });
|
||
|
||
// Place the cursor on the trimmed space (line 0, col 4)
|
||
editor.handleInput("\x01"); // Ctrl+A
|
||
for (let i = 0; i < 4; i++) editor.handleInput("\x1b[C");
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 4 });
|
||
|
||
// Down must move within line 0's wrapped segments. Before the fix the
|
||
// position was unmapped: the cursor fell through to the buffer's last
|
||
// visual line and Down became a no-op.
|
||
editor.handleInput("\x1b[B");
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 9 });
|
||
});
|
||
});
|
||
|
||
describe("Atomic tokens in kill operations", () => {
|
||
it("extends word-delete backwards over an intersected atomic token", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.atomicTokenPattern = /\[(?:Image|Paste) #\d+(?:,[^\]\n]*)?\]/g;
|
||
editor.setText("a [Paste #1, +12 lines]");
|
||
|
||
// Ctrl+W from the end must consume the whole marker, not leave "[Paste #1, +12 " behind
|
||
editor.handleInput("\x17");
|
||
expect(editor.getText()).toBe("a ");
|
||
});
|
||
|
||
it("extends kill-to-end-of-line over an atomic token the cursor sits inside", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.atomicTokenPattern = /\[(?:Image|Paste) #\d+(?:,[^\]\n]*)?\]/g;
|
||
editor.setText("a [Paste #1, +12 lines] b");
|
||
|
||
editor.handleInput("\x01"); // Ctrl+A
|
||
for (let i = 0; i < 4; i++) editor.handleInput("\x1b[C"); // into the marker
|
||
editor.handleInput("\x0b"); // Ctrl+K
|
||
expect(editor.getText()).toBe("a ");
|
||
expect(editor.getCursor()).toEqual({ line: 0, col: 2 });
|
||
});
|
||
});
|
||
|
||
describe("Undo coalescing", () => {
|
||
it("coalesces consecutive word typing into a single undo unit", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.handleInput("h");
|
||
editor.handleInput("i");
|
||
editor.handleInput(" ");
|
||
editor.handleInput("y");
|
||
editor.handleInput("o");
|
||
expect(editor.getText()).toBe("hi yo");
|
||
|
||
editor.handleInput("\x1b[45;5u"); // undo → removes "yo"
|
||
expect(editor.getText()).toBe("hi ");
|
||
editor.handleInput("\x1b[45;5u"); // undo → removes the space
|
||
expect(editor.getText()).toBe("hi");
|
||
editor.handleInput("\x1b[45;5u"); // undo → removes "hi"
|
||
expect(editor.getText()).toBe("");
|
||
});
|
||
});
|
||
|
||
describe("decorateText around the cursor seam", () => {
|
||
// Editor.#decorate is the only seam that sees both the user prose AND the
|
||
// trailing CURSOR_MARKER, so a decorator with a right-boundary lookahead
|
||
// (like the magic-keyword regex /(?<!\S)ultrathink(?!\S)/g) would reject
|
||
// matches glued to the marker — ESC is non-whitespace. The editor must
|
||
// split around the marker so each side decorates as if it were a complete
|
||
// line. This guards the "ultrathink doesn't glow until you type a trailing
|
||
// character" regression reported in #2475.
|
||
const WORD_RE = /(?<!\S)ultrathink(?!\S)/g;
|
||
const PAINT_PREFIX = "<<";
|
||
const PAINT_SUFFIX = ">>";
|
||
const paintKeyword = (text: string): string => text.replace(WORD_RE, m => `${PAINT_PREFIX}${m}${PAINT_SUFFIX}`);
|
||
|
||
it("decorates a keyword glued to the trailing cursor marker", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.decorateText = paintKeyword;
|
||
editor.focused = true;
|
||
editor.setText("ultrathink");
|
||
|
||
const line = editor.render(40).join("\n");
|
||
// Without the seam fix, the decorator's right-boundary `(?!\S)` would
|
||
// trip on ESC (the first byte of CURSOR_MARKER) and the keyword would
|
||
// survive verbatim. With it, the marker bookends the painted region.
|
||
expect(line).toContain(`${PAINT_PREFIX}ultrathink${PAINT_SUFFIX}`);
|
||
});
|
||
|
||
it("decorates keywords on both sides of the cursor in terminal-cursor mode", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.decorateText = paintKeyword;
|
||
editor.focused = true;
|
||
editor.setUseTerminalCursor(true);
|
||
editor.setText("ultrathink ultrathink");
|
||
// Position the hardware cursor between the two keywords.
|
||
editor.handleInput("\x01"); // Ctrl+A → start of line
|
||
editor.handleInput("\x05"); // Ctrl+E → end of line
|
||
for (let i = 0; i < "ultrathink".length; i++) editor.handleInput("\x1b[D"); // 10× left
|
||
|
||
const line = editor.render(60).join("\n");
|
||
// Both keywords are painted independently — left side ends just before
|
||
// the marker, right side begins right after it.
|
||
const occurrences = line.split(`${PAINT_PREFIX}ultrathink${PAINT_SUFFIX}`).length - 1;
|
||
expect(occurrences).toBe(2);
|
||
expect(line).toContain(CURSOR_MARKER);
|
||
});
|
||
|
||
it("preserves the marker as-is — never splits or duplicates it", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.decorateText = paintKeyword;
|
||
editor.focused = true;
|
||
editor.setText("ultrathink");
|
||
|
||
const line = editor.render(40).join("\n");
|
||
expect(line.split(CURSOR_MARKER).length - 1).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe("volatile speech-to-text preview", () => {
|
||
it("replaces the volatile preview in place rather than appending", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setVolatileText("hel");
|
||
expect(editor.getText()).toBe("hel");
|
||
editor.setVolatileText("hello wor");
|
||
expect(editor.getText()).toBe("hello wor");
|
||
editor.setVolatileText("hello world");
|
||
expect(editor.getText()).toBe("hello world");
|
||
});
|
||
|
||
it("commits the preview as permanent text and previews the next phrase after it", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setVolatileText("hello wor");
|
||
editor.commitVolatileText("hello world");
|
||
expect(editor.getText()).toBe("hello world");
|
||
editor.setVolatileText(" goodby");
|
||
expect(editor.getText()).toBe("hello world goodby");
|
||
editor.commitVolatileText(" goodbye");
|
||
expect(editor.getText()).toBe("hello world goodbye");
|
||
});
|
||
|
||
it("clears the preview without committing it", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.insertText("note: ");
|
||
editor.setVolatileText("scratch that");
|
||
expect(editor.getText()).toBe("note: scratch that");
|
||
editor.clearVolatileText();
|
||
expect(editor.getText()).toBe("note: ");
|
||
});
|
||
|
||
it("keeps preview churn out of the undo history (one undo drops a committed phrase)", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.insertText("pre ");
|
||
editor.setVolatileText("u");
|
||
editor.setVolatileText("um");
|
||
editor.setVolatileText("um hel");
|
||
editor.commitVolatileText("hello");
|
||
expect(editor.getText()).toBe("pre hello");
|
||
editor.handleInput("\x1b[45;5u"); // undo → removes the committed phrase, not preview fragments
|
||
expect(editor.getText()).toBe("pre ");
|
||
});
|
||
|
||
it("replaces a multi-line preview across line boundaries", () => {
|
||
const editor = new Editor(defaultEditorTheme);
|
||
editor.setVolatileText("line one\nline two");
|
||
expect(editor.getText()).toBe("line one\nline two");
|
||
editor.setVolatileText("single line");
|
||
expect(editor.getText()).toBe("single line");
|
||
});
|
||
});
|
||
});
|